frust_text/shape_cache.rs
1//! A bounded, width-independent shape cache for laid-out text.
2//!
3//! Adapts SkParagraph's separation of **shaping** (font matching + glyph
4//! selection, width-independent) from **line-breaking** (width-dependent) to
5//! parley 0.11. A cached [`parley::Layout`] retains its shaped runs; a width
6//! change re-runs [`parley::Layout::break_all_lines`] (line-breaking only) on
7//! that cached layout rather than re-shaping from scratch.
8//!
9//! The cache is a bounded LRU (Flutter's SkParagraph precedent: 128 entries)
10//! with generation-based (`last_used` tick) eviction — no unbounded growth.
11//! No `parley` type appears in this module's public API (scene-layer purity);
12//! only [`ShapeCacheStats`] (plain counters) is exported.
13
14use std::collections::HashMap;
15
16use peniko::Brush;
17
18use crate::style::{
19 FamilyName, FontFamily, FontStyle, GenericSlot, LineHeight, TextAlign, TextStyle,
20 to_parley_align,
21};
22
23/// Default cache capacity, mirroring Flutter's SkParagraph LRU paragraph cache
24/// (128 entries, keyed by text + styling).
25pub(crate) const DEFAULT_CAPACITY: usize = 128;
26
27/// Instrumentation counters for the shape cache — the observable hook the
28/// shape-cache tests assert against, and a perf signal otherwise.
29///
30/// Plain scalar data (no `parley`/`vello`/`wgpu` leak), safe to expose from
31/// [`crate::TextContext::shape_cache_stats`].
32#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
33pub struct ShapeCacheStats {
34 /// Full shaping passes performed (a cache miss — parley `build`).
35 pub shapes: u64,
36 /// Line-break-only relayouts on an already-shaped cached entry (a width
37 /// change with the shaping reused).
38 pub line_breaks: u64,
39 /// Full reuses: the shaped layout was already broken at the requested
40 /// width, so neither shaping nor line-breaking ran.
41 pub hits: u64,
42 /// LRU evictions performed under capacity pressure.
43 pub evictions: u64,
44}
45
46/// A hashable, equality-comparable projection of the shaping inputs.
47///
48/// Shaping is width-independent, so the key deliberately excludes `max_width`
49/// — a width change re-breaks the cached shaped layout, it never re-shapes.
50/// Float fields are keyed by their bit pattern (`f32::to_bits`) so equality
51/// and hashing agree; the style values here (sizes, spacings, sRGB channels)
52/// are never `NaN`, so bitwise keying is exact.
53///
54/// `align` is part of the key even though alignment is applied *after*
55/// shaping (during line-breaking, not shaping) — parley bakes the aligned
56/// line positions into the cached [`parley::Layout`] itself
57/// ([`ShapeCache::get`] mutates the cached layout in place), so two texts
58/// differing only in alignment must not collide on the same cache entry: a
59/// collision would make the second text's request silently reuse and repaint
60/// the first text's aligned line positions.
61#[derive(Clone, PartialEq, Eq, Hash, Debug)]
62pub(crate) struct ShapeKey {
63 text: String,
64 family: FamilyBits,
65 weight: u32,
66 slant: SlantBits,
67 size: u32,
68 color: [u32; 4],
69 letter_spacing: u32,
70 line_height: LineHeightBits,
71 align: TextAlign,
72}
73
74impl ShapeKey {
75 /// Projects the shaping inputs (text + style, width-independent) into a
76 /// hashable key.
77 pub(crate) fn new(text: &str, style: &TextStyle) -> Self {
78 Self {
79 text: text.to_string(),
80 family: FamilyBits::from(&style.family),
81 weight: style.weight.value().to_bits(),
82 slant: SlantBits::from(style.style),
83 size: style.size.to_bits(),
84 color: style.color.components.map(f32::to_bits),
85 letter_spacing: style.letter_spacing.to_bits(),
86 line_height: LineHeightBits::from(style.line_height),
87 align: style.align,
88 }
89 }
90}
91
92#[derive(Clone, PartialEq, Eq, Hash, Debug)]
93enum FamilyBits {
94 SystemUi,
95 Named(Vec<String>),
96 NamedWithGeneric(Vec<(Option<String>, Option<u8>)>),
97}
98
99impl From<&FontFamily> for FamilyBits {
100 fn from(family: &FontFamily) -> Self {
101 match family {
102 FontFamily::SystemUi => FamilyBits::SystemUi,
103 FontFamily::Named(names) => FamilyBits::Named(names.clone()),
104 FontFamily::NamedWithGeneric(families) => {
105 let bits = families
106 .iter()
107 .map(|f| match f {
108 FamilyName::Named(name) => (Some(name.clone()), None),
109 FamilyName::Generic(slot) => {
110 // Use discriminant as a unique identifier for each generic slot
111 let discriminant = match slot {
112 GenericSlot::Monospace => 0u8,
113 GenericSlot::SansSerif => 1u8,
114 GenericSlot::Serif => 2u8,
115 GenericSlot::SystemUi => 3u8,
116 GenericSlot::Emoji => 4u8,
117 };
118 (None, Some(discriminant))
119 }
120 })
121 .collect();
122 FamilyBits::NamedWithGeneric(bits)
123 }
124 }
125 }
126}
127
128#[derive(Clone, PartialEq, Eq, Hash, Debug)]
129enum SlantBits {
130 Normal,
131 Italic,
132 /// The optional oblique angle's bit pattern (`None` = engine default).
133 Oblique(Option<u32>),
134}
135
136impl From<FontStyle> for SlantBits {
137 fn from(style: FontStyle) -> Self {
138 match style {
139 FontStyle::Normal => SlantBits::Normal,
140 FontStyle::Italic => SlantBits::Italic,
141 FontStyle::Oblique(angle) => SlantBits::Oblique(angle.map(f32::to_bits)),
142 }
143 }
144}
145
146#[derive(Clone, PartialEq, Eq, Hash, Debug)]
147enum LineHeightBits {
148 MetricsRelative(u32),
149 FontSizeRelative(u32),
150 Absolute(u32),
151}
152
153impl From<LineHeight> for LineHeightBits {
154 fn from(line_height: LineHeight) -> Self {
155 match line_height {
156 LineHeight::MetricsRelative(v) => LineHeightBits::MetricsRelative(v.to_bits()),
157 LineHeight::FontSizeRelative(v) => LineHeightBits::FontSizeRelative(v.to_bits()),
158 LineHeight::Absolute(v) => LineHeightBits::Absolute(v.to_bits()),
159 }
160 }
161}
162
163/// One cached shaped layout plus the width it was last line-broken at.
164struct Entry {
165 /// The shaped, line-broken, aligned parley layout.
166 layout: parley::Layout<Brush>,
167 /// The `max_width` `layout` is currently broken at — a differing request
168 /// re-breaks (line-breaking only).
169 broken_width: Option<f32>,
170 /// Monotonic access tick for LRU eviction (higher = more recently used).
171 last_used: u64,
172}
173
174/// A bounded LRU cache of shaped layouts, keyed width-independently.
175pub(crate) struct ShapeCache {
176 entries: HashMap<ShapeKey, Entry>,
177 capacity: usize,
178 tick: u64,
179 stats: ShapeCacheStats,
180}
181
182impl ShapeCache {
183 pub(crate) fn new(capacity: usize) -> Self {
184 Self {
185 entries: HashMap::new(),
186 capacity: capacity.max(1),
187 tick: 0,
188 stats: ShapeCacheStats::default(),
189 }
190 }
191
192 /// Looks up a cached shaped layout for `key`, re-breaking it to `max_width`
193 /// (line-breaking only, no re-shaping) when the cached break width differs.
194 ///
195 /// Returns a clone of the laid-out layout on a hit, or `None` on a miss (the
196 /// caller must shape, then [`insert`](Self::insert)). Records the hit /
197 /// line-break in [`ShapeCacheStats`] and refreshes the entry's LRU tick.
198 pub(crate) fn get(
199 &mut self,
200 key: &ShapeKey,
201 max_width: Option<f32>,
202 ) -> Option<parley::Layout<Brush>> {
203 self.tick += 1;
204 let tick = self.tick;
205 // Disjoint field borrows: `entry` borrows `self.entries`, the stat
206 // bumps touch `self.stats` — the borrow checker allows this within one
207 // fn body (no method call re-borrows all of `self`).
208 let entry = self.entries.get_mut(key)?;
209 entry.last_used = tick;
210 if !same_width(entry.broken_width, max_width) {
211 entry.layout.break_all_lines(max_width);
212 // `key.align` (not a hardcoded `Start`): this is the width-change
213 // re-break path context.rs's initial shape+break call doesn't
214 // reach, so it must independently re-apply the same alignment or
215 // a resized layout silently reverts to `Start` — see `ShapeKey`'s
216 // docs.
217 entry.layout.align(
218 to_parley_align(key.align),
219 parley::layout::AlignmentOptions::default(),
220 );
221 entry.broken_width = max_width;
222 self.stats.line_breaks += 1;
223 } else {
224 self.stats.hits += 1;
225 }
226 Some(entry.layout.clone())
227 }
228
229 /// Inserts a freshly shaped (and broken/aligned) layout, evicting the
230 /// least-recently-used entry first when at capacity. Records the shape.
231 pub(crate) fn insert(
232 &mut self,
233 key: ShapeKey,
234 layout: parley::Layout<Brush>,
235 broken_width: Option<f32>,
236 ) {
237 self.stats.shapes += 1;
238 self.tick += 1;
239 if self.entries.len() >= self.capacity
240 && !self.entries.contains_key(&key)
241 && let Some(evict) = self
242 .entries
243 .iter()
244 .min_by_key(|(_, e)| e.last_used)
245 .map(|(k, _)| k.clone())
246 {
247 self.entries.remove(&evict);
248 self.stats.evictions += 1;
249 }
250 self.entries.insert(
251 key,
252 Entry {
253 layout,
254 broken_width,
255 last_used: self.tick,
256 },
257 );
258 }
259
260 /// The current instrumentation counters.
261 pub(crate) fn stats(&self) -> ShapeCacheStats {
262 self.stats
263 }
264}
265
266/// Whether two break widths are equal, comparing `Some` values by bit pattern
267/// so `-0.0`/`+0.0` and any incidental representation differences never trigger
268/// a needless re-break.
269fn same_width(a: Option<f32>, b: Option<f32>) -> bool {
270 match (a, b) {
271 (Some(a), Some(b)) => a.to_bits() == b.to_bits(),
272 (None, None) => true,
273 _ => false,
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use peniko::Color;
281
282 fn key(text: &str, size: f32) -> ShapeKey {
283 ShapeKey::new(text, &TextStyle::new(size, Color::BLACK))
284 }
285
286 #[test]
287 fn key_ignores_width_but_distinguishes_text_and_style() {
288 // Width is not part of the key (shaping is width-independent).
289 assert_eq!(key("hello", 16.0), key("hello", 16.0));
290 // Text and style still distinguish keys.
291 assert_ne!(key("hello", 16.0), key("world", 16.0));
292 assert_ne!(key("hello", 16.0), key("hello", 24.0));
293 }
294
295 #[test]
296 fn key_distinguishes_alignment() {
297 // Two texts identical but for alignment must not collide in the
298 // cache — see `ShapeKey`'s docs on why `align` is part of the key.
299 let start = ShapeKey::new(
300 "hello",
301 &TextStyle {
302 align: TextAlign::Start,
303 ..TextStyle::new(16.0, Color::BLACK)
304 },
305 );
306 let center = ShapeKey::new(
307 "hello",
308 &TextStyle {
309 align: TextAlign::Center,
310 ..TextStyle::new(16.0, Color::BLACK)
311 },
312 );
313 assert_ne!(start, center);
314 }
315
316 #[test]
317 fn same_width_compares_optionals_bitwise() {
318 assert!(same_width(None, None));
319 assert!(same_width(Some(80.0), Some(80.0)));
320 assert!(!same_width(Some(80.0), None));
321 assert!(!same_width(Some(80.0), Some(81.0)));
322 }
323}