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 #[cfg(feature = "installed-fonts")]
46 pub fn with_installed_faces(&self) -> Option<Self> {
47 self.fonts
48 .with_installed_faces()
49 .map(|fonts| Self { fonts })
50 }
51}
52
53impl Default for SoftwareTextResources {
54 fn default() -> Self {
55 Self::default_font()
56 }
57}
58
59pub fn fallback_char_width(font_size: f32) -> f32 {
60 font_size.max(1.0) * 0.55
61}
62
63pub fn fallback_line_height(font_size: f32) -> f32 {
64 font_size.max(1.0) * 1.2
65}
66
67pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
68 let line_height = fallback_line_height(font_size);
69 let mut line_count = 0usize;
70 let mut max_chars = 0usize;
71 for line in text.split('\n') {
72 line_count += 1;
73 max_chars = max_chars.max(line.chars().count());
74 }
75 let line_count = line_count.max(1);
76 TextMetrics {
77 width: max_chars as f32 * fallback_char_width(font_size),
78 height: line_count as f32 * line_height,
79 line_height,
80 line_count,
81 }
82}
83
84pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
85 let clamped = byte_offset.min(text.len());
86 let char_count = if clamped == text.len() {
87 text.chars().count()
88 } else {
89 text.char_indices()
90 .take_while(|(index, _)| *index < clamped)
91 .count()
92 };
93 char_count as f32 * fallback_char_width(font_size)
94}
95
96pub struct CachedFontTextMeasurer {
97 text_resources: SoftwareTextResources,
98 cache: Mutex<TextMetricsCache>,
99 hyphenation: HyphenationDictionaryStore,
100}
101
102type TextMetricsParams = (u32, u64);
105
106struct TextMetricsCache {
107 map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
108}
109
110impl TextMetricsCache {
111 fn new(capacity: usize) -> Self {
112 Self {
113 map: PassAgedCache::with_capacity_at_least_one(capacity),
114 }
115 }
116
117 fn get_or_measure<F>(
120 &mut self,
121 text: &str,
122 font_size: f32,
123 style_hash: u64,
124 measure: F,
125 ) -> TextMetrics
126 where
127 F: FnOnce(&str, f32) -> TextMetrics,
128 {
129 let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
130 if let Some(metrics) = self.map.get(probe.key()).copied() {
131 return metrics;
132 }
133
134 let metrics = measure(text, font_size);
135 self.map.push(probe.to_owned_key(), metrics);
136 metrics
137 }
138}
139
140impl CachedFontTextMeasurer {
141 pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
142 Self {
143 text_resources,
144 cache: Mutex::new(TextMetricsCache::new(capacity)),
145 hyphenation: HyphenationDictionaryStore::new(),
146 }
147 }
148
149 fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
150 self.cache.lock().unwrap_or_else(PoisonError::into_inner)
151 }
152}
153
154fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
155 style.resolve_font_size(14.0)
156}
157
158impl TextMeasurer for CachedFontTextMeasurer {
159 fn begin_layout_pass(&self) {
160 self.lock_cache().map.begin_pass(|_| {});
161 }
162
163 fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
164 let font = self.text_resources.fonts().resolve(style)?;
165 Some(crate::software_text_raster::font_glyph_line_box(
166 style, font,
167 ))
168 }
169
170 fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
171 Some(self.line_box(style)?.baseline)
172 }
173
174 fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
175 let font = self.text_resources.fonts().resolve(style)?;
176 Some(crate::software_text_raster::font_line_box(
177 style,
178 font,
179 resolve_font_size(style),
180 ))
181 }
182
183 fn visit_line_boxes(
184 &self,
185 text: &cranpose_ui::text::AnnotatedString,
186 style: &cranpose_ui::text::TextStyle,
187 visit: &mut dyn FnMut(cranpose_ui::text::LineBox),
188 ) -> Option<()> {
189 visit_annotated_line_boxes(text, style, self.text_resources.fonts(), visit)
190 }
191
192 fn measure(
193 &self,
194 text: &cranpose_ui::text::AnnotatedString,
195 style: &cranpose_ui::text::TextStyle,
196 ) -> TextMetrics {
197 let text_str = text.text.as_str();
198 let font_size = resolve_font_size(style);
199 let mut style_hash = style.measurement_hash();
200 if !text.span_styles.is_empty() {
201 let mut hasher = cranpose_ui_graphics::FxHasher::default();
202 style_hash.hash(&mut hasher);
203 text.span_measurement_hash().hash(&mut hasher);
204 style_hash = hasher.finish();
205 }
206 self.lock_cache()
207 .get_or_measure(text_str, font_size, style_hash, |_, size| {
208 measure_annotated_text_with_font_set(text, style, size, self.text_resources.fonts())
209 })
210 }
211
212 fn get_offset_for_position(
213 &self,
214 text: &cranpose_ui::text::AnnotatedString,
215 style: &cranpose_ui::text::TextStyle,
216 x: f32,
217 y: f32,
218 ) -> usize {
219 annotated_offset_for_position(text, style, x, y, self.text_resources.fonts())
220 }
221
222 fn get_cursor_x_for_offset(
223 &self,
224 text: &cranpose_ui::text::AnnotatedString,
225 style: &cranpose_ui::text::TextStyle,
226 offset: usize,
227 ) -> f32 {
228 annotated_cursor_x_for_offset(text, style, offset, self.text_resources.fonts())
229 }
230
231 fn layout(
232 &self,
233 text: &cranpose_ui::text::AnnotatedString,
234 style: &cranpose_ui::text::TextStyle,
235 ) -> TextLayoutResult {
236 layout_annotated_text_with_font_set(text, style, self.text_resources.fonts())
237 }
238
239 fn choose_auto_hyphen_break(
240 &self,
241 line: &str,
242 style: &cranpose_ui::text::TextStyle,
243 segment_start_char: usize,
244 measured_break_char: usize,
245 ) -> Option<usize> {
246 self.hyphenation.choose_auto_hyphen_break(
247 line,
248 style,
249 segment_start_char,
250 measured_break_char,
251 )
252 }
253}
254
255#[cfg(test)]
256#[path = "tests/text_measure_tests.rs"]
257mod tests;