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