1use 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#[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 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
176fn 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}