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