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