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    borrow::Borrow,
9    hash::{Hash, Hasher},
10    rc::Rc,
11    sync::{Mutex, MutexGuard},
12};
13
14use cranpose_ui::{TextMeasurer, TextMetrics, text_layout_result::TextLayoutResult};
15
16use crate::{
17    bounded_lru_cache::BoundedLruCache,
18    software_text_raster::{
19        SoftwareTextFont, SoftwareTextFontSet, cursor_x_for_offset_with_font,
20        layout_text_with_font, measure_text_with_font, text_offset_for_position_with_font,
21    },
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    pub fn default_font() -> Self {
34        Self {
35            fonts: SoftwareTextFontSet::from_fonts_or_default(&[]),
36        }
37    }
38
39    pub fn fonts(&self) -> &SoftwareTextFontSet {
40        &self.fonts
41    }
42}
43
44impl Default for SoftwareTextResources {
45    fn default() -> Self {
46        Self::default_font()
47    }
48}
49
50pub fn fallback_char_width(font_size: f32) -> f32 {
51    font_size.max(1.0) * 0.55
52}
53
54pub fn fallback_line_height(font_size: f32) -> f32 {
55    font_size.max(1.0) * 1.2
56}
57
58pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
59    let line_height = fallback_line_height(font_size);
60    let mut line_count = 0usize;
61    let mut max_chars = 0usize;
62    for line in text.split('\n') {
63        line_count += 1;
64        max_chars = max_chars.max(line.chars().count());
65    }
66    let line_count = line_count.max(1);
67    TextMetrics {
68        width: max_chars as f32 * fallback_char_width(font_size),
69        height: line_count as f32 * line_height,
70        line_height,
71        line_count,
72    }
73}
74
75pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
76    let clamped = byte_offset.min(text.len());
77    let char_count = if clamped == text.len() {
78        text.chars().count()
79    } else {
80        text.char_indices()
81            .take_while(|(index, _)| *index < clamped)
82            .count()
83    };
84    char_count as f32 * fallback_char_width(font_size)
85}
86
87pub struct CachedFontTextMeasurer {
88    text_resources: SoftwareTextResources,
89    cache: Mutex<TextMetricsCache>,
90    hyphenation: HyphenationDictionaryStore,
91}
92
93#[derive(Clone)]
94struct TextKey {
95    text: Rc<str>,
96    font_size_bits: u32,
97    style_hash: u64,
98}
99
100impl PartialEq for TextKey {
101    fn eq(&self, other: &Self) -> bool {
102        (Rc::ptr_eq(&self.text, &other.text) || *self.text == *other.text)
103            && self.font_size_bits == other.font_size_bits
104            && self.style_hash == other.style_hash
105    }
106}
107
108impl Eq for TextKey {}
109
110impl Hash for TextKey {
111    fn hash<H: Hasher>(&self, state: &mut H) {
112        self.text.hash(state);
113        self.font_size_bits.hash(state);
114        self.style_hash.hash(state);
115    }
116}
117
118impl Borrow<str> for TextKey {
119    fn borrow(&self) -> &str {
120        &self.text
121    }
122}
123
124struct TextMetricsCache {
125    map: BoundedLruCache<TextKey, TextMetrics>,
126}
127
128impl TextMetricsCache {
129    fn new(capacity: usize) -> Self {
130        Self {
131            map: BoundedLruCache::with_capacity_at_least_one(capacity),
132        }
133    }
134
135    fn get_or_measure<F>(
136        &mut self,
137        text: &str,
138        font_size: f32,
139        style_hash: u64,
140        measure: F,
141    ) -> TextMetrics
142    where
143        F: FnOnce(&str, f32) -> TextMetrics,
144    {
145        // Note: Borrow<str> lookup doesn't work well with composite key.
146        // We construct key for lookup.
147        let key = TextKey {
148            text: Rc::from(text),
149            font_size_bits: font_size.to_bits(),
150            style_hash,
151        };
152
153        if let Some(metrics) = self.map.get(&key).copied() {
154            return metrics;
155        }
156
157        let metrics = measure(text, font_size);
158        self.map.put(key, metrics);
159        metrics
160    }
161}
162
163impl CachedFontTextMeasurer {
164    pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
165        Self {
166            text_resources,
167            cache: Mutex::new(TextMetricsCache::new(capacity)),
168            hyphenation: HyphenationDictionaryStore::new(),
169        }
170    }
171
172    fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
173        self.cache
174            .lock()
175            .unwrap_or_else(|poisoned| poisoned.into_inner())
176    }
177}
178
179// Helper to resolve font size from style
180fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
181    style.resolve_font_size(14.0)
182}
183
184impl TextMeasurer for CachedFontTextMeasurer {
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 style_hash = style.measurement_hash();
193        self.lock_cache()
194            .get_or_measure(text_str, font_size, style_hash, |value, size| {
195                measure_text_impl(
196                    value,
197                    style,
198                    size,
199                    self.text_resources.fonts().resolve(style),
200                )
201            })
202    }
203
204    fn get_offset_for_position(
205        &self,
206        text: &cranpose_ui::text::AnnotatedString,
207        style: &cranpose_ui::text::TextStyle,
208        x: f32,
209        _y: f32,
210    ) -> usize {
211        let text = text.text.as_str();
212        if text.is_empty() {
213            return 0;
214        }
215
216        let Some(font) = self.text_resources.fonts().resolve(style) else {
217            let font_size = resolve_font_size(style);
218            return TextLayoutResult::monospaced(
219                text,
220                fallback_char_width(font_size),
221                fallback_line_height(font_size),
222            )
223            .get_offset_for_x(x);
224        };
225
226        text_offset_for_position_with_font(text, style, x, _y, font)
227    }
228
229    fn get_cursor_x_for_offset(
230        &self,
231        text: &cranpose_ui::text::AnnotatedString,
232        style: &cranpose_ui::text::TextStyle,
233        offset: usize,
234    ) -> f32 {
235        let text = text.text.as_str();
236        let clamped_offset = offset.min(text.len());
237        if clamped_offset == 0 {
238            return 0.0;
239        }
240
241        let Some(font) = self.text_resources.fonts().resolve(style) else {
242            return fallback_cursor_x_for_byte_offset(
243                text,
244                clamped_offset,
245                resolve_font_size(style),
246            );
247        };
248
249        cursor_x_for_offset_with_font(text, style, clamped_offset, font)
250    }
251
252    fn layout(
253        &self,
254        text: &cranpose_ui::text::AnnotatedString,
255        style: &cranpose_ui::text::TextStyle,
256    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
257        let font_size = resolve_font_size(style);
258        let Some(font) = self.text_resources.fonts().resolve(style) else {
259            return TextLayoutResult::monospaced(
260                text.text.as_str(),
261                fallback_char_width(font_size),
262                fallback_line_height(font_size),
263            );
264        };
265
266        layout_text_with_font(text.text.as_str(), style, font)
267    }
268
269    fn choose_auto_hyphen_break(
270        &self,
271        line: &str,
272        style: &cranpose_ui::text::TextStyle,
273        segment_start_char: usize,
274        measured_break_char: usize,
275    ) -> Option<usize> {
276        self.hyphenation.choose_auto_hyphen_break(
277            line,
278            style,
279            segment_start_char,
280            measured_break_char,
281        )
282    }
283}
284
285fn measure_text_impl(
286    text: &str,
287    style: &cranpose_ui::text::TextStyle,
288    font_size: f32,
289    font: Option<&SoftwareTextFont>,
290) -> TextMetrics {
291    let Some(font) = font else {
292        return fallback_text_metrics(text, font_size);
293    };
294
295    measure_text_with_font(text, style, font_size, font)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn fallback_text_metrics_cover_empty_and_multiline_text() {
304        let empty = fallback_text_metrics("", 10.0);
305        assert_eq!(empty.line_count, 1);
306        assert_eq!(empty.width, 0.0);
307        assert_eq!(empty.height, fallback_line_height(10.0));
308
309        let multiline = fallback_text_metrics("ab\ncde", 10.0);
310        assert_eq!(multiline.line_count, 2);
311        assert_eq!(multiline.width, 3.0 * fallback_char_width(10.0));
312        assert_eq!(multiline.height, 2.0 * fallback_line_height(10.0));
313    }
314
315    #[test]
316    fn fallback_cursor_position_handles_non_boundary_byte_offsets() {
317        let text = "éx";
318        let width = fallback_char_width(12.0);
319        assert_eq!(fallback_cursor_x_for_byte_offset(text, 0, 12.0), 0.0);
320        assert_eq!(fallback_cursor_x_for_byte_offset(text, 1, 12.0), width);
321        assert_eq!(
322            fallback_cursor_x_for_byte_offset(text, text.len(), 12.0),
323            width * 2.0
324        );
325    }
326
327    #[test]
328    fn cached_font_text_metrics_cache_recovers_after_poison() {
329        let measurer =
330            CachedFontTextMeasurer::with_text_resources(SoftwareTextResources::default(), 8);
331        let text = cranpose_ui::text::AnnotatedString::from("Recovered software text");
332
333        let poison_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
334            let _guard = measurer
335                .cache
336                .lock()
337                .unwrap_or_else(|poisoned| poisoned.into_inner());
338            panic!("poison software text metrics cache for recovery test");
339        }));
340
341        assert!(poison_result.is_err());
342
343        let metrics = measurer.measure(&text, &cranpose_ui::text::TextStyle::default());
344        assert!(metrics.width > 0.0);
345        assert!(metrics.height > 0.0);
346    }
347}