Skip to main content

ftui_widgets/
cached.rs

1#![forbid(unsafe_code)]
2
3//! Cached widget wrapper with manual invalidation and optional cache keys.
4
5use crate::{StatefulWidget, Widget};
6use ftui_core::geometry::Rect;
7use ftui_render::buffer::Buffer;
8use ftui_render::cell::Cell;
9use ftui_render::frame::Frame;
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12use std::mem::size_of;
13
14#[cfg(feature = "tracing")]
15use tracing::{debug, trace};
16
17/// Cache key strategy for a widget.
18pub trait CacheKey<W> {
19    /// Return a cache key for the widget, or `None` to disable key-based invalidation.
20    fn cache_key(&self, widget: &W) -> Option<u64>;
21}
22
23/// No cache key: invalidation is manual only.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct NoCacheKey;
26
27impl<W> CacheKey<W> for NoCacheKey {
28    fn cache_key(&self, _widget: &W) -> Option<u64> {
29        None
30    }
31}
32
33/// Hash-based cache key using `std::hash::Hash`.
34#[derive(Debug, Clone, Copy, Default)]
35pub struct HashKey;
36
37impl<W: Hash> CacheKey<W> for HashKey {
38    fn cache_key(&self, widget: &W) -> Option<u64> {
39        Some(hash_value(widget))
40    }
41}
42
43/// Custom key function wrapper.
44#[derive(Debug, Clone, Copy)]
45pub struct FnKey<F>(pub F);
46
47impl<W, F: Fn(&W) -> u64> CacheKey<W> for FnKey<F> {
48    fn cache_key(&self, widget: &W) -> Option<u64> {
49        Some((self.0)(widget))
50    }
51}
52
53/// Cached widget wrapper.
54///
55/// Use with [`CachedWidgetState`] via the [`StatefulWidget`] trait.
56pub struct CachedWidget<W, K = NoCacheKey> {
57    inner: W,
58    key: K,
59}
60
61/// Internal cached buffer.
62#[derive(Debug, Clone)]
63struct CachedBuffer {
64    buffer: Buffer,
65}
66
67/// State for a cached widget.
68#[derive(Debug, Clone, Default)]
69pub struct CachedWidgetState {
70    cache: Option<CachedBuffer>,
71    last_area: Option<Rect>,
72    dirty: bool,
73    last_key: Option<u64>,
74}
75
76#[cfg(feature = "tracing")]
77#[derive(Debug, Clone, Copy)]
78enum CacheMissReason {
79    Empty,
80    Dirty,
81    AreaChanged,
82    KeyChanged,
83}
84
85impl<W> CachedWidget<W, NoCacheKey> {
86    /// Create a cached widget with manual invalidation.
87    pub fn new(widget: W) -> Self {
88        Self {
89            inner: widget,
90            key: NoCacheKey,
91        }
92    }
93}
94
95impl<W: Hash> CachedWidget<W, HashKey> {
96    /// Create a cached widget using `Hash` as the cache key.
97    pub fn with_hash(widget: W) -> Self {
98        Self {
99            inner: widget,
100            key: HashKey,
101        }
102    }
103}
104
105impl<W, F: Fn(&W) -> u64> CachedWidget<W, FnKey<F>> {
106    /// Create a cached widget with a custom cache key function.
107    pub fn with_key(widget: W, key_fn: F) -> Self {
108        Self {
109            inner: widget,
110            key: FnKey(key_fn),
111        }
112    }
113}
114
115impl<W, K> CachedWidget<W, K> {
116    /// Access the inner widget.
117    pub fn inner(&self) -> &W {
118        &self.inner
119    }
120
121    /// Mutable access to the inner widget.
122    pub fn inner_mut(&mut self) -> &mut W {
123        &mut self.inner
124    }
125
126    /// Consume the wrapper and return the inner widget.
127    pub fn into_inner(self) -> W {
128        self.inner
129    }
130
131    /// Mark the cache as dirty (forces a re-render on next draw).
132    pub fn mark_dirty(&self, state: &mut CachedWidgetState) {
133        state.mark_dirty();
134        #[cfg(feature = "tracing")]
135        debug!(
136            widget = std::any::type_name::<W>(),
137            "Cache invalidated via mark_dirty()"
138        );
139    }
140}
141
142impl CachedWidgetState {
143    /// Create a new empty cache state.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Mark the cache dirty without logging.
149    pub fn mark_dirty(&mut self) {
150        self.dirty = true;
151    }
152
153    /// Drop cached buffer to free memory.
154    pub fn clear_cache(&mut self) {
155        self.cache = None;
156    }
157
158    /// Approximate cache size in bytes.
159    pub fn cache_size_bytes(&self) -> usize {
160        self.cache
161            .as_ref()
162            .map(|cache| cache.buffer.len() * size_of::<Cell>())
163            .unwrap_or(0)
164    }
165}
166
167impl<W: Widget, K: CacheKey<W>> StatefulWidget for CachedWidget<W, K> {
168    type State = CachedWidgetState;
169
170    fn render(&self, area: Rect, frame: &mut Frame, state: &mut CachedWidgetState) {
171        #[cfg(feature = "tracing")]
172        let _span = tracing::debug_span!(
173            "widget_render",
174            widget = "CachedWidget",
175            x = area.x,
176            y = area.y,
177            w = area.width,
178            h = area.height
179        )
180        .entered();
181
182        if area.is_empty() {
183            state.clear_cache();
184            state.last_area = Some(area);
185            return;
186        }
187
188        let key = self.key.cache_key(&self.inner);
189        let area_changed = state.last_area != Some(area);
190        let key_changed = key != state.last_key;
191
192        let needs_render = state.cache.is_none() || state.dirty || area_changed || key_changed;
193
194        #[cfg(feature = "tracing")]
195        let reason = if state.cache.is_none() {
196            CacheMissReason::Empty
197        } else if state.dirty {
198            CacheMissReason::Dirty
199        } else if area_changed {
200            CacheMissReason::AreaChanged
201        } else {
202            CacheMissReason::KeyChanged
203        };
204
205        if needs_render {
206            let local_area = Rect::from_size(area.width, area.height);
207            // Create a temporary frame for the inner widget
208            let mut cache_frame = Frame::new(area.width, area.height, frame.pool);
209            self.inner.render(local_area, &mut cache_frame);
210            // Extract the buffer from the frame for caching
211            state.cache = Some(CachedBuffer {
212                buffer: cache_frame.buffer,
213            });
214            state.last_area = Some(area);
215            state.dirty = false;
216            state.last_key = key;
217
218            #[cfg(feature = "tracing")]
219            debug!(
220                widget = std::any::type_name::<W>(),
221                reason = ?reason,
222                "Cache miss, re-rendering"
223            );
224        } else {
225            #[cfg(feature = "tracing")]
226            trace!(
227                widget = std::any::type_name::<W>(),
228                "Cache hit, using cached buffer"
229            );
230        }
231
232        if let Some(cache) = &state.cache {
233            let src_rect = Rect::from_size(area.width, area.height);
234            frame
235                .buffer
236                .copy_from(&cache.buffer, src_rect, area.x, area.y);
237        }
238    }
239}
240
241fn hash_value<T: Hash>(value: &T) -> u64 {
242    let mut hasher = DefaultHasher::new();
243    value.hash(&mut hasher);
244    hasher.finish()
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use ftui_render::grapheme_pool::GraphemePool;
251    use std::cell::Cell as CounterCell;
252    use std::rc::Rc;
253
254    #[derive(Debug, Clone)]
255    struct CountWidget {
256        count: Rc<CounterCell<u32>>,
257    }
258
259    impl Widget for CountWidget {
260        fn render(&self, area: Rect, frame: &mut Frame) {
261            self.count.set(self.count.get() + 1);
262            if !area.is_empty() {
263                frame.buffer.set(area.x, area.y, Cell::from_char('x'));
264            }
265        }
266    }
267
268    #[derive(Debug, Clone)]
269    struct KeyWidget {
270        count: Rc<CounterCell<u32>>,
271        key: Rc<CounterCell<u64>>,
272    }
273
274    impl Widget for KeyWidget {
275        fn render(&self, area: Rect, frame: &mut Frame) {
276            self.count.set(self.count.get() + 1);
277            if !area.is_empty() {
278                frame.buffer.set(area.x, area.y, Cell::from_char('k'));
279            }
280        }
281    }
282
283    #[test]
284    fn cache_hit_skips_rerender() {
285        let count = Rc::new(CounterCell::new(0));
286        let widget = CountWidget {
287            count: count.clone(),
288        };
289        let cached = CachedWidget::new(widget);
290        let mut state = CachedWidgetState::default();
291        let mut pool = GraphemePool::new();
292        let mut frame = Frame::new(5, 5, &mut pool);
293        let area = Rect::new(1, 1, 3, 3);
294
295        cached.render(area, &mut frame, &mut state);
296        cached.render(area, &mut frame, &mut state);
297
298        assert_eq!(count.get(), 1);
299    }
300
301    #[test]
302    fn area_change_forces_rerender() {
303        let count = Rc::new(CounterCell::new(0));
304        let widget = CountWidget {
305            count: count.clone(),
306        };
307        let cached = CachedWidget::new(widget);
308        let mut state = CachedWidgetState::default();
309        let mut pool = GraphemePool::new();
310        let mut frame = Frame::new(6, 6, &mut pool);
311
312        cached.render(Rect::new(0, 0, 3, 3), &mut frame, &mut state);
313        cached.render(Rect::new(1, 1, 3, 3), &mut frame, &mut state);
314
315        assert_eq!(count.get(), 2);
316    }
317
318    #[test]
319    fn mark_dirty_forces_rerender() {
320        let count = Rc::new(CounterCell::new(0));
321        let widget = CountWidget {
322            count: count.clone(),
323        };
324        let cached = CachedWidget::new(widget);
325        let mut state = CachedWidgetState::default();
326        let mut pool = GraphemePool::new();
327        let mut frame = Frame::new(5, 5, &mut pool);
328        let area = Rect::new(0, 0, 3, 3);
329
330        cached.render(area, &mut frame, &mut state);
331        cached.mark_dirty(&mut state);
332        cached.render(area, &mut frame, &mut state);
333
334        assert_eq!(count.get(), 2);
335    }
336
337    #[test]
338    fn key_change_forces_rerender() {
339        let count = Rc::new(CounterCell::new(0));
340        let key = Rc::new(CounterCell::new(1));
341        let widget = KeyWidget {
342            count: count.clone(),
343            key: key.clone(),
344        };
345        let cached = CachedWidget::with_key(widget, |w| w.key.get());
346        let mut state = CachedWidgetState::default();
347        let mut pool = GraphemePool::new();
348        let mut frame = Frame::new(5, 5, &mut pool);
349        let area = Rect::new(0, 0, 3, 3);
350
351        cached.render(area, &mut frame, &mut state);
352        key.set(2);
353        cached.render(area, &mut frame, &mut state);
354
355        assert_eq!(count.get(), 2);
356    }
357
358    #[test]
359    fn empty_area_clears_cache() {
360        let count = Rc::new(CounterCell::new(0));
361        let widget = CountWidget {
362            count: count.clone(),
363        };
364        let cached = CachedWidget::new(widget);
365        let mut state = CachedWidgetState::default();
366        let mut pool = GraphemePool::new();
367        let mut frame = Frame::new(5, 5, &mut pool);
368
369        // First render populates cache
370        cached.render(Rect::new(0, 0, 3, 3), &mut frame, &mut state);
371        assert!(state.cache.is_some());
372
373        // Empty area should clear cache
374        cached.render(Rect::new(0, 0, 0, 0), &mut frame, &mut state);
375        assert!(state.cache.is_none());
376        assert_eq!(count.get(), 1);
377    }
378
379    #[test]
380    fn cache_size_bytes_empty() {
381        let state = CachedWidgetState::new();
382        assert_eq!(state.cache_size_bytes(), 0);
383    }
384
385    #[test]
386    fn cache_size_bytes_after_render() {
387        let count = Rc::new(CounterCell::new(0));
388        let widget = CountWidget {
389            count: count.clone(),
390        };
391        let cached = CachedWidget::new(widget);
392        let mut state = CachedWidgetState::new();
393        let mut pool = GraphemePool::new();
394        let mut frame = Frame::new(5, 5, &mut pool);
395
396        cached.render(Rect::new(0, 0, 3, 3), &mut frame, &mut state);
397        assert!(state.cache_size_bytes() > 0);
398        assert_eq!(state.cache_size_bytes(), 9 * std::mem::size_of::<Cell>());
399    }
400
401    #[test]
402    fn clear_cache_drops_buffer() {
403        let count = Rc::new(CounterCell::new(0));
404        let widget = CountWidget {
405            count: count.clone(),
406        };
407        let cached = CachedWidget::new(widget);
408        let mut state = CachedWidgetState::new();
409        let mut pool = GraphemePool::new();
410        let mut frame = Frame::new(5, 5, &mut pool);
411
412        cached.render(Rect::new(0, 0, 3, 3), &mut frame, &mut state);
413        assert!(state.cache_size_bytes() > 0);
414
415        state.clear_cache();
416        assert_eq!(state.cache_size_bytes(), 0);
417    }
418
419    #[test]
420    fn mark_dirty_then_clear_on_render() {
421        let count = Rc::new(CounterCell::new(0));
422        let widget = CountWidget {
423            count: count.clone(),
424        };
425        let cached = CachedWidget::new(widget);
426        let mut state = CachedWidgetState::new();
427        let mut pool = GraphemePool::new();
428        let mut frame = Frame::new(5, 5, &mut pool);
429        let area = Rect::new(0, 0, 3, 3);
430
431        cached.render(area, &mut frame, &mut state);
432        assert_eq!(count.get(), 1);
433
434        state.mark_dirty();
435        assert!(state.dirty);
436
437        cached.render(area, &mut frame, &mut state);
438        assert_eq!(count.get(), 2);
439        assert!(!state.dirty);
440    }
441
442    #[test]
443    fn no_cache_key_returns_none() {
444        let key = NoCacheKey;
445        assert_eq!(CacheKey::<u32>::cache_key(&key, &42), None);
446    }
447
448    #[test]
449    fn hash_key_returns_some() {
450        let key = HashKey;
451        let result = CacheKey::<String>::cache_key(&key, &"hello".to_string());
452        assert!(result.is_some());
453    }
454
455    #[test]
456    fn hash_key_same_value_same_key() {
457        let key = HashKey;
458        let a = CacheKey::<u64>::cache_key(&key, &42);
459        let b = CacheKey::<u64>::cache_key(&key, &42);
460        assert_eq!(a, b);
461    }
462
463    #[test]
464    fn hash_key_different_value_different_key() {
465        let key = HashKey;
466        let a = CacheKey::<u64>::cache_key(&key, &1);
467        let b = CacheKey::<u64>::cache_key(&key, &2);
468        assert_ne!(a, b);
469    }
470
471    #[test]
472    fn fn_key_custom_function() {
473        let key = FnKey(|x: &u32| (*x as u64) * 100);
474        assert_eq!(CacheKey::<u32>::cache_key(&key, &5), Some(500));
475        assert_eq!(CacheKey::<u32>::cache_key(&key, &0), Some(0));
476    }
477
478    #[test]
479    fn inner_accessors() {
480        let count = Rc::new(CounterCell::new(0));
481        let widget = CountWidget {
482            count: count.clone(),
483        };
484        let mut cached = CachedWidget::new(widget);
485
486        assert_eq!(cached.inner().count.get(), 0);
487
488        cached.inner_mut().count.set(5);
489        assert_eq!(count.get(), 5);
490
491        let inner = cached.into_inner();
492        assert_eq!(inner.count.get(), 5);
493    }
494
495    #[test]
496    fn cached_content_matches_uncached() {
497        let count = Rc::new(CounterCell::new(0));
498        let widget = CountWidget {
499            count: count.clone(),
500        };
501        let cached = CachedWidget::new(widget.clone());
502        let mut state = CachedWidgetState::new();
503        let area = Rect::new(0, 0, 3, 3);
504
505        let mut pool_cached = GraphemePool::new();
506        let mut frame_cached = Frame::new(3, 3, &mut pool_cached);
507        cached.render(area, &mut frame_cached, &mut state);
508
509        let mut pool_direct = GraphemePool::new();
510        let mut frame_direct = Frame::new(3, 3, &mut pool_direct);
511        widget.render(area, &mut frame_direct);
512
513        assert_eq!(
514            frame_cached.buffer.get(0, 0).unwrap().content.as_char(),
515            frame_direct.buffer.get(0, 0).unwrap().content.as_char()
516        );
517    }
518
519    #[test]
520    fn multiple_cache_hits_never_rerender() {
521        let count = Rc::new(CounterCell::new(0));
522        let widget = CountWidget {
523            count: count.clone(),
524        };
525        let cached = CachedWidget::new(widget);
526        let mut state = CachedWidgetState::new();
527        let mut pool = GraphemePool::new();
528        let mut frame = Frame::new(5, 5, &mut pool);
529        let area = Rect::new(0, 0, 3, 3);
530
531        for _ in 0..10 {
532            cached.render(area, &mut frame, &mut state);
533        }
534        assert_eq!(count.get(), 1);
535    }
536
537    #[test]
538    fn with_hash_uses_hash_key() {
539        // with_hash requires W: Hash, so use a simple hashable wrapper
540        #[derive(Debug, Clone, Hash)]
541        struct HashableLabel(String);
542
543        impl Widget for HashableLabel {
544            fn render(&self, area: Rect, frame: &mut Frame) {
545                if !area.is_empty() {
546                    frame.buffer.set(area.x, area.y, Cell::from_char('h'));
547                }
548            }
549        }
550
551        let widget = HashableLabel("hello".to_string());
552        let cached = CachedWidget::with_hash(widget);
553        let mut state = CachedWidgetState::new();
554        let mut pool = GraphemePool::new();
555        let mut frame = Frame::new(5, 5, &mut pool);
556        let area = Rect::new(0, 0, 3, 3);
557
558        // First render: miss
559        cached.render(area, &mut frame, &mut state);
560        assert!(state.cache.is_some());
561
562        // Same hash => hit (no key change)
563        cached.render(area, &mut frame, &mut state);
564        // Verify the cache key was set
565        assert!(state.last_key.is_some());
566    }
567
568    #[test]
569    fn no_cache_key_default() {
570        let key = NoCacheKey;
571        assert_eq!(CacheKey::<u32>::cache_key(&key, &100), None);
572    }
573
574    #[test]
575    fn hash_key_default() {
576        let key = HashKey;
577        let result = CacheKey::<u32>::cache_key(&key, &42);
578        assert!(result.is_some());
579    }
580
581    #[test]
582    fn cached_widget_state_new_equals_default() {
583        let a = CachedWidgetState::new();
584        let b = CachedWidgetState::default();
585        assert_eq!(a.cache_size_bytes(), b.cache_size_bytes());
586        assert!(!a.dirty);
587        assert!(!b.dirty);
588    }
589
590    #[test]
591    fn same_key_no_rerender() {
592        let count = Rc::new(CounterCell::new(0));
593        let key = Rc::new(CounterCell::new(42));
594        let widget = KeyWidget {
595            count: count.clone(),
596            key: key.clone(),
597        };
598        let cached = CachedWidget::with_key(widget, |w| w.key.get());
599        let mut state = CachedWidgetState::new();
600        let mut pool = GraphemePool::new();
601        let mut frame = Frame::new(5, 5, &mut pool);
602        let area = Rect::new(0, 0, 3, 3);
603
604        cached.render(area, &mut frame, &mut state);
605        cached.render(area, &mut frame, &mut state);
606        cached.render(area, &mut frame, &mut state);
607
608        assert_eq!(count.get(), 1);
609    }
610}