1use 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 SoftwareTextFont, SoftwareTextFontSet, annotated_cursor_x_for_offset,
18 annotated_offset_for_position, cursor_x_for_offset_with_font,
19 layout_annotated_text_with_font_set, layout_text_with_font,
20 measure_annotated_text_with_font_set, measure_text_with_font,
21 text_offset_for_position_with_font, visit_annotated_line_boxes,
22 },
23 text_cache_key::{TextCacheKey, TextProbe},
24 text_hyphenation::HyphenationDictionaryStore,
25};
26
27#[derive(Clone)]
30pub struct SoftwareTextResources {
31 fonts: SoftwareTextFontSet,
32}
33
34impl SoftwareTextResources {
35 pub fn default_font() -> Self {
36 Self {
37 fonts: SoftwareTextFontSet::from_fonts_or_default(&[]),
38 }
39 }
40
41 pub fn fonts(&self) -> &SoftwareTextFontSet {
42 &self.fonts
43 }
44}
45
46impl Default for SoftwareTextResources {
47 fn default() -> Self {
48 Self::default_font()
49 }
50}
51
52pub fn fallback_char_width(font_size: f32) -> f32 {
53 font_size.max(1.0) * 0.55
54}
55
56pub fn fallback_line_height(font_size: f32) -> f32 {
57 font_size.max(1.0) * 1.2
58}
59
60pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
61 let line_height = fallback_line_height(font_size);
62 let mut line_count = 0usize;
63 let mut max_chars = 0usize;
64 for line in text.split('\n') {
65 line_count += 1;
66 max_chars = max_chars.max(line.chars().count());
67 }
68 let line_count = line_count.max(1);
69 TextMetrics {
70 width: max_chars as f32 * fallback_char_width(font_size),
71 height: line_count as f32 * line_height,
72 line_height,
73 line_count,
74 }
75}
76
77pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
78 let clamped = byte_offset.min(text.len());
79 let char_count = if clamped == text.len() {
80 text.chars().count()
81 } else {
82 text.char_indices()
83 .take_while(|(index, _)| *index < clamped)
84 .count()
85 };
86 char_count as f32 * fallback_char_width(font_size)
87}
88
89pub struct CachedFontTextMeasurer {
90 text_resources: SoftwareTextResources,
91 cache: Mutex<TextMetricsCache>,
92 hyphenation: HyphenationDictionaryStore,
93}
94
95type TextMetricsParams = (u32, u64);
98
99struct TextMetricsCache {
100 map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
101}
102
103impl TextMetricsCache {
104 fn new(capacity: usize) -> Self {
105 Self {
106 map: PassAgedCache::with_capacity_at_least_one(capacity),
107 }
108 }
109
110 fn get_or_measure<F>(
113 &mut self,
114 text: &str,
115 font_size: f32,
116 style_hash: u64,
117 measure: F,
118 ) -> TextMetrics
119 where
120 F: FnOnce(&str, f32) -> TextMetrics,
121 {
122 let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
123 if let Some(metrics) = self.map.get(probe.key()).copied() {
124 return metrics;
125 }
126
127 let metrics = measure(text, font_size);
128 self.map.push(probe.to_owned_key(), metrics);
129 metrics
130 }
131}
132
133impl CachedFontTextMeasurer {
134 pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
135 Self {
136 text_resources,
137 cache: Mutex::new(TextMetricsCache::new(capacity)),
138 hyphenation: HyphenationDictionaryStore::new(),
139 }
140 }
141
142 fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
143 self.cache.lock().unwrap_or_else(PoisonError::into_inner)
144 }
145}
146
147fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
148 style.resolve_font_size(14.0)
149}
150
151impl TextMeasurer for CachedFontTextMeasurer {
152 fn begin_layout_pass(&self) {
153 self.lock_cache().map.begin_pass(|_| {});
154 }
155
156 fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
157 let font = self.text_resources.fonts().resolve(style)?;
158 Some(crate::software_text_raster::font_glyph_line_box(
159 style, font,
160 ))
161 }
162
163 fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
164 Some(self.line_box(style)?.baseline)
165 }
166
167 fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
168 let font = self.text_resources.fonts().resolve(style)?;
169 Some(crate::software_text_raster::font_line_box(
170 style,
171 font,
172 resolve_font_size(style),
173 ))
174 }
175
176 fn visit_line_boxes(
177 &self,
178 text: &cranpose_ui::text::AnnotatedString,
179 style: &cranpose_ui::text::TextStyle,
180 visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
181 ) -> Option<()> {
182 visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
183 }
184
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 mut style_hash = style.measurement_hash();
193 if !text.span_styles.is_empty() {
194 let mut hasher = cranpose_ui_graphics::FxHasher::default();
195 style_hash.hash(&mut hasher);
196 text.span_styles_hash().hash(&mut hasher);
197 style_hash = hasher.finish();
198 }
199 self.lock_cache()
200 .get_or_measure(text_str, font_size, style_hash, |value, size| {
201 if !text.span_styles.is_empty() {
202 return measure_annotated_text_with_font_set(
203 text,
204 style,
205 size,
206 self.text_resources.fonts(),
207 );
208 }
209 measure_text_impl(
210 value,
211 style,
212 size,
213 self.text_resources.fonts().resolve(style),
214 )
215 })
216 }
217
218 fn get_offset_for_position(
219 &self,
220 text: &cranpose_ui::text::AnnotatedString,
221 style: &cranpose_ui::text::TextStyle,
222 x: f32,
223 y: f32,
224 ) -> usize {
225 if !text.span_styles.is_empty() {
226 return annotated_offset_for_position(text, style, x, y, self.text_resources.fonts());
227 }
228 let text = text.text.as_str();
229 if text.is_empty() {
230 return 0;
231 }
232
233 let Some(font) = self.text_resources.fonts().resolve(style) else {
234 let font_size = resolve_font_size(style);
235 return TextLayoutResult::monospaced(
236 text,
237 fallback_char_width(font_size),
238 fallback_line_height(font_size),
239 )
240 .get_offset_for_x(x);
241 };
242
243 text_offset_for_position_with_font(text, style, x, y, font)
244 }
245
246 fn get_cursor_x_for_offset(
247 &self,
248 text: &cranpose_ui::text::AnnotatedString,
249 style: &cranpose_ui::text::TextStyle,
250 offset: usize,
251 ) -> f32 {
252 if !text.span_styles.is_empty() {
253 return annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts());
254 }
255 let text = text.text.as_str();
256 let clamped_offset = offset.min(text.len());
257 if clamped_offset == 0 {
258 return 0.0;
259 }
260
261 let Some(font) = self.text_resources.fonts().resolve(style) else {
262 return fallback_cursor_x_for_byte_offset(
263 text,
264 clamped_offset,
265 resolve_font_size(style),
266 );
267 };
268
269 cursor_x_for_offset_with_font(text, style, clamped_offset, font)
270 }
271
272 fn layout(
273 &self,
274 text: &cranpose_ui::text::AnnotatedString,
275 style: &cranpose_ui::text::TextStyle,
276 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
277 if !text.span_styles.is_empty() {
278 return layout_annotated_text_with_font_set(text, style, self.text_resources.fonts());
279 }
280 let font_size = resolve_font_size(style);
281 let Some(font) = self.text_resources.fonts().resolve(style) else {
282 return TextLayoutResult::monospaced(
283 text.text.as_str(),
284 fallback_char_width(font_size),
285 fallback_line_height(font_size),
286 );
287 };
288
289 layout_text_with_font(text.text.as_str(), style, font)
290 }
291
292 fn choose_auto_hyphen_break(
293 &self,
294 line: &str,
295 style: &cranpose_ui::text::TextStyle,
296 segment_start_char: usize,
297 measured_break_char: usize,
298 ) -> Option<usize> {
299 self.hyphenation.choose_auto_hyphen_break(
300 line,
301 style,
302 segment_start_char,
303 measured_break_char,
304 )
305 }
306}
307
308fn measure_text_impl(
309 text: &str,
310 style: &cranpose_ui::text::TextStyle,
311 font_size: f32,
312 font: Option<&SoftwareTextFont>,
313) -> TextMetrics {
314 let Some(font) = font else {
315 return fallback_text_metrics(text, font_size);
316 };
317
318 measure_text_with_font(text, style, font_size, font)
319}
320
321#[cfg(test)]
322#[path = "tests/text_measure_tests.rs"]
323mod tests;