Skip to main content

dear_file_browser/
thumbnails.rs

1use std::collections::{HashMap, VecDeque};
2use std::path::{Path, PathBuf};
3
4use dear_imgui_rs::texture::TextureId;
5
6/// Decoded thumbnail image in RGBA8 format.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct DecodedRgbaImage {
9    /// Width in pixels.
10    pub width: u32,
11    /// Height in pixels.
12    pub height: u32,
13    /// RGBA8 pixel data (`width * height * 4` bytes).
14    pub rgba: Vec<u8>,
15}
16
17/// Thumbnail decoder/provider.
18///
19/// Implementations are expected to:
20/// - decode files (often images) to RGBA8,
21/// - optionally downscale to `req.max_size`,
22/// - return errors for unsupported formats.
23pub trait ThumbnailProvider {
24    /// Decode a thumbnail request into an RGBA8 image.
25    fn decode(&mut self, req: &ThumbnailRequest) -> Result<DecodedRgbaImage, String>;
26}
27
28/// Thumbnail renderer interface (upload/destroy).
29///
30/// Implementations own the GPU lifecycle of `TextureId`.
31pub trait ThumbnailRenderer {
32    /// Upload an RGBA8 thumbnail image to the GPU and return a `TextureId`.
33    fn upload_rgba8(&mut self, image: &DecodedRgbaImage) -> Result<TextureId, String>;
34    /// Destroy a previously created `TextureId`.
35    fn destroy(&mut self, texture_id: TextureId);
36}
37
38/// Convenience wrapper passed to [`ThumbnailCache::maintain`].
39pub struct ThumbnailBackend<'a> {
40    /// Decoder/provider.
41    pub provider: &'a mut dyn ThumbnailProvider,
42    /// Renderer (upload/destroy).
43    pub renderer: &'a mut dyn ThumbnailRenderer,
44}
45
46/// Configuration for [`ThumbnailCache`].
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct ThumbnailCacheConfig {
49    /// Maximum number of cached thumbnails.
50    pub max_entries: usize,
51    /// Maximum number of new requests issued per frame.
52    pub max_new_requests_per_frame: usize,
53}
54
55impl Default for ThumbnailCacheConfig {
56    fn default() -> Self {
57        Self {
58            max_entries: 256,
59            max_new_requests_per_frame: 24,
60        }
61    }
62}
63
64/// A thumbnail request produced by [`ThumbnailCache`].
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct ThumbnailRequest {
67    /// Full filesystem path to the file.
68    pub path: PathBuf,
69    /// Maximum thumbnail size in pixels (width, height).
70    pub max_size: [u32; 2],
71}
72
73#[derive(Clone, Debug)]
74enum ThumbnailState {
75    Queued,
76    InFlight,
77    Ready { texture_id: TextureId },
78    Failed,
79}
80
81#[derive(Clone, Debug)]
82struct ThumbnailEntry {
83    state: ThumbnailState,
84    lru_stamp: u64,
85}
86
87/// Monotonic per-frame token used by [`ThumbnailCache`] bookkeeping.
88///
89/// This is a semantic frame identity, not a duration or filesystem entry count.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
91pub struct ThumbnailFrameIndex(u64);
92
93impl ThumbnailFrameIndex {
94    /// Creates a thumbnail frame token from a raw counter value.
95    #[cfg(test)]
96    #[inline]
97    const fn new(value: u64) -> Self {
98        Self(value)
99    }
100
101    #[inline]
102    const fn zero() -> Self {
103        Self(0)
104    }
105
106    #[inline]
107    fn next_wrapping(self) -> Self {
108        Self(self.0.wrapping_add(1))
109    }
110}
111
112/// An in-memory thumbnail request queue + LRU cache.
113///
114/// This type is renderer-agnostic: the application is expected to:
115/// 1) call [`advance_frame`](Self::advance_frame) once per UI frame,
116/// 2) drive visibility by calling [`request_visible`](Self::request_visible) for entries that are
117///    currently visible,
118/// 3) drain requests via [`take_requests`](Self::take_requests), decode/upload thumbnails in user
119///    code, then call [`fulfill`](Self::fulfill),
120/// 4) destroy evicted GPU textures from [`take_pending_destroys`](Self::take_pending_destroys).
121#[derive(Clone, Debug)]
122pub struct ThumbnailCache {
123    /// Cache configuration.
124    pub config: ThumbnailCacheConfig,
125
126    frame_index: ThumbnailFrameIndex,
127    issued_this_frame: usize,
128    next_stamp: u64,
129
130    entries: HashMap<PathBuf, ThumbnailEntry>,
131    lru: VecDeque<(PathBuf, u64)>,
132    requests: VecDeque<ThumbnailRequest>,
133    pending_destroys: Vec<TextureId>,
134}
135
136/// Snapshot of thumbnail cache state, useful for UI indicators (e.g. "generation progress").
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
138pub struct ThumbnailStats {
139    /// Total number of tracked thumbnail entries (including ready/failed/in-flight).
140    pub total: usize,
141    /// Number of entries queued (requested but not yet decoded/uploaded).
142    pub queued: usize,
143    /// Number of entries currently marked as in-flight.
144    pub in_flight: usize,
145    /// Number of ready-to-display thumbnails.
146    pub ready: usize,
147    /// Number of failed thumbnails (decode/upload failure).
148    pub failed: usize,
149    /// Number of requests waiting in the decode/upload queue.
150    pub pending_requests: usize,
151    /// New requests issued in the current frame (budgeted by `max_new_requests_per_frame`).
152    pub issued_this_frame: usize,
153    /// Per-frame request budget.
154    pub max_new_requests_per_frame: usize,
155}
156
157impl Default for ThumbnailCache {
158    fn default() -> Self {
159        Self::new(ThumbnailCacheConfig::default())
160    }
161}
162
163impl ThumbnailCache {
164    /// Create a new cache with the given config.
165    pub fn new(config: ThumbnailCacheConfig) -> Self {
166        Self {
167            config,
168            frame_index: ThumbnailFrameIndex::zero(),
169            issued_this_frame: 0,
170            next_stamp: 1,
171            entries: HashMap::new(),
172            lru: VecDeque::new(),
173            requests: VecDeque::new(),
174            pending_destroys: Vec::new(),
175        }
176    }
177
178    /// Advance per-frame bookkeeping.
179    ///
180    /// Call this once per UI frame before issuing visibility requests.
181    pub fn advance_frame(&mut self) {
182        self.frame_index = self.frame_index.next_wrapping();
183        self.issued_this_frame = 0;
184    }
185
186    /// Returns the internal frame counter.
187    pub fn frame_index(&self) -> ThumbnailFrameIndex {
188        self.frame_index
189    }
190
191    /// Request a thumbnail for a visible file.
192    ///
193    /// If the thumbnail is not already cached, a request may be queued depending on the per-frame
194    /// request budget.
195    pub fn request_visible(&mut self, path: &Path, max_size: [u32; 2]) {
196        let key = path.to_path_buf();
197
198        if let Some(e) = self.entries.get(&key) {
199            // Touch existing entries so they are not evicted.
200            self.touch_existing(&key, e.lru_stamp);
201            return;
202        }
203
204        if self.issued_this_frame >= self.config.max_new_requests_per_frame {
205            return;
206        }
207        self.issued_this_frame += 1;
208
209        let stamp = self.alloc_stamp();
210        self.entries.insert(
211            key.clone(),
212            ThumbnailEntry {
213                state: ThumbnailState::Queued,
214                lru_stamp: stamp,
215            },
216        );
217        self.lru.push_back((key.clone(), stamp));
218        self.requests.push_back(ThumbnailRequest {
219            path: key,
220            max_size,
221        });
222        self.evict_to_fit();
223    }
224
225    /// Returns the cached texture id for a path, if available.
226    pub fn texture_id(&self, path: &Path) -> Option<TextureId> {
227        self.entries.get(path).and_then(|e| match &e.state {
228            ThumbnailState::Ready { texture_id } => Some(*texture_id),
229            _ => None,
230        })
231    }
232
233    /// Drain queued thumbnail requests.
234    ///
235    /// Drained requests are marked as "in flight" until [`fulfill`](Self::fulfill) is called.
236    pub fn take_requests(&mut self) -> Vec<ThumbnailRequest> {
237        let mut out = Vec::new();
238        while let Some(req) = self.requests.pop_front() {
239            if let Some(entry) = self.entries.get_mut(&req.path) {
240                if let ThumbnailState::Queued = entry.state {
241                    entry.state = ThumbnailState::InFlight;
242                }
243            }
244            out.push(req);
245        }
246        out
247    }
248
249    /// Complete a request with either a ready texture id or an error string.
250    ///
251    /// Returns any evicted texture ids that should be destroyed by the renderer.
252    pub fn fulfill(&mut self, path: &Path, result: Result<TextureId, String>, _max_size: [u32; 2]) {
253        let key = path.to_path_buf();
254        let stamp = self.alloc_stamp();
255        let state = match result {
256            Ok(texture_id) => ThumbnailState::Ready { texture_id },
257            Err(_message) => ThumbnailState::Failed,
258        };
259
260        if let Some(old) = self.entries.insert(
261            key.clone(),
262            ThumbnailEntry {
263                state,
264                lru_stamp: stamp,
265            },
266        ) {
267            if let ThumbnailState::Ready { texture_id } = old.state {
268                self.pending_destroys.push(texture_id);
269            }
270        }
271        self.lru.push_back((key, stamp));
272        self.evict_to_fit();
273    }
274
275    /// Complete a previously issued request.
276    pub fn fulfill_request(&mut self, req: &ThumbnailRequest, result: Result<TextureId, String>) {
277        self.fulfill(&req.path, result, req.max_size);
278    }
279
280    /// Process queued requests and perform pending destroys.
281    ///
282    /// This is a convenience helper for applications that want `dear-file-browser` to drive the
283    /// request lifecycle:
284    /// - Decodes queued requests using [`ThumbnailProvider`],
285    /// - Uploads them using [`ThumbnailRenderer`],
286    /// - Fulfills the cache, and
287    /// - Destroys evicted/replaced GPU textures via the renderer.
288    ///
289    /// If you prefer to manage decoding/upload externally, you can instead use
290    /// [`take_requests`](Self::take_requests), [`fulfill_request`](Self::fulfill_request), and
291    /// [`take_pending_destroys`](Self::take_pending_destroys).
292    pub fn maintain(&mut self, backend: &mut ThumbnailBackend<'_>) {
293        let requests = self.take_requests();
294        for req in &requests {
295            let decoded = backend.provider.decode(req);
296            let uploaded = match decoded {
297                Ok(img) => backend.renderer.upload_rgba8(&img),
298                Err(e) => Err(e),
299            };
300            self.fulfill_request(req, uploaded);
301        }
302
303        let destroys = self.take_pending_destroys();
304        for tex in destroys {
305            backend.renderer.destroy(tex);
306        }
307    }
308
309    /// Drain GPU textures that should be destroyed after eviction or replacement.
310    pub fn take_pending_destroys(&mut self) -> Vec<TextureId> {
311        std::mem::take(&mut self.pending_destroys)
312    }
313
314    /// Returns a snapshot of the cache state for UI display.
315    pub fn stats(&self) -> ThumbnailStats {
316        let mut stats = ThumbnailStats {
317            total: self.entries.len(),
318            pending_requests: self.requests.len(),
319            issued_this_frame: self.issued_this_frame,
320            max_new_requests_per_frame: self.config.max_new_requests_per_frame,
321            ..ThumbnailStats::default()
322        };
323
324        for entry in self.entries.values() {
325            match entry.state {
326                ThumbnailState::Queued => stats.queued += 1,
327                ThumbnailState::InFlight => stats.in_flight += 1,
328                ThumbnailState::Ready { .. } => stats.ready += 1,
329                ThumbnailState::Failed => stats.failed += 1,
330            }
331        }
332
333        stats
334    }
335
336    fn alloc_stamp(&mut self) -> u64 {
337        let s = self.next_stamp;
338        self.next_stamp = self.next_stamp.wrapping_add(1);
339        s
340    }
341
342    fn touch_existing(&mut self, key: &PathBuf, old_stamp: u64) {
343        let stamp = self.alloc_stamp();
344        if let Some(e) = self.entries.get_mut(key) {
345            e.lru_stamp = stamp;
346        }
347        self.lru.push_back((key.clone(), stamp));
348
349        // Avoid unbounded growth if the user constantly hovers a single entry.
350        // This is a soft heuristic: clean a little when the queue gets too large.
351        if self.lru.len() > self.config.max_entries.saturating_mul(8).max(64) {
352            self.compact_lru(old_stamp);
353        }
354    }
355
356    fn compact_lru(&mut self, _hint_stamp: u64) {
357        // Drop stale LRU nodes from the front.
358        let target = self.config.max_entries.saturating_mul(4).max(32);
359        while self.lru.len() > target {
360            let Some((k, s)) = self.lru.pop_front() else {
361                break;
362            };
363            let keep = self.entries.get(&k).is_some_and(|e| e.lru_stamp == s);
364            if keep {
365                self.lru.push_front((k, s));
366                break;
367            }
368        }
369    }
370
371    fn evict_to_fit(&mut self) {
372        while self.entries.len() > self.config.max_entries {
373            let Some((key, stamp)) = self.lru.pop_front() else {
374                break;
375            };
376            let Some(entry) = self.entries.get(&key) else {
377                continue;
378            };
379            if entry.lru_stamp != stamp {
380                continue;
381            }
382            let removed = self.entries.remove(&key);
383            if let Some(removed) = removed {
384                if let ThumbnailState::Ready { texture_id } = removed.state {
385                    self.pending_destroys.push(texture_id);
386                }
387            }
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[derive(Default)]
397    struct DummyProvider;
398
399    impl ThumbnailProvider for DummyProvider {
400        fn decode(&mut self, _req: &ThumbnailRequest) -> Result<DecodedRgbaImage, String> {
401            Ok(DecodedRgbaImage {
402                width: 1,
403                height: 1,
404                rgba: vec![255, 0, 0, 255],
405            })
406        }
407    }
408
409    #[derive(Default)]
410    struct DummyRenderer {
411        next: u64,
412        destroyed: Vec<TextureId>,
413    }
414
415    impl ThumbnailRenderer for DummyRenderer {
416        fn upload_rgba8(&mut self, _image: &DecodedRgbaImage) -> Result<TextureId, String> {
417            self.next += 1;
418            Ok(TextureId::new(self.next))
419        }
420
421        fn destroy(&mut self, texture_id: TextureId) {
422            self.destroyed.push(texture_id);
423        }
424    }
425
426    #[test]
427    fn respects_request_budget_per_frame() {
428        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
429            max_entries: 16,
430            max_new_requests_per_frame: 2,
431        });
432        c.advance_frame();
433        c.request_visible(Path::new("/a.png"), [64, 64]);
434        c.request_visible(Path::new("/b.png"), [64, 64]);
435        c.request_visible(Path::new("/c.png"), [64, 64]);
436        let reqs = c.take_requests();
437        assert_eq!(reqs.len(), 2);
438    }
439
440    #[test]
441    fn thumbnail_frame_index_is_typed_and_advances() {
442        let mut c = ThumbnailCache::new(ThumbnailCacheConfig::default());
443
444        assert_eq!(c.frame_index(), ThumbnailFrameIndex::new(0));
445
446        c.advance_frame();
447
448        assert_eq!(c.frame_index(), ThumbnailFrameIndex::new(1));
449    }
450
451    #[test]
452    fn evicts_lru_and_collects_pending_destroys() {
453        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
454            max_entries: 1,
455            max_new_requests_per_frame: 10,
456        });
457        c.advance_frame();
458        c.request_visible(Path::new("/a.png"), [64, 64]);
459        c.take_requests();
460        c.fulfill(Path::new("/a.png"), Ok(TextureId::new(1)), [64, 64]);
461
462        c.advance_frame();
463        c.request_visible(Path::new("/b.png"), [64, 64]);
464        c.take_requests();
465        c.fulfill(Path::new("/b.png"), Ok(TextureId::new(2)), [64, 64]);
466
467        let destroyed = c.take_pending_destroys();
468        assert!(destroyed.contains(&TextureId::new(1)));
469        assert!(c.texture_id(Path::new("/a.png")).is_none());
470        assert_eq!(c.texture_id(Path::new("/b.png")), Some(TextureId::new(2)));
471    }
472
473    #[test]
474    fn maintain_decodes_uploads_and_destroys() {
475        let mut c = ThumbnailCache::new(ThumbnailCacheConfig {
476            max_entries: 1,
477            max_new_requests_per_frame: 10,
478        });
479        let mut provider = DummyProvider::default();
480        let mut renderer = DummyRenderer::default();
481        let mut backend = ThumbnailBackend {
482            provider: &mut provider,
483            renderer: &mut renderer,
484        };
485
486        c.advance_frame();
487        c.request_visible(Path::new("/a.png"), [64, 64]);
488        c.maintain(&mut backend);
489        assert!(c.texture_id(Path::new("/a.png")).is_some());
490
491        c.advance_frame();
492        c.request_visible(Path::new("/b.png"), [64, 64]);
493        c.maintain(&mut backend);
494        assert!(renderer.destroyed.iter().any(|t| t == &TextureId::new(1)));
495    }
496}