1use std::sync::{Mutex, MutexGuard, PoisonError};
8
9use cranpose_core::collections::pass_aged::PassAgedCache;
10use cranpose_ui::{TextMeasurer, TextMetrics, text_layout_result::TextLayoutResult};
11
12use crate::{
13 software_text_raster::{
14 SoftwareTextFont, SoftwareTextFontSet, cursor_x_for_offset_with_font,
15 layout_text_with_font, measure_text_with_font, text_offset_for_position_with_font,
16 },
17 text_cache_key::{TextCacheKey, TextProbe},
18 text_hyphenation::HyphenationDictionaryStore,
19};
20
21#[derive(Clone)]
24pub struct SoftwareTextResources {
25 fonts: SoftwareTextFontSet,
26}
27
28impl SoftwareTextResources {
29 pub fn default_font() -> Self {
30 Self {
31 fonts: SoftwareTextFontSet::from_fonts_or_default(&[]),
32 }
33 }
34
35 pub fn fonts(&self) -> &SoftwareTextFontSet {
36 &self.fonts
37 }
38}
39
40impl Default for SoftwareTextResources {
41 fn default() -> Self {
42 Self::default_font()
43 }
44}
45
46pub fn fallback_char_width(font_size: f32) -> f32 {
47 font_size.max(1.0) * 0.55
48}
49
50pub fn fallback_line_height(font_size: f32) -> f32 {
51 font_size.max(1.0) * 1.2
52}
53
54pub fn fallback_text_metrics(text: &str, font_size: f32) -> TextMetrics {
55 let line_height = fallback_line_height(font_size);
56 let mut line_count = 0usize;
57 let mut max_chars = 0usize;
58 for line in text.split('\n') {
59 line_count += 1;
60 max_chars = max_chars.max(line.chars().count());
61 }
62 let line_count = line_count.max(1);
63 TextMetrics {
64 width: max_chars as f32 * fallback_char_width(font_size),
65 height: line_count as f32 * line_height,
66 line_height,
67 line_count,
68 }
69}
70
71pub fn fallback_cursor_x_for_byte_offset(text: &str, byte_offset: usize, font_size: f32) -> f32 {
72 let clamped = byte_offset.min(text.len());
73 let char_count = if clamped == text.len() {
74 text.chars().count()
75 } else {
76 text.char_indices()
77 .take_while(|(index, _)| *index < clamped)
78 .count()
79 };
80 char_count as f32 * fallback_char_width(font_size)
81}
82
83pub struct CachedFontTextMeasurer {
84 text_resources: SoftwareTextResources,
85 cache: Mutex<TextMetricsCache>,
86 hyphenation: HyphenationDictionaryStore,
87}
88
89type TextMetricsParams = (u32, u64);
92
93struct TextMetricsCache {
94 map: PassAgedCache<TextCacheKey<TextMetricsParams>, TextMetrics>,
95}
96
97impl TextMetricsCache {
98 fn new(capacity: usize) -> Self {
99 Self {
100 map: PassAgedCache::with_capacity_at_least_one(capacity),
101 }
102 }
103
104 fn get_or_measure<F>(
107 &mut self,
108 text: &str,
109 font_size: f32,
110 style_hash: u64,
111 measure: F,
112 ) -> TextMetrics
113 where
114 F: FnOnce(&str, f32) -> TextMetrics,
115 {
116 let probe = TextProbe::new(text, (font_size.to_bits(), style_hash));
117 if let Some(metrics) = self.map.get(probe.key()).copied() {
118 return metrics;
119 }
120
121 let metrics = measure(text, font_size);
122 self.map.push(probe.to_owned_key(), metrics);
123 metrics
124 }
125}
126
127impl CachedFontTextMeasurer {
128 pub fn with_text_resources(text_resources: SoftwareTextResources, capacity: usize) -> Self {
129 Self {
130 text_resources,
131 cache: Mutex::new(TextMetricsCache::new(capacity)),
132 hyphenation: HyphenationDictionaryStore::new(),
133 }
134 }
135
136 fn lock_cache(&self) -> MutexGuard<'_, TextMetricsCache> {
137 self.cache.lock().unwrap_or_else(PoisonError::into_inner)
138 }
139}
140
141fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
142 style.resolve_font_size(14.0)
143}
144
145impl TextMeasurer for CachedFontTextMeasurer {
146 fn begin_layout_pass(&self) {
147 self.lock_cache().map.begin_pass(|_| {});
148 }
149
150 fn glyph_line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<(f32, f32)> {
151 let font = self.text_resources.fonts().resolve(style)?;
152 Some(crate::software_text_raster::font_glyph_line_box(
153 style, font,
154 ))
155 }
156
157 fn first_baseline(&self, style: &cranpose_ui::text::TextStyle) -> Option<f32> {
158 Some(self.line_box(style)?.baseline)
159 }
160
161 fn line_box(&self, style: &cranpose_ui::text::TextStyle) -> Option<cranpose_ui::text::LineBox> {
162 let font = self.text_resources.fonts().resolve(style)?;
163 Some(crate::software_text_raster::font_line_box(
164 style,
165 font,
166 resolve_font_size(style),
167 ))
168 }
169
170 fn measure(
171 &self,
172 text: &cranpose_ui::text::AnnotatedString,
173 style: &cranpose_ui::text::TextStyle,
174 ) -> TextMetrics {
175 let text_str = text.text.as_str();
176 let font_size = resolve_font_size(style);
177 let style_hash = style.measurement_hash();
178 self.lock_cache()
179 .get_or_measure(text_str, font_size, style_hash, |value, size| {
180 measure_text_impl(
181 value,
182 style,
183 size,
184 self.text_resources.fonts().resolve(style),
185 )
186 })
187 }
188
189 fn get_offset_for_position(
190 &self,
191 text: &cranpose_ui::text::AnnotatedString,
192 style: &cranpose_ui::text::TextStyle,
193 x: f32,
194 _y: f32,
195 ) -> usize {
196 let text = text.text.as_str();
197 if text.is_empty() {
198 return 0;
199 }
200
201 let Some(font) = self.text_resources.fonts().resolve(style) else {
202 let font_size = resolve_font_size(style);
203 return TextLayoutResult::monospaced(
204 text,
205 fallback_char_width(font_size),
206 fallback_line_height(font_size),
207 )
208 .get_offset_for_x(x);
209 };
210
211 text_offset_for_position_with_font(text, style, x, _y, font)
212 }
213
214 fn get_cursor_x_for_offset(
215 &self,
216 text: &cranpose_ui::text::AnnotatedString,
217 style: &cranpose_ui::text::TextStyle,
218 offset: usize,
219 ) -> f32 {
220 let text = text.text.as_str();
221 let clamped_offset = offset.min(text.len());
222 if clamped_offset == 0 {
223 return 0.0;
224 }
225
226 let Some(font) = self.text_resources.fonts().resolve(style) else {
227 return fallback_cursor_x_for_byte_offset(
228 text,
229 clamped_offset,
230 resolve_font_size(style),
231 );
232 };
233
234 cursor_x_for_offset_with_font(text, style, clamped_offset, font)
235 }
236
237 fn layout(
238 &self,
239 text: &cranpose_ui::text::AnnotatedString,
240 style: &cranpose_ui::text::TextStyle,
241 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
242 let font_size = resolve_font_size(style);
243 let Some(font) = self.text_resources.fonts().resolve(style) else {
244 return TextLayoutResult::monospaced(
245 text.text.as_str(),
246 fallback_char_width(font_size),
247 fallback_line_height(font_size),
248 );
249 };
250
251 layout_text_with_font(text.text.as_str(), style, font)
252 }
253
254 fn choose_auto_hyphen_break(
255 &self,
256 line: &str,
257 style: &cranpose_ui::text::TextStyle,
258 segment_start_char: usize,
259 measured_break_char: usize,
260 ) -> Option<usize> {
261 self.hyphenation.choose_auto_hyphen_break(
262 line,
263 style,
264 segment_start_char,
265 measured_break_char,
266 )
267 }
268}
269
270fn measure_text_impl(
271 text: &str,
272 style: &cranpose_ui::text::TextStyle,
273 font_size: f32,
274 font: Option<&SoftwareTextFont>,
275) -> TextMetrics {
276 let Some(font) = font else {
277 return fallback_text_metrics(text, font_size);
278 };
279
280 measure_text_with_font(text, style, font_size, font)
281}
282
283#[cfg(test)]
284#[path = "tests/text_measure_tests.rs"]
285mod tests;