Skip to main content

fret_render_text/
measure.rs

1use crate::cache_keys::{
2    TextMeasureKey, TextMeasureShapingKey, hash_text, spans_shaping_fingerprint,
3};
4use crate::cache_tuning;
5use crate::geometry::{metrics_for_uniform_lines, metrics_from_wrapped_lines};
6use crate::parley_shaper;
7use crate::parley_shaper::ParleyShaper;
8use crate::wrapper;
9use fret_core::{
10    AttributedText, TextConstraints, TextInputRef, TextMetrics, TextOverflow, TextSlant, TextSpan,
11    TextStyle, TextWrap,
12};
13use std::collections::{HashMap, VecDeque};
14use std::sync::Arc;
15
16#[derive(Debug, Clone)]
17struct TextMeasureEntry {
18    text_hash: u64,
19    spans_hash: u64,
20    text: Arc<str>,
21    spans: Option<Arc<[TextSpan]>>,
22    metrics: TextMetrics,
23}
24
25#[derive(Debug, Clone)]
26struct TextMeasureShapingEntry {
27    text: Arc<str>,
28    spans: Option<Arc<[TextSpan]>>,
29    width_px: f32,
30    baseline_px: f32,
31    line_height_px: f32,
32    clusters: Arc<[parley_shaper::ShapedCluster]>,
33}
34
35#[derive(Debug)]
36pub struct TextMeasureCaches {
37    measure_cache: HashMap<TextMeasureKey, VecDeque<TextMeasureEntry>>,
38    measure_shaping_cache: HashMap<TextMeasureShapingKey, TextMeasureShapingEntry>,
39    measure_shaping_fifo: VecDeque<TextMeasureShapingKey>,
40}
41
42impl Default for TextMeasureCaches {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl TextMeasureCaches {
49    pub fn new() -> Self {
50        let shaping_entries = cache_tuning::measure_shaping_cache_entries();
51        Self {
52            measure_cache: HashMap::new(),
53            // Pre-reserve to avoid HashMap rehash spikes on editor pages that touch thousands of
54            // unique text strings during a single resize/layout sequence.
55            measure_shaping_cache: HashMap::with_capacity(shaping_entries.min(65_536)),
56            measure_shaping_fifo: VecDeque::with_capacity(shaping_entries.min(65_536)),
57        }
58    }
59
60    pub fn clear(&mut self) {
61        self.measure_cache.clear();
62        self.measure_shaping_cache.clear();
63        self.measure_shaping_fifo.clear();
64    }
65
66    pub fn buckets_len(&self) -> usize {
67        self.measure_cache.len()
68    }
69
70    pub fn shaping_entries_len(&self) -> usize {
71        self.measure_shaping_cache.len()
72    }
73
74    pub fn measure_plain(
75        &mut self,
76        shaper: &mut ParleyShaper,
77        text: &str,
78        style: &TextStyle,
79        constraints: TextConstraints,
80        font_stack_key: u64,
81    ) -> TextMetrics {
82        const MEASURE_CACHE_PER_BUCKET_LIMIT: usize = 256;
83        const MEASURE_CACHE_PER_BUCKET_LIMIT_WRAP_NONE: usize = 2048;
84
85        let mut normalized_constraints = constraints;
86        if normalized_constraints.wrap == TextWrap::None {
87            normalized_constraints.max_width = None;
88        }
89
90        let key = TextMeasureKey::new(style, normalized_constraints, font_stack_key);
91        let text_hash = hash_text(text);
92        if let Some(bucket) = self.measure_cache.get_mut(&key)
93            && let Some(idx) = bucket.iter().position(|e| {
94                e.text_hash == text_hash && e.spans_hash == 0 && e.text.as_ref() == text
95            })
96            && let Some(hit) = bucket.remove(idx)
97        {
98            let mut metrics = hit.metrics;
99            bucket.push_back(hit);
100            if constraints.wrap == TextWrap::None
101                && constraints.overflow == TextOverflow::Ellipsis
102                && let Some(max_width) = constraints.max_width
103            {
104                metrics.size.width = max_width;
105            }
106            return metrics;
107        }
108
109        let scale = crate::effective_text_scale_factor(constraints.scale_factor);
110        let allow_fast_wrap_measure =
111            constraints.scale_factor.is_finite() && constraints.scale_factor.fract().abs() <= 1e-4;
112        let max_width_for_fast = match constraints {
113            TextConstraints {
114                max_width: Some(max_width),
115                wrap: TextWrap::Word | TextWrap::Balance | TextWrap::WordBreak | TextWrap::Grapheme,
116                overflow: TextOverflow::Clip,
117                ..
118            } if allow_fast_wrap_measure && !text.contains('\n') => Some(max_width),
119            _ => None,
120        };
121
122        let metrics = if let Some(max_width) = max_width_for_fast {
123            let allow_shaping_cache =
124                text.len() >= cache_tuning::measure_shaping_cache_min_text_len_bytes();
125
126            let shaping_key = TextMeasureShapingKey {
127                text_hash,
128                text_len: text.len(),
129                spans_shaping_key: 0,
130                font: style.font.clone(),
131                font_stack_key,
132                size_bits: style.size.0.to_bits(),
133                weight: style.weight.0,
134                slant: match style.slant {
135                    TextSlant::Normal => 0,
136                    TextSlant::Italic => 1,
137                    TextSlant::Oblique => 2,
138                },
139                line_height_bits: style.line_height.map(|px| px.0.to_bits()),
140                line_height_em_bits: style.line_height_em.map(|v| v.to_bits()),
141                line_height_policy: match style.line_height_policy {
142                    fret_core::TextLineHeightPolicy::ExpandToFit => 0,
143                    fret_core::TextLineHeightPolicy::FixedFromStyle => 1,
144                },
145                leading_distribution: match style.leading_distribution {
146                    fret_core::text::TextLeadingDistribution::Even => 0,
147                    fret_core::text::TextLeadingDistribution::Proportional => 1,
148                },
149                strut_force: style
150                    .strut_style
151                    .as_ref()
152                    .map(|s| if s.force { 1 } else { 0 })
153                    .unwrap_or(0),
154                strut_font: style.strut_style.as_ref().and_then(|s| s.font.clone()),
155                strut_size_bits: style
156                    .strut_style
157                    .as_ref()
158                    .and_then(|s| s.size.map(|px| px.0.to_bits())),
159                strut_line_height_bits: style
160                    .strut_style
161                    .as_ref()
162                    .and_then(|s| s.line_height.map(|px| px.0.to_bits())),
163                strut_line_height_em_bits: style
164                    .strut_style
165                    .as_ref()
166                    .and_then(|s| s.line_height_em.map(|v| v.to_bits())),
167                strut_leading_distribution: style.strut_style.as_ref().and_then(|s| {
168                    s.leading_distribution.map(|d| match d {
169                        fret_core::text::TextLeadingDistribution::Even => 0,
170                        fret_core::text::TextLeadingDistribution::Proportional => 1,
171                    })
172                }),
173                letter_spacing_bits: style.letter_spacing_em.map(|v| v.to_bits()),
174                scale_bits: constraints.scale_factor.to_bits(),
175            };
176
177            let max_width_px = max_width.0 * scale;
178
179            if allow_shaping_cache {
180                let (width_px, baseline_px, line_height_px, _clusters) = if let Some(hit) =
181                    self.measure_shaping_cache.get(&shaping_key)
182                    && hit.text.as_ref() == text
183                    && hit.spans.is_none()
184                {
185                    (
186                        hit.width_px,
187                        hit.baseline_px,
188                        hit.line_height_px,
189                        hit.clusters.clone(),
190                    )
191                } else {
192                    let mut line =
193                        shaper.shape_single_line_metrics(TextInputRef::plain(text, style), scale);
194                    let clusters: Arc<[parley_shaper::ShapedCluster]> =
195                        Arc::from(line.take_clusters());
196
197                    let existed = self
198                        .measure_shaping_cache
199                        .insert(
200                            shaping_key.clone(),
201                            TextMeasureShapingEntry {
202                                text: Arc::<str>::from(text),
203                                spans: None,
204                                width_px: line.width(),
205                                baseline_px: line.baseline(),
206                                line_height_px: line.line_height(),
207                                clusters: clusters.clone(),
208                            },
209                        )
210                        .is_some();
211                    if !existed {
212                        self.measure_shaping_fifo.push_back(shaping_key.clone());
213                        let limit = cache_tuning::measure_shaping_cache_entries();
214                        while self.measure_shaping_fifo.len() > limit {
215                            let Some(evict) = self.measure_shaping_fifo.pop_front() else {
216                                break;
217                            };
218                            self.measure_shaping_cache.remove(&evict);
219                        }
220                    }
221
222                    (line.width(), line.baseline(), line.line_height(), clusters)
223                };
224
225                if width_px <= max_width_px + 0.5 {
226                    metrics_for_uniform_lines(
227                        width_px.max(0.0),
228                        1,
229                        baseline_px,
230                        line_height_px,
231                        scale,
232                    )
233                } else {
234                    let wrapped = wrapper::wrap_with_constraints_measure_only(
235                        shaper,
236                        TextInputRef::plain(text, style),
237                        normalized_constraints,
238                    );
239                    metrics_from_wrapped_lines(wrapped.lines(), scale)
240                }
241            } else {
242                let mut line =
243                    shaper.shape_single_line_metrics(TextInputRef::plain(text, style), scale);
244                let width_px = line.width();
245                let baseline_px = line.baseline();
246                let line_height_px = line.line_height();
247                let _clusters = line.take_clusters();
248
249                if width_px <= max_width_px + 0.5 {
250                    metrics_for_uniform_lines(
251                        width_px.max(0.0),
252                        1,
253                        baseline_px,
254                        line_height_px,
255                        scale,
256                    )
257                } else {
258                    let wrapped = wrapper::wrap_with_constraints_measure_only(
259                        shaper,
260                        TextInputRef::plain(text, style),
261                        normalized_constraints,
262                    );
263                    metrics_from_wrapped_lines(wrapped.lines(), scale)
264                }
265            }
266        } else {
267            // Keep measurement aligned with prepare/paint under fractional scale factors while
268            // avoiding per-glyph work in layout. The metrics-only wrapper shares the same Parley
269            // shaping + line breaking, but does not materialize glyph runs.
270            let wrapped = wrapper::wrap_with_constraints_measure_only(
271                shaper,
272                TextInputRef::plain(text, style),
273                normalized_constraints,
274            );
275            metrics_from_wrapped_lines(wrapped.lines(), scale)
276        };
277
278        let bucket = self.measure_cache.entry(key).or_default();
279        bucket.push_back(TextMeasureEntry {
280            text_hash,
281            spans_hash: 0,
282            text: Arc::<str>::from(text),
283            spans: None,
284            metrics,
285        });
286        let limit = match normalized_constraints.wrap {
287            TextWrap::None => MEASURE_CACHE_PER_BUCKET_LIMIT_WRAP_NONE,
288            TextWrap::Word | TextWrap::Balance | TextWrap::WordBreak | TextWrap::Grapheme => {
289                MEASURE_CACHE_PER_BUCKET_LIMIT
290            }
291        };
292        while bucket.len() > limit {
293            bucket.pop_front();
294        }
295
296        let mut metrics = metrics;
297        if constraints.wrap == TextWrap::None
298            && constraints.overflow == TextOverflow::Ellipsis
299            && let Some(max_width) = constraints.max_width
300        {
301            metrics.size.width = max_width;
302        }
303        metrics
304    }
305
306    pub fn measure_attributed(
307        &mut self,
308        shaper: &mut ParleyShaper,
309        rich: &AttributedText,
310        base_style: &TextStyle,
311        constraints: TextConstraints,
312        font_stack_key: u64,
313    ) -> TextMetrics {
314        const MEASURE_CACHE_PER_BUCKET_LIMIT: usize = 256;
315        const MEASURE_CACHE_PER_BUCKET_LIMIT_WRAP_NONE: usize = 2048;
316
317        let mut normalized_constraints = constraints;
318        if normalized_constraints.wrap == TextWrap::None {
319            normalized_constraints.max_width = None;
320        }
321
322        let key = TextMeasureKey::new(base_style, normalized_constraints, font_stack_key);
323        let text_hash = hash_text(rich.text.as_ref());
324        let spans_hash = spans_shaping_fingerprint(rich.spans.as_ref());
325
326        if let Some(bucket) = self.measure_cache.get_mut(&key)
327            && let Some(idx) = bucket.iter().position(|e| {
328                e.text_hash == text_hash
329                    && e.spans_hash == spans_hash
330                    && e.text.as_ref() == rich.text.as_ref()
331                    && e.spans.as_ref().is_some_and(|s| {
332                        Arc::ptr_eq(s, &rich.spans) || s.as_ref() == rich.spans.as_ref()
333                    })
334            })
335            && let Some(hit) = bucket.remove(idx)
336        {
337            let mut metrics = hit.metrics;
338            bucket.push_back(hit);
339            if constraints.wrap == TextWrap::None
340                && constraints.overflow == TextOverflow::Ellipsis
341                && let Some(max_width) = constraints.max_width
342            {
343                metrics.size.width = max_width;
344            }
345            return metrics;
346        }
347
348        let scale = crate::effective_text_scale_factor(constraints.scale_factor);
349        let allow_fast_wrap_measure =
350            constraints.scale_factor.is_finite() && constraints.scale_factor.fract().abs() <= 1e-4;
351        let max_width_for_fast = match constraints {
352            TextConstraints {
353                max_width: Some(max_width),
354                wrap: TextWrap::Word | TextWrap::Balance | TextWrap::WordBreak | TextWrap::Grapheme,
355                overflow: TextOverflow::Clip,
356                ..
357            } if allow_fast_wrap_measure && !rich.text.as_ref().contains('\n') => Some(max_width),
358            _ => None,
359        };
360
361        let metrics = if let Some(max_width) = max_width_for_fast {
362            let allow_shaping_cache =
363                rich.text.len() >= cache_tuning::measure_shaping_cache_min_text_len_bytes();
364
365            let shaping_key = TextMeasureShapingKey {
366                text_hash,
367                text_len: rich.text.len(),
368                spans_shaping_key: spans_hash,
369                font: base_style.font.clone(),
370                font_stack_key,
371                size_bits: base_style.size.0.to_bits(),
372                weight: base_style.weight.0,
373                slant: match base_style.slant {
374                    TextSlant::Normal => 0,
375                    TextSlant::Italic => 1,
376                    TextSlant::Oblique => 2,
377                },
378                line_height_bits: base_style.line_height.map(|px| px.0.to_bits()),
379                line_height_em_bits: base_style.line_height_em.map(|v| v.to_bits()),
380                line_height_policy: match base_style.line_height_policy {
381                    fret_core::TextLineHeightPolicy::ExpandToFit => 0,
382                    fret_core::TextLineHeightPolicy::FixedFromStyle => 1,
383                },
384                leading_distribution: match base_style.leading_distribution {
385                    fret_core::text::TextLeadingDistribution::Even => 0,
386                    fret_core::text::TextLeadingDistribution::Proportional => 1,
387                },
388                strut_force: base_style
389                    .strut_style
390                    .as_ref()
391                    .map(|s| if s.force { 1 } else { 0 })
392                    .unwrap_or(0),
393                strut_font: base_style.strut_style.as_ref().and_then(|s| s.font.clone()),
394                strut_size_bits: base_style
395                    .strut_style
396                    .as_ref()
397                    .and_then(|s| s.size.map(|px| px.0.to_bits())),
398                strut_line_height_bits: base_style
399                    .strut_style
400                    .as_ref()
401                    .and_then(|s| s.line_height.map(|px| px.0.to_bits())),
402                strut_line_height_em_bits: base_style
403                    .strut_style
404                    .as_ref()
405                    .and_then(|s| s.line_height_em.map(|v| v.to_bits())),
406                strut_leading_distribution: base_style.strut_style.as_ref().and_then(|s| {
407                    s.leading_distribution.map(|d| match d {
408                        fret_core::text::TextLeadingDistribution::Even => 0,
409                        fret_core::text::TextLeadingDistribution::Proportional => 1,
410                    })
411                }),
412                letter_spacing_bits: base_style.letter_spacing_em.map(|v| v.to_bits()),
413                scale_bits: constraints.scale_factor.to_bits(),
414            };
415
416            let max_width_px = max_width.0 * scale;
417            let text = rich.text.as_ref();
418
419            if allow_shaping_cache {
420                let (width_px, baseline_px, line_height_px, _clusters) = if let Some(hit) =
421                    self.measure_shaping_cache.get(&shaping_key)
422                    && hit.text.as_ref() == rich.text.as_ref()
423                    && hit.spans.as_ref().is_some_and(|s| {
424                        Arc::ptr_eq(s, &rich.spans) || s.as_ref() == rich.spans.as_ref()
425                    }) {
426                    (
427                        hit.width_px,
428                        hit.baseline_px,
429                        hit.line_height_px,
430                        hit.clusters.clone(),
431                    )
432                } else {
433                    let mut line = shaper.shape_single_line_metrics(
434                        TextInputRef::Attributed {
435                            text: rich.text.as_ref(),
436                            base: base_style,
437                            spans: rich.spans.as_ref(),
438                        },
439                        scale,
440                    );
441                    let clusters: Arc<[parley_shaper::ShapedCluster]> =
442                        Arc::from(line.take_clusters());
443
444                    let existed = self
445                        .measure_shaping_cache
446                        .insert(
447                            shaping_key.clone(),
448                            TextMeasureShapingEntry {
449                                text: rich.text.clone(),
450                                spans: Some(rich.spans.clone()),
451                                width_px: line.width(),
452                                baseline_px: line.baseline(),
453                                line_height_px: line.line_height(),
454                                clusters: clusters.clone(),
455                            },
456                        )
457                        .is_some();
458                    if !existed {
459                        self.measure_shaping_fifo.push_back(shaping_key.clone());
460                        let limit = cache_tuning::measure_shaping_cache_entries();
461                        while self.measure_shaping_fifo.len() > limit {
462                            let Some(evict) = self.measure_shaping_fifo.pop_front() else {
463                                break;
464                            };
465                            self.measure_shaping_cache.remove(&evict);
466                        }
467                    }
468
469                    (line.width(), line.baseline(), line.line_height(), clusters)
470                };
471
472                if width_px <= max_width_px + 0.5 {
473                    metrics_for_uniform_lines(
474                        width_px.max(0.0),
475                        1,
476                        baseline_px,
477                        line_height_px,
478                        scale,
479                    )
480                } else {
481                    let wrapped = wrapper::wrap_with_constraints_measure_only(
482                        shaper,
483                        TextInputRef::Attributed {
484                            text,
485                            base: base_style,
486                            spans: rich.spans.as_ref(),
487                        },
488                        normalized_constraints,
489                    );
490                    metrics_from_wrapped_lines(wrapped.lines(), scale)
491                }
492            } else {
493                let mut line = shaper.shape_single_line_metrics(
494                    TextInputRef::Attributed {
495                        text,
496                        base: base_style,
497                        spans: rich.spans.as_ref(),
498                    },
499                    scale,
500                );
501                let width_px = line.width();
502                let baseline_px = line.baseline();
503                let line_height_px = line.line_height();
504                let _clusters = line.take_clusters();
505
506                if width_px <= max_width_px + 0.5 {
507                    metrics_for_uniform_lines(
508                        width_px.max(0.0),
509                        1,
510                        baseline_px,
511                        line_height_px,
512                        scale,
513                    )
514                } else {
515                    let wrapped = wrapper::wrap_with_constraints_measure_only(
516                        shaper,
517                        TextInputRef::Attributed {
518                            text,
519                            base: base_style,
520                            spans: rich.spans.as_ref(),
521                        },
522                        normalized_constraints,
523                    );
524                    metrics_from_wrapped_lines(wrapped.lines(), scale)
525                }
526            }
527        } else {
528            let wrapped = wrapper::wrap_with_constraints_measure_only(
529                shaper,
530                TextInputRef::Attributed {
531                    text: rich.text.as_ref(),
532                    base: base_style,
533                    spans: rich.spans.as_ref(),
534                },
535                normalized_constraints,
536            );
537            metrics_from_wrapped_lines(wrapped.lines(), scale)
538        };
539
540        let bucket = self.measure_cache.entry(key).or_default();
541        bucket.push_back(TextMeasureEntry {
542            text_hash,
543            spans_hash,
544            text: rich.text.clone(),
545            spans: Some(rich.spans.clone()),
546            metrics,
547        });
548        let limit = match normalized_constraints.wrap {
549            TextWrap::None => MEASURE_CACHE_PER_BUCKET_LIMIT_WRAP_NONE,
550            TextWrap::Word | TextWrap::Balance | TextWrap::WordBreak | TextWrap::Grapheme => {
551                MEASURE_CACHE_PER_BUCKET_LIMIT
552            }
553        };
554        while bucket.len() > limit {
555            bucket.pop_front();
556        }
557
558        let mut metrics = metrics;
559        if constraints.wrap == TextWrap::None
560            && constraints.overflow == TextOverflow::Ellipsis
561            && let Some(max_width) = constraints.max_width
562        {
563            metrics.size.width = max_width;
564        }
565        metrics
566    }
567}