Skip to main content

orinium_browser/platform/renderer/
text_cache.rs

1//! Bounded cache for shaped text results keyed by text and style.
2//!
3//! Values are shared through [`Arc`] so cache hits stay cheap: the measurer
4//! keeps using the shaped text by reference while the cache holds another
5//! handle to the same allocation.
6
7use std::hash::{Hash, Hasher};
8use std::num::NonZeroUsize;
9use std::sync::{Arc, Mutex};
10
11use lru::LruCache;
12use smol_str::SmolStr;
13
14use orinium_text::ShapedText;
15
16use crate::platform::cache_stats::{CacheStats, CacheStatsSnapshot};
17
18/// Maximum number of shaped texts kept before the least recently used entry is
19/// evicted. The cache used to be an unbounded `HashMap`, which let long-lived
20/// pages with dynamic content grow without limit.
21const DEFAULT_CAPACITY: usize = 4096;
22
23#[derive(Clone)]
24pub struct TextShapeCache {
25    inner: Arc<Mutex<LruCache<TextShapeCacheKey, Arc<ShapedText>>>>,
26    stats: Arc<CacheStats>,
27}
28
29impl Default for TextShapeCache {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl TextShapeCache {
36    pub fn new() -> Self {
37        Self::with_capacity(DEFAULT_CAPACITY)
38    }
39
40    /// Creates a cache holding at most `capacity` shaped texts.
41    pub fn with_capacity(capacity: usize) -> Self {
42        let capacity = NonZeroUsize::new(capacity.max(1)).unwrap();
43        Self {
44            inner: Arc::new(Mutex::new(LruCache::new(capacity))),
45            stats: Arc::new(CacheStats::new("text_shape")),
46        }
47    }
48
49    pub fn get(&self, text: &str, style: &orinium_text::TextStyle) -> Option<Arc<ShapedText>> {
50        let key = TextShapeCacheKey::new(text, style);
51
52        let Ok(mut cache) = self.inner.lock() else {
53            self.stats.record_miss();
54            return None;
55        };
56        match cache.get(&key) {
57            Some(shaped) => {
58                self.stats.record_hit();
59                Some(Arc::clone(shaped))
60            }
61            None => {
62                self.stats.record_miss();
63                None
64            }
65        }
66    }
67
68    pub fn insert(&self, text: &str, style: &orinium_text::TextStyle, shaped: Arc<ShapedText>) {
69        let key = TextShapeCacheKey::new(text, style);
70
71        if let Ok(mut cache) = self.inner.lock() {
72            // `push` returns the replaced entry when the key already exists
73            // and the least recently used entry when the cache overflows.
74            let unchanged_key = key.clone();
75            if let Some((evicted_key, _)) = cache.push(key, shaped)
76                && evicted_key != unchanged_key
77            {
78                self.stats.record_eviction();
79            }
80        }
81    }
82
83    /// Drops every entry; the counters keep accumulating.
84    pub fn clear(&self) {
85        if let Ok(mut cache) = self.inner.lock() {
86            cache.clear();
87        }
88    }
89
90    /// Number of entries currently held.
91    pub fn len(&self) -> usize {
92        self.inner.lock().map(|cache| cache.len()).unwrap_or(0)
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.len() == 0
97    }
98
99    /// Snapshot of this cache's hit/miss/eviction counters.
100    pub fn stats(&self) -> CacheStatsSnapshot {
101        self.stats.snapshot()
102    }
103}
104
105#[derive(Clone, Eq)]
106struct TextShapeCacheKey {
107    text: SmolStr,
108    style_hash: u64,
109}
110
111impl TextShapeCacheKey {
112    fn new(text: &str, style: &orinium_text::TextStyle) -> Self {
113        let mut hasher = std::collections::hash_map::DefaultHasher::new();
114        hash_style(style, &mut hasher);
115
116        Self {
117            text: SmolStr::new(text),
118            style_hash: hasher.finish(),
119        }
120    }
121}
122
123impl PartialEq for TextShapeCacheKey {
124    fn eq(&self, other: &Self) -> bool {
125        self.text == other.text && self.style_hash == other.style_hash
126    }
127}
128
129impl std::hash::Hash for TextShapeCacheKey {
130    fn hash<H: Hasher>(&self, state: &mut H) {
131        self.text.hash(state);
132        self.style_hash.hash(state);
133    }
134}
135
136fn hash_style(style: &orinium_text::TextStyle, hasher: &mut impl Hasher) {
137    style.font_size.to_bits().hash(hasher);
138    style.line_height.to_bits().hash(hasher);
139
140    style.color.0.hash(hasher);
141    style.color.1.hash(hasher);
142    style.color.2.hash(hasher);
143    style.color.3.hash(hasher);
144
145    style.font_weight.0.hash(hasher);
146    style.font_style.hash(hasher);
147
148    style.bidi_mode.hash(hasher);
149    style.variant.hash(hasher);
150
151    for family in &style.font_families {
152        family.hash(hasher);
153    }
154
155    for font in &style.exact_fonts {
156        font.hash(hasher);
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use orinium_text::{FontSystem, PlatformFallback, TextLayouter, TextStyle};
164
165    /// Produces a real `ShapedText` without touching system fonts: the font
166    /// system starts from an empty database, and shaping an empty database
167    /// returns an empty (but valid) result.
168    fn sample_shape() -> Arc<ShapedText> {
169        let db = orinium_text::fontdb::Database::new();
170        let mut fs = FontSystem::new_with_locale_and_db_and_fallback(db, PlatformFallback);
171        Arc::new(TextLayouter::new().shape_text(&mut fs, "sample", &TextStyle::default()))
172    }
173
174    #[test]
175    fn stores_and_returns_shaped_text() {
176        let cache = TextShapeCache::new();
177
178        assert!(cache.is_empty());
179        assert!(cache.get("hello", &TextStyle::default()).is_none());
180
181        cache.insert("hello", &TextStyle::default(), sample_shape());
182        assert!(
183            cache.get("hello", &TextStyle::default()).is_some(),
184            "entry should hit"
185        );
186
187        let stats = cache.stats();
188        assert_eq!(stats.name, "text_shape");
189        assert_eq!(stats.hits, 1);
190        assert_eq!(stats.misses, 1);
191        assert_eq!(stats.evictions, 0);
192    }
193
194    #[test]
195    fn different_styles_are_cached_separately() {
196        let cache = TextShapeCache::new();
197        let other = TextStyle {
198            font_size: 20.0,
199            ..TextStyle::default()
200        };
201
202        cache.insert("hello", &TextStyle::default(), sample_shape());
203
204        assert!(cache.get("hello", &other).is_none());
205        assert!(cache.get("hello", &TextStyle::default()).is_some());
206        assert_eq!(cache.len(), 1);
207    }
208
209    #[test]
210    fn hits_share_the_same_allocation() {
211        let cache = TextShapeCache::new();
212        cache.insert("hello", &TextStyle::default(), sample_shape());
213
214        let first = cache.get("hello", &TextStyle::default()).unwrap();
215        let second = cache.get("hello", &TextStyle::default()).unwrap();
216
217        assert!(Arc::ptr_eq(&first, &second));
218    }
219
220    #[test]
221    fn evicts_the_least_recently_used_entry() {
222        let cache = TextShapeCache::with_capacity(2);
223
224        cache.insert("a", &TextStyle::default(), sample_shape());
225        cache.insert("b", &TextStyle::default(), sample_shape());
226        assert!(cache.get("a", &TextStyle::default()).is_some());
227        // "b" is now the least recently used entry.
228        cache.insert("c", &TextStyle::default(), sample_shape());
229
230        assert!(cache.get("b", &TextStyle::default()).is_none());
231        assert!(cache.get("a", &TextStyle::default()).is_some());
232        assert!(cache.get("c", &TextStyle::default()).is_some());
233
234        let stats = cache.stats();
235        assert_eq!(stats.evictions, 1);
236        assert_eq!(cache.len(), 2);
237    }
238
239    #[test]
240    fn clear_empties_the_cache_and_keeps_counters() {
241        let cache = TextShapeCache::new();
242
243        cache.insert("a", &TextStyle::default(), sample_shape());
244        cache.clear();
245
246        assert!(cache.is_empty());
247        assert_eq!(cache.stats().evictions, 0);
248    }
249
250    #[test]
251    fn stays_bounded_under_unbounded_insertion() {
252        let cache = TextShapeCache::with_capacity(64);
253
254        for i in 0..10_000 {
255            cache.insert(
256                &format!("dynamic content {i}"),
257                &TextStyle::default(),
258                sample_shape(),
259            );
260        }
261
262        assert_eq!(cache.len(), 64, "the cache must stay within its capacity");
263        assert_eq!(cache.stats().evictions, 10_000 - 64);
264    }
265}