1use kurbo::BezPath;
18use std::collections::{HashMap, VecDeque};
19
20struct Entry {
29 path: BezPath,
30 touched: u64,
31}
32
33pub struct GlyphOutlineCache {
45 entries: HashMap<u128, Entry>,
46 recency: VecDeque<(u128, u64)>,
54 scratch: Option<BezPath>,
58 hits: u64,
59 misses: u64,
60 capacity: usize,
61 enabled: bool,
62}
63
64impl std::fmt::Debug for GlyphOutlineCache {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("GlyphOutlineCache")
67 .field("len", &self.entries.len())
68 .field("hits", &self.hits)
69 .field("misses", &self.misses)
70 .field("capacity", &self.capacity)
71 .field("enabled", &self.enabled)
72 .finish_non_exhaustive()
73 }
74}
75
76impl GlyphOutlineCache {
77 pub const DEFAULT_CAPACITY: usize = 4096;
88
89 pub fn new() -> Self {
91 Self::with_capacity(Self::DEFAULT_CAPACITY)
92 }
93
94 pub fn with_capacity(capacity: usize) -> Self {
99 Self {
100 entries: HashMap::new(),
101 recency: VecDeque::new(),
102 scratch: None,
103 hits: 0,
104 misses: 0,
105 capacity,
106 enabled: true,
107 }
108 }
109
110 pub fn set_enabled(&mut self, enabled: bool) {
114 self.enabled = enabled;
115 }
116
117 pub fn is_enabled(&self) -> bool {
119 self.enabled
120 }
121
122 pub fn hits(&self) -> u64 {
124 self.hits
125 }
126
127 pub fn misses(&self) -> u64 {
131 self.misses
132 }
133
134 pub fn len(&self) -> usize {
136 self.entries.len()
137 }
138
139 pub fn is_empty(&self) -> bool {
141 self.entries.is_empty()
142 }
143
144 pub fn capacity(&self) -> usize {
146 self.capacity
147 }
148
149 pub fn clear(&mut self) {
151 self.entries.clear();
152 self.recency.clear();
153 self.scratch = None;
154 self.hits = 0;
155 self.misses = 0;
156 }
157
158 pub(crate) fn get_or_insert_with(
166 &mut self,
167 key: u128,
168 build: impl FnOnce() -> BezPath,
169 ) -> &BezPath {
170 if !self.enabled {
171 self.misses += 1;
172 self.scratch = Some(build());
173 return self.scratch.as_ref().expect("outline just stored");
174 }
175 if self.entries.contains_key(&key) {
176 self.hits += 1;
177 let touched = {
178 let entry = self.entries.get_mut(&key).expect("key just checked");
179 entry.touched += 1;
180 entry.touched
181 };
182 self.recency.push_back((key, touched));
183 self.compact_recency();
184 return &self.entries.get(&key).expect("key just checked").path;
185 }
186 self.misses += 1;
187 let path = build();
188 if self.capacity == 0 {
189 self.scratch = Some(path);
190 return self.scratch.as_ref().expect("outline just stored");
191 }
192 if self.entries.len() >= self.capacity {
193 self.evict_lru();
194 }
195 self.entries.insert(key, Entry { path, touched: 1 });
196 self.recency.push_back((key, 1));
197 self.compact_recency();
198 &self.entries.get(&key).expect("outline just inserted").path
199 }
200
201 fn evict_lru(&mut self) {
206 while let Some((key, touched)) = self.recency.pop_front() {
207 let live = self
208 .entries
209 .get(&key)
210 .is_some_and(|entry| entry.touched == touched);
211 if live {
212 self.entries.remove(&key);
213 return;
214 }
215 }
216 debug_assert!(false, "LRU queue lost its live pair");
217 if let Some(victim) = self.entries.keys().next().copied() {
218 self.entries.remove(&victim);
219 }
220 }
221
222 fn compact_recency(&mut self) {
227 let limit = self.capacity.saturating_mul(4).max(16);
228 if self.recency.len() <= limit {
229 return;
230 }
231 let live: Vec<(u128, u64)> = self
232 .recency
233 .drain(..)
234 .filter(|(key, touched)| {
235 self.entries
236 .get(key)
237 .is_some_and(|entry| entry.touched == *touched)
238 })
239 .collect();
240 self.recency.extend(live);
241 }
242}
243
244impl Default for GlyphOutlineCache {
245 fn default() -> Self {
246 Self::new()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::cell::Cell;
254
255 fn path_with_seed(seed: f64) -> BezPath {
256 let mut path = BezPath::new();
257 path.move_to((seed, seed));
258 path.line_to((seed + 1.0, seed + 1.0));
259 path
260 }
261
262 #[test]
263 fn hit_returns_cached_path_and_counts_stats() {
264 let builds = Cell::new(0u32);
265 let mut cache = GlyphOutlineCache::new();
266 let first = cache
267 .get_or_insert_with(7, || {
268 builds.set(builds.get() + 1);
269 path_with_seed(1.0)
270 })
271 .elements()
272 .to_vec();
273 let second = cache
274 .get_or_insert_with(7, || {
275 builds.set(builds.get() + 1);
276 path_with_seed(2.0)
277 })
278 .elements()
279 .to_vec();
280 assert_eq!(first, second, "hit must return the stored path");
281 assert_eq!(builds.get(), 1, "one build for two lookups");
282 assert_eq!((cache.hits(), cache.misses()), (1, 1));
283 assert_eq!(cache.len(), 1);
284 }
285
286 #[test]
287 fn evicts_least_recently_used() {
288 let builds = Cell::new([0u32; 4]);
289 let build = |i: usize| {
290 let mut counts = builds.get();
291 counts[i] += 1;
292 builds.set(counts);
293 path_with_seed(i as f64)
294 };
295 let mut cache = GlyphOutlineCache::with_capacity(2);
296 cache.get_or_insert_with(1, || build(1));
297 cache.get_or_insert_with(2, || build(2));
298 cache.get_or_insert_with(1, || build(1));
299 cache.get_or_insert_with(3, || build(3));
300
301 cache.get_or_insert_with(2, || build(2));
306 cache.get_or_insert_with(1, || build(1));
307 cache.get_or_insert_with(3, || build(3));
308 assert_eq!(builds.get(), [0, 2, 2, 2]);
309 assert_eq!(cache.len(), 2);
310 }
311
312 #[test]
313 fn bound_holds_for_many_distinct_keys() {
314 let mut cache = GlyphOutlineCache::with_capacity(8);
315 for key in 0..200u128 {
316 cache.get_or_insert_with(key, || path_with_seed(key as f64));
317 }
318 assert_eq!(cache.len(), 8);
319 }
320
321 #[test]
322 fn disabled_cache_builds_every_time_and_stores_nothing() {
323 let builds = Cell::new(0u32);
324 let mut cache = GlyphOutlineCache::new();
325 cache.set_enabled(false);
326 assert!(!cache.is_enabled());
327 for _ in 0..3 {
328 cache.get_or_insert_with(1, || {
329 builds.set(builds.get() + 1);
330 path_with_seed(builds.get() as f64)
331 });
332 }
333 assert_eq!(builds.get(), 3);
334 assert_eq!((cache.hits(), cache.misses()), (0, 3));
335 assert_eq!(cache.len(), 0);
336 assert!(cache.is_empty());
337
338 cache.set_enabled(true);
339 cache.get_or_insert_with(1, || {
340 builds.set(builds.get() + 1);
341 path_with_seed(9.0)
342 });
343 cache.get_or_insert_with(1, || {
344 builds.set(builds.get() + 1);
345 path_with_seed(10.0)
346 });
347 assert_eq!(builds.get(), 4, "one build after re-enabling, then a hit");
348 assert_eq!((cache.hits(), cache.misses()), (1, 4));
349 }
350
351 #[test]
352 fn zero_capacity_stores_nothing() {
353 let builds = Cell::new(0u32);
354 let mut cache = GlyphOutlineCache::with_capacity(0);
355 for _ in 0..2 {
356 cache.get_or_insert_with(1, || {
357 builds.set(builds.get() + 1);
358 path_with_seed(1.0)
359 });
360 }
361 assert_eq!(builds.get(), 2);
362 assert_eq!(cache.len(), 0);
363 assert_eq!((cache.hits(), cache.misses()), (0, 2));
364 }
365
366 #[test]
367 fn clear_drops_entries_and_resets_stats() {
368 let mut cache = GlyphOutlineCache::new();
369 cache.get_or_insert_with(1, || path_with_seed(1.0));
370 cache.get_or_insert_with(1, || path_with_seed(1.0));
371 cache.clear();
372 assert!(cache.is_empty());
373 assert_eq!((cache.hits(), cache.misses()), (0, 0));
374 }
375
376 #[test]
377 fn recency_queue_stays_bounded_under_repeated_touches() {
378 let mut cache = GlyphOutlineCache::with_capacity(4);
379 for key in 0..4u128 {
380 cache.get_or_insert_with(key, || path_with_seed(key as f64));
381 }
382 for _ in 0..10_000 {
383 cache.get_or_insert_with(0, || path_with_seed(0.0));
384 }
385 assert_eq!(cache.len(), 4);
386 assert!(
387 cache.recency.len() <= 32,
388 "queue must compact, len = {}",
389 cache.recency.len()
390 );
391 }
392
393 #[test]
394 fn reinserted_key_is_most_recently_used() {
395 let mut cache = GlyphOutlineCache::with_capacity(2);
396 cache.get_or_insert_with(1, || path_with_seed(1.0));
397 cache.get_or_insert_with(2, || path_with_seed(2.0));
398 cache.get_or_insert_with(3, || path_with_seed(3.0));
400 cache.get_or_insert_with(1, || path_with_seed(1.0));
401 cache.get_or_insert_with(4, || path_with_seed(4.0));
402
403 let builds = Cell::new(0u32);
404 cache.get_or_insert_with(1, || {
405 builds.set(builds.get() + 1);
406 path_with_seed(1.0)
407 });
408 assert_eq!(builds.get(), 0, "re-inserted key 1 must still be stored");
409 assert_eq!(cache.len(), 2);
410 }
411}