orinium_browser/platform/renderer/
text_cache.rs1use 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
18const 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 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 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 pub fn clear(&self) {
85 if let Ok(mut cache) = self.inner.lock() {
86 cache.clear();
87 }
88 }
89
90 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 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 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 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}