Skip to main content

ftui_layout/
cache.rs

1//! Layout cache for memoizing layout computation results.
2//!
3//! This module provides [`LayoutCache`] which caches the `Vec<Rect>` results from
4//! layout computations to avoid redundant constraint solving during rendering.
5//!
6//! # Overview
7//!
8//! Layout computation (constraint solving, flex distribution) can be expensive for
9//! complex nested layouts. During a single frame, the same layout may be queried
10//! multiple times with identical parameters. The cache eliminates this redundancy.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_layout::{Flex, Constraint, LayoutCache, LayoutCacheKey, Direction};
16//! use ftui_core::geometry::Rect;
17//!
18//! let mut cache = LayoutCache::new(64);
19//!
20//! let flex = Flex::horizontal()
21//!     .constraints([Constraint::Percentage(50.0), Constraint::Fill]);
22//!
23//! let area = Rect::new(0, 0, 80, 24);
24//!
25//! // First call computes and caches
26//! let rects = flex.split_cached(area, &mut cache);
27//!
28//! // Second call returns cached result
29//! let cached = flex.split_cached(area, &mut cache);
30//! ```
31//!
32//! # Invalidation
33//!
34//! ## Generation-Based (Primary)
35//!
36//! Call [`LayoutCache::invalidate_all()`] after any state change affecting layouts:
37//!
38//! ```ignore
39//! match msg {
40//!     Msg::DataChanged(_) => {
41//!         self.layout_cache.invalidate_all();
42//!     }
43//!     Msg::Resize(_) => {
44//!         // Area is part of cache key, no invalidation needed!
45//!     }
46//! }
47//! ```
48//!
49//! # Cache Eviction
50//!
51//! The cache uses LRU (Least Recently Used) eviction when at capacity.
52
53use std::hash::{Hash, Hasher};
54
55use ftui_core::geometry::Rect;
56use rustc_hash::FxHashMap;
57
58use crate::{Constraint, Direction, LayoutSizeHint};
59
60/// Key for layout cache lookups.
61///
62/// Includes all parameters that affect layout computation:
63/// - The available area (stored as components for Hash)
64/// - A fingerprint of all constraints
65/// - The length of the constraints slice
66/// - The layout direction
67/// - Optionally, a fingerprint of intrinsic size hints
68#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
69pub struct LayoutCacheKey {
70    /// Area x-coordinate.
71    pub area_x: u16,
72    /// Area y-coordinate.
73    pub area_y: u16,
74    /// Area width.
75    pub area_width: u16,
76    /// Area height.
77    pub area_height: u16,
78    /// Primary hash fingerprint of constraints.
79    pub constraints_hash: u64,
80    /// Secondary hash fingerprint of constraints (for 128-bit collision resistance).
81    pub constraints_hash_fx: u64,
82    /// Length of constraints slice.
83    pub constraints_len: u16,
84    /// Layout direction.
85    pub direction: Direction,
86    /// Primary hash fingerprint of intrinsic sizes (if using FitContent).
87    pub intrinsics_hash: Option<u64>,
88    /// Secondary hash fingerprint of intrinsic sizes.
89    pub intrinsics_hash_fx: Option<u64>,
90}
91
92impl LayoutCacheKey {
93    /// Create a new cache key from layout parameters.
94    ///
95    /// # Arguments
96    ///
97    /// * `area` - The available rectangle for layout
98    /// * `constraints` - The constraint list
99    /// * `direction` - Horizontal or Vertical layout
100    /// * `intrinsics` - Optional size hints for FitContent constraints
101    pub fn new(
102        area: Rect,
103        constraints: &[Constraint],
104        direction: Direction,
105        intrinsics: Option<&[LayoutSizeHint]>,
106    ) -> Self {
107        let (ch1, ch2) = Self::hash_constraints(constraints);
108        let intr_hashes = intrinsics.map(Self::hash_intrinsics);
109        Self {
110            area_x: area.x,
111            area_y: area.y,
112            area_width: area.width,
113            area_height: area.height,
114            constraints_hash: ch1,
115            constraints_hash_fx: ch2,
116            constraints_len: constraints.len() as u16,
117            direction,
118            intrinsics_hash: intr_hashes.map(|h| h.0),
119            intrinsics_hash_fx: intr_hashes.map(|h| h.1),
120        }
121    }
122
123    /// Reconstruct the area Rect from cached components.
124    #[inline]
125    pub fn area(&self) -> Rect {
126        Rect::new(self.area_x, self.area_y, self.area_width, self.area_height)
127    }
128
129    /// Hash a slice of constraints with two independent hashers.
130    fn hash_constraints(constraints: &[Constraint]) -> (u64, u64) {
131        let mut h1 = std::collections::hash_map::DefaultHasher::new();
132        let mut h2 = rustc_hash::FxHasher::default();
133        for c in constraints {
134            std::mem::discriminant(c).hash(&mut h1);
135            std::mem::discriminant(c).hash(&mut h2);
136            match c {
137                Constraint::Fixed(v) => {
138                    v.hash(&mut h1);
139                    v.hash(&mut h2);
140                }
141                Constraint::Percentage(p) => {
142                    p.to_bits().hash(&mut h1);
143                    p.to_bits().hash(&mut h2);
144                }
145                Constraint::Min(v) => {
146                    v.hash(&mut h1);
147                    v.hash(&mut h2);
148                }
149                Constraint::Max(v) => {
150                    v.hash(&mut h1);
151                    v.hash(&mut h2);
152                }
153                Constraint::Ratio(n, d) => {
154                    fn gcd(mut a: u32, mut b: u32) -> u32 {
155                        while b != 0 {
156                            let t = b;
157                            b = a % b;
158                            a = t;
159                        }
160                        a
161                    }
162                    let divisor = gcd(*n, *d);
163                    if let (Some(n_div), Some(d_div)) =
164                        (n.checked_div(divisor), d.checked_div(divisor))
165                    {
166                        n_div.hash(&mut h1);
167                        d_div.hash(&mut h1);
168                        n_div.hash(&mut h2);
169                        d_div.hash(&mut h2);
170                    } else {
171                        n.hash(&mut h1);
172                        d.hash(&mut h1);
173                        n.hash(&mut h2);
174                        d.hash(&mut h2);
175                    }
176                }
177                Constraint::Fill => {}
178                Constraint::FitContent => {}
179                Constraint::FitContentBounded { min, max } => {
180                    min.hash(&mut h1);
181                    max.hash(&mut h1);
182                    min.hash(&mut h2);
183                    max.hash(&mut h2);
184                }
185                Constraint::FitMin => {}
186            }
187        }
188        (h1.finish(), h2.finish())
189    }
190
191    /// Hash a slice of intrinsic size hints.
192    fn hash_intrinsics(intrinsics: &[LayoutSizeHint]) -> (u64, u64) {
193        let mut h1 = std::collections::hash_map::DefaultHasher::new();
194        let mut h2 = rustc_hash::FxHasher::default();
195        for hint in intrinsics {
196            hint.min.hash(&mut h1);
197            hint.preferred.hash(&mut h1);
198            hint.max.hash(&mut h1);
199            hint.min.hash(&mut h2);
200            hint.preferred.hash(&mut h2);
201            hint.max.hash(&mut h2);
202        }
203        (h1.finish(), h2.finish())
204    }
205}
206
207/// Cached layout result with metadata for eviction.
208#[derive(Clone, Debug)]
209struct CachedLayoutEntry {
210    /// The cached layout rectangles.
211    chunks: crate::Rects,
212    /// Generation when this entry was created/updated.
213    generation: u64,
214    /// Access count for LRU eviction.
215    access_count: u32,
216}
217
218/// Statistics about layout cache performance.
219#[derive(Debug, Clone, Default)]
220pub struct LayoutCacheStats {
221    /// Number of entries currently in the cache.
222    pub entries: usize,
223    /// Total cache hits since creation or last reset.
224    pub hits: u64,
225    /// Total cache misses since creation or last reset.
226    pub misses: u64,
227    /// Hit rate as a fraction (0.0 to 1.0).
228    pub hit_rate: f64,
229}
230
231/// Cache for layout computation results.
232///
233/// Stores `Vec<Rect>` results keyed by [`LayoutCacheKey`] to avoid redundant
234/// constraint solving during rendering.
235///
236/// # Capacity
237///
238/// The cache has a fixed maximum capacity. When full, the least recently used
239/// entries are evicted to make room for new ones.
240///
241/// # Generation-Based Invalidation
242///
243/// Each entry is tagged with a generation number. Calling [`invalidate_all()`]
244/// bumps the generation, making all existing entries stale.
245///
246/// [`invalidate_all()`]: LayoutCache::invalidate_all
247#[derive(Debug)]
248pub struct LayoutCache {
249    entries: FxHashMap<LayoutCacheKey, CachedLayoutEntry>,
250    generation: u64,
251    max_entries: usize,
252    hits: u64,
253    misses: u64,
254}
255
256impl LayoutCache {
257    /// Create a new cache with the specified maximum capacity.
258    ///
259    /// # Arguments
260    ///
261    /// * `max_entries` - Maximum number of entries before LRU eviction occurs.
262    ///   A typical value is 64-256 for most UIs.
263    ///
264    /// # Example
265    ///
266    /// ```
267    /// use ftui_layout::LayoutCache;
268    /// let cache = LayoutCache::new(64);
269    /// ```
270    #[inline]
271    pub fn new(max_entries: usize) -> Self {
272        Self {
273            entries: FxHashMap::with_capacity_and_hasher(max_entries, Default::default()),
274            generation: 0,
275            max_entries,
276            hits: 0,
277            misses: 0,
278        }
279    }
280
281    /// Get cached layout or compute and cache a new one.
282    ///
283    /// If a valid (same generation) cache entry exists for the given key,
284    /// returns a clone of it. Otherwise, calls the `compute` closure,
285    /// caches the result, and returns it.
286    ///
287    /// # Arguments
288    ///
289    /// * `key` - The cache key identifying this layout computation
290    /// * `compute` - Closure to compute the layout if not cached
291    ///
292    /// # Example
293    ///
294    /// ```ignore
295    /// let key = LayoutCacheKey::new(area, &constraints, Direction::Horizontal, None);
296    /// let rects = cache.get_or_compute(key, || flex.split(area));
297    /// ```
298    pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> crate::Rects
299    where
300        F: FnOnce() -> crate::Rects,
301    {
302        // Check for existing valid entry
303        if let Some(entry) = self.entries.get_mut(&key)
304            && entry.generation == self.generation
305        {
306            self.hits += 1;
307            entry.access_count = entry.access_count.saturating_add(1);
308            return entry.chunks.clone();
309        }
310
311        // Cache miss - compute the value
312        self.misses += 1;
313        let chunks = compute();
314
315        // Evict if at capacity
316        if self.entries.len() >= self.max_entries {
317            self.evict_lru();
318        }
319
320        // Insert new entry
321        self.entries.insert(
322            key,
323            CachedLayoutEntry {
324                chunks: chunks.clone(),
325                generation: self.generation,
326                access_count: 1,
327            },
328        );
329
330        chunks
331    }
332
333    /// Invalidate all entries by bumping the generation.
334    ///
335    /// Existing entries become stale and will be recomputed on next access.
336    /// This is an O(1) operation - entries are not immediately removed.
337    ///
338    /// # When to Call
339    ///
340    /// Call this after any state change that affects layout:
341    /// - Model data changes that affect widget content
342    /// - Theme/font changes that affect sizing
343    ///
344    /// # Note
345    ///
346    /// Resize events don't require invalidation because the area
347    /// is part of the cache key.
348    #[inline]
349    pub fn invalidate_all(&mut self) {
350        self.generation = self.generation.wrapping_add(1);
351    }
352
353    /// Get current cache statistics.
354    ///
355    /// Returns hit/miss counts and the current hit rate.
356    pub fn stats(&self) -> LayoutCacheStats {
357        let total = self.hits + self.misses;
358        LayoutCacheStats {
359            entries: self.entries.len(),
360            hits: self.hits,
361            misses: self.misses,
362            hit_rate: if total > 0 {
363                self.hits as f64 / total as f64
364            } else {
365                0.0
366            },
367        }
368    }
369
370    /// Reset statistics counters to zero.
371    ///
372    /// Useful for measuring hit rate over a specific period (e.g., per frame).
373    #[inline]
374    pub fn reset_stats(&mut self) {
375        self.hits = 0;
376        self.misses = 0;
377    }
378
379    /// Clear all entries from the cache.
380    ///
381    /// Unlike [`invalidate_all()`], this immediately frees memory.
382    ///
383    /// [`invalidate_all()`]: LayoutCache::invalidate_all
384    #[inline]
385    pub fn clear(&mut self) {
386        self.entries.clear();
387        self.generation = self.generation.wrapping_add(1);
388    }
389
390    /// Returns the current number of entries in the cache.
391    #[inline]
392    pub fn len(&self) -> usize {
393        self.entries.len()
394    }
395
396    /// Returns true if the cache is empty.
397    #[inline]
398    pub fn is_empty(&self) -> bool {
399        self.entries.is_empty()
400    }
401
402    /// Returns the maximum capacity of the cache.
403    #[inline]
404    pub fn capacity(&self) -> usize {
405        self.max_entries
406    }
407
408    /// Evict the least recently used entry.
409    fn evict_lru(&mut self) {
410        if let Some(key) = self
411            .entries
412            .iter()
413            .min_by_key(|(_, e)| e.access_count)
414            .map(|(k, _)| *k)
415        {
416            self.entries.remove(&key);
417        }
418    }
419}
420
421impl Default for LayoutCache {
422    /// Creates a cache with default capacity of 64 entries.
423    fn default() -> Self {
424        Self::new(64)
425    }
426}
427
428// ---------------------------------------------------------------------------
429// S3-FIFO Layout Cache (bd-l6yba.3)
430// ---------------------------------------------------------------------------
431
432/// Layout cache backed by S3-FIFO eviction.
433///
434/// Drop-in replacement for [`LayoutCache`] that uses the scan-resistant
435/// S3-FIFO eviction policy instead of the HashMap-based LRU. This protects
436/// frequently-accessed layout computations from being evicted by transient
437/// layouts (e.g. popup menus or tooltips that appear once).
438///
439/// Supports the same generation-based invalidation as [`LayoutCache`].
440#[derive(Debug)]
441pub struct S3FifoLayoutCache {
442    cache: ftui_core::s3_fifo::S3Fifo<LayoutCacheKey, CachedLayoutEntry>,
443    generation: u64,
444    max_entries: usize,
445    hits: u64,
446    misses: u64,
447}
448
449impl S3FifoLayoutCache {
450    /// Create a new S3-FIFO layout cache with the given capacity.
451    #[inline]
452    pub fn new(max_entries: usize) -> Self {
453        Self {
454            cache: ftui_core::s3_fifo::S3Fifo::new(max_entries.max(2)),
455            generation: 0,
456            max_entries: max_entries.max(2),
457            hits: 0,
458            misses: 0,
459        }
460    }
461
462    /// Get cached layout or compute and cache a new one.
463    ///
464    /// Same semantics as [`LayoutCache::get_or_compute`]: entries from
465    /// a previous generation are treated as misses.
466    pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> crate::Rects
467    where
468        F: FnOnce() -> crate::Rects,
469    {
470        if let Some(entry) = self.cache.get(&key) {
471            if entry.generation == self.generation {
472                self.hits += 1;
473                return entry.chunks.clone();
474            }
475            // Stale entry (old generation) — remove and recompute.
476            self.cache.remove(&key);
477        }
478
479        self.misses += 1;
480        let chunks = compute();
481
482        self.cache.insert(
483            key,
484            CachedLayoutEntry {
485                chunks: chunks.clone(),
486                generation: self.generation,
487                access_count: 1,
488            },
489        );
490
491        chunks
492    }
493
494    /// Invalidate all entries by bumping the generation (O(1)).
495    #[inline]
496    pub fn invalidate_all(&mut self) {
497        self.generation = self.generation.wrapping_add(1);
498    }
499
500    /// Get current cache statistics.
501    pub fn stats(&self) -> LayoutCacheStats {
502        let total = self.hits + self.misses;
503        LayoutCacheStats {
504            entries: self.cache.len(),
505            hits: self.hits,
506            misses: self.misses,
507            hit_rate: if total > 0 {
508                self.hits as f64 / total as f64
509            } else {
510                0.0
511            },
512        }
513    }
514
515    /// Reset statistics counters.
516    #[inline]
517    pub fn reset_stats(&mut self) {
518        self.hits = 0;
519        self.misses = 0;
520    }
521
522    /// Clear all entries.
523    #[inline]
524    pub fn clear(&mut self) {
525        self.cache.clear();
526        self.generation = self.generation.wrapping_add(1);
527    }
528
529    /// Current number of entries.
530    #[inline]
531    pub fn len(&self) -> usize {
532        self.cache.len()
533    }
534
535    /// Whether the cache is empty.
536    #[inline]
537    pub fn is_empty(&self) -> bool {
538        self.cache.is_empty()
539    }
540
541    /// Maximum capacity.
542    #[inline]
543    pub fn capacity(&self) -> usize {
544        self.max_entries
545    }
546}
547
548impl Default for S3FifoLayoutCache {
549    fn default() -> Self {
550        Self::new(64)
551    }
552}
553
554// ---------------------------------------------------------------------------
555// Coherence Cache: Temporal Stability for Layout Rounding
556// ---------------------------------------------------------------------------
557
558/// Identity for a layout computation, used as key in the coherence cache.
559///
560/// This is a subset of [`LayoutCacheKey`] that identifies a *layout slot*
561/// independently of the available area. Two computations with the same
562/// `CoherenceId` but different area sizes represent the same logical layout
563/// being re-rendered at a different terminal size.
564#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
565pub struct CoherenceId {
566    /// Hash fingerprint of constraints.
567    pub constraints_hash: u64,
568    /// Layout direction.
569    pub direction: Direction,
570}
571
572impl CoherenceId {
573    /// Create a coherence ID from layout parameters.
574    pub fn new(constraints: &[Constraint], direction: Direction) -> Self {
575        Self {
576            constraints_hash: LayoutCacheKey::hash_constraints(constraints).0,
577            direction,
578        }
579    }
580
581    /// Create a coherence ID from an existing cache key.
582    pub fn from_cache_key(key: &LayoutCacheKey) -> Self {
583        Self {
584            constraints_hash: key.constraints_hash,
585            direction: key.direction,
586        }
587    }
588}
589
590/// Stores previous layout allocations for temporal coherence.
591///
592/// When terminal size changes, the layout solver re-computes positions from
593/// scratch. Without coherence, rounding tie-breaks can cause widgets to
594/// "jump" between frames even when the total size change is small.
595///
596/// The `CoherenceCache` feeds previous allocations to
597/// [`round_layout_stable`](crate::round_layout_stable) so that tie-breaking
598/// favours keeping elements where they were.
599///
600/// # Usage
601///
602/// ```ignore
603/// use ftui_layout::{CoherenceCache, CoherenceId, round_layout_stable, Constraint, Direction};
604///
605/// let mut coherence = CoherenceCache::new(64);
606///
607/// let id = CoherenceId::new(&constraints, Direction::Horizontal);
608/// let prev = coherence.get(&id);
609/// let alloc = round_layout_stable(&targets, total, prev);
610/// coherence.store(id, alloc.clone());
611/// ```
612///
613/// # Eviction
614///
615/// Entries are evicted on a least-recently-stored basis when the cache
616/// reaches capacity. The cache does not grow unboundedly.
617#[derive(Debug, Clone)]
618pub struct CoherenceCache {
619    entries: FxHashMap<CoherenceId, CoherenceEntry>,
620    max_entries: usize,
621    /// Monotonic counter for LRU eviction.
622    tick: u64,
623}
624
625#[derive(Debug, Clone)]
626struct CoherenceEntry {
627    /// Previous allocation sizes.
628    allocation: crate::Sizes,
629    /// Tick when this entry was last stored.
630    last_stored: u64,
631}
632
633impl CoherenceCache {
634    /// Create a new coherence cache with the specified capacity.
635    pub fn new(max_entries: usize) -> Self {
636        Self {
637            entries: FxHashMap::with_capacity_and_hasher(max_entries.min(256), Default::default()),
638            max_entries,
639            tick: 0,
640        }
641    }
642
643    /// Retrieve the previous allocation for a layout, if available.
644    ///
645    /// Returns `Some(allocation)` suitable for passing directly to
646    /// [`round_layout_stable`](crate::round_layout_stable).
647    #[inline]
648    pub fn get(&self, id: &CoherenceId) -> Option<crate::Sizes> {
649        self.entries.get(id).map(|e| e.allocation.clone())
650    }
651
652    /// Store a layout allocation for future coherence lookups.
653    ///
654    /// If the cache is at capacity, evicts the oldest entry.
655    pub fn store(&mut self, id: CoherenceId, allocation: crate::Sizes) {
656        self.tick = self.tick.wrapping_add(1);
657
658        if self.entries.len() >= self.max_entries && !self.entries.contains_key(&id) {
659            self.evict_oldest();
660        }
661
662        self.entries.insert(
663            id,
664            CoherenceEntry {
665                allocation,
666                last_stored: self.tick,
667            },
668        );
669    }
670
671    /// Clear all stored allocations.
672    #[inline]
673    pub fn clear(&mut self) {
674        self.entries.clear();
675    }
676
677    /// Number of entries in the cache.
678    #[inline]
679    pub fn len(&self) -> usize {
680        self.entries.len()
681    }
682
683    /// Returns true if the cache is empty.
684    #[inline]
685    pub fn is_empty(&self) -> bool {
686        self.entries.is_empty()
687    }
688
689    /// Compute displacement between a new allocation and the previously stored one.
690    ///
691    /// Returns `(sum_displacement, max_displacement)` in cells.
692    /// If no previous allocation exists for the ID, returns `(0, 0)`.
693    pub fn displacement(&self, id: &CoherenceId, new_alloc: &[u16]) -> (u64, u32) {
694        match self.entries.get(id) {
695            Some(entry) => {
696                let prev = &entry.allocation;
697                let len = prev.len().min(new_alloc.len());
698                let mut sum: u64 = 0;
699                let mut max: u32 = 0;
700                for i in 0..len {
701                    let d = (new_alloc[i] as i32 - prev[i] as i32).unsigned_abs();
702                    sum += u64::from(d);
703                    max = max.max(d);
704                }
705                // If lengths differ, count the extra elements as full displacement
706                for &v in &prev[len..] {
707                    sum += u64::from(v);
708                    max = max.max(u32::from(v));
709                }
710                for &v in &new_alloc[len..] {
711                    sum += u64::from(v);
712                    max = max.max(u32::from(v));
713                }
714                (sum, max)
715            }
716            None => (0, 0),
717        }
718    }
719
720    fn evict_oldest(&mut self) {
721        if let Some(key) = self
722            .entries
723            .iter()
724            .min_by_key(|(_, e)| e.last_stored)
725            .map(|(k, _)| *k)
726        {
727            self.entries.remove(&key);
728        }
729    }
730}
731
732impl Default for CoherenceCache {
733    fn default() -> Self {
734        Self::new(64)
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    fn make_key(width: u16, height: u16) -> LayoutCacheKey {
743        LayoutCacheKey::new(
744            Rect::new(0, 0, width, height),
745            &[Constraint::Percentage(50.0), Constraint::Fill],
746            Direction::Horizontal,
747            None,
748        )
749    }
750
751    fn should_not_call(label: &str) -> crate::Rects {
752        unreachable!("{label}");
753    }
754
755    // --- LayoutCacheKey tests ---
756
757    #[test]
758    fn same_params_produce_same_key() {
759        let k1 = make_key(80, 24);
760        let k2 = make_key(80, 24);
761        assert_eq!(k1, k2);
762    }
763
764    #[test]
765    fn different_area_different_key() {
766        let k1 = make_key(80, 24);
767        let k2 = make_key(120, 40);
768        assert_ne!(k1, k2);
769    }
770
771    #[test]
772    fn different_constraints_different_key() {
773        let k1 = LayoutCacheKey::new(
774            Rect::new(0, 0, 80, 24),
775            &[Constraint::Fixed(20)],
776            Direction::Horizontal,
777            None,
778        );
779        let k2 = LayoutCacheKey::new(
780            Rect::new(0, 0, 80, 24),
781            &[Constraint::Fixed(30)],
782            Direction::Horizontal,
783            None,
784        );
785        assert_ne!(k1, k2);
786    }
787
788    #[test]
789    fn different_direction_different_key() {
790        let k1 = LayoutCacheKey::new(
791            Rect::new(0, 0, 80, 24),
792            &[Constraint::Fill],
793            Direction::Horizontal,
794            None,
795        );
796        let k2 = LayoutCacheKey::new(
797            Rect::new(0, 0, 80, 24),
798            &[Constraint::Fill],
799            Direction::Vertical,
800            None,
801        );
802        assert_ne!(k1, k2);
803    }
804
805    #[test]
806    fn different_intrinsics_different_key() {
807        let hints1 = [LayoutSizeHint {
808            min: 10,
809            preferred: 20,
810            max: None,
811        }];
812        let hints2 = [LayoutSizeHint {
813            min: 10,
814            preferred: 30,
815            max: None,
816        }];
817
818        let k1 = LayoutCacheKey::new(
819            Rect::new(0, 0, 80, 24),
820            &[Constraint::FitContent],
821            Direction::Horizontal,
822            Some(&hints1),
823        );
824        let k2 = LayoutCacheKey::new(
825            Rect::new(0, 0, 80, 24),
826            &[Constraint::FitContent],
827            Direction::Horizontal,
828            Some(&hints2),
829        );
830        assert_ne!(k1, k2);
831    }
832
833    // --- LayoutCache tests ---
834
835    #[test]
836    fn cache_returns_same_result() {
837        let mut cache = LayoutCache::new(100);
838        let key = make_key(80, 24);
839
840        let mut compute_count = 0;
841        let compute = || {
842            compute_count += 1;
843            smallvec::smallvec![Rect::new(0, 0, 40, 24), Rect::new(40, 0, 40, 24)]
844        };
845
846        let r1 = cache.get_or_compute(key, compute);
847        let r2 = cache.get_or_compute(key, || should_not_call("should not call"));
848
849        assert_eq!(r1, r2);
850        assert_eq!(compute_count, 1);
851    }
852
853    #[test]
854    fn different_area_is_cache_miss() {
855        let mut cache = LayoutCache::new(100);
856
857        let mut compute_count = 0;
858        let mut compute = || {
859            compute_count += 1;
860            smallvec::smallvec![Rect::default()]
861        };
862
863        let k1 = make_key(80, 24);
864        let k2 = make_key(120, 40);
865
866        cache.get_or_compute(k1, &mut compute);
867        cache.get_or_compute(k2, &mut compute);
868
869        assert_eq!(compute_count, 2);
870    }
871
872    #[test]
873    fn invalidation_clears_cache() {
874        let mut cache = LayoutCache::new(100);
875        let key = make_key(80, 24);
876
877        let mut compute_count = 0;
878        let mut compute = || {
879            compute_count += 1;
880            smallvec::smallvec![]
881        };
882
883        cache.get_or_compute(key, &mut compute);
884        cache.invalidate_all();
885        cache.get_or_compute(key, &mut compute);
886
887        assert_eq!(compute_count, 2);
888    }
889
890    #[test]
891    fn lru_eviction_works() {
892        let mut cache = LayoutCache::new(2);
893
894        let k1 = make_key(10, 10);
895        let k2 = make_key(20, 20);
896        let k3 = make_key(30, 30);
897
898        // Insert two entries
899        cache.get_or_compute(k1, || smallvec::smallvec![Rect::new(0, 0, 10, 10)]);
900        cache.get_or_compute(k2, || smallvec::smallvec![Rect::new(0, 0, 20, 20)]);
901
902        // Access k1 again (increases access count)
903        cache.get_or_compute(k1, || should_not_call("k1 should hit"));
904
905        // Insert k3, should evict k2 (least accessed)
906        cache.get_or_compute(k3, || smallvec::smallvec![Rect::new(0, 0, 30, 30)]);
907
908        assert_eq!(cache.len(), 2);
909
910        // k2 should be evicted
911        let mut was_called = false;
912        cache.get_or_compute(k2, || {
913            was_called = true;
914            smallvec::smallvec![]
915        });
916        assert!(was_called, "k2 should have been evicted");
917
918        // k1 should still be cached
919        cache.get_or_compute(k1, || should_not_call("k1 should still be cached"));
920    }
921
922    #[test]
923    fn stats_track_hits_and_misses() {
924        let mut cache = LayoutCache::new(100);
925
926        let k1 = make_key(80, 24);
927        let k2 = make_key(120, 40);
928
929        cache.get_or_compute(k1, crate::Rects::new); // miss
930        cache.get_or_compute(k1, || should_not_call("hit")); // hit
931        cache.get_or_compute(k2, crate::Rects::new); // miss
932
933        let stats = cache.stats();
934        assert_eq!(stats.hits, 1);
935        assert_eq!(stats.misses, 2);
936        assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
937    }
938
939    #[test]
940    fn reset_stats_clears_counters() {
941        let mut cache = LayoutCache::new(100);
942        let key = make_key(80, 24);
943
944        cache.get_or_compute(key, crate::Rects::new);
945        cache.get_or_compute(key, || should_not_call("hit"));
946
947        let stats = cache.stats();
948        assert_eq!(stats.hits, 1);
949        assert_eq!(stats.misses, 1);
950
951        cache.reset_stats();
952
953        let stats = cache.stats();
954        assert_eq!(stats.hits, 0);
955        assert_eq!(stats.misses, 0);
956        assert_eq!(stats.hit_rate, 0.0);
957    }
958
959    #[test]
960    fn clear_removes_all_entries() {
961        let mut cache = LayoutCache::new(100);
962
963        cache.get_or_compute(make_key(80, 24), crate::Rects::new);
964        cache.get_or_compute(make_key(120, 40), crate::Rects::new);
965
966        assert_eq!(cache.len(), 2);
967
968        cache.clear();
969
970        assert_eq!(cache.len(), 0);
971        assert!(cache.is_empty());
972
973        // All entries should miss now
974        let mut was_called = false;
975        cache.get_or_compute(make_key(80, 24), || {
976            was_called = true;
977            smallvec::smallvec![]
978        });
979        assert!(was_called);
980    }
981
982    #[test]
983    fn default_capacity_is_64() {
984        let cache = LayoutCache::default();
985        assert_eq!(cache.capacity(), 64);
986    }
987
988    #[test]
989    fn generation_wraps_around() {
990        let mut cache = LayoutCache::new(100);
991        cache.generation = u64::MAX;
992        cache.invalidate_all();
993        assert_eq!(cache.generation, 0);
994    }
995
996    // --- Constraint hashing tests ---
997
998    #[test]
999    fn constraint_hash_is_stable() {
1000        let constraints = [
1001            Constraint::Fixed(20),
1002            Constraint::Percentage(50.0),
1003            Constraint::Min(10),
1004        ];
1005
1006        let h1 = LayoutCacheKey::hash_constraints(&constraints);
1007        let h2 = LayoutCacheKey::hash_constraints(&constraints);
1008
1009        assert_eq!(h1, h2);
1010    }
1011
1012    #[test]
1013    fn different_constraint_values_different_hash() {
1014        let c1 = [Constraint::Fixed(20)];
1015        let c2 = [Constraint::Fixed(30)];
1016
1017        let h1 = LayoutCacheKey::hash_constraints(&c1);
1018        let h2 = LayoutCacheKey::hash_constraints(&c2);
1019
1020        assert_ne!(h1, h2);
1021    }
1022
1023    #[test]
1024    fn different_constraint_types_different_hash() {
1025        let c1 = [Constraint::Fixed(20)];
1026        let c2 = [Constraint::Min(20)];
1027
1028        let h1 = LayoutCacheKey::hash_constraints(&c1);
1029        let h2 = LayoutCacheKey::hash_constraints(&c2);
1030
1031        assert_ne!(h1, h2);
1032    }
1033
1034    #[test]
1035    fn fit_content_bounded_values_in_hash() {
1036        let c1 = [Constraint::FitContentBounded { min: 10, max: 50 }];
1037        let c2 = [Constraint::FitContentBounded { min: 10, max: 60 }];
1038
1039        let h1 = LayoutCacheKey::hash_constraints(&c1);
1040        let h2 = LayoutCacheKey::hash_constraints(&c2);
1041
1042        assert_ne!(h1, h2);
1043    }
1044
1045    // --- Intrinsics hashing tests ---
1046
1047    #[test]
1048    fn intrinsics_hash_is_stable() {
1049        let hints = [
1050            LayoutSizeHint {
1051                min: 10,
1052                preferred: 20,
1053                max: Some(30),
1054            },
1055            LayoutSizeHint {
1056                min: 5,
1057                preferred: 15,
1058                max: None,
1059            },
1060        ];
1061
1062        let h1 = LayoutCacheKey::hash_intrinsics(&hints);
1063        let h2 = LayoutCacheKey::hash_intrinsics(&hints);
1064
1065        assert_eq!(h1, h2);
1066    }
1067
1068    #[test]
1069    fn different_intrinsics_different_hash() {
1070        let h1 = [LayoutSizeHint {
1071            min: 10,
1072            preferred: 20,
1073            max: None,
1074        }];
1075        let h2 = [LayoutSizeHint {
1076            min: 10,
1077            preferred: 25,
1078            max: None,
1079        }];
1080
1081        let hash1 = LayoutCacheKey::hash_intrinsics(&h1);
1082        let hash2 = LayoutCacheKey::hash_intrinsics(&h2);
1083
1084        assert_ne!(hash1, hash2);
1085    }
1086
1087    // --- Property-like tests ---
1088
1089    #[test]
1090    fn cache_is_deterministic() {
1091        let mut cache1 = LayoutCache::new(100);
1092        let mut cache2 = LayoutCache::new(100);
1093
1094        for i in 0..10u16 {
1095            let key = make_key(i * 10, i * 5);
1096            let result = smallvec::smallvec![Rect::new(0, 0, i, i)];
1097
1098            cache1.get_or_compute(key, || result.clone());
1099            cache2.get_or_compute(key, || result);
1100        }
1101
1102        assert_eq!(cache1.stats().entries, cache2.stats().entries);
1103        assert_eq!(cache1.stats().misses, cache2.stats().misses);
1104    }
1105
1106    #[test]
1107    fn hit_count_increments_on_each_access() {
1108        let mut cache = LayoutCache::new(100);
1109        let key = make_key(80, 24);
1110
1111        // First access is a miss
1112        cache.get_or_compute(key, crate::Rects::new);
1113
1114        // Subsequent accesses are hits
1115        for _ in 0..5 {
1116            cache.get_or_compute(key, || should_not_call("should hit"));
1117        }
1118
1119        let stats = cache.stats();
1120        assert_eq!(stats.misses, 1);
1121        assert_eq!(stats.hits, 5);
1122    }
1123
1124    // -----------------------------------------------------------------------
1125    // Coherence Cache Tests (bd-4kq0.4.2)
1126    // -----------------------------------------------------------------------
1127
1128    fn make_coherence_id(n: u16) -> CoherenceId {
1129        CoherenceId::new(
1130            &[Constraint::Fixed(n), Constraint::Fill],
1131            Direction::Horizontal,
1132        )
1133    }
1134
1135    #[test]
1136    fn coherence_store_and_get() {
1137        let mut cc = CoherenceCache::new(64);
1138        let id = make_coherence_id(1);
1139
1140        assert!(cc.get(&id).is_none());
1141
1142        cc.store(id, smallvec::smallvec![30u16, 50u16]);
1143        assert_eq!(cc.get(&id), Some(smallvec::smallvec![30u16, 50u16]));
1144    }
1145
1146    #[test]
1147    fn coherence_update_replaces_allocation() {
1148        let mut cc = CoherenceCache::new(64);
1149        let id = make_coherence_id(1);
1150
1151        cc.store(id, smallvec::smallvec![30u16, 50u16]);
1152        cc.store(id, smallvec::smallvec![31u16, 49u16]);
1153
1154        assert_eq!(cc.get(&id), Some(smallvec::smallvec![31u16, 49u16]));
1155        assert_eq!(cc.len(), 1);
1156    }
1157
1158    #[test]
1159    fn coherence_different_ids_are_separate() {
1160        let mut cc = CoherenceCache::new(64);
1161        let id1 = make_coherence_id(1);
1162        let id2 = make_coherence_id(2);
1163
1164        cc.store(id1, smallvec::smallvec![40u16, 40u16]);
1165        cc.store(id2, smallvec::smallvec![30u16, 50u16]);
1166
1167        assert_eq!(cc.get(&id1), Some(smallvec::smallvec![40u16, 40u16]));
1168        assert_eq!(cc.get(&id2), Some(smallvec::smallvec![30u16, 50u16]));
1169    }
1170
1171    #[test]
1172    fn coherence_eviction_at_capacity() {
1173        let mut cc = CoherenceCache::new(2);
1174
1175        let id1 = make_coherence_id(1);
1176        let id2 = make_coherence_id(2);
1177        let id3 = make_coherence_id(3);
1178
1179        cc.store(id1, smallvec::smallvec![10u16]);
1180        cc.store(id2, smallvec::smallvec![20u16]);
1181        cc.store(id3, smallvec::smallvec![30u16]);
1182
1183        assert_eq!(cc.len(), 2);
1184        // id1 should be evicted (oldest)
1185        assert!(cc.get(&id1).is_none());
1186        assert_eq!(cc.get(&id2), Some(smallvec::smallvec![20u16]));
1187        assert_eq!(cc.get(&id3), Some(smallvec::smallvec![30u16]));
1188    }
1189
1190    #[test]
1191    fn coherence_clear() {
1192        let mut cc = CoherenceCache::new(64);
1193        let id = make_coherence_id(1);
1194
1195        cc.store(id, smallvec::smallvec![10u16, 20u16]);
1196        assert_eq!(cc.len(), 1);
1197
1198        cc.clear();
1199        assert!(cc.is_empty());
1200        assert!(cc.get(&id).is_none());
1201    }
1202
1203    #[test]
1204    fn coherence_displacement_with_previous() {
1205        let mut cc = CoherenceCache::new(64);
1206        let id = make_coherence_id(1);
1207
1208        cc.store(id, smallvec::smallvec![30u16, 50u16]);
1209
1210        // New allocation differs by 2 cells in each slot
1211        let (sum, max) = cc.displacement(&id, &[32, 48]);
1212        assert_eq!(sum, 4); // |32-30| + |48-50| = 2 + 2
1213        assert_eq!(max, 2);
1214    }
1215
1216    #[test]
1217    fn coherence_displacement_without_previous() {
1218        let cc = CoherenceCache::new(64);
1219        let id = make_coherence_id(1);
1220
1221        let (sum, max) = cc.displacement(&id, &[30, 50]);
1222        assert_eq!(sum, 0);
1223        assert_eq!(max, 0);
1224    }
1225
1226    #[test]
1227    fn coherence_displacement_different_lengths() {
1228        let mut cc = CoherenceCache::new(64);
1229        let id = make_coherence_id(1);
1230
1231        cc.store(id, smallvec::smallvec![30u16, 50u16]);
1232
1233        // New allocation has 3 elements (extra element counted as displacement)
1234        let (sum, max) = cc.displacement(&id, &[30, 50, 10]);
1235        assert_eq!(sum, 10);
1236        assert_eq!(max, 10);
1237    }
1238
1239    #[test]
1240    fn coherence_from_cache_key() {
1241        let key = make_key(80, 24);
1242        let id = CoherenceId::from_cache_key(&key);
1243
1244        // Same constraints + direction should produce same ID regardless of area
1245        let key2 = make_key(120, 40);
1246        let id2 = CoherenceId::from_cache_key(&key2);
1247
1248        assert_eq!(id, id2);
1249    }
1250
1251    // --- unit_cache_reuse ---
1252
1253    #[test]
1254    fn unit_cache_reuse_unchanged_constraints_yield_identical_layout() {
1255        use crate::round_layout_stable;
1256
1257        let mut cc = CoherenceCache::new(64);
1258        let id = make_coherence_id(1);
1259
1260        // First layout at width 80
1261        let targets = [26.67, 26.67, 26.66];
1262        let total = 80;
1263        let alloc1 = round_layout_stable(&targets, total, cc.get(&id));
1264        cc.store(id, alloc1.clone());
1265
1266        // Same constraints, same width → should produce identical result
1267        let alloc2 = round_layout_stable(&targets, total, cc.get(&id));
1268        assert_eq!(alloc1, alloc2, "Same inputs should yield identical layout");
1269    }
1270
1271    // --- e2e_resize_sweep ---
1272
1273    #[test]
1274    fn e2e_resize_sweep_bounded_displacement() {
1275        use crate::round_layout_stable;
1276
1277        let mut cc = CoherenceCache::new(64);
1278        let id = make_coherence_id(1);
1279
1280        // Equal three-way split: targets are total/3 each
1281        // Sweep terminal width from 60 to 120 in steps of 1
1282        let mut max_displacement_ever: u32 = 0;
1283        let mut total_displacement_sum: u64 = 0;
1284        let steps = 61; // 60..=120
1285
1286        for width in 60u16..=120 {
1287            let third = f64::from(width) / 3.0;
1288            let targets = [third, third, third];
1289
1290            let prev = cc.get(&id);
1291            let alloc = round_layout_stable(&targets, width, prev);
1292
1293            let (d_sum, d_max) = cc.displacement(&id, &alloc);
1294            total_displacement_sum += d_sum;
1295            max_displacement_ever = max_displacement_ever.max(d_max);
1296
1297            cc.store(id, alloc);
1298        }
1299
1300        // Each 1-cell width change should cause at most 1 cell of displacement
1301        // in each slot (ideal: only 1 slot changes by 1).
1302        assert!(
1303            max_displacement_ever <= 2,
1304            "Max single-step displacement should be <=2 cells, got {}",
1305            max_displacement_ever
1306        );
1307
1308        // Average displacement per step should be ~1 (one extra cell redistributed)
1309        let avg = total_displacement_sum as f64 / steps as f64;
1310        assert!(
1311            avg < 3.0,
1312            "Average displacement per step should be <3 cells, got {:.2}",
1313            avg
1314        );
1315    }
1316
1317    #[test]
1318    fn e2e_resize_sweep_deterministic() {
1319        use crate::round_layout_stable;
1320
1321        // Two identical sweeps should produce identical displacement logs
1322        let sweep = |seed: u16| -> Vec<(u16, crate::Sizes, u64, u32)> {
1323            let mut cc = CoherenceCache::new(64);
1324            let id = CoherenceId::new(
1325                &[Constraint::Percentage(30.0), Constraint::Fill],
1326                Direction::Horizontal,
1327            );
1328
1329            let mut log = Vec::new();
1330            for width in (40 + seed)..(100 + seed) {
1331                let targets = [f64::from(width) * 0.3, f64::from(width) * 0.7];
1332                let prev = cc.get(&id);
1333                let alloc = round_layout_stable(&targets, width, prev);
1334                let (d_sum, d_max) = cc.displacement(&id, &alloc);
1335                cc.store(id, alloc.clone());
1336                log.push((width, alloc, d_sum, d_max));
1337            }
1338            log
1339        };
1340
1341        let log1 = sweep(0);
1342        let log2 = sweep(0);
1343        assert_eq!(log1, log2, "Identical sweeps should produce identical logs");
1344    }
1345
1346    #[test]
1347    fn default_coherence_cache_capacity_is_64() {
1348        let cc = CoherenceCache::default();
1349        assert_eq!(cc.max_entries, 64);
1350    }
1351
1352    // ── S3-FIFO Layout Cache ────────────────────────────────────────
1353
1354    fn s3_fifo_test_key(x: u16, w: u16) -> LayoutCacheKey {
1355        LayoutCacheKey {
1356            area_x: x,
1357            area_y: 0,
1358            area_width: w,
1359            area_height: 24,
1360            constraints_hash: 42,
1361            constraints_hash_fx: 42,
1362            constraints_len: 2,
1363            direction: Direction::Horizontal,
1364            intrinsics_hash: None,
1365            intrinsics_hash_fx: None,
1366        }
1367    }
1368
1369    #[test]
1370    fn s3fifo_layout_new_is_empty() {
1371        let cache = S3FifoLayoutCache::new(64);
1372        assert!(cache.is_empty());
1373        assert_eq!(cache.len(), 0);
1374        assert_eq!(cache.capacity(), 64);
1375    }
1376
1377    #[test]
1378    fn s3fifo_layout_default_capacity() {
1379        let cache = S3FifoLayoutCache::default();
1380        assert_eq!(cache.capacity(), 64);
1381    }
1382
1383    #[test]
1384    fn s3fifo_layout_get_or_compute_caches() {
1385        let mut cache = S3FifoLayoutCache::new(64);
1386        let key = s3_fifo_test_key(0, 80);
1387        let rects1 = cache.get_or_compute(key, || smallvec::smallvec![Rect::new(0, 0, 40, 24)]);
1388        let rects2 = cache.get_or_compute(key, || panic!("should not recompute"));
1389        assert_eq!(rects1, rects2);
1390        let stats = cache.stats();
1391        assert_eq!(stats.hits, 1);
1392        assert_eq!(stats.misses, 1);
1393    }
1394
1395    #[test]
1396    fn s3fifo_layout_generation_invalidation() {
1397        let mut cache = S3FifoLayoutCache::new(64);
1398        let key = s3_fifo_test_key(0, 80);
1399        cache.get_or_compute(key, || smallvec::smallvec![Rect::new(0, 0, 40, 24)]);
1400
1401        cache.invalidate_all();
1402
1403        // After invalidation, should recompute
1404        let rects = cache.get_or_compute(key, || smallvec::smallvec![Rect::new(0, 0, 80, 24)]);
1405        assert_eq!(rects.as_slice(), &[Rect::new(0, 0, 80, 24)]);
1406        let stats = cache.stats();
1407        assert_eq!(stats.misses, 2);
1408    }
1409
1410    #[test]
1411    fn s3fifo_layout_clear() {
1412        let mut cache = S3FifoLayoutCache::new(64);
1413        let key = s3_fifo_test_key(0, 80);
1414        cache.get_or_compute(key, || smallvec::smallvec![Rect::new(0, 0, 40, 24)]);
1415        cache.clear();
1416        assert!(cache.is_empty());
1417    }
1418
1419    #[test]
1420    fn s3fifo_layout_different_keys() {
1421        let mut cache = S3FifoLayoutCache::new(64);
1422        let k1 = s3_fifo_test_key(0, 80);
1423        let k2 = s3_fifo_test_key(0, 120);
1424        cache.get_or_compute(k1, || smallvec::smallvec![Rect::new(0, 0, 40, 24)]);
1425        cache.get_or_compute(k2, || smallvec::smallvec![Rect::new(0, 0, 60, 24)]);
1426        assert_eq!(cache.len(), 2);
1427    }
1428
1429    #[test]
1430    fn s3fifo_layout_reset_stats() {
1431        let mut cache = S3FifoLayoutCache::new(64);
1432        let key = s3_fifo_test_key(0, 80);
1433        cache.get_or_compute(key, crate::Rects::new);
1434        cache.get_or_compute(key, crate::Rects::new);
1435        cache.reset_stats();
1436        let stats = cache.stats();
1437        assert_eq!(stats.hits, 0);
1438        assert_eq!(stats.misses, 0);
1439    }
1440
1441    #[test]
1442    fn s3fifo_layout_produces_same_results_as_lru() {
1443        let mut lru = LayoutCache::new(64);
1444        let mut s3 = S3FifoLayoutCache::new(64);
1445
1446        for w in [80, 100, 120, 160, 200] {
1447            let key = s3_fifo_test_key(0, w);
1448            let expected = smallvec::smallvec![Rect::new(0, 0, w / 2, 24)];
1449            let lru_r = lru.get_or_compute(key, || expected.clone());
1450            let s3_r = s3.get_or_compute(key, || expected.clone());
1451            assert_eq!(lru_r, s3_r, "mismatch for width={w}");
1452        }
1453    }
1454}