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        SoftwareTextFontSet, annotated_cursor_x_for_offset, annotated_offset_for_position,
18        layout_annotated_text_with_font_set, measure_annotated_text_with_font_set,
19        visit_annotated_line_boxes,
20    },
21    text_cache_key::{TextCacheKey, TextProbe},
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    /// These resources with the faces installed since they were made, or
44    /// `None` while no face was installed since.
45    #[cfg(feature = "installed-fonts")]
46    pub fn with_installed_faces(&self) -> Option<Self> {
47        self.fonts
48            .with_installed_faces()
49            .map(|fonts| Self { fonts })
50    }
51}
52
53impl Default for SoftwareTextResources {
54    fn default() -> Self {
55        Self::default_font()
56    }
57}
58
59pub fn fallback_char_width(font_size: f32) -> f32 {
60    font_size.max(1.0) * 0.55
61}
62
63pub fn fallback_line_height(font_size: f32) -> f32 {
64    font_size.max(1.0) * 1.2
65}
66
67pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
68    let line_height = fallback_line_height(font_size);
69    let mut line_count = 0usize;
70    let mut max_chars = 0usize;
71    for line in text.split('\n') {
72        line_count += 1;
73        max_chars = max_chars.max(line.chars().count());
74    }
75    let line_count = line_count.max(1);
76    TextMetrics {
77        width: max_chars as f32 * fallback_char_width(font_size),
78        height: line_count as f32 * line_height,
79        line_height,
80        line_count,
81    }
82}
83
84pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
85    let clamped = byte_offset.min(text.len());
86    let char_count = if clamped == text.len() {
87        text.chars().count()
88    } else {
89        text.char_indices()
90            .take_while(|(index, _)| *index < clamped)
91            .count()
92    };
93    char_count as f32 * fallback_char_width(font_size)
94}
95
96pub struct CachedFontTextMeasurer {
97    text_resources: SoftwareTextResources,
98    cache: Mutex<TextMetricsCache>,
99    hyphenation: HyphenationDictionaryStore,
100}
101
102/// A measurement's parameters besides its text: the font size's bits and
103/// the style hash.
104type TextMetricsParams = (u32, u64);
105
106struct TextMetricsCache {
107    map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
108}
109
110impl TextMetricsCache {
111    fn new(capacity: usize) -> Self {
112        Self {
113            map: PassAgedCache::with_capacity_at_least_one(capacity),
114        }
115    }
116
117    /// The cached metrics of `text`, measured by `measure` on a miss. A hit
118    /// borrows `text`; only a miss copies it into the key it stores.
119    fn get_or_measure<F>(
120        &mut self,
121        text: &str,
122        font_size: f32,
123        style_hash: u64,
124        measure: F,
125    ) -> TextMetrics
126    where
127        F: FnOnce(&str, f32) -> TextMetrics,
128    {
129        let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
130        if let Some(metrics) = self.map.get(probe.key()).copied() {
131            return metrics;
132        }
133
134        let metrics = measure(text, font_size);
135        self.map.push(probe.to_owned_key(), metrics);
136        metrics
137    }
138}
139
140impl CachedFontTextMeasurer {
141    pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
142        Self {
143            text_resources,
144            cache: Mutex::new(TextMetricsCache::new(capacity)),
145            hyphenation: HyphenationDictionaryStore::new(),
146        }
147    }
148
149    fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
150        self.cache.lock().unwrap_or_else(PoisonError::into_inner)
151    }
152}
153
154fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
155    style.resolve_font_size(14.0)
156}
157
158impl TextMeasurer for CachedFontTextMeasurer {
159    fn begin_layout_pass(&self) {
160        self.lock_cache().map.begin_pass(|_| {});
161    }
162
163    fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
164        let font = self.text_resources.fonts().resolve(style)?;
165        Some(crate::software_text_raster::font_glyph_line_box(
166            style, font,
167        ))
168    }
169
170    fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
171        Some(self.line_box(style)?.baseline)
172    }
173
174    fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
175        let font = self.text_resources.fonts().resolve(style)?;
176        Some(crate::software_text_raster::font_line_box(
177            style,
178            font,
179            resolve_font_size(style),
180        ))
181    }
182
183    fn visit_line_boxes(
184        &self,
185        text: &cranpose_ui::text::AnnotatedString,
186        style: &cranpose_ui::text::TextStyle,
187        visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
188    ) -> Option<()> {
189        visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
190    }
191
192    fn measure(
193        &self,
194        text: &cranpose_ui::text::AnnotatedString,
195        style: &cranpose_ui::text::TextStyle,
196    ) -> TextMetrics {
197        let text_str = text.text.as_str();
198        let font_size = resolve_font_size(style);
199        let mut style_hash = style.measurement_hash();
200        if !text.span_styles.is_empty() {
201            let mut hasher = cranpose_ui_graphics::FxHasher::default();
202            style_hash.hash(&mut hasher);
203            text.span_measurement_hash().hash(&mut hasher);
204            style_hash = hasher.finish();
205        }
206        self.lock_cache()
207            .get_or_measure(text_str, font_size, style_hash, |_, size| {
208                measure_annotated_text_with_font_set(text, style, size, self.text_resources.fonts())
209            })
210    }
211
212    fn get_offset_for_position(
213        &self,
214        text: &cranpose_ui::text::AnnotatedString,
215        style: &cranpose_ui::text::TextStyle,
216        x: f32,
217        y: f32,
218    ) -> usize {
219        annotated_offset_for_position(text, style, x, y, self.text_resources.fonts())
220    }
221
222    fn get_cursor_x_for_offset(
223        &self,
224        text: &cranpose_ui::text::AnnotatedString,
225        style: &cranpose_ui::text::TextStyle,
226        offset: usize,
227    ) -> f32 {
228        annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts())
229    }
230
231    fn layout(
232        &self,
233        text: &cranpose_ui::text::AnnotatedString,
234        style: &cranpose_ui::text::TextStyle,
235    ) -> TextLayoutResult {
236        layout_annotated_text_with_font_set(text, style, self.text_resources.fonts())
237    }
238
239    fn choose_auto_hyphen_break(
240        &self,
241        line: &str,
242        style: &cranpose_ui::text::TextStyle,
243        segment_start_char: usize,
244        measured_break_char: usize,
245    ) -> Option<usize> {
246        self.hyphenation.choose_auto_hyphen_break(
247            line,
248            style,
249            segment_start_char,
250            measured_break_char,
251        )
252    }
253}
254
255#[cfg(test)]
256#[path = "tests/text_measure_tests.rs"]
257mod tests;