Skip to main content

oxiui_text/
cache.rs

1//! Hand-rolled LRU shaping cache — no external `lru` crate.
2//!
3//! Uses [`std::collections::HashMap`] for O(1) lookup and a
4//! [`std::collections::VecDeque`] as a recency queue (front = most recently
5//! used).
6
7use std::collections::{HashMap, VecDeque};
8
9use crate::ShapedText;
10
11// ── CacheKey ──────────────────────────────────────────────────────────────────
12
13/// Key that uniquely identifies a shaping request.
14#[derive(Hash, Eq, PartialEq, Clone, Debug)]
15pub struct CacheKey {
16    /// The text to shape.
17    pub text: String,
18    /// The font family, if any.
19    pub font_family: Option<String>,
20    /// `font_size.to_bits()` — bit-exact f32 for hashing.
21    pub font_size_bits: u32,
22    /// `max_width.to_bits()`.
23    pub max_width_bits: u32,
24}
25
26// ── ShapingCache ──────────────────────────────────────────────────────────────
27
28/// LRU cache mapping [`CacheKey`] → [`ShapedText`].
29///
30/// Capacity is the maximum number of entries held in memory.  When capacity
31/// is exceeded the least-recently-used entry is evicted.
32pub struct ShapingCache {
33    capacity: usize,
34    entries: HashMap<CacheKey, ShapedText>,
35    /// Front = most recently used, back = least recently used.
36    order: VecDeque<CacheKey>,
37    hits: u64,
38    misses: u64,
39    evictions: u64,
40}
41
42impl ShapingCache {
43    /// Create a new cache with the given maximum `capacity`.
44    ///
45    /// A capacity of 0 is treated as effectively disabled (every lookup is a
46    /// miss and nothing is stored).
47    pub fn new(capacity: usize) -> Self {
48        Self {
49            capacity,
50            entries: HashMap::new(),
51            order: VecDeque::new(),
52            hits: 0,
53            misses: 0,
54            evictions: 0,
55        }
56    }
57
58    /// Look up `key` and return a reference to the cached [`ShapedText`] if
59    /// present.
60    ///
61    /// On a hit the key is promoted to the front (most-recently-used)
62    /// position.
63    pub fn get(&mut self, key: &CacheKey) -> Option<&ShapedText> {
64        if self.entries.contains_key(key) {
65            self.hits += 1;
66            // Promote key to the front of the recency queue.
67            if let Some(pos) = self.order.iter().position(|k| k == key) {
68                self.order.remove(pos);
69            }
70            self.order.push_front(key.clone());
71            self.entries.get(key)
72        } else {
73            self.misses += 1;
74            None
75        }
76    }
77
78    /// Insert a `value` for `key`.
79    ///
80    /// If the cache is at capacity the least-recently-used entry is evicted
81    /// first.  If `capacity == 0` the entry is never stored.
82    pub fn insert(&mut self, key: CacheKey, value: ShapedText) {
83        if self.capacity == 0 {
84            return;
85        }
86        // If already present, update in place and promote.
87        if self.entries.contains_key(&key) {
88            if let Some(pos) = self.order.iter().position(|k| k == &key) {
89                self.order.remove(pos);
90            }
91            self.order.push_front(key.clone());
92            self.entries.insert(key, value);
93            return;
94        }
95        // Evict LRU if at capacity.
96        while self.entries.len() >= self.capacity {
97            self.evict_lru();
98        }
99        self.order.push_front(key.clone());
100        self.entries.insert(key, value);
101    }
102
103    /// Return the cache hit rate as `hits / (hits + misses)`, or `0.0` when
104    /// no lookups have been performed.
105    pub fn hit_rate(&self) -> f64 {
106        let total = self.hits + self.misses;
107        if total == 0 {
108            0.0
109        } else {
110            self.hits as f64 / total as f64
111        }
112    }
113
114    /// Return `(hits, misses, evictions)`.
115    pub fn stats(&self) -> (u64, u64, u64) {
116        (self.hits, self.misses, self.evictions)
117    }
118
119    /// Clear all entries and reset statistics.
120    pub fn clear(&mut self) {
121        self.entries.clear();
122        self.order.clear();
123        self.hits = 0;
124        self.misses = 0;
125        self.evictions = 0;
126    }
127
128    /// Return the number of entries currently in the cache.
129    pub fn len(&self) -> usize {
130        self.entries.len()
131    }
132
133    /// Returns `true` when the cache is empty.
134    pub fn is_empty(&self) -> bool {
135        self.entries.is_empty()
136    }
137
138    /// Remove the least-recently-used entry (back of `order`).
139    fn evict_lru(&mut self) {
140        if let Some(lru_key) = self.order.pop_back() {
141            self.entries.remove(&lru_key);
142            self.evictions += 1;
143        }
144    }
145}
146
147// ── Tests ─────────────────────────────────────────────────────────────────────
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn make_key(text: &str) -> CacheKey {
154        CacheKey {
155            text: text.to_owned(),
156            font_family: None,
157            font_size_bits: 16.0_f32.to_bits(),
158            max_width_bits: 0.0_f32.to_bits(),
159        }
160    }
161
162    fn shaped(w: f32, h: f32) -> ShapedText {
163        ShapedText {
164            lines: Vec::new(),
165            total_width: w,
166            total_height: h,
167        }
168    }
169
170    #[test]
171    fn cache_hit_after_insert() {
172        let mut cache = ShapingCache::new(8);
173        let k = make_key("hello");
174        cache.insert(k.clone(), shaped(60.0, 16.0));
175        assert!(cache.get(&k).is_some());
176    }
177
178    #[test]
179    fn cache_miss_after_eviction() {
180        // Capacity=1: insert A then B → A is evicted, get(A) returns None.
181        let mut cache = ShapingCache::new(1);
182        let a = make_key("A");
183        let b = make_key("B");
184        cache.insert(a.clone(), shaped(10.0, 16.0));
185        cache.insert(b.clone(), shaped(10.0, 16.0));
186        assert!(cache.get(&a).is_none(), "A should have been evicted");
187        assert!(cache.get(&b).is_some(), "B should still be present");
188    }
189
190    #[test]
191    fn cache_hit_rate_tracking() {
192        let mut cache = ShapingCache::new(8);
193        let k = make_key("x");
194        cache.insert(k.clone(), shaped(5.0, 16.0));
195
196        // 3 hits
197        cache.get(&k);
198        cache.get(&k);
199        cache.get(&k);
200        // 2 misses
201        cache.get(&make_key("missing1"));
202        cache.get(&make_key("missing2"));
203
204        let rate = cache.hit_rate();
205        assert!(
206            (rate - 0.6).abs() < 1e-9,
207            "hit rate should be 0.6, got {rate}"
208        );
209    }
210
211    #[test]
212    fn cache_clear_resets_everything() {
213        let mut cache = ShapingCache::new(4);
214        let k = make_key("hello");
215        cache.insert(k.clone(), shaped(10.0, 16.0));
216        cache.get(&k);
217        cache.clear();
218        assert!(cache.is_empty());
219        let (h, m, e) = cache.stats();
220        assert_eq!((h, m, e), (0, 0, 0));
221        assert!(cache.get(&k).is_none());
222    }
223
224    #[test]
225    fn cache_lru_order_promotion() {
226        // Capacity=2: insert A, B; get(A) promotes A; insert C → evicts B not A.
227        let mut cache = ShapingCache::new(2);
228        let a = make_key("A");
229        let b = make_key("B");
230        let c = make_key("C");
231
232        cache.insert(a.clone(), shaped(1.0, 1.0));
233        cache.insert(b.clone(), shaped(2.0, 2.0));
234        // Promote A
235        cache.get(&a);
236        // Insert C — should evict B (LRU)
237        cache.insert(c.clone(), shaped(3.0, 3.0));
238
239        assert!(
240            cache.get(&a).is_some(),
241            "A was promoted, must still be present"
242        );
243        assert!(
244            cache.get(&b).is_none(),
245            "B is LRU after A was promoted, must be evicted"
246        );
247        assert!(
248            cache.get(&c).is_some(),
249            "C was just inserted, must be present"
250        );
251    }
252
253    #[test]
254    fn cache_zero_capacity_never_stores() {
255        let mut cache = ShapingCache::new(0);
256        let k = make_key("hello");
257        cache.insert(k.clone(), shaped(10.0, 16.0));
258        assert!(cache.get(&k).is_none());
259        assert!(cache.is_empty());
260    }
261
262    #[test]
263    fn cache_evictions_count() {
264        let mut cache = ShapingCache::new(1);
265        cache.insert(make_key("A"), shaped(1.0, 1.0));
266        cache.insert(make_key("B"), shaped(1.0, 1.0));
267        cache.insert(make_key("C"), shaped(1.0, 1.0));
268        let (_, _, e) = cache.stats();
269        assert_eq!(e, 2, "two evictions should have occurred");
270    }
271}