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(¶graph),
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}