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    hash::{Hash, Hasher},
9    sync::{Mutex, MutexGuard, PoisonError},
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        SoftwareTextFont, SoftwareTextFontSet, annotated_cursor_x_for_offset,
18        annotated_offset_for_position, cursor_x_for_offset_with_font,
19        layout_annotated_text_with_font_set, layout_text_with_font,
20        measure_annotated_text_with_font_set, measure_text_with_font,
21        text_offset_for_position_with_font, visit_annotated_line_boxes,
22    },
23    text_cache_key::{TextCacheKey, TextProbe},
24    text_hyphenation::HyphenationDictionaryStore,
25};
26
27/// Renderer-owned text resources: the resolved font used for software
28/// rasterization and measurement, if any.
29#[derive(Clone)]
30pub struct SoftwareTextResources {
31    fonts: SoftwareTextFontSet,
32}
33
34impl SoftwareTextResources {
35    pub fn default_font() -> Self {
36        Self {
37            fonts: SoftwareTextFontSet::from_fonts_or_default(&[]),
38        }
39    }
40
41    pub fn fonts(&self) -> &SoftwareTextFontSet {
42        &self.fonts
43    }
44}
45
46impl Default for SoftwareTextResources {
47    fn default() -> Self {
48        Self::default_font()
49    }
50}
51
52pub fn fallback_char_width(font_size: f32) -> f32 {
53    font_size.max(1.0) * 0.55
54}
55
56pub fn fallback_line_height(font_size: f32) -> f32 {
57    font_size.max(1.0) * 1.2
58}
59
60pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
61    let line_height = fallback_line_height(font_size);
62    let mut line_count = 0usize;
63    let mut max_chars = 0usize;
64    for line in text.split('\n') {
65        line_count += 1;
66        max_chars = max_chars.max(line.chars().count());
67    }
68    let line_count = line_count.max(1);
69    TextMetrics {
70        width: max_chars as f32 * fallback_char_width(font_size),
71        height: line_count as f32 * line_height,
72        line_height,
73        line_count,
74    }
75}
76
77pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
78    let clamped = byte_offset.min(text.len());
79    let char_count = if clamped == text.len() {
80        text.chars().count()
81    } else {
82        text.char_indices()
83            .take_while(|(index, _)| *index < clamped)
84            .count()
85    };
86    char_count as f32 * fallback_char_width(font_size)
87}
88
89pub struct CachedFontTextMeasurer {
90    text_resources: SoftwareTextResources,
91    cache: Mutex<TextMetricsCache>,
92    hyphenation: HyphenationDictionaryStore,
93}
94
95/// A measurement's parameters besides its text: the font size's bits and
96/// the style hash.
97type TextMetricsParams = (u32, u64);
98
99struct TextMetricsCache {
100    map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
101}
102
103impl TextMetricsCache {
104    fn new(capacity: usize) -> Self {
105        Self {
106            map: PassAgedCache::with_capacity_at_least_one(capacity),
107        }
108    }
109
110    /// The cached metrics of `text`, measured by `measure` on a miss. A hit
111    /// borrows `text`; only a miss copies it into the key it stores.
112    fn get_or_measure<F>(
113        &mut self,
114        text: &str,
115        font_size: f32,
116        style_hash: u64,
117        measure: F,
118    ) -> TextMetrics
119    where
120        F: FnOnce(&str, f32) -> TextMetrics,
121    {
122        let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
123        if let Some(metrics) = self.map.get(probe.key()).copied() {
124            return metrics;
125        }
126
127        let metrics = measure(text, font_size);
128        self.map.push(probe.to_owned_key(), metrics);
129        metrics
130    }
131}
132
133impl CachedFontTextMeasurer {
134    pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
135        Self {
136            text_resources,
137            cache: Mutex::new(TextMetricsCache::new(capacity)),
138            hyphenation: HyphenationDictionaryStore::new(),
139        }
140    }
141
142    fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
143        self.cache.lock().unwrap_or_else(PoisonError::into_inner)
144    }
145}
146
147fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
148    style.resolve_font_size(14.0)
149}
150
151impl TextMeasurer for CachedFontTextMeasurer {
152    fn begin_layout_pass(&self) {
153        self.lock_cache().map.begin_pass(|_| {});
154    }
155
156    fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
157        let font = self.text_resources.fonts().resolve(style)?;
158        Some(crate::software_text_raster::font_glyph_line_box(
159            style, font,
160        ))
161    }
162
163    fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
164        Some(self.line_box(style)?.baseline)
165    }
166
167    fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
168        let font = self.text_resources.fonts().resolve(style)?;
169        Some(crate::software_text_raster::font_line_box(
170            style,
171            font,
172            resolve_font_size(style),
173        ))
174    }
175
176    fn visit_line_boxes(
177        &self,
178        text: &cranpose_ui::text::AnnotatedString,
179        style: &cranpose_ui::text::TextStyle,
180        visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
181    ) -> Option<()> {
182        visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
183    }
184
185    fn measure(
186        &self,
187        text: &cranpose_ui::text::AnnotatedString,
188        style: &cranpose_ui::text::TextStyle,
189    ) -> TextMetrics {
190        let text_str = text.text.as_str();
191        let font_size = resolve_font_size(style);
192        let mut style_hash = style.measurement_hash();
193        if !text.span_styles.is_empty() {
194            let mut hasher = cranpose_ui_graphics::FxHasher::default();
195            style_hash.hash(&mut hasher);
196            text.span_styles_hash().hash(&mut hasher);
197            style_hash = hasher.finish();
198        }
199        self.lock_cache()
200            .get_or_measure(text_str, font_size, style_hash, |value, size| {
201                if !text.span_styles.is_empty() {
202                    return measure_annotated_text_with_font_set(
203                        text,
204                        style,
205                        size,
206                        self.text_resources.fonts(),
207                    );
208                }
209                measure_text_impl(
210                    value,
211                    style,
212                    size,
213                    self.text_resources.fonts().resolve(style),
214                )
215            })
216    }
217
218    fn get_offset_for_position(
219        &self,
220        text: &cranpose_ui::text::AnnotatedString,
221        style: &cranpose_ui::text::TextStyle,
222        x: f32,
223        y: f32,
224    ) -> usize {
225        if !text.span_styles.is_empty() {
226            return annotated_offset_for_position(text, style, x, y, self.text_resources.fonts());
227        }
228        let text = text.text.as_str();
229        if text.is_empty() {
230            return 0;
231        }
232
233        let Some(font) = self.text_resources.fonts().resolve(style) else {
234            let font_size = resolve_font_size(style);
235            return TextLayoutResult::monospaced(
236                text,
237                fallback_char_width(font_size),
238                fallback_line_height(font_size),
239            )
240            .get_offset_for_x(x);
241        };
242
243        text_offset_for_position_with_font(text, style, x, y, font)
244    }
245
246    fn get_cursor_x_for_offset(
247        &self,
248        text: &cranpose_ui::text::AnnotatedString,
249        style: &cranpose_ui::text::TextStyle,
250        offset: usize,
251    ) -> f32 {
252        if !text.span_styles.is_empty() {
253            return annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts());
254        }
255        let text = text.text.as_str();
256        let clamped_offset = offset.min(text.len());
257        if clamped_offset == 0 {
258            return 0.0;
259        }
260
261        let Some(font) = self.text_resources.fonts().resolve(style) else {
262            return fallback_cursor_x_for_byte_offset(
263                text,
264                clamped_offset,
265                resolve_font_size(style),
266            );
267        };
268
269        cursor_x_for_offset_with_font(text, style, clamped_offset, font)
270    }
271
272    fn layout(
273        &self,
274        text: &cranpose_ui::text::AnnotatedString,
275        style: &cranpose_ui::text::TextStyle,
276    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
277        if !text.span_styles.is_empty() {
278            return layout_annotated_text_with_font_set(text, style, self.text_resources.fonts());
279        }
280        let font_size = resolve_font_size(style);
281        let Some(font) = self.text_resources.fonts().resolve(style) else {
282            return TextLayoutResult::monospaced(
283                text.text.as_str(),
284                fallback_char_width(font_size),
285                fallback_line_height(font_size),
286            );
287        };
288
289        layout_text_with_font(text.text.as_str(), style, font)
290    }
291
292    fn choose_auto_hyphen_break(
293        &self,
294        line: &str,
295        style: &cranpose_ui::text::TextStyle,
296        segment_start_char: usize,
297        measured_break_char: usize,
298    ) -> Option<usize> {
299        self.hyphenation.choose_auto_hyphen_break(
300            line,
301            style,
302            segment_start_char,
303            measured_break_char,
304        )
305    }
306}
307
308fn measure_text_impl(
309    text: &str,
310    style: &cranpose_ui::text::TextStyle,
311    font_size: f32,
312    font: Option<&SoftwareTextFont>,
313) -> TextMetrics {
314    let Some(font) = font else {
315        return fallback_text_metrics(text, font_size);
316    };
317
318    measure_text_with_font(text, style, font_size, font)
319}
320
321#[cfg(test)]
322#[path = "tests/text_measure_tests.rs"]
323mod tests;