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