use kurbo::BezPath;
use std::collections::{HashMap, VecDeque};
struct Entry {
path: BezPath,
touched: u64,
}
pub struct GlyphOutlineCache {
entries: HashMap<u128, Entry>,
recency: VecDeque<(u128, u64)>,
scratch: Option<BezPath>,
hits: u64,
misses: u64,
capacity: usize,
enabled: bool,
}
impl std::fmt::Debug for GlyphOutlineCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GlyphOutlineCache")
.field("len", &self.entries.len())
.field("hits", &self.hits)
.field("misses", &self.misses)
.field("capacity", &self.capacity)
.field("enabled", &self.enabled)
.finish_non_exhaustive()
}
}
impl GlyphOutlineCache {
pub const DEFAULT_CAPACITY: usize = 4096;
pub fn new() -> Self {
Self::with_capacity(Self::DEFAULT_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
entries: HashMap::new(),
recency: VecDeque::new(),
scratch: None,
hits: 0,
misses: 0,
capacity,
enabled: true,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn hits(&self) -> u64 {
self.hits
}
pub fn misses(&self) -> u64 {
self.misses
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn clear(&mut self) {
self.entries.clear();
self.recency.clear();
self.scratch = None;
self.hits = 0;
self.misses = 0;
}
pub(crate) fn get_or_insert_with(
&mut self,
key: u128,
build: impl FnOnce() -> BezPath,
) -> &BezPath {
if !self.enabled {
self.misses += 1;
self.scratch = Some(build());
return self.scratch.as_ref().expect("outline just stored");
}
if self.entries.contains_key(&key) {
self.hits += 1;
let touched = {
let entry = self.entries.get_mut(&key).expect("key just checked");
entry.touched += 1;
entry.touched
};
self.recency.push_back((key, touched));
self.compact_recency();
return &self.entries.get(&key).expect("key just checked").path;
}
self.misses += 1;
let path = build();
if self.capacity == 0 {
self.scratch = Some(path);
return self.scratch.as_ref().expect("outline just stored");
}
if self.entries.len() >= self.capacity {
self.evict_lru();
}
self.entries.insert(key, Entry { path, touched: 1 });
self.recency.push_back((key, 1));
self.compact_recency();
&self.entries.get(&key).expect("outline just inserted").path
}
fn evict_lru(&mut self) {
while let Some((key, touched)) = self.recency.pop_front() {
let live = self
.entries
.get(&key)
.is_some_and(|entry| entry.touched == touched);
if live {
self.entries.remove(&key);
return;
}
}
debug_assert!(false, "LRU queue lost its live pair");
if let Some(victim) = self.entries.keys().next().copied() {
self.entries.remove(&victim);
}
}
fn compact_recency(&mut self) {
let limit = self.capacity.saturating_mul(4).max(16);
if self.recency.len() <= limit {
return;
}
let live: Vec<(u128, u64)> = self
.recency
.drain(..)
.filter(|(key, touched)| {
self.entries
.get(key)
.is_some_and(|entry| entry.touched == *touched)
})
.collect();
self.recency.extend(live);
}
}
impl Default for GlyphOutlineCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn path_with_seed(seed: f64) -> BezPath {
let mut path = BezPath::new();
path.move_to((seed, seed));
path.line_to((seed + 1.0, seed + 1.0));
path
}
#[test]
fn hit_returns_cached_path_and_counts_stats() {
let builds = Cell::new(0u32);
let mut cache = GlyphOutlineCache::new();
let first = cache
.get_or_insert_with(7, || {
builds.set(builds.get() + 1);
path_with_seed(1.0)
})
.elements()
.to_vec();
let second = cache
.get_or_insert_with(7, || {
builds.set(builds.get() + 1);
path_with_seed(2.0)
})
.elements()
.to_vec();
assert_eq!(first, second, "hit must return the stored path");
assert_eq!(builds.get(), 1, "one build for two lookups");
assert_eq!((cache.hits(), cache.misses()), (1, 1));
assert_eq!(cache.len(), 1);
}
#[test]
fn evicts_least_recently_used() {
let builds = Cell::new([0u32; 4]);
let build = |i: usize| {
let mut counts = builds.get();
counts[i] += 1;
builds.set(counts);
path_with_seed(i as f64)
};
let mut cache = GlyphOutlineCache::with_capacity(2);
cache.get_or_insert_with(1, || build(1));
cache.get_or_insert_with(2, || build(2));
cache.get_or_insert_with(1, || build(1));
cache.get_or_insert_with(3, || build(3));
cache.get_or_insert_with(2, || build(2));
cache.get_or_insert_with(1, || build(1));
cache.get_or_insert_with(3, || build(3));
assert_eq!(builds.get(), [0, 2, 2, 2]);
assert_eq!(cache.len(), 2);
}
#[test]
fn bound_holds_for_many_distinct_keys() {
let mut cache = GlyphOutlineCache::with_capacity(8);
for key in 0..200u128 {
cache.get_or_insert_with(key, || path_with_seed(key as f64));
}
assert_eq!(cache.len(), 8);
}
#[test]
fn disabled_cache_builds_every_time_and_stores_nothing() {
let builds = Cell::new(0u32);
let mut cache = GlyphOutlineCache::new();
cache.set_enabled(false);
assert!(!cache.is_enabled());
for _ in 0..3 {
cache.get_or_insert_with(1, || {
builds.set(builds.get() + 1);
path_with_seed(builds.get() as f64)
});
}
assert_eq!(builds.get(), 3);
assert_eq!((cache.hits(), cache.misses()), (0, 3));
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
cache.set_enabled(true);
cache.get_or_insert_with(1, || {
builds.set(builds.get() + 1);
path_with_seed(9.0)
});
cache.get_or_insert_with(1, || {
builds.set(builds.get() + 1);
path_with_seed(10.0)
});
assert_eq!(builds.get(), 4, "one build after re-enabling, then a hit");
assert_eq!((cache.hits(), cache.misses()), (1, 4));
}
#[test]
fn zero_capacity_stores_nothing() {
let builds = Cell::new(0u32);
let mut cache = GlyphOutlineCache::with_capacity(0);
for _ in 0..2 {
cache.get_or_insert_with(1, || {
builds.set(builds.get() + 1);
path_with_seed(1.0)
});
}
assert_eq!(builds.get(), 2);
assert_eq!(cache.len(), 0);
assert_eq!((cache.hits(), cache.misses()), (0, 2));
}
#[test]
fn clear_drops_entries_and_resets_stats() {
let mut cache = GlyphOutlineCache::new();
cache.get_or_insert_with(1, || path_with_seed(1.0));
cache.get_or_insert_with(1, || path_with_seed(1.0));
cache.clear();
assert!(cache.is_empty());
assert_eq!((cache.hits(), cache.misses()), (0, 0));
}
#[test]
fn recency_queue_stays_bounded_under_repeated_touches() {
let mut cache = GlyphOutlineCache::with_capacity(4);
for key in 0..4u128 {
cache.get_or_insert_with(key, || path_with_seed(key as f64));
}
for _ in 0..10_000 {
cache.get_or_insert_with(0, || path_with_seed(0.0));
}
assert_eq!(cache.len(), 4);
assert!(
cache.recency.len() <= 32,
"queue must compact, len = {}",
cache.recency.len()
);
}
#[test]
fn reinserted_key_is_most_recently_used() {
let mut cache = GlyphOutlineCache::with_capacity(2);
cache.get_or_insert_with(1, || path_with_seed(1.0));
cache.get_or_insert_with(2, || path_with_seed(2.0));
cache.get_or_insert_with(3, || path_with_seed(3.0));
cache.get_or_insert_with(1, || path_with_seed(1.0));
cache.get_or_insert_with(4, || path_with_seed(4.0));
let builds = Cell::new(0u32);
cache.get_or_insert_with(1, || {
builds.set(builds.get() + 1);
path_with_seed(1.0)
});
assert_eq!(builds.get(), 0, "re-inserted key 1 must still be stored");
assert_eq!(cache.len(), 2);
}
}