dear-file-browser 0.14.0

File dialogs and in-UI file browser for dear-imgui-rs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};

use dear_imgui_rs::texture::TextureId;

/// Decoded thumbnail image in RGBA8 format.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DecodedRgbaImage {
    /// Width in pixels.
    pub width: u32,
    /// Height in pixels.
    pub height: u32,
    /// RGBA8 pixel data (`width * height * 4` bytes).
    pub rgba: Vec<u8>,
}

/// Thumbnail decoder/provider.
///
/// Implementations are expected to:
/// - decode files (often images) to RGBA8,
/// - optionally downscale to `req.max_size`,
/// - return errors for unsupported formats.
pub trait ThumbnailProvider {
    /// Decode a thumbnail request into an RGBA8 image.
    fn decode(&mut self, req: &ThumbnailRequest) -> Result<DecodedRgbaImage, String>;
}

/// Thumbnail renderer interface (upload/destroy).
///
/// Implementations own the GPU lifecycle of `TextureId`.
pub trait ThumbnailRenderer {
    /// Upload an RGBA8 thumbnail image to the GPU and return a `TextureId`.
    fn upload_rgba8(&mut self, image: &DecodedRgbaImage) -> Result<TextureId, String>;
    /// Destroy a previously created `TextureId`.
    fn destroy(&mut self, texture_id: TextureId);
}

/// Convenience wrapper passed to [`ThumbnailCache::maintain`].
pub struct ThumbnailBackend<'a> {
    /// Decoder/provider.
    pub provider: &'a mut dyn ThumbnailProvider,
    /// Renderer (upload/destroy).
    pub renderer: &'a mut dyn ThumbnailRenderer,
}

/// Configuration for [`ThumbnailCache`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ThumbnailCacheConfig {
    /// Maximum number of cached thumbnails.
    pub max_entries: usize,
    /// Maximum number of new requests issued per frame.
    pub max_new_requests_per_frame: usize,
}

impl Default for ThumbnailCacheConfig {
    fn default() -> Self {
        Self {
            max_entries: 256,
            max_new_requests_per_frame: 24,
        }
    }
}

/// A thumbnail request produced by [`ThumbnailCache`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThumbnailRequest {
    /// Full filesystem path to the file.
    pub path: PathBuf,
    /// Maximum thumbnail size in pixels (width, height).
    pub max_size: [u32; 2],
}

#[derive(Clone, Debug)]
enum ThumbnailState {
    Queued,
    InFlight,
    Ready { texture_id: TextureId },
    Failed,
}

#[derive(Clone, Debug)]
struct ThumbnailEntry {
    state: ThumbnailState,
    lru_stamp: u64,
}

/// Monotonic per-frame token used by [`ThumbnailCache`] bookkeeping.
///
/// This is a semantic frame identity, not a duration or filesystem entry count.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ThumbnailFrameIndex(u64);

impl ThumbnailFrameIndex {
    /// Creates a thumbnail frame token from a raw counter value.
    #[cfg(test)]
    #[inline]
    const fn new(value: u64) -> Self {
        Self(value)
    }

    #[inline]
    const fn zero() -> Self {
        Self(0)
    }

    #[inline]
    fn next_wrapping(self) -> Self {
        Self(self.0.wrapping_add(1))
    }
}

/// An in-memory thumbnail request queue + LRU cache.
///
/// This type is renderer-agnostic: the application is expected to:
/// 1) call [`advance_frame`](Self::advance_frame) once per UI frame,
/// 2) drive visibility by calling [`request_visible`](Self::request_visible) for entries that are
///    currently visible,
/// 3) drain requests via [`take_requests`](Self::take_requests), decode/upload thumbnails in user
///    code, then call [`fulfill`](Self::fulfill),
/// 4) destroy evicted GPU textures from [`take_pending_destroys`](Self::take_pending_destroys).
#[derive(Clone, Debug)]
pub struct ThumbnailCache {
    /// Cache configuration.
    pub config: ThumbnailCacheConfig,

    frame_index: ThumbnailFrameIndex,
    issued_this_frame: usize,
    next_stamp: u64,

    entries: HashMap<PathBuf, ThumbnailEntry>,
    lru: VecDeque<(PathBuf, u64)>,
    requests: VecDeque<ThumbnailRequest>,
    pending_destroys: Vec<TextureId>,
}

/// Snapshot of thumbnail cache state, useful for UI indicators (e.g. "generation progress").
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ThumbnailStats {
    /// Total number of tracked thumbnail entries (including ready/failed/in-flight).
    pub total: usize,
    /// Number of entries queued (requested but not yet decoded/uploaded).
    pub queued: usize,
    /// Number of entries currently marked as in-flight.
    pub in_flight: usize,
    /// Number of ready-to-display thumbnails.
    pub ready: usize,
    /// Number of failed thumbnails (decode/upload failure).
    pub failed: usize,
    /// Number of requests waiting in the decode/upload queue.
    pub pending_requests: usize,
    /// New requests issued in the current frame (budgeted by `max_new_requests_per_frame`).
    pub issued_this_frame: usize,
    /// Per-frame request budget.
    pub max_new_requests_per_frame: usize,
}

impl Default for ThumbnailCache {
    fn default() -> Self {
        Self::new(ThumbnailCacheConfig::default())
    }
}

impl ThumbnailCache {
    /// Create a new cache with the given config.
    pub fn new(config: ThumbnailCacheConfig) -> Self {
        Self {
            config,
            frame_index: ThumbnailFrameIndex::zero(),
            issued_this_frame: 0,
            next_stamp: 1,
            entries: HashMap::new(),
            lru: VecDeque::new(),
            requests: VecDeque::new(),
            pending_destroys: Vec::new(),
        }
    }

    /// Advance per-frame bookkeeping.
    ///
    /// Call this once per UI frame before issuing visibility requests.
    pub fn advance_frame(&mut self) {
        self.frame_index = self.frame_index.next_wrapping();
        self.issued_this_frame = 0;
    }

    /// Returns the internal frame counter.
    pub fn frame_index(&self) -> ThumbnailFrameIndex {
        self.frame_index
    }

    /// Request a thumbnail for a visible file.
    ///
    /// If the thumbnail is not already cached, a request may be queued depending on the per-frame
    /// request budget.
    pub fn request_visible(&mut self, path: &Path, max_size: [u32; 2]) {
        let key = path.to_path_buf();

        if let Some(e) = self.entries.get(&key) {
            // Touch existing entries so they are not evicted.
            self.touch_existing(&key, e.lru_stamp);
            return;
        }

        if self.issued_this_frame >= self.config.max_new_requests_per_frame {
            return;
        }
        self.issued_this_frame += 1;

        let stamp = self.alloc_stamp();
        self.entries.insert(
            key.clone(),
            ThumbnailEntry {
                state: ThumbnailState::Queued,
                lru_stamp: stamp,
            },
        );
        self.lru.push_back((key.clone(), stamp));
        self.requests.push_back(ThumbnailRequest {
            path: key,
            max_size,
        });
        self.evict_to_fit();
    }

    /// Returns the cached texture id for a path, if available.
    pub fn texture_id(&self, path: &Path) -> Option<TextureId> {
        self.entries.get(path).and_then(|e| match &e.state {
            ThumbnailState::Ready { texture_id } => Some(*texture_id),
            _ => None,
        })
    }

    /// Drain queued thumbnail requests.
    ///
    /// Drained requests are marked as "in flight" until [`fulfill`](Self::fulfill) is called.
    pub fn take_requests(&mut self) -> Vec<ThumbnailRequest> {
        let mut out = Vec::new();
        while let Some(req) = self.requests.pop_front() {
            if let Some(entry) = self.entries.get_mut(&req.path) {
                if let ThumbnailState::Queued = entry.state {
                    entry.state = ThumbnailState::InFlight;
                }
            }
            out.push(req);
        }
        out
    }

    /// Complete a request with either a ready texture id or an error string.
    ///
    /// Returns any evicted texture ids that should be destroyed by the renderer.
    pub fn fulfill(&mut self, path: &Path, result: Result<TextureId, String>, _max_size: [u32; 2]) {
        let key = path.to_path_buf();
        let stamp = self.alloc_stamp();
        let state = match result {
            Ok(texture_id) => ThumbnailState::Ready { texture_id },
            Err(_message) => ThumbnailState::Failed,
        };

        if let Some(old) = self.entries.insert(
            key.clone(),
            ThumbnailEntry {
                state,
                lru_stamp: stamp,
            },
        ) {
            if let ThumbnailState::Ready { texture_id } = old.state {
                self.pending_destroys.push(texture_id);
            }
        }
        self.lru.push_back((key, stamp));
        self.evict_to_fit();
    }

    /// Complete a previously issued request.
    pub fn fulfill_request(&mut self, req: &ThumbnailRequest, result: Result<TextureId, String>) {
        self.fulfill(&req.path, result, req.max_size);
    }

    /// Process queued requests and perform pending destroys.
    ///
    /// This is a convenience helper for applications that want `dear-file-browser` to drive the
    /// request lifecycle:
    /// - Decodes queued requests using [`ThumbnailProvider`],
    /// - Uploads them using [`ThumbnailRenderer`],
    /// - Fulfills the cache, and
    /// - Destroys evicted/replaced GPU textures via the renderer.
    ///
    /// If you prefer to manage decoding/upload externally, you can instead use
    /// [`take_requests`](Self::take_requests), [`fulfill_request`](Self::fulfill_request), and
    /// [`take_pending_destroys`](Self::take_pending_destroys).
    pub fn maintain(&mut self, backend: &mut ThumbnailBackend<'_>) {
        let requests = self.take_requests();
        for req in &requests {
            let decoded = backend.provider.decode(req);
            let uploaded = match decoded {
                Ok(img) => backend.renderer.upload_rgba8(&img),
                Err(e) => Err(e),
            };
            self.fulfill_request(req, uploaded);
        }

        let destroys = self.take_pending_destroys();
        for tex in destroys {
            backend.renderer.destroy(tex);
        }
    }

    /// Drain GPU textures that should be destroyed after eviction or replacement.
    pub fn take_pending_destroys(&mut self) -> Vec<TextureId> {
        std::mem::take(&mut self.pending_destroys)
    }

    /// Returns a snapshot of the cache state for UI display.
    pub fn stats(&self) -> ThumbnailStats {
        let mut stats = ThumbnailStats {
            total: self.entries.len(),
            pending_requests: self.requests.len(),
            issued_this_frame: self.issued_this_frame,
            max_new_requests_per_frame: self.config.max_new_requests_per_frame,
            ..ThumbnailStats::default()
        };

        for entry in self.entries.values() {
            match entry.state {
                ThumbnailState::Queued => stats.queued += 1,
                ThumbnailState::InFlight => stats.in_flight += 1,
                ThumbnailState::Ready { .. } => stats.ready += 1,
                ThumbnailState::Failed => stats.failed += 1,
            }
        }

        stats
    }

    fn alloc_stamp(&mut self) -> u64 {
        let s = self.next_stamp;
        self.next_stamp = self.next_stamp.wrapping_add(1);
        s
    }

    fn touch_existing(&mut self, key: &PathBuf, old_stamp: u64) {
        let stamp = self.alloc_stamp();
        if let Some(e) = self.entries.get_mut(key) {
            e.lru_stamp = stamp;
        }
        self.lru.push_back((key.clone(), stamp));

        // Avoid unbounded growth if the user constantly hovers a single entry.
        // This is a soft heuristic: clean a little when the queue gets too large.
        if self.lru.len() > self.config.max_entries.saturating_mul(8).max(64) {
            self.compact_lru(old_stamp);
        }
    }

    fn compact_lru(&mut self, _hint_stamp: u64) {
        // Drop stale LRU nodes from the front.
        let target = self.config.max_entries.saturating_mul(4).max(32);
        while self.lru.len() > target {
            let Some((k, s)) = self.lru.pop_front() else {
                break;
            };
            let keep = self.entries.get(&k).is_some_and(|e| e.lru_stamp == s);
            if keep {
                self.lru.push_front((k, s));
                break;
            }
        }
    }

    fn evict_to_fit(&mut self) {
        while self.entries.len() > self.config.max_entries {
            let Some((key, stamp)) = self.lru.pop_front() else {
                break;
            };
            let Some(entry) = self.entries.get(&key) else {
                continue;
            };
            if entry.lru_stamp != stamp {
                continue;
            }
            let removed = self.entries.remove(&key);
            if let Some(removed) = removed {
                if let ThumbnailState::Ready { texture_id } = removed.state {
                    self.pending_destroys.push(texture_id);
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Default)]
    struct DummyProvider;

    impl ThumbnailProvider for DummyProvider {
        fn decode(&mut self, _req: &ThumbnailRequest) -> Result<DecodedRgbaImage, String> {
            Ok(DecodedRgbaImage {
                width: 1,
                height: 1,
                rgba: vec![255, 0, 0, 255],
            })
        }
    }

    #[derive(Default)]
    struct DummyRenderer {
        next: u64,
        destroyed: Vec<TextureId>,
    }

    impl ThumbnailRenderer for DummyRenderer {
        fn upload_rgba8(&mut self, _image: &DecodedRgbaImage) -> Result<TextureId, String> {
            self.next += 1;
            Ok(TextureId::new(self.next))
        }

        fn destroy(&mut self, texture_id: TextureId) {
            self.destroyed.push(texture_id);
        }
    }

    #[test]
    fn respects_request_budget_per_frame() {
        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
            max_entries: 16,
            max_new_requests_per_frame: 2,
        });
        c.advance_frame();
        c.request_visible(Path::new("/a.png"), [64, 64]);
        c.request_visible(Path::new("/b.png"), [64, 64]);
        c.request_visible(Path::new("/c.png"), [64, 64]);
        let reqs = c.take_requests();
        assert_eq!(reqs.len(), 2);
    }

    #[test]
    fn thumbnail_frame_index_is_typed_and_advances() {
        let mut c = ThumbnailCache::new(ThumbnailCacheConfig::default());

        assert_eq!(c.frame_index(), ThumbnailFrameIndex::new(0));

        c.advance_frame();

        assert_eq!(c.frame_index(), ThumbnailFrameIndex::new(1));
    }

    #[test]
    fn evicts_lru_and_collects_pending_destroys() {
        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
            max_entries: 1,
            max_new_requests_per_frame: 10,
        });
        c.advance_frame();
        c.request_visible(Path::new("/a.png"), [64, 64]);
        c.take_requests();
        c.fulfill(Path::new("/a.png"), Ok(TextureId::new(1)), [64, 64]);

        c.advance_frame();
        c.request_visible(Path::new("/b.png"), [64, 64]);
        c.take_requests();
        c.fulfill(Path::new("/b.png"), Ok(TextureId::new(2)), [64, 64]);

        let destroyed = c.take_pending_destroys();
        assert!(destroyed.contains(&TextureId::new(1)));
        assert!(c.texture_id(Path::new("/a.png")).is_none());
        assert_eq!(c.texture_id(Path::new("/b.png")), Some(TextureId::new(2)));
    }

    #[test]
    fn maintain_decodes_uploads_and_destroys() {
        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
            max_entries: 1,
            max_new_requests_per_frame: 10,
        });
        let mut provider = DummyProvider::default();
        let mut renderer = DummyRenderer::default();
        let mut backend = ThumbnailBackend {
            provider: &mut provider,
            renderer: &mut renderer,
        };

        c.advance_frame();
        c.request_visible(Path::new("/a.png"), [64, 64]);
        c.maintain(&mut backend);
        assert!(c.texture_id(Path::new("/a.png")).is_some());

        c.advance_frame();
        c.request_visible(Path::new("/b.png"), [64, 64]);
        c.maintain(&mut backend);
        assert!(renderer.destroyed.iter().any(|t| t == &TextureId::new(1)));
    }
}