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