Skip to main content

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}