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