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 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#[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
93type TextMetricsParams = (u32, u64);
96
97struct TextMetricsCache {
98 map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
99}
100
101impl TextMetricsCache {
102 fn new(capacity: usize) -> Self {
103 Self {
104 map: PassAgedCache::with_capacity_at_least_one(capacity),
105 }
106 }
107
108 fn get_or_measure<F>(
111 &mut self,
112 text: &str,
113 font_size: f32,
114 style_hash: u64,
115 measure: F,
116 ) -> TextMetrics
117 where
118 F: FnOnce(&str, f32) -> TextMetrics,
119 {
120 let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
121 if let Some(metrics) = self.map.get(probe.key()).copied() {
122 return metrics;
123 }
124
125 let metrics = measure(text, font_size);
126 self.map.push(probe.to_owned_key(), metrics);
127 metrics
128 }
129}
130
131impl CachedFontTextMeasurer {
132 pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
133 Self {
134 text_resources,
135 cache: Mutex::new(TextMetricsCache::new(capacity)),
136 hyphenation: HyphenationDictionaryStore::new(),
137 }
138 }
139
140 fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
141 self.cache.lock().unwrap_or_else(PoisonError::into_inner)
142 }
143}
144
145fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
146 style.resolve_font_size(14.0)
147}
148
149impl TextMeasurer for CachedFontTextMeasurer {
150 fn begin_layout_pass(&self) {
151 self.lock_cache().map.begin_pass(|_| {});
152 }
153
154 fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
155 let font = self.text_resources.fonts().resolve(style)?;
156 Some(crate::software_text_raster::font_glyph_line_box(
157 style, font,
158 ))
159 }
160
161 fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
162 Some(self.line_box(style)?.baseline)
163 }
164
165 fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
166 let font = self.text_resources.fonts().resolve(style)?;
167 Some(crate::software_text_raster::font_line_box(
168 style,
169 font,
170 resolve_font_size(style),
171 ))
172 }
173
174 fn visit_line_boxes(
175 &self,
176 text: &cranpose_ui::text::AnnotatedString,
177 style: &cranpose_ui::text::TextStyle,
178 visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
179 ) -> Option<()> {
180 visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
181 }
182
183 fn measure(
184 &self,
185 text: &cranpose_ui::text::AnnotatedString,
186 style: &cranpose_ui::text::TextStyle,
187 ) -> TextMetrics {
188 let text_str = text.text.as_str();
189 let font_size = resolve_font_size(style);
190 let mut style_hash = style.measurement_hash();
191 if !text.span_styles.is_empty() {
192 let mut hasher = cranpose_ui_graphics::FxHasher::default();
193 style_hash.hash(&mut hasher);
194 text.span_measurement_hash().hash(&mut hasher);
195 style_hash = hasher.finish();
196 }
197 self.lock_cache()
198 .get_or_measure(text_str, font_size, style_hash, |_, size| {
199 measure_annotated_text_with_font_set(text, style, size, self.text_resources.fonts())
200 })
201 }
202
203 fn get_offset_for_position(
204 &self,
205 text: &cranpose_ui::text::AnnotatedString,
206 style: &cranpose_ui::text::TextStyle,
207 x: f32,
208 y: f32,
209 ) -> usize {
210 annotated_offset_for_position(text, style, x, y, self.text_resources.fonts())
211 }
212
213 fn get_cursor_x_for_offset(
214 &self,
215 text: &cranpose_ui::text::AnnotatedString,
216 style: &cranpose_ui::text::TextStyle,
217 offset: usize,
218 ) -> f32 {
219 annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts())
220 }
221
222 fn layout(
223 &self,
224 text: &cranpose_ui::text::AnnotatedString,
225 style: &cranpose_ui::text::TextStyle,
226 ) -> TextLayoutResult {
227 layout_annotated_text_with_font_set(text, style, self.text_resources.fonts())
228 }
229
230 fn choose_auto_hyphen_break(
231 &self,
232 line: &str,
233 style: &cranpose_ui::text::TextStyle,
234 segment_start_char: usize,
235 measured_break_char: usize,
236 ) -> Option<usize> {
237 self.hyphenation.choose_auto_hyphen_break(
238 line,
239 style,
240 segment_start_char,
241 measured_break_char,
242 )
243 }
244}
245
246#[cfg(test)]
247#[path = "tests/text_measure_tests.rs"]
248mod tests;