Skip to main content

ratatui_style/
cache.rs

1//! An opt-in LRU cache for [`ComputedStyle`](crate::ComputedStyle) results.
2//!
3//! The cascade is pure: given the same `(node identity, ancestor chain, media
4//! context, stylesheet generation)`, it always produces the same
5//! `ComputedStyle`. A stable TUI tree re-renders frame after frame with no
6//! inputs changed, so recomputing every node's style each frame is wasted work.
7//! This module memoizes those results.
8//!
9//! The cache is **opt-in and off by default**. `CascadeContext::new` and the
10//! one-shot `Stylesheet::compute` path are byte-for-byte identical in behavior
11//! and overhead to the uncached baseline. Attach a cache via
12//! [`CascadeContext::with_cache`](crate::CascadeContext::with_cache).
13//!
14//! # Correctness backbone
15//!
16//! Two invariants make the cache safe:
17//!
18//! 1. **The signature captures every input.** [`node_signature`] folds the
19//!    node's selector-relevant identity (type, id, sorted classes, state bits,
20//!    position), the PARENT's signature (which transitively captures the whole
21//!    ancestor chain), the previous-sibling identities (for `+`/`~` sibling
22//!    combinators), and the media context. Two computes with the same signature
23//!    produce the same `ComputedStyle`.
24//!
25//! 2. **Stylesheet mutations auto-invalidate.** [`Stylesheet`](crate::Stylesheet)
26//!    bumps its generation counter at the start of every mutation that can
27//!    change compute output (`add`, `add_rule`, `extend`, `tokens_mut`). The
28//!    cache detects a generation mismatch on every lookup/insert and clears
29//!    itself entirely — so a stylesheet edit between two walks throws away
30//!    every stale entry with no caller action.
31//!
32//! # Eviction policy
33//!
34//! Capacity is a hard bound. The cache is an **access-order LRU**: the
35//! least-recently-USED entry is evicted when a NEW key must be inserted into a
36//! full cache. Both a `get` hit and an `insert` of an existing key promote that
37//! key to the back of the order (most-recently-used), so hot entries survive
38//! longer than cold ones. Eviction takes from the front (least-recently-used).
39//!
40//! Promotion uses a linear scan over the order deque, so `get` and `insert` are
41//! O(capacity). That is fine here — the cache is small and bounded — but it
42//! means a very large capacity would raise per-access cost. Capacity 0 disables
43//! storage entirely (`get` always misses, `insert` is a no-op).
44
45use std::collections::hash_map::DefaultHasher;
46use std::collections::{HashMap, VecDeque};
47use std::hash::{Hash, Hasher};
48
49use crate::cascade::ComputedStyle;
50use crate::media::MediaContext;
51use crate::node::{Position, State};
52use crate::selector::NodeIdentity;
53
54/// An opt-in bounded cache for [`ComputedStyle`] results, keyed by an opaque
55/// signature that captures (node identity, ancestor-chain signature, media
56/// context, stylesheet generation).
57///
58/// See the [module docs](crate::cache) for the correctness invariants and the
59/// eviction policy. Two computes with the same signature yield the same
60/// `ComputedStyle`, so a hit short-circuits the cascade. The cache is an
61/// **access-order LRU**: both a `get` hit and an `insert` of an existing key
62/// promote that key to most-recently-used, and the least-recently-used entry is
63/// evicted when the cache is full.
64///
65/// `get` takes `&mut self` for two reasons: a stylesheet generation mismatch
66/// (detected on every access) clears the cache in place — see
67/// [`Self::check_generation`] — and a hit promotes the key to the back of the
68/// eviction order. Both `get` and `insert` are O(capacity) because promotion
69/// scans the order deque.
70pub struct ComputeCache {
71    entries: HashMap<u64, ComputedStyle>,
72    order: VecDeque<u64>,
73    capacity: usize,
74    /// The [`Stylesheet::generation`](crate::Stylesheet::generation) this cache
75    /// currently holds entries for. Any mismatch against the live generation
76    /// clears the cache.
77    generation: u64,
78}
79
80impl ComputeCache {
81    /// Build a cache with the given hard capacity. `capacity == 0` disables
82    /// storage: `get` always returns `None` and `insert` is a no-op.
83    pub fn new(capacity: usize) -> Self {
84        Self {
85            entries: HashMap::new(),
86            order: VecDeque::new(),
87            capacity,
88            generation: 0,
89        }
90    }
91
92    /// If `current_gen` differs from the generation this cache was populated
93    /// under, clear every entry and adopt the new generation. Called at the
94    /// start of every `get`/`insert` so a stylesheet mutation between accesses
95    /// auto-invalidates the whole cache.
96    fn check_generation(&mut self, current_gen: u64) {
97        if self.generation != current_gen {
98            self.clear();
99            self.generation = current_gen;
100        }
101    }
102
103    /// Look up a cached result by signature. Returns an owned
104    /// [`ComputedStyle`] clone on a hit (cheap — a post-resolution
105    /// `ComputedStyle` holds no heap `Color::Var`).
106    ///
107    /// A hit promotes `sig` to the back of the eviction order
108    /// (most-recently-used), so frequently accessed entries survive longer.
109    /// This is the access-order LRU promotion. The scan over the order deque
110    /// makes `get` O(capacity); acceptable for a small bounded cache. A miss
111    /// does NOT mutate the order.
112    ///
113    /// `&mut self` because [`check_generation`](Self::check_generation) may
114    /// clear, and a hit promotes.
115    pub fn get(&mut self, sig: u64, current_gen: u64) -> Option<ComputedStyle> {
116        self.check_generation(current_gen);
117        if self.entries.contains_key(&sig) {
118            // Promote to most-recently-used (back of the order).
119            if let Some(pos) = self.order.iter().position(|&k| k == sig) {
120                self.order.remove(pos);
121            }
122            self.order.push_back(sig);
123        }
124        self.entries.get(&sig).cloned()
125    }
126
127    /// Insert a computed result under `sig`. If the key already exists, update
128    /// its value and move it to the back of the eviction order
129    /// (most-recently-used) — an access-order LRU promotion. If at capacity and
130    /// the key is new, evict the least-recently-used entry (front of the order).
131    /// A no-op when capacity is 0. O(capacity) due to the promotion scan.
132    pub fn insert(&mut self, sig: u64, value: ComputedStyle, current_gen: u64) {
133        self.check_generation(current_gen);
134        if self.capacity == 0 {
135            return;
136        }
137        if let Some(existing) = self.entries.get_mut(&sig) {
138            // Update in place; refresh its position in the order.
139            *existing = value;
140            if let Some(pos) = self.order.iter().position(|&k| k == sig) {
141                self.order.remove(pos);
142            }
143            self.order.push_back(sig);
144            return;
145        }
146        // New key. Evict the oldest insertion if at capacity.
147        while self.entries.len() >= self.capacity {
148            if let Some(evicted) = self.order.pop_front() {
149                self.entries.remove(&evicted);
150            } else {
151                break;
152            }
153        }
154        self.entries.insert(sig, value);
155        self.order.push_back(sig);
156    }
157
158    /// Drop every entry. Keeps the capacity for reuse.
159    pub fn clear(&mut self) {
160        self.entries.clear();
161        self.order.clear();
162    }
163
164    /// Number of entries currently cached.
165    pub fn len(&self) -> usize {
166        self.entries.len()
167    }
168
169    /// Whether the cache holds zero entries.
170    pub fn is_empty(&self) -> bool {
171        self.entries.is_empty()
172    }
173}
174
175impl Default for ComputeCache {
176    fn default() -> Self {
177        Self::new(0)
178    }
179}
180
181/// Fold every selector-relevant field of a node into a 64-bit signature.
182///
183/// The signature folds:
184/// - **Node identity**: `type_name`, `id`, `classes` (sorted before hashing so
185///   order is irrelevant — class match is set-membership), `state` bits, and
186///   `position` (`index`, `sibling_count`, `parent_type`).
187/// - **The parent's signature** (`parent_sig`): transitively captures the
188///   entire ancestor chain, so descendant/child combinator and inheritance
189///   dependencies are captured by a single 64-bit fold.
190/// - **The previous-sibling identities** (`siblings`): the adjacent (`+`) and
191///   general (`~`) sibling combinators match against these, so they must be in
192///   the signature. Each sibling's selector-relevant fields are folded in
193///   order. Empty slice for the no-sibling / one-shot path.
194/// - **The media context**: `cols`, `rows`, `truecolor`, `no_color`.
195///
196/// Two nodes with identical `(identity, parent_sig, siblings, media)` produce
197/// identical signatures (deterministic hashing); differing in any folded field
198/// differs the signature with overwhelming probability.
199///
200/// Uses [`DefaultHasher`] (no new dependency). The exact hash value is an
201/// implementation detail and MUST NOT be relied upon across builds — only
202/// equality within a single run.
203pub(crate) fn node_signature(
204    node_id: &NodeIdentity,
205    parent_sig: Option<u64>,
206    siblings: &[NodeIdentity],
207    media: &MediaContext,
208) -> u64 {
209    let mut h = DefaultHasher::new();
210
211    // Marker so a re-ordering of fields never silently collides with an older
212    // layout — bumped if the folded set ever changes.
213    0xC5_C4_06_14u64.hash(&mut h);
214
215    // Parent signature first: transitive ancestor chain.
216    parent_sig.hash(&mut h);
217
218    // Node identity — in a fixed order.
219    node_id.type_name.hash(&mut h);
220    node_id.id.hash(&mut h);
221
222    // Classes are set-membership, so sort before hashing for order-independence.
223    let mut sorted: Vec<&str> = node_id.classes.iter().map(String::as_str).collect();
224    sorted.sort_unstable();
225    sorted.len().hash(&mut h);
226    for c in sorted {
227        c.hash(&mut h);
228    }
229
230    hash_state(&mut h, node_id.state);
231    hash_position(&mut h, &node_id.position);
232
233    // Previous siblings (in order — the adjacent combinator keys off the LAST
234    // one, the general combinator off the whole list). Fold each one's
235    // selector-relevant fields.
236    siblings.len().hash(&mut h);
237    for sib in siblings {
238        sib.type_name.hash(&mut h);
239        sib.id.hash(&mut h);
240        let mut sib_classes: Vec<&str> = sib.classes.iter().map(String::as_str).collect();
241        sib_classes.sort_unstable();
242        sib_classes.len().hash(&mut h);
243        for c in sib_classes {
244            c.hash(&mut h);
245        }
246        hash_state(&mut h, sib.state);
247        hash_position(&mut h, &sib.position);
248    }
249
250    // Media context bytes.
251    media.cols.hash(&mut h);
252    media.rows.hash(&mut h);
253    media.truecolor.hash(&mut h);
254    media.no_color.hash(&mut h);
255
256    h.finish()
257}
258
259fn hash_state(h: &mut DefaultHasher, state: State) {
260    state.focus.hash(h);
261    state.hover.hash(h);
262    state.disabled.hash(h);
263    state.checked.hash(h);
264    state.active.hash(h);
265}
266
267fn hash_position(h: &mut DefaultHasher, pos: &Position) {
268    pos.index.hash(h);
269    pos.sibling_count.hash(h);
270    pos.parent_type.hash(h);
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::cascade::ComputedStyle;
277    use crate::node::Position;
278    use crate::selector::NodeIdentity;
279    use ratatui::style::Color as RC;
280
281    // ---- signature tests ---------------------------------------------------
282
283    fn nid(type_name: &str) -> NodeIdentity {
284        NodeIdentity {
285            type_name: type_name.to_string(),
286            id: None,
287            classes: Vec::new(),
288            state: State::empty(),
289            position: Position::default(),
290        }
291    }
292
293    fn nid_with_classes(type_name: &str, classes: &[&str]) -> NodeIdentity {
294        NodeIdentity {
295            type_name: type_name.to_string(),
296            id: None,
297            classes: classes.iter().map(|s| s.to_string()).collect(),
298            state: State::empty(),
299            position: Position::default(),
300        }
301    }
302
303    fn default_media() -> MediaContext {
304        MediaContext::default()
305    }
306
307    #[test]
308    fn signature_identical_inputs_match() {
309        let a = nid("Button");
310        let b = nid("Button");
311        let m = default_media();
312        assert_eq!(
313            node_signature(&a, None, &[], &m),
314            node_signature(&b, None, &[], &m)
315        );
316    }
317
318    #[test]
319    fn signature_differs_on_type() {
320        let m = default_media();
321        let a = nid("Button");
322        let b = nid("Text");
323        assert_ne!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
324    }
325
326    #[test]
327    fn signature_differs_on_id() {
328        let m = default_media();
329        let mut a = nid("Button");
330        a.id = Some("save".to_string());
331        let b = nid("Button");
332        assert_ne!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
333    }
334
335    #[test]
336    fn signature_classes_are_order_independent() {
337        let m = default_media();
338        let a = nid_with_classes("Button", &["primary", "large"]);
339        let b = nid_with_classes("Button", &["large", "primary"]);
340        assert_eq!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
341    }
342
343    #[test]
344    fn signature_differs_on_classes() {
345        let m = default_media();
346        let a = nid_with_classes("Button", &["primary"]);
347        let b = nid_with_classes("Button", &["secondary"]);
348        assert_ne!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
349    }
350
351    #[test]
352    fn signature_differs_on_state() {
353        let m = default_media();
354        let mut a = nid("Button");
355        a.state = State::focus();
356        let b = nid("Button");
357        assert_ne!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
358    }
359
360    #[test]
361    fn signature_differs_on_position() {
362        let m = default_media();
363        let mut a = nid("Item");
364        a.position = Position::new(0, 3);
365        let mut b = nid("Item");
366        b.position = Position::new(1, 3);
367        assert_ne!(node_signature(&a, None, &[], &m), node_signature(&b, None, &[], &m));
368    }
369
370    #[test]
371    fn signature_differs_on_parent_sig() {
372        let m = default_media();
373        let n = nid("Text");
374        let s_none = node_signature(&n, None, &[], &m);
375        let s_some = node_signature(&n, Some(42), &[], &m);
376        assert_ne!(s_none, s_some);
377    }
378
379    #[test]
380    fn signature_differs_on_media() {
381        let n = nid("Button");
382        let m1 = MediaContext { cols: 80, ..Default::default() };
383        let m2 = MediaContext { cols: 100, ..Default::default() };
384        assert_ne!(node_signature(&n, None, &[], &m1), node_signature(&n, None, &[], &m2));
385
386        // truecolor flag
387        let mt = MediaContext { truecolor: true, ..Default::default() };
388        let mf = MediaContext { truecolor: false, ..Default::default() };
389        assert_ne!(node_signature(&n, None, &[], &mt), node_signature(&n, None, &[], &mf));
390
391        // no_color flag
392        let mc = MediaContext { no_color: true, ..Default::default() };
393        let mn = MediaContext { no_color: false, ..Default::default() };
394        assert_ne!(node_signature(&n, None, &[], &mc), node_signature(&n, None, &[], &mn));
395    }
396
397    #[test]
398    fn signature_transitively_captures_parent() {
399        // Two Text nodes with identical identity but different parent
400        // signatures get different signatures.
401        let m = default_media();
402        let n = nid("Text");
403        let s1 = node_signature(&n, Some(111), &[], &m);
404        let s2 = node_signature(&n, Some(222), &[], &m);
405        assert_ne!(s1, s2);
406    }
407
408    #[test]
409    fn signature_differs_on_siblings() {
410        // A node with no previous siblings vs the same node with one previous
411        // sibling must get different signatures — the adjacent (`+`) and
412        // general (`~`) sibling combinators depend on the previous-sibling list.
413        let m = default_media();
414        let n = nid("Item");
415        let no_sibs = node_signature(&n, None, &[], &m);
416        let with_sib = node_signature(&n, None, std::slice::from_ref(&nid("Item")), &m);
417        assert_ne!(no_sibs, with_sib);
418
419        // Different sibling content → different signature.
420        let with_other = node_signature(&n, None, std::slice::from_ref(&nid("Other")), &m);
421        assert_ne!(with_sib, with_other);
422    }
423
424    // ---- ComputeCache tests ------------------------------------------------
425
426    fn cs(c: RC) -> ComputedStyle {
427        ComputedStyle::new(crate::style::CssStyle::new().color(c))
428    }
429
430    #[test]
431    fn cache_get_miss_on_empty() {
432        let mut cache = ComputeCache::new(8);
433        assert!(cache.get(1, 0).is_none());
434        assert!(cache.is_empty());
435        assert_eq!(cache.len(), 0);
436    }
437
438    #[test]
439    fn cache_insert_then_get_hit() {
440        let mut cache = ComputeCache::new(8);
441        let val = cs(RC::Red);
442        cache.insert(1, val.clone(), 0);
443        let got = cache.get(1, 0).expect("hit");
444        assert_eq!(got, val);
445        assert_eq!(cache.len(), 1);
446    }
447
448    #[test]
449    fn lru_get_promotes_entry_so_it_survives_eviction() {
450        let mut cache = ComputeCache::new(2);
451        cache.insert(10, cs(RC::Red), 0); // A
452        cache.insert(20, cs(RC::Blue), 0); // B
453        // Order (front -> back): [A, B]. Promote A via a get hit.
454        let _ = cache.get(10, 0);
455        // Order is now [B, A]. Inserting C evicts the LRU, which is B — not A.
456        cache.insert(30, cs(RC::Green), 0); // C
457        assert!(cache.get(10, 0).is_some(), "A survived because it was promoted");
458        assert!(cache.get(20, 0).is_none(), "B evicted as least-recently-used");
459        assert!(cache.get(30, 0).is_some(), "C present");
460        assert_eq!(cache.len(), 2);
461    }
462
463    #[test]
464    fn lru_insert_update_promotes() {
465        let mut cache = ComputeCache::new(2);
466        cache.insert(10, cs(RC::Red), 0); // A
467        cache.insert(20, cs(RC::Blue), 0); // B
468        // Update A in place — this also promotes A to most-recently-used.
469        cache.insert(10, cs(RC::Yellow), 0);
470        // Order is now [B, A]. Inserting C evicts the LRU = B, not A.
471        cache.insert(30, cs(RC::Green), 0); // C
472        let got = cache.get(10, 0).expect("A present (promoted by update)");
473        assert_eq!(got.style.color, Some(crate::color::Color::literal(RC::Yellow)));
474        assert!(cache.get(20, 0).is_none(), "B evicted as least-recently-used");
475        assert!(cache.get(30, 0).is_some(), "C present");
476        assert_eq!(cache.len(), 2);
477    }
478
479    #[test]
480    fn lru_miss_does_not_reorder() {
481        let mut cache = ComputeCache::new(2);
482        cache.insert(10, cs(RC::Red), 0); // A
483        cache.insert(20, cs(RC::Blue), 0); // B
484        // A miss must NOT mutate the order — A stays the least-recently-used.
485        assert!(cache.get(999, 0).is_none());
486        // Inserting C therefore evicts A (the LRU), leaving B and C.
487        cache.insert(30, cs(RC::Green), 0); // C
488        assert!(cache.get(10, 0).is_none(), "A evicted (oldest, never promoted)");
489        assert!(cache.get(20, 0).is_some(), "B present");
490        assert!(cache.get(30, 0).is_some(), "C present");
491        assert_eq!(cache.len(), 2);
492    }
493
494    #[test]
495    fn lru_capacity_zero_never_stores() {
496        let mut cache = ComputeCache::new(0);
497        cache.insert(1, cs(RC::Red), 0);
498        assert!(cache.is_empty());
499        assert_eq!(cache.len(), 0);
500        assert!(cache.get(1, 0).is_none());
501    }
502
503    #[test]
504    fn lru_generation_clear_resets_order() {
505        // Insert under gen 0.
506        let mut cache = ComputeCache::new(2);
507        cache.insert(10, cs(RC::Red), 0); // A
508        cache.insert(20, cs(RC::Blue), 0); // B
509        // Bump gen — the next access clears everything, including the order.
510        // Inserting under gen 1 starts fresh: old A is gone.
511        cache.insert(30, cs(RC::Green), 1); // C, fresh sequence
512        assert!(cache.get(10, 1).is_none(), "A cleared by generation bump");
513        // The fresh order should be just [C] (capacity 2, one entry).
514        // Fill it again to confirm eviction follows the NEW order, not the old.
515        cache.insert(40, cs(RC::Yellow), 1); // D
516        assert_eq!(cache.len(), 2);
517        // Promote C, then insert E — D becomes the LRU and is evicted.
518        let _ = cache.get(30, 1);
519        cache.insert(50, cs(RC::Magenta), 1); // E
520        assert!(cache.get(30, 1).is_some(), "C survived (promoted)");
521        assert!(cache.get(40, 1).is_none(), "D evicted as LRU of the fresh order");
522        assert!(cache.get(50, 1).is_some(), "E present");
523    }
524
525    #[test]
526    fn cache_generation_mismatch_clears_on_get() {
527        let mut cache = ComputeCache::new(8);
528        cache.insert(1, cs(RC::Red), 0);
529        assert_eq!(cache.len(), 1);
530        // get under a different generation: clears, returns None.
531        let got = cache.get(1, 1);
532        assert!(got.is_none());
533        assert!(cache.is_empty(), "cache cleared by gen mismatch");
534    }
535
536    #[test]
537    fn cache_generation_mismatch_clears_on_insert() {
538        let mut cache = ComputeCache::new(8);
539        cache.insert(1, cs(RC::Red), 0);
540        assert_eq!(cache.len(), 1);
541        // Insert under a different generation: clears, then inserts the new key.
542        cache.insert(2, cs(RC::Blue), 1);
543        assert_eq!(cache.len(), 1, "old entry cleared, only the new one remains");
544        // The old key is gone.
545        assert!(cache.get(1, 1).is_none());
546        // The new one is present.
547        assert!(cache.get(2, 1).is_some());
548    }
549
550    #[test]
551    fn cache_capacity_zero_never_stores() {
552        let mut cache = ComputeCache::new(0);
553        cache.insert(1, cs(RC::Red), 0);
554        assert_eq!(cache.len(), 0);
555        assert!(cache.is_empty());
556        assert!(cache.get(1, 0).is_none());
557    }
558
559    #[test]
560    fn cache_clear_drops_entries() {
561        let mut cache = ComputeCache::new(8);
562        cache.insert(1, cs(RC::Red), 0);
563        cache.insert(2, cs(RC::Blue), 0);
564        cache.clear();
565        assert!(cache.is_empty());
566        assert_eq!(cache.len(), 0);
567        assert!(cache.get(1, 0).is_none());
568    }
569
570    #[test]
571    fn cache_stable_across_same_gen_lookups() {
572        // Many gets/inserts under the same generation never clear.
573        let mut cache = ComputeCache::new(4);
574        for i in 0u64..4 {
575            cache.insert(i, cs(RC::Red), 0);
576        }
577        for i in 0u64..4 {
578            assert!(cache.get(i, 0).is_some());
579        }
580        assert_eq!(cache.len(), 4);
581    }
582}