Skip to main content

oxiui_core/
cache.rs

1//! Layout memoization keyed by `(WidgetId, input Size, content hash)`.
2//!
3//! Re-laying out an unchanged subtree every frame is wasteful. [`LayoutCache`]
4//! stores the computed [`Rect`] for a node keyed by the inputs that determine
5//! it: the node's [`WidgetId`], the available [`Size`] it was laid out into, and
6//! a `u64` *content hash* the caller supplies (a digest of whatever else affects
7//! the result — text, font, child sizes, …). A lookup hits only when all three
8//! match, so any change in available space or content misses and recomputes.
9//!
10//! Entries can also be invalidated explicitly via a per-node **dirty** flag,
11//! which a tree mutation (resize, child add/remove) sets. The cache tracks hit
12//! and miss counts so callers can measure effectiveness.
13//!
14//! `f32` sizes are not `Hash`/`Eq`, so the size is quantised to its raw bits
15//! (`to_bits`) for the key; this treats `0.0` and `-0.0` as equal and is exact
16//! for finite values, which is what we want for cache identity.
17
18use crate::geometry::{Rect, Size};
19use crate::tree::WidgetId;
20use std::collections::{HashMap, HashSet};
21
22/// The composite key identifying a cached layout result.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24struct LayoutKey {
25    id: WidgetId,
26    width_bits: u32,
27    height_bits: u32,
28    content_hash: u64,
29}
30
31impl LayoutKey {
32    fn new(id: WidgetId, avail: Size, content_hash: u64) -> Self {
33        Self {
34            id,
35            width_bits: avail.width.to_bits(),
36            height_bits: avail.height.to_bits(),
37            content_hash,
38        }
39    }
40}
41
42/// A memoizing cache of computed layout rectangles.
43#[derive(Debug, Default)]
44pub struct LayoutCache {
45    entries: HashMap<LayoutKey, Rect>,
46    /// Nodes explicitly marked dirty; their entries are ignored until refreshed.
47    dirty: HashSet<WidgetId>,
48    hits: u64,
49    misses: u64,
50}
51
52impl LayoutCache {
53    /// Create an empty cache.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Number of stored entries.
59    pub fn len(&self) -> usize {
60        self.entries.len()
61    }
62
63    /// Whether the cache holds no entries.
64    pub fn is_empty(&self) -> bool {
65        self.entries.is_empty()
66    }
67
68    /// Cache hit count since creation (or last [`reset_stats`](Self::reset_stats)).
69    pub fn hits(&self) -> u64 {
70        self.hits
71    }
72
73    /// Cache miss count since creation (or last [`reset_stats`](Self::reset_stats)).
74    pub fn misses(&self) -> u64 {
75        self.misses
76    }
77
78    /// Hit rate in `[0, 1]`; `0` when there have been no accesses.
79    pub fn hit_rate(&self) -> f32 {
80        let total = self.hits + self.misses;
81        if total == 0 {
82            0.0
83        } else {
84            self.hits as f32 / total as f32
85        }
86    }
87
88    /// Reset the hit/miss counters (leaves entries intact).
89    pub fn reset_stats(&mut self) {
90        self.hits = 0;
91        self.misses = 0;
92    }
93
94    /// Look up the cached rect for `(id, avail, content_hash)`.
95    ///
96    /// Returns `None` (a miss) when there is no matching entry **or** when `id`
97    /// is currently marked dirty. Updates the hit/miss counters.
98    pub fn get(&mut self, id: WidgetId, avail: Size, content_hash: u64) -> Option<Rect> {
99        if self.dirty.contains(&id) {
100            self.misses += 1;
101            return None;
102        }
103        let key = LayoutKey::new(id, avail, content_hash);
104        match self.entries.get(&key) {
105            Some(rect) => {
106                self.hits += 1;
107                Some(*rect)
108            }
109            None => {
110                self.misses += 1;
111                None
112            }
113        }
114    }
115
116    /// Store the computed `rect` for `(id, avail, content_hash)`, clearing any
117    /// dirty flag on `id` (it is now freshly computed).
118    pub fn put(&mut self, id: WidgetId, avail: Size, content_hash: u64, rect: Rect) {
119        self.dirty.remove(&id);
120        self.entries
121            .insert(LayoutKey::new(id, avail, content_hash), rect);
122    }
123
124    /// Mark `id` dirty so its next [`get`](Self::get) misses regardless of key.
125    /// The stale entries are dropped immediately to bound memory.
126    pub fn invalidate(&mut self, id: WidgetId) {
127        self.dirty.insert(id);
128        self.entries.retain(|k, _| k.id != id);
129    }
130
131    /// Mark several nodes dirty at once (e.g. an invalidated subtree).
132    pub fn invalidate_many(&mut self, ids: impl IntoIterator<Item = WidgetId>) {
133        for id in ids {
134            self.invalidate(id);
135        }
136    }
137
138    /// Whether `id` is currently flagged dirty.
139    pub fn is_dirty(&self, id: WidgetId) -> bool {
140        self.dirty.contains(&id)
141    }
142
143    /// Drop every entry and dirty flag (a full layout invalidation).
144    pub fn clear(&mut self) {
145        self.entries.clear();
146        self.dirty.clear();
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn sz(w: f32, h: f32) -> Size {
155        Size::new(w, h)
156    }
157
158    #[test]
159    fn hit_after_put() {
160        let mut c = LayoutCache::new();
161        let id = WidgetId(1);
162        let rect = Rect::new(0.0, 0.0, 10.0, 20.0);
163        // First access misses.
164        assert!(c.get(id, sz(100.0, 100.0), 7).is_none());
165        c.put(id, sz(100.0, 100.0), 7, rect);
166        // Now it hits with identical inputs.
167        assert_eq!(c.get(id, sz(100.0, 100.0), 7), Some(rect));
168        assert_eq!(c.hits(), 1);
169        assert_eq!(c.misses(), 1);
170    }
171
172    #[test]
173    fn miss_on_different_size_or_content() {
174        let mut c = LayoutCache::new();
175        let id = WidgetId(1);
176        c.put(id, sz(100.0, 100.0), 7, Rect::ZERO);
177        // Different available size -> miss.
178        assert!(c.get(id, sz(120.0, 100.0), 7).is_none());
179        // Different content hash -> miss.
180        assert!(c.get(id, sz(100.0, 100.0), 8).is_none());
181        // Original still hits.
182        assert_eq!(c.get(id, sz(100.0, 100.0), 7), Some(Rect::ZERO));
183    }
184
185    #[test]
186    fn dirty_flag_forces_miss_until_refreshed() {
187        let mut c = LayoutCache::new();
188        let id = WidgetId(2);
189        c.put(id, sz(50.0, 50.0), 1, Rect::new(0.0, 0.0, 5.0, 5.0));
190        assert!(c.get(id, sz(50.0, 50.0), 1).is_some());
191        // Invalidate -> next get misses even with identical key.
192        c.invalidate(id);
193        assert!(c.is_dirty(id));
194        assert!(c.get(id, sz(50.0, 50.0), 1).is_none());
195        // Re-putting clears dirty and restores hits.
196        c.put(id, sz(50.0, 50.0), 1, Rect::new(0.0, 0.0, 5.0, 5.0));
197        assert!(!c.is_dirty(id));
198        assert!(c.get(id, sz(50.0, 50.0), 1).is_some());
199    }
200
201    #[test]
202    fn invalidate_drops_stale_entries() {
203        let mut c = LayoutCache::new();
204        let id = WidgetId(3);
205        // Two entries for the same node under different sizes.
206        c.put(id, sz(10.0, 10.0), 0, Rect::ZERO);
207        c.put(id, sz(20.0, 20.0), 0, Rect::ZERO);
208        assert_eq!(c.len(), 2);
209        c.invalidate(id);
210        // Both gone (bounded memory), node marked dirty.
211        assert_eq!(c.len(), 0);
212        assert!(c.is_dirty(id));
213    }
214
215    #[test]
216    fn hit_rate_and_reset() {
217        let mut c = LayoutCache::new();
218        let id = WidgetId(4);
219        c.put(id, sz(1.0, 1.0), 0, Rect::ZERO);
220        let _ = c.get(id, sz(1.0, 1.0), 0); // hit
221        let _ = c.get(id, sz(2.0, 2.0), 0); // miss
222        assert!((c.hit_rate() - 0.5).abs() < 1e-6);
223        c.reset_stats();
224        assert_eq!(c.hits(), 0);
225        assert_eq!(c.misses(), 0);
226        assert_eq!(c.hit_rate(), 0.0);
227    }
228
229    #[test]
230    fn invalidate_many_and_clear() {
231        let mut c = LayoutCache::new();
232        c.put(WidgetId(1), sz(1.0, 1.0), 0, Rect::ZERO);
233        c.put(WidgetId(2), sz(1.0, 1.0), 0, Rect::ZERO);
234        c.invalidate_many([WidgetId(1), WidgetId(2)]);
235        assert!(c.is_dirty(WidgetId(1)) && c.is_dirty(WidgetId(2)));
236        c.clear();
237        assert!(c.is_empty());
238        assert!(!c.is_dirty(WidgetId(1)));
239    }
240}