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