Skip to main content

cranpose_render_common/
text_measure.rs

1//! Shared CPU [`TextMeasurer`] used by the software rasterizer backends
2//! (pixels, vulkan). Both backends draw text by rasterizing glyphs directly
3//! into a pixel buffer rather than through a GPU glyph atlas, so they share
4//! one font-backed measurer plus its fallback-metrics path for when no font
5//! is installed.
6
7use std::{
8    cell::{RefCell, RefMut},
9    hash::{Hash, Hasher},
10};
11
12use cranpose_core::collections::pass_aged::PassAgedCache;
13use cranpose_ui::{TextMeasurer, TextMetrics, text_layout_result::TextLayoutResult};
14
15use crate::{
16    software_text_raster::{
17        SoftwareTextFontSet, annotated_cursor_x_for_offset, annotated_offset_for_position,
18        layout_annotated_text_with_font_set, measure_annotated_text_with_font_set,
19        visit_annotated_line_boxes,
20    },
21    text_cache_key::{TextCacheKey, TextProbe},
22    text_hyphenation::HyphenationDictionaryStore,
23};
24
25/// Renderer-owned text resources: the resolved font used for software
26/// rasterization and measurement, if any.
27#[derive(Clone)]
28pub struct SoftwareTextResources {
29    fonts: SoftwareTextFontSet,
30}
31
32impl SoftwareTextResources {
33    /// Resources that use the embedded default font when the `embedded-default-font` feature is on,
34    /// and draw no text otherwise.
35    pub fn default_font() -> Self {
36        Self::from_fonts(&[])
37    }
38
39    /// Resources that draw text in `fonts`. With none that parses, they use
40    /// the embedded default font when the `embedded-default-font` feature is
41    /// on, and draw no text otherwise.
42    pub fn from_fonts(fonts: &[&'static [u8]]) -> Self {
43        Self {
44            fonts: SoftwareTextFontSet::from_fonts_or_default(fonts),
45        }
46    }
47
48    /// The font set that text is measured and drawn with.
49    pub fn fonts(&self) -> &SoftwareTextFontSet {
50        &self.fonts
51    }
52
53    /// These resources with the faces installed since they were made, or
54    /// `None` while no face was installed since.
55    #[cfg(feature = "installed-fonts")]
56    pub fn with_installed_faces(&self) -> Option<Self> {
57        self.fonts
58            .with_installed_faces()
59            .map(|fonts| Self { fonts })
60    }
61}
62
63impl Default for SoftwareTextResources {
64    fn default() -> Self {
65        Self::default_font()
66    }
67}
68
69/// Character width in logical pixels that the fallback metrics use: 0.55 times `font_size`,
70/// where a font size below one counts as one.
71pub fn fallback_char_width(font_size: f32) -> f32 {
72    font_size.max(1.0) * 0.55
73}
74
75/// Line height in logical pixels that the fallback metrics use: 1.2 times `font_size`, where a
76/// font size below one counts as one.
77pub fn fallback_line_height(font_size: f32) -> f32 {
78    font_size.max(1.0) * 1.2
79}
80
81/// Estimates the metrics of `text` at `font_size` without a font: every character has the
82/// fallback width and every line break starts a line of the fallback height.
83pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
84    let line_height = fallback_line_height(font_size);
85    let mut line_count = 0usize;
86    let mut max_chars = 0usize;
87    for line in text.split('\n') {
88        line_count += 1;
89        max_chars = max_chars.max(line.chars().count());
90    }
91    let line_count = line_count.max(1);
92    TextMetrics {
93        width: max_chars as f32 * fallback_char_width(font_size),
94        height: line_count as f32 * line_height,
95        line_height,
96        line_count,
97    }
98}
99
100/// Estimates the x offset in logical pixels of the caret at `byte_offset` of `text` without a
101/// font: the characters before it times the fallback width. An offset past the end counts as the
102/// end.
103pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
104    let clamped = byte_offset.min(text.len());
105    let char_count = if clamped == text.len() {
106        text.chars().count()
107    } else {
108        text.char_indices()
109            .take_while(|(index, _)| *index < clamped)
110            .count()
111    };
112    char_count as f32 * fallback_char_width(font_size)
113}
114
115/// A `TextMeasurer` that measures with the fonts of `SoftwareTextResources` and caches the
116/// metrics by text, font size and style.
117pub struct CachedFontTextMeasurer {
118    text_resources: SoftwareTextResources,
119    /// In a `RefCell`: a measurer serves the one thread of its composition,
120    /// and on ESP-IDF a `Mutex` is a FreeRTOS mutex, taken every layout pass.
121    cache: RefCell<TextMetricsCache>,
122    hyphenation: HyphenationDictionaryStore,
123}
124
125/// A measurement's parameters besides its text: the font size's bits and
126/// the style hash.
127type TextMetricsParams = (u32, u64);
128
129struct TextMetricsCache {
130    map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
131}
132
133impl TextMetricsCache {
134    fn new(capacity: usize) -> Self {
135        Self {
136            map: PassAgedCache::with_capacity_at_least_one(capacity),
137        }
138    }
139
140    /// The cached metrics of `text`, measured by `measure` on a miss. A hit
141    /// borrows `text`; only a miss copies it into the key it stores.
142    fn get_or_measure<F>(
143        &mut self,
144        text: &str,
145        font_size: f32,
146        style_hash: u64,
147        measure: F,
148    ) -> TextMetrics
149    where
150        F: FnOnce(&str, f32) -> TextMetrics,
151    {
152        let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
153        if let Some(metrics) = self.map.get(probe.key()).copied() {
154            return metrics;
155        }
156
157        let metrics = measure(text, font_size);
158        self.map.push(probe.to_owned_key(), metrics);
159        metrics
160    }
161}
162
163impl CachedFontTextMeasurer {
164    /// Creates a measurer for `text_resources` with a metrics cache of `capacity` entries, at least
165    /// one.
166    pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
167        Self {
168            text_resources,
169            cache: RefCell::new(TextMetricsCache::new(capacity)),
170            hyphenation: HyphenationDictionaryStore::new(),
171        }
172    }
173
174    fn cache_mut(&self) -> RefMut<'_, TextMetricsCache> {
175        self.cache.borrow_mut()
176    }
177}
178
179fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
180    style.resolve_font_size(14.0)
181}
182
183impl TextMeasurer for CachedFontTextMeasurer {
184    fn begin_layout_pass(&self) {
185        self.cache_mut().map.begin_pass(|_| {});
186    }
187
188    fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
189        let font = self.text_resources.fonts().resolve(style)?;
190        Some(crate::software_text_raster::font_glyph_line_box(
191            style, font,
192        ))
193    }
194
195    fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
196        Some(self.line_box(style)?.baseline)
197    }
198
199    fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
200        let font = self.text_resources.fonts().resolve(style)?;
201        Some(crate::software_text_raster::font_line_box(
202            style,
203            font,
204            resolve_font_size(style),
205        ))
206    }
207
208    fn visit_line_boxes(
209        &self,
210        text: &cranpose_ui::text::AnnotatedString,
211        style: &cranpose_ui::text::TextStyle,
212        visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
213    ) -> Option<()> {
214        visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
215    }
216
217    fn measure(
218        &self,
219        text: &cranpose_ui::text::AnnotatedString,
220        style: &cranpose_ui::text::TextStyle,
221    ) -> TextMetrics {
222        let text_str = text.text.as_str();
223        let font_size = resolve_font_size(style);
224        let mut style_hash = style.measurement_hash();
225        if !text.span_styles.is_empty() {
226            let mut hasher = cranpose_ui_graphics::FxHasher::default();
227            style_hash.hash(&mut hasher);
228            text.span_measurement_hash().hash(&mut hasher);
229            style_hash = hasher.finish();
230        }
231        self.cache_mut()
232            .get_or_measure(text_str, font_size, style_hash, |_, size| {
233                measure_annotated_text_with_font_set(text, style, size, self.text_resources.fonts())
234            })
235    }
236
237    fn get_offset_for_position(
238        &self,
239        text: &cranpose_ui::text::AnnotatedString,
240        style: &cranpose_ui::text::TextStyle,
241        x: f32,
242        y: f32,
243    ) -> usize {
244        annotated_offset_for_position(text, style, x, y, self.text_resources.fonts())
245    }
246
247    fn get_cursor_x_for_offset(
248        &self,
249        text: &cranpose_ui::text::AnnotatedString,
250        style: &cranpose_ui::text::TextStyle,
251        offset: usize,
252    ) -> f32 {
253        annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts())
254    }
255
256    fn layout(
257        &self,
258        text: &cranpose_ui::text::AnnotatedString,
259        style: &cranpose_ui::text::TextStyle,
260    ) -> TextLayoutResult {
261        layout_annotated_text_with_font_set(text, style, self.text_resources.fonts())
262    }
263
264    fn choose_auto_hyphen_break(
265        &self,
266        line: &str,
267        style: &cranpose_ui::text::TextStyle,
268        segment_start_char: usize,
269        measured_break_char: usize,
270    ) -> Option<usize> {
271        self.hyphenation.choose_auto_hyphen_break(
272            line,
273            style,
274            segment_start_char,
275            measured_break_char,
276        )
277    }
278}
279
280#[cfg(test)]
281#[path = "tests/text_measure_tests.rs"]
282mod tests;