Skip to main content

i_slint_core/textlayout/sharedparley/
cache.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore RAII
5
6//! The cache of shaped paragraphs, keyed by item.
7//!
8//! An entry holds the output of [`shaping`](super::shaping) for one item, and is invalidated by
9//! the property dependencies the shaping registered.
10//! [`cached_paragraphs`] is only meant to be called through `with_text_layout`, which pairs it
11//! with the one shaping function every path must share.
12
13use super::layout::RetainedLineBreaking;
14use super::shaping::TextParagraph;
15use super::*;
16
17/// Shaped paragraphs together with the wrap mode they were shaped with.
18///
19/// The glyph geometry only depends on (text, font, wrap, scale factor): the width is applied
20/// later by `break_all_lines`, and the fill/stroke/selection brushes only change colors, not
21/// positions. So one entry serves measuring, hit-testing and drawing alike -- but only for the
22/// wrap mode it was shaped with, because parley bakes the break opportunities into the shaped
23/// layout via `WordBreak`/`OverflowWrap`/`TextWrapMode` (see `ranged_builder`). The scale factor
24/// is the other input baked into the shaping, but it applies to every entry at once and so is
25/// handled by the cache as a whole.
26struct CachedParagraphs {
27    wrap: TextWrap,
28    /// What [`super::layout::layout`] derived when it last broke these paragraphs, so an
29    /// unchanged-input call can skip the breaking. `None` after a reshape (fresh entries
30    /// start without one) and while checked out through the guard.
31    line_breaking: Option<RetainedLineBreaking>,
32    /// `None` while a [`CachedParagraphsGuard`] has the paragraphs checked out; the guard puts
33    /// them back when it drops. Finding `None` here therefore means the previous caller returned
34    /// without handing them back, and the entry has to be reshaped rather than served empty.
35    paragraphs: Option<Vec<TextParagraph>>,
36    /// The [`TextLayoutCache::generation`] at which this entry was last served.
37    last_used: u32,
38}
39
40type InnerTextLayoutCache = crate::item_rendering::ItemCache<CachedParagraphs>;
41
42/// Entry count above which [`TextLayoutCache::sweep`] runs (~4.7KB per entry).
43const ENTRY_LIMIT: usize = 1024;
44
45/// Cache for shaped text paragraphs (before line breaking), keyed by ItemRc.
46pub struct TextLayoutCache {
47    inner: InnerTextLayoutCache,
48    /// Caches the result of [`super::text_content_widths`]. The widths are two scalars derived from
49    /// paragraphs that no other path can reuse, because they are shaped without
50    /// `OverflowWrap::Anywhere`, so the result is kept instead of the paragraphs.
51    content_widths: crate::item_rendering::ItemCache<crate::renderer::ContentWidths>,
52    /// Bumped once per rendered frame; entries are stamped with it when served.
53    generation: std::cell::Cell<u32>,
54    /// Approximate entry count; a stale-high value just triggers one extra sweep.
55    entry_count_estimate: std::cell::Cell<usize>,
56    /// Estimate above which the next sweep runs; raised when the active set exceeds the limit.
57    sweep_threshold: std::cell::Cell<usize>,
58    #[cfg(feature = "testing")]
59    cache_miss_count: std::cell::Cell<u64>,
60    #[cfg(feature = "testing")]
61    layout_miss_count: std::cell::Cell<u64>,
62    #[cfg(feature = "testing")]
63    content_widths_miss_count: std::cell::Cell<u64>,
64}
65
66#[allow(clippy::derivable_impls)] // clippy doesn't see the feature = "testing" code
67impl Default for TextLayoutCache {
68    fn default() -> Self {
69        Self {
70            inner: Default::default(),
71            content_widths: Default::default(),
72            generation: Default::default(),
73            entry_count_estimate: Default::default(),
74            sweep_threshold: std::cell::Cell::new(ENTRY_LIMIT),
75            #[cfg(feature = "testing")]
76            cache_miss_count: std::cell::Cell::new(0),
77            #[cfg(feature = "testing")]
78            layout_miss_count: std::cell::Cell::new(0),
79            #[cfg(feature = "testing")]
80            content_widths_miss_count: std::cell::Cell::new(0),
81        }
82    }
83}
84
85impl TextLayoutCache {
86    /// Drops everything shaped for the previous scale factor. Glyph advances are in physical
87    /// pixels, so a new scale factor invalidates every entry at once. Called on the way into the
88    /// cache rather than when rendering starts, because the layout pass that follows a scale
89    /// factor change measures before anything renders.
90    pub(super) fn clear_if_scale_factor_changed(&self, window: &crate::api::Window) {
91        self.inner.clear_cache_if_scale_factor_changed(window);
92        self.content_widths.clear_cache_if_scale_factor_changed(window);
93    }
94    pub fn component_destroyed(&self, component: crate::item_tree::ItemTreeRef) {
95        self.inner.component_destroyed(component);
96        self.content_widths.component_destroyed(component);
97    }
98    pub fn clear_all(&self) {
99        self.inner.clear_all();
100        self.content_widths.clear_all();
101    }
102
103    /// Returns the cached content widths of `item_rc`, computing them on a miss.
104    ///
105    /// The entry is invalidated by the properties `compute` reads, so it must read the
106    /// text, the font request and the line limit itself. The scale factor is handled by
107    /// [`Self::clear_if_scale_factor_changed`], which the caller runs first.
108    pub(super) fn content_widths(
109        &self,
110        item_rc: &crate::item_tree::ItemRc,
111        compute: impl FnOnce() -> crate::renderer::ContentWidths,
112    ) -> crate::renderer::ContentWidths {
113        self.content_widths.get_or_update_cache_entry(item_rc, || {
114            #[cfg(feature = "testing")]
115            self.content_widths_miss_count.set(self.content_widths_miss_count.get() + 1);
116            compute()
117        })
118    }
119
120    /// Marks the beginning of a frame; called once per rendered frame.
121    pub fn begin_frame(&self) {
122        self.generation.set(self.generation.get().wrapping_add(1));
123    }
124
125    /// Drops the entries that were not served in the current or the previous frame.
126    fn sweep(&self) {
127        let generation = self.generation.get();
128        let mut kept = 0;
129        self.inner.retain(|entry| {
130            let keep = generation.wrapping_sub(entry.last_used) <= 1;
131            kept += keep as usize;
132            keep
133        });
134        self.entry_count_estimate.set(kept);
135        self.sweep_threshold.set(ENTRY_LIMIT.max(kept + ENTRY_LIMIT / 2));
136    }
137}
138
139#[cfg(feature = "testing")]
140impl TextLayoutCache {
141    pub fn cache_miss_count(&self) -> u64 {
142        self.cache_miss_count.get()
143    }
144    pub fn reset_cache_miss_count(&self) {
145        self.cache_miss_count.set(0);
146    }
147    /// How many times a layout pass had to break the lines of a cached item again rather than
148    /// reuse the retained breaking.
149    pub fn layout_miss_count(&self) -> u64 {
150        self.layout_miss_count.get()
151    }
152    pub fn reset_layout_miss_count(&self) {
153        self.layout_miss_count.set(0);
154    }
155    /// How many times the content widths of an item had to be shaped rather than served
156    /// from the cache.
157    pub fn content_widths_miss_count(&self) -> u64 {
158        self.content_widths_miss_count.get()
159    }
160    pub fn reset_content_widths_miss_count(&self) {
161        self.content_widths_miss_count.set(0);
162    }
163    pub(super) fn count_layout_miss(&self) {
164        self.layout_miss_count.set(self.layout_miss_count.get() + 1);
165    }
166}
167
168/// RAII guard: takes the shaped paragraphs out of the cache on creation, puts them back on drop.
169pub(super) struct CachedParagraphsGuard<'a> {
170    paragraphs: Option<Vec<TextParagraph>>,
171    line_breaking: Option<RetainedLineBreaking>,
172    container: Option<std::cell::RefMut<'a, CachedParagraphs>>,
173}
174
175impl CachedParagraphsGuard<'_> {
176    /// Lends the paragraphs to [`layout`], which hands them back as part of its `Layout`.
177    pub(super) fn take(&mut self) -> Vec<TextParagraph> {
178        self.paragraphs.take().unwrap_or_default()
179    }
180
181    /// Hands the retained breaking to [`layout`], which decides whether it still applies.
182    pub(super) fn take_line_breaking(&mut self) -> Option<RetainedLineBreaking> {
183        self.container.as_mut().and_then(|container| container.line_breaking.take())
184    }
185
186    /// Returns the paragraphs and the line breaking they carry, so that the next caller reuses
187    /// both the shaping and, with unchanged inputs, the breaking.
188    pub(super) fn restore(
189        &mut self,
190        paragraphs: Vec<TextParagraph>,
191        line_breaking: RetainedLineBreaking,
192    ) {
193        self.paragraphs = Some(paragraphs);
194        self.line_breaking = Some(line_breaking);
195    }
196}
197
198impl Drop for CachedParagraphsGuard<'_> {
199    fn drop(&mut self) {
200        if let Some(container) = &mut self.container {
201            if let Some(paragraphs) = self.paragraphs.take() {
202                container.paragraphs = Some(paragraphs);
203            }
204            if let Some(line_breaking) = self.line_breaking.take() {
205                container.line_breaking = Some(line_breaking);
206            }
207        }
208    }
209}
210
211/// Shapes the text of `item_rc` for `wrap`, reusing the `TextLayoutCache` entry when it holds
212/// paragraphs shaped for the same wrap mode and none of the properties `shape` read have changed
213/// since. Without a cache or item it just shapes, so the caller doesn't need to special-case that.
214///
215/// `shape` runs inside the entry's dependency tracker, so everything it reads (the text and the
216/// font request, at least) invalidates the entry when it changes. Properties evaluated by the
217/// caller before this point are clean by then and thus can't re-enter here.
218pub(super) fn cached_paragraphs<'a>(
219    cache: Option<&'a TextLayoutCache>,
220    item_rc: Option<&crate::item_tree::ItemRc>,
221    wrap: TextWrap,
222    window: &crate::api::Window,
223    font_context: &mut parley::FontContext,
224    shape: &dyn Fn(&mut parley::FontContext) -> Vec<TextParagraph>,
225) -> CachedParagraphsGuard<'a> {
226    let Some((cache, item_rc)) = cache.zip(item_rc) else {
227        return CachedParagraphsGuard {
228            paragraphs: Some(shape(font_context)),
229            line_breaking: None,
230            container: None,
231        };
232    };
233
234    cache.clear_if_scale_factor_changed(window);
235
236    // Shaped geometry must never be mixed across wrap modes, and the entry only holds one mode
237    // at a time. Drop a mismatching one up front so the shaping below happens in the regular
238    // (vacant) path, inside a fresh dependency tracker and without the cache borrowed.
239    //
240    // Paragraphs that were never handed back can't be served either.
241    let stale = cache
242        .inner
243        .with_entry(item_rc, |entry| {
244            (entry.wrap != wrap || entry.paragraphs.is_none()).then_some(())
245        })
246        .is_some();
247    if stale {
248        cache.inner.release(item_rc);
249    }
250
251    // Sweep before this item's entry is checked out and blocks `retain`'s access.
252    if cache.entry_count_estimate.get() > cache.sweep_threshold.get() {
253        cache.sweep();
254    }
255
256    let mut entry = cache.inner.get_or_update_cache_entry_ref(item_rc, || {
257        #[cfg(feature = "testing")]
258        cache.cache_miss_count.set(cache.cache_miss_count.get() + 1);
259        cache.entry_count_estimate.set(cache.entry_count_estimate.get() + 1);
260        CachedParagraphs {
261            wrap,
262            paragraphs: Some(shape(font_context)),
263            line_breaking: None,
264            last_used: 0, // stamped right below, for both a hit and this miss
265        }
266    });
267    entry.last_used = cache.generation.get();
268    let paragraphs = entry.paragraphs.take().unwrap_or_default();
269    CachedParagraphsGuard {
270        paragraphs: Some(paragraphs),
271        line_breaking: None,
272        container: Some(entry),
273    }
274}