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