Skip to main content

ftui_widgets/
measure_cache.rs

1//! Measure cache for memoizing widget `measure()` results.
2//!
3//! This module provides [`MeasureCache`] which caches [`SizeConstraints`] returned by
4//! [`MeasurableWidget::measure()`] to avoid redundant computation during layout passes.
5//!
6//! # Overview
7//!
8//! During a single layout pass, the same widget may be queried multiple times with the
9//! same available size. Complex widgets like Tables with many cells can be expensive
10//! to measure. The cache eliminates this redundancy.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_core::geometry::Size;
16//! use ftui_widgets::{MeasureCache, WidgetId, SizeConstraints};
17//!
18//! let mut cache = MeasureCache::new(100);
19//!
20//! // First call computes the value
21//! let constraints = cache.get_or_compute(
22//!     WidgetId::from_ptr(&my_widget),
23//!     Size::new(80, 24),
24//!     || my_widget.measure(Size::new(80, 24)),
25//! );
26//!
27//! // Second call with same key returns cached value
28//! let cached = cache.get_or_compute(
29//!     WidgetId::from_ptr(&my_widget),
30//!     Size::new(80, 24),
31//!     || SizeConstraints::default(),
32//! );
33//! ```
34//!
35//! # Invalidation Strategies
36//!
37//! ## Generation-Based (Primary)
38//!
39//! Call [`MeasureCache::invalidate_all()`] after any state change affecting layout:
40//!
41//! ```ignore
42//! match msg {
43//!     Msg::DataChanged(data) => {
44//!         self.data = data;
45//!         self.measure_cache.invalidate_all();
46//!     }
47//!     Msg::Resize(_) => {
48//!         // Size is part of cache key, no invalidation needed!
49//!     }
50//! }
51//! ```
52//!
53//! ## Widget-Specific Invalidation
54//!
55//! When only one widget's content changes:
56//!
57//! ```ignore
58//! match msg {
59//!     Msg::ListItemAdded(item) => {
60//!         self.list.push(item);
61//!         self.measure_cache.invalidate_widget(WidgetId::from_hash(&"list"));
62//!     }
63//! }
64//! ```
65//!
66//! ## Content-Addressed (Automatic)
67//!
68//! Use content hash as widget ID for automatic invalidation:
69//!
70//! ```ignore
71//! impl MeasurableWidget for Paragraph<'_> {
72//!     fn widget_id(&self) -> WidgetId {
73//!         WidgetId::from_hash(&self.text)
74//!     }
75//! }
76//! ```
77//!
78//! # Cache Eviction
79//!
80//! The cache uses LRU (Least Recently Used) eviction when at capacity.
81//! Access count tracks usage; least-accessed entries are evicted first.
82
83use std::collections::HashMap;
84use std::hash::{DefaultHasher, Hash, Hasher};
85
86use ftui_core::geometry::Size;
87
88use crate::measurable::SizeConstraints;
89
90/// Unique identifier for a widget instance.
91///
92/// Used as part of the cache key to distinguish between different widgets.
93///
94/// # Creating WidgetIds
95///
96/// ## From pointer (stable for widget lifetime):
97/// ```ignore
98/// WidgetId::from_ptr(&my_widget)
99/// ```
100///
101/// ## From content hash (stable across recreations):
102/// ```ignore
103/// WidgetId::from_hash(&my_widget.text)
104/// ```
105///
106/// ## From arbitrary u64:
107/// ```ignore
108/// WidgetId(42)
109/// ```
110#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
111pub struct WidgetId(pub u64);
112
113impl WidgetId {
114    /// Create a WidgetId from a memory address.
115    ///
116    /// Stable for the lifetime of the widget. Use when the widget instance
117    /// persists across multiple layout passes.
118    ///
119    /// # Note
120    ///
121    /// If the widget is recreated (e.g., in a loop), the pointer will change.
122    /// For such cases, prefer [`WidgetId::from_hash`].
123    #[inline]
124    pub fn from_ptr<T>(ptr: &T) -> Self {
125        Self(ptr as *const T as u64)
126    }
127
128    /// Create a WidgetId from a content hash.
129    ///
130    /// Stable across widget recreations as long as content is the same.
131    /// Use when widgets are ephemeral (created each frame) but content is stable.
132    #[inline]
133    pub fn from_hash<T: Hash>(value: &T) -> Self {
134        let mut hasher = DefaultHasher::new();
135        value.hash(&mut hasher);
136        Self(hasher.finish())
137    }
138}
139
140/// Cache key combining widget identity and available size.
141#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
142struct CacheKey {
143    widget_id: WidgetId,
144    available: Size,
145}
146
147/// Cached measurement result with metadata for eviction.
148#[derive(Clone, Debug)]
149struct CacheEntry {
150    /// The cached size constraints.
151    constraints: SizeConstraints,
152    /// Generation when this entry was created/updated.
153    generation: u64,
154    /// Access count for LRU eviction.
155    access_count: u32,
156}
157
158/// Statistics about cache performance.
159#[derive(Debug, Clone, Default)]
160pub struct CacheStats {
161    /// Number of entries currently in the cache.
162    pub entries: usize,
163    /// Total cache hits since creation or last reset.
164    pub hits: u64,
165    /// Total cache misses since creation or last reset.
166    pub misses: u64,
167    /// Hit rate as a fraction (0.0 to 1.0).
168    pub hit_rate: f64,
169}
170
171/// Thread-local cache for widget measure results.
172///
173/// Caches [`SizeConstraints`] returned by `MeasurableWidget::measure()` to
174/// avoid redundant computation during layout passes.
175///
176/// # Capacity
177///
178/// The cache has a fixed maximum capacity. When full, the least recently used
179/// entries are evicted to make room for new ones.
180///
181/// # Generation-Based Invalidation
182///
183/// Each entry is tagged with a generation number. Calling [`invalidate_all()`]
184/// bumps the generation, making all existing entries stale. Stale entries are
185/// treated as cache misses and will be recomputed on next access.
186///
187/// [`invalidate_all()`]: MeasureCache::invalidate_all
188#[derive(Debug)]
189pub struct MeasureCache {
190    entries: HashMap<CacheKey, CacheEntry>,
191    generation: u64,
192    max_entries: usize,
193    hits: u64,
194    misses: u64,
195}
196
197impl MeasureCache {
198    /// Create a new cache with the specified maximum capacity.
199    ///
200    /// # Arguments
201    ///
202    /// * `max_entries` - Maximum number of entries before LRU eviction occurs.
203    ///   A typical value is 100-1000 depending on widget complexity.
204    ///
205    /// # Example
206    ///
207    /// ```
208    /// use ftui_widgets::MeasureCache;
209    /// let cache = MeasureCache::new(256);
210    /// ```
211    #[inline]
212    pub fn new(max_entries: usize) -> Self {
213        Self {
214            entries: HashMap::with_capacity(max_entries),
215            generation: 0,
216            max_entries,
217            hits: 0,
218            misses: 0,
219        }
220    }
221
222    /// Get cached result or compute and cache a new one.
223    ///
224    /// If a valid (same generation) cache entry exists for the given widget ID
225    /// and available size, returns it immediately. Otherwise, calls the `compute`
226    /// closure, caches the result, and returns it.
227    ///
228    /// # Arguments
229    ///
230    /// * `widget_id` - Unique identifier for the widget instance
231    /// * `available` - Available space for the measurement
232    /// * `compute` - Closure to compute the constraints if not cached
233    ///
234    /// # Example
235    ///
236    /// ```ignore
237    /// let constraints = cache.get_or_compute(
238    ///     WidgetId::from_ptr(&paragraph),
239    ///     Size::new(80, 24),
240    ///     || paragraph.measure(Size::new(80, 24)),
241    /// );
242    /// ```
243    pub fn get_or_compute<F>(
244        &mut self,
245        widget_id: WidgetId,
246        available: Size,
247        compute: F,
248    ) -> SizeConstraints
249    where
250        F: FnOnce() -> SizeConstraints,
251    {
252        let key = CacheKey {
253            widget_id,
254            available,
255        };
256
257        // Check for existing valid entry
258        if let Some(entry) = self.entries.get_mut(&key)
259            && entry.generation == self.generation
260        {
261            self.hits += 1;
262            entry.access_count = entry.access_count.saturating_add(1);
263            return entry.constraints;
264        }
265
266        // Cache miss - compute the value
267        self.misses += 1;
268        let constraints = compute();
269
270        // Evict if at capacity
271        if self.entries.len() >= self.max_entries {
272            self.evict_lru();
273        }
274
275        // Insert new entry
276        self.entries.insert(
277            key,
278            CacheEntry {
279                constraints,
280                generation: self.generation,
281                access_count: 1,
282            },
283        );
284
285        constraints
286    }
287
288    /// Invalidate all entries by bumping the generation.
289    ///
290    /// Existing entries become stale and will be recomputed on next access.
291    /// This is an O(1) operation - entries are not immediately removed.
292    ///
293    /// # When to Call
294    ///
295    /// Call this after any state change that affects widget measurements:
296    /// - Model data changes
297    /// - Font/theme changes (if they affect sizing)
298    /// - Locale changes (if they affect text)
299    ///
300    /// # Note
301    ///
302    /// Resize events don't require invalidation because the available size
303    /// is part of the cache key.
304    #[inline]
305    pub fn invalidate_all(&mut self) {
306        self.generation = self.generation.wrapping_add(1);
307    }
308
309    /// Invalidate entries for a specific widget.
310    ///
311    /// Removes all cache entries associated with the given widget ID.
312    /// Use this for targeted invalidation when only one widget's content changes.
313    ///
314    /// # Arguments
315    ///
316    /// * `widget_id` - The widget whose entries should be invalidated
317    pub fn invalidate_widget(&mut self, widget_id: WidgetId) {
318        self.entries.retain(|k, _| k.widget_id != widget_id);
319    }
320
321    /// Get current cache statistics.
322    ///
323    /// Returns hit/miss counts and the current hit rate.
324    ///
325    /// # Example
326    ///
327    /// ```
328    /// use ftui_widgets::MeasureCache;
329    /// let cache = MeasureCache::new(100);
330    /// let stats = cache.stats();
331    /// println!("Hit rate: {:.1}%", stats.hit_rate * 100.0);
332    /// ```
333    pub fn stats(&self) -> CacheStats {
334        let total = self.hits + self.misses;
335        CacheStats {
336            entries: self.entries.len(),
337            hits: self.hits,
338            misses: self.misses,
339            hit_rate: if total > 0 {
340                self.hits as f64 / total as f64
341            } else {
342                0.0
343            },
344        }
345    }
346
347    /// Reset statistics counters to zero.
348    ///
349    /// Useful for measuring hit rate over a specific period (e.g., per frame).
350    #[inline]
351    pub fn reset_stats(&mut self) {
352        self.hits = 0;
353        self.misses = 0;
354    }
355
356    /// Clear all entries from the cache.
357    ///
358    /// Unlike [`invalidate_all()`], this immediately frees memory.
359    /// Use when transitioning to a completely different view.
360    ///
361    /// [`invalidate_all()`]: MeasureCache::invalidate_all
362    #[inline]
363    pub fn clear(&mut self) {
364        self.entries.clear();
365        self.generation = self.generation.wrapping_add(1);
366    }
367
368    /// Returns the current number of entries in the cache.
369    #[inline]
370    pub fn len(&self) -> usize {
371        self.entries.len()
372    }
373
374    /// Returns true if the cache is empty.
375    #[inline]
376    pub fn is_empty(&self) -> bool {
377        self.entries.is_empty()
378    }
379
380    /// Returns the maximum capacity of the cache.
381    #[inline]
382    pub fn capacity(&self) -> usize {
383        self.max_entries
384    }
385
386    /// Evict the least recently used entry.
387    fn evict_lru(&mut self) {
388        // Find entry with lowest access_count
389        if let Some(key) = self
390            .entries
391            .iter()
392            .min_by_key(|(_, e)| e.access_count)
393            .map(|(k, _)| *k)
394        {
395            self.entries.remove(&key);
396        }
397    }
398}
399
400impl Default for MeasureCache {
401    /// Creates a cache with default capacity of 256 entries.
402    fn default() -> Self {
403        Self::new(256)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    // --- WidgetId tests ---
412
413    #[test]
414    fn widget_id_from_ptr_is_stable() {
415        let widget = 42u64;
416        let id1 = WidgetId::from_ptr(&widget);
417        let id2 = WidgetId::from_ptr(&widget);
418        assert_eq!(id1, id2);
419    }
420
421    #[test]
422    fn widget_id_from_hash_is_stable() {
423        let text = "hello world";
424        let id1 = WidgetId::from_hash(&text);
425        let id2 = WidgetId::from_hash(&text);
426        assert_eq!(id1, id2);
427    }
428
429    #[test]
430    fn widget_id_from_hash_differs_for_different_content() {
431        let id1 = WidgetId::from_hash(&"hello");
432        let id2 = WidgetId::from_hash(&"world");
433        assert_ne!(id1, id2);
434    }
435
436    // --- MeasureCache tests ---
437
438    #[test]
439    fn cache_returns_same_result() {
440        let mut cache = MeasureCache::new(100);
441        let widget_id = WidgetId(42);
442        let available = Size::new(80, 24);
443
444        let mut call_count = 0;
445        let compute = || {
446            call_count += 1;
447            SizeConstraints {
448                min: Size::new(10, 1),
449                preferred: Size::new(50, 5),
450                max: None,
451            }
452        };
453
454        let r1 = cache.get_or_compute(widget_id, available, compute);
455        let r2 = cache.get_or_compute(widget_id, available, || unreachable!("should not call"));
456
457        assert_eq!(r1, r2);
458        assert_eq!(call_count, 1); // Only called once
459    }
460
461    #[test]
462    fn different_size_is_cache_miss() {
463        let mut cache = MeasureCache::new(100);
464        let widget_id = WidgetId(42);
465
466        let mut call_count = 0;
467        let mut compute = || {
468            call_count += 1;
469            SizeConstraints {
470                min: Size::ZERO,
471                preferred: Size::new(call_count as u16, 1),
472                max: None,
473            }
474        };
475
476        cache.get_or_compute(widget_id, Size::new(80, 24), &mut compute);
477        cache.get_or_compute(widget_id, Size::new(120, 40), &mut compute);
478
479        assert_eq!(call_count, 2); // Called twice for different sizes
480    }
481
482    #[test]
483    fn different_widget_is_cache_miss() {
484        let mut cache = MeasureCache::new(100);
485        let available = Size::new(80, 24);
486
487        let mut call_count = 0;
488        let mut compute = || {
489            call_count += 1;
490            SizeConstraints::ZERO
491        };
492
493        cache.get_or_compute(WidgetId(1), available, &mut compute);
494        cache.get_or_compute(WidgetId(2), available, &mut compute);
495
496        assert_eq!(call_count, 2);
497    }
498
499    #[test]
500    fn invalidation_clears_cache() {
501        let mut cache = MeasureCache::new(100);
502        let widget_id = WidgetId(42);
503        let available = Size::new(80, 24);
504
505        let mut call_count = 0;
506        let mut compute = || {
507            call_count += 1;
508            SizeConstraints::ZERO
509        };
510
511        cache.get_or_compute(widget_id, available, &mut compute);
512        cache.invalidate_all();
513        cache.get_or_compute(widget_id, available, &mut compute);
514
515        assert_eq!(call_count, 2); // Re-computed after invalidation
516    }
517
518    #[test]
519    fn widget_specific_invalidation() {
520        let mut cache = MeasureCache::new(100);
521        let widget1 = WidgetId(1);
522        let widget2 = WidgetId(2);
523        let available = Size::new(80, 24);
524
525        let mut count1 = 0;
526        let mut count2 = 0;
527
528        cache.get_or_compute(widget1, available, || {
529            count1 += 1;
530            SizeConstraints::ZERO
531        });
532        cache.get_or_compute(widget2, available, || {
533            count2 += 1;
534            SizeConstraints::ZERO
535        });
536
537        // Invalidate only widget1
538        cache.invalidate_widget(widget1);
539
540        // widget1 should miss, widget2 should hit
541        cache.get_or_compute(widget1, available, || {
542            count1 += 1;
543            SizeConstraints::ZERO
544        });
545        cache.get_or_compute(widget2, available, || unreachable!("should hit"));
546
547        assert_eq!(count1, 2);
548        assert_eq!(count2, 1);
549    }
550
551    #[test]
552    fn lru_eviction_works() {
553        let mut cache = MeasureCache::new(2); // Small cache
554
555        // Insert two entries
556        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
557        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
558
559        // Access widget 1 again (increases its access count)
560        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
561            unreachable!("should hit")
562        });
563
564        // Insert third entry, should evict widget 2 (least accessed)
565        cache.get_or_compute(WidgetId(3), Size::new(10, 10), || SizeConstraints::ZERO);
566
567        assert_eq!(cache.len(), 2);
568
569        // Widget 2 should be evicted
570        let mut was_called = false;
571        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || {
572            was_called = true;
573            SizeConstraints::ZERO
574        });
575        assert!(was_called, "widget 2 should have been evicted");
576
577        // Widget 1 should still be cached
578        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
579            unreachable!("widget 1 should still be cached")
580        });
581    }
582
583    #[test]
584    fn stats_track_hits_and_misses() {
585        let mut cache = MeasureCache::new(100);
586
587        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
588        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!("hit"));
589        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
590
591        let stats = cache.stats();
592        assert_eq!(stats.hits, 1);
593        assert_eq!(stats.misses, 2);
594        assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
595    }
596
597    #[test]
598    fn reset_stats_clears_counters() {
599        let mut cache = MeasureCache::new(100);
600
601        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
602        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!("hit"));
603
604        let stats = cache.stats();
605        assert_eq!(stats.hits, 1);
606        assert_eq!(stats.misses, 1);
607
608        cache.reset_stats();
609
610        let stats = cache.stats();
611        assert_eq!(stats.hits, 0);
612        assert_eq!(stats.misses, 0);
613        assert_eq!(stats.hit_rate, 0.0);
614    }
615
616    #[test]
617    fn clear_removes_all_entries() {
618        let mut cache = MeasureCache::new(100);
619
620        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
621        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
622
623        assert_eq!(cache.len(), 2);
624
625        cache.clear();
626
627        assert_eq!(cache.len(), 0);
628        assert!(cache.is_empty());
629
630        // All entries should miss now
631        let mut was_called = false;
632        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
633            was_called = true;
634            SizeConstraints::ZERO
635        });
636        assert!(was_called);
637    }
638
639    #[test]
640    fn default_capacity_is_256() {
641        let cache = MeasureCache::default();
642        assert_eq!(cache.capacity(), 256);
643    }
644
645    #[test]
646    fn generation_wraps_around() {
647        let mut cache = MeasureCache::new(100);
648        cache.generation = u64::MAX;
649        cache.invalidate_all();
650        assert_eq!(cache.generation, 0);
651    }
652
653    // --- Property-like tests ---
654
655    #[test]
656    fn cache_is_deterministic() {
657        let mut cache1 = MeasureCache::new(100);
658        let mut cache2 = MeasureCache::new(100);
659
660        for i in 0..10u64 {
661            let id = WidgetId(i);
662            let size = Size::new((i * 10) as u16, (i * 5) as u16);
663            let c = SizeConstraints {
664                min: Size::new(i as u16, 1),
665                preferred: Size::new((i * 2) as u16, 2),
666                max: None,
667            };
668
669            cache1.get_or_compute(id, size, || c);
670            cache2.get_or_compute(id, size, || c);
671        }
672
673        // Same inputs should produce same stats
674        assert_eq!(cache1.stats().entries, cache2.stats().entries);
675        assert_eq!(cache1.stats().misses, cache2.stats().misses);
676    }
677
678    #[test]
679    fn hit_count_increments_on_each_access() {
680        let mut cache = MeasureCache::new(100);
681        let id = WidgetId(42);
682        let size = Size::new(80, 24);
683
684        // First access is a miss
685        cache.get_or_compute(id, size, || SizeConstraints::ZERO);
686
687        // Subsequent accesses are hits
688        for _ in 0..5 {
689            cache.get_or_compute(id, size, || unreachable!("should hit"));
690        }
691
692        let stats = cache.stats();
693        assert_eq!(stats.misses, 1);
694        assert_eq!(stats.hits, 5);
695    }
696}