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