Skip to main content

rustmotion_components/
intrinsic.rs

1use skia_safe::font_style::Weight;
2use skia_safe::{Font, FontStyle as SkFontStyle, Typeface};
3
4use rustmotion_core::css::style::{
5    CssStyle, FontStyle as CssFontStyle, LineHeight, TextAlign as CssTextAlign, WhiteSpace,
6    TEXT_AUTOFIT_MIN_FONT_PX,
7};
8use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
9use rustmotion_core::engine::deps::{TextMetrics, TextMetricsProvider};
10use rustmotion_core::engine::renderer::{
11    compute_glyph_metrics, css_font_weight, emoji_typeface, format_counter_value,
12    measure_text_with_fallback, subpixel_font, typeface_with_fallback, wrap_text_with_tracking,
13    GlyphMetric,
14};
15
16use crate::badge::{Badge, BadgeSize};
17use crate::caption::Caption;
18use crate::counter::Counter;
19use crate::gradient_text::GradientText;
20use crate::kbd::Kbd;
21use crate::text::Text;
22
23use rustmotion_core::css::units::LengthContext;
24
25pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32) -> LengthContext {
26    LengthContext {
27        viewport_width,
28        viewport_height,
29        parent_size,
30        font_size: 16.0,
31        root_font_size: 16.0,
32    }
33}
34
35pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext {
36    font_size_ctx(1920.0, 1080.0, parent_size)
37}
38
39pub struct TextIntrinsic {
40    content: String,
41    font_family: Option<String>,
42    font_size: f32,
43    line_height_resolved: f32,
44    weight: Weight,
45    italic: bool,
46    letter_spacing: f32,
47    max_width: Option<f32>,
48    wrap: bool,
49    text_align: CssTextAlign,
50    text_autofit: bool,
51}
52
53impl TextIntrinsic {
54    pub fn from_text(text: &Text) -> Self {
55        let wrap = !matches!(
56            text.style.white_space,
57            Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
58        );
59        let widest = text
60            .all_labels()
61            .max_by_key(|label| label.chars().count())
62            .unwrap_or(&text.content);
63        Self::from_parts_with_wrap(widest, &text.style, text.max_width, wrap)
64            .with_autofit(matches!(text.style.text_autofit, Some(true)))
65    }
66
67    pub fn with_autofit(mut self, on: bool) -> Self {
68        self.text_autofit = on;
69        self
70    }
71
72    pub fn from_parts(content: &str, style: &CssStyle, max_width: Option<f32>) -> Self {
73        let base_ctx = measure_time_font_size_ctx(0.0);
74        let (font_size, letter_spacing, line_height_resolved) =
75            style.typography_px_ctx(&base_ctx, 48.0);
76        let text_align = match style.text_align {
77            Some(CssTextAlign::Center) => CssTextAlign::Center,
78            Some(CssTextAlign::Right | CssTextAlign::End) => CssTextAlign::Right,
79            _ => CssTextAlign::Left,
80        };
81        Self {
82            content: content.to_string(),
83            font_family: style.font_family.clone(),
84            font_size,
85            line_height_resolved,
86            weight: css_font_weight(style.font_weight.as_ref()),
87            italic: matches!(style.font_style, Some(CssFontStyle::Italic)),
88            letter_spacing,
89            max_width,
90            wrap: true,
91            text_align,
92            text_autofit: false,
93        }
94    }
95
96    pub fn from_parts_with_wrap(
97        content: &str,
98        style: &CssStyle,
99        max_width: Option<f32>,
100        wrap: bool,
101    ) -> Self {
102        let mut t = Self::from_parts(content, style, max_width);
103        t.wrap = wrap;
104        t
105    }
106}
107
108impl IntrinsicMeasure for TextIntrinsic {
109    fn measure(
110        &self,
111        known: (Option<f32>, Option<f32>),
112        available: (AvailableSpace, AvailableSpace),
113    ) -> (f32, f32) {
114        let max_width = if let Some(w) = known.0 {
115            Some(w)
116        } else {
117            let avail_w = match available.0 {
118                AvailableSpace::Definite(w) => Some(w),
119                AvailableSpace::MaxContent => None,
120                AvailableSpace::MinContent => Some(0.0),
121            };
122            match (self.max_width, avail_w) {
123                (Some(a), Some(b)) => Some(a.min(b)),
124                (Some(a), None) => Some(a),
125                (None, Some(b)) => Some(b),
126                (None, None) => None,
127            }
128        };
129
130        let Some(typeface) = self.typeface() else {
131            return (0.0, 0.0);
132        };
133        let wrap_at = if self.wrap { max_width } else { None };
134        let (base_w, base_h) = wrap_and_measure(
135            &self.content,
136            &typeface,
137            self.font_size,
138            wrap_at,
139            self.letter_spacing,
140            self.line_height_resolved,
141        );
142
143        if !self.text_autofit {
144            return (base_w, base_h);
145        }
146
147        let target_height = match known.1 {
148            Some(h) => Some(h),
149            None => match available.1 {
150                AvailableSpace::Definite(h) => Some(h),
151                AvailableSpace::MaxContent => None,
152                AvailableSpace::MinContent => Some(0.0),
153            },
154        };
155
156        let (final_size, final_ls, final_lh) = resolve_text_autofit(
157            &self.content,
158            &typeface,
159            self.font_size,
160            self.letter_spacing,
161            self.line_height_resolved,
162            self.wrap,
163            max_width,
164            target_height,
165        );
166
167        if final_size >= self.font_size {
168            return (base_w, base_h);
169        }
170        let wrap_at = if self.wrap { max_width } else { None };
171        wrap_and_measure(
172            &self.content,
173            &typeface,
174            final_size,
175            wrap_at,
176            final_ls,
177            final_lh,
178        )
179    }
180}
181
182impl TextIntrinsic {
183    fn sk_font_style(&self) -> SkFontStyle {
184        let slant = if self.italic {
185            skia_safe::font_style::Slant::Italic
186        } else {
187            skia_safe::font_style::Slant::Upright
188        };
189        SkFontStyle::new(self.weight, skia_safe::font_style::Width::NORMAL, slant)
190    }
191
192    fn typeface(&self) -> Option<Typeface> {
193        let family = self.font_family.as_deref().unwrap_or("Inter");
194        typeface_with_fallback(family, self.sk_font_style()).ok()
195    }
196
197    pub fn text_metrics(&self, content_box_width: f32) -> Option<TextMetrics> {
198        let typeface = self.typeface()?;
199        let font = subpixel_font(typeface, self.font_size);
200        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, self.font_size));
201
202        let wrap_at = if self.wrap {
203            Some(
204                self.max_width
205                    .map(|m| m.min(content_box_width))
206                    .unwrap_or(content_box_width),
207            )
208        } else {
209            None
210        };
211        let lines = wrap_text_with_tracking(
212            &self.content,
213            &font,
214            &emoji_font,
215            wrap_at,
216            self.letter_spacing,
217        );
218
219        let mut text_width = 0.0f32;
220        let mut glyphs: Vec<GlyphMetric> = Vec::new();
221        for line in &lines {
222            let advance = measure_text_with_fallback(line, &font, &emoji_font, self.letter_spacing);
223            text_width = text_width.max(advance);
224            let line_x = match self.text_align {
225                CssTextAlign::Center => (content_box_width - advance) / 2.0,
226                CssTextAlign::Right => content_box_width - advance,
227                _ => 0.0,
228            };
229            let line_glyphs = compute_glyph_metrics(line, &font, &emoji_font, self.letter_spacing);
230            glyphs.extend(line_glyphs.into_iter().map(|g| GlyphMetric {
231                x: g.x + line_x,
232                width: g.width,
233            }));
234        }
235
236        let (_, metrics) = font.metrics();
237        let ascender = -metrics.ascent;
238        let descender = metrics.descent;
239        let baseline = (self.line_height_resolved + ascender - descender) / 2.0;
240
241        Some(TextMetrics {
242            text_width,
243            cap_height: metrics.cap_height,
244            ascender,
245            baseline,
246            glyphs,
247        })
248    }
249}
250
251pub struct ComponentTextMetrics;
252
253impl TextMetricsProvider for ComponentTextMetrics {
254    fn text_metrics(
255        &self,
256        payload: &(dyn std::any::Any + Send + Sync),
257        content_box_width: f32,
258    ) -> Option<TextMetrics> {
259        if let Some(t) = payload.downcast_ref::<Text>() {
260            return TextIntrinsic::from_text(t).text_metrics(content_box_width);
261        }
262        if let Some(g) = payload.downcast_ref::<GradientText>() {
263            return GradientTextIntrinsic::from_gradient_text(g)
264                .0
265                .text_metrics(content_box_width);
266        }
267        None
268    }
269}
270
271fn wrap_and_measure(
272    content: &str,
273    typeface: &Typeface,
274    font_size: f32,
275    wrap_at: Option<f32>,
276    letter_spacing: f32,
277    line_height: f32,
278) -> (f32, f32) {
279    let font = subpixel_font(typeface.clone(), font_size);
280    let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
281    let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing);
282    let mut max_w = 0.0f32;
283    for line in &lines {
284        max_w = max_w.max(measure_text_with_fallback(
285            line,
286            &font,
287            &emoji_font,
288            letter_spacing,
289        ));
290    }
291    let line_count = lines.len().max(1) as f32;
292    (max_w, line_count * line_height)
293}
294
295#[allow(clippy::too_many_arguments)]
296pub fn resolve_text_autofit(
297    content: &str,
298    typeface: &Typeface,
299    requested_font_size: f32,
300    requested_letter_spacing: f32,
301    requested_line_height: f32,
302    wrap: bool,
303    box_width: Option<f32>,
304    declared_height: Option<f32>,
305) -> (f32, f32, f32) {
306    if requested_font_size <= 0.0 || (box_width.is_none() && declared_height.is_none()) {
307        return (
308            requested_font_size,
309            requested_letter_spacing,
310            requested_line_height,
311        );
312    }
313    let wrap_at = if wrap { box_width } else { None };
314    let measure_at = |size: f32| -> (f32, f32) {
315        let ratio = size / requested_font_size;
316        wrap_and_measure(
317            content,
318            typeface,
319            size,
320            wrap_at,
321            requested_letter_spacing * ratio,
322            requested_line_height * ratio,
323        )
324    };
325    let floor = TEXT_AUTOFIT_MIN_FONT_PX.min(requested_font_size);
326    let final_size = shrink_to_fit(
327        requested_font_size,
328        floor,
329        box_width,
330        declared_height,
331        measure_at,
332    );
333    if final_size >= requested_font_size {
334        (
335            requested_font_size,
336            requested_letter_spacing,
337            requested_line_height,
338        )
339    } else {
340        let ratio = final_size / requested_font_size;
341        (
342            final_size,
343            requested_letter_spacing * ratio,
344            requested_line_height * ratio,
345        )
346    }
347}
348
349fn shrink_to_fit(
350    requested_font_size: f32,
351    floor_px: f32,
352    target_width: Option<f32>,
353    target_height: Option<f32>,
354    mut measure_at: impl FnMut(f32) -> (f32, f32),
355) -> f32 {
356    let eps = 0.5;
357    let fits = |w: f32, h: f32| {
358        target_width.is_none_or(|tw| w <= tw + eps) && target_height.is_none_or(|th| h <= th + eps)
359    };
360
361    let (w0, h0) = measure_at(requested_font_size);
362    if fits(w0, h0) {
363        return requested_font_size;
364    }
365
366    let floor_px = floor_px.min(requested_font_size).max(0.1);
367    if floor_px >= requested_font_size {
368        return requested_font_size;
369    }
370
371    let (mut lo, mut hi) = (floor_px, requested_font_size);
372    let (w_floor, h_floor) = measure_at(lo);
373    if !fits(w_floor, h_floor) {
374        return lo;
375    }
376    for _ in 0..16 {
377        let mid = (lo + hi) / 2.0;
378        let (w, h) = measure_at(mid);
379        if fits(w, h) {
380            lo = mid;
381        } else {
382            hi = mid;
383        }
384    }
385    lo
386}
387
388pub struct GradientTextIntrinsic(TextIntrinsic);
389
390impl GradientTextIntrinsic {
391    pub fn from_gradient_text(t: &GradientText) -> Self {
392        use rustmotion_core::css::style::Size as CSize;
393        use rustmotion_core::css::units::LengthPercentage;
394        let max_width = match &t.style.width {
395            Some(CSize::Length(LengthPercentage::Px(v))) => Some(*v),
396            _ => None,
397        };
398        let wrap = !matches!(
399            t.style.white_space,
400            Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
401        );
402        Self(
403            TextIntrinsic::from_parts_with_wrap(&t.content, &t.style, max_width, wrap)
404                .with_autofit(matches!(t.style.text_autofit, Some(true))),
405        )
406    }
407}
408
409impl IntrinsicMeasure for GradientTextIntrinsic {
410    fn measure(
411        &self,
412        known: (Option<f32>, Option<f32>),
413        available: (AvailableSpace, AvailableSpace),
414    ) -> (f32, f32) {
415        self.0.measure(known, available)
416    }
417}
418
419pub struct CaptionIntrinsic(TextIntrinsic);
420
421impl CaptionIntrinsic {
422    pub fn from_caption(c: &Caption) -> Self {
423        let joined = c
424            .words
425            .iter()
426            .map(|w| w.text.as_str())
427            .collect::<Vec<_>>()
428            .join(" ");
429        let wrap = !matches!(
430            c.style.white_space,
431            Some(WhiteSpace::Nowrap | WhiteSpace::Pre)
432        );
433        Self(TextIntrinsic::from_parts_with_wrap(
434            &joined,
435            &c.style,
436            c.max_width,
437            wrap,
438        ))
439    }
440}
441
442impl IntrinsicMeasure for CaptionIntrinsic {
443    fn measure(
444        &self,
445        known: (Option<f32>, Option<f32>),
446        available: (AvailableSpace, AvailableSpace),
447    ) -> (f32, f32) {
448        self.0.measure(known, available)
449    }
450}
451
452pub struct KbdIntrinsic {
453    text: TextIntrinsic,
454    h_padding: f32,
455    v_padding: f32,
456    min_width: f32,
457}
458
459impl KbdIntrinsic {
460    pub fn from_kbd(k: &Kbd) -> Self {
461        let fs = k
462            .style
463            .font_size_px_ctx(&measure_time_font_size_ctx(0.0), k.font_size);
464        let synthetic_style = synthesize_text_style(&k.style, fs, "SF Mono");
465        Self {
466            text: TextIntrinsic::from_parts_with_wrap(&k.key, &synthetic_style, None, false),
467            h_padding: fs * 0.7,
468            v_padding: fs * 0.4,
469            min_width: fs * 1.8,
470        }
471    }
472}
473
474impl IntrinsicMeasure for KbdIntrinsic {
475    fn measure(
476        &self,
477        known: (Option<f32>, Option<f32>),
478        available: (AvailableSpace, AvailableSpace),
479    ) -> (f32, f32) {
480        let (tw, th) = self.text.measure(known, available);
481        let w = (tw + self.h_padding * 2.0).max(self.min_width);
482        let h = th + self.v_padding * 2.0;
483        (w, h)
484    }
485}
486
487pub struct CounterIntrinsic(TextIntrinsic);
488
489impl CounterIntrinsic {
490    pub fn from_counter(c: &Counter) -> Self {
491        let absmax = c.from.abs().max(c.to.abs());
492        let signed = if c.from < 0.0 || c.to < 0.0 {
493            -absmax
494        } else {
495            absmax
496        };
497        let display = format_counter_value(signed, c.decimals, &c.separator, &c.prefix, &c.suffix);
498        Self(TextIntrinsic::from_parts_with_wrap(
499            &display, &c.style, None, false,
500        ))
501    }
502}
503
504impl IntrinsicMeasure for CounterIntrinsic {
505    fn measure(
506        &self,
507        known: (Option<f32>, Option<f32>),
508        available: (AvailableSpace, AvailableSpace),
509    ) -> (f32, f32) {
510        self.0.measure(known, available)
511    }
512}
513
514pub struct NumberWheelIntrinsic(TextIntrinsic);
515
516impl NumberWheelIntrinsic {
517    pub fn from_number_wheel(w: &crate::number_wheel::NumberWheel) -> Self {
518        let widest = (0..10)
519            .map(|d| {
520                let ch = char::from_digit(d, 10).expect("0..10 is a digit");
521                w.value
522                    .chars()
523                    .map(|c| if c.is_ascii_digit() { ch } else { c })
524                    .collect::<String>()
525            })
526            .max_by(|a, b| {
527                let measure = |s: &str| {
528                    TextIntrinsic::from_parts_with_wrap(s, &w.style, None, false)
529                        .measure(
530                            (None, None),
531                            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
532                        )
533                        .0
534                };
535                measure(a)
536                    .partial_cmp(&measure(b))
537                    .unwrap_or(std::cmp::Ordering::Equal)
538            })
539            .unwrap_or_else(|| w.value.clone());
540        Self(TextIntrinsic::from_parts_with_wrap(
541            &widest, &w.style, None, false,
542        ))
543    }
544}
545
546impl IntrinsicMeasure for NumberWheelIntrinsic {
547    fn measure(
548        &self,
549        known: (Option<f32>, Option<f32>),
550        available: (AvailableSpace, AvailableSpace),
551    ) -> (f32, f32) {
552        self.0.measure(known, available)
553    }
554}
555
556pub struct BadgeIntrinsic {
557    text: TextIntrinsic,
558    h_padding: f32,
559    v_padding: f32,
560    icon_extra: f32,
561    font_size: f32,
562}
563
564impl BadgeIntrinsic {
565    pub fn from_badge(b: &Badge) -> Self {
566        let (default_fs, h_pad, v_pad, icon_size) = badge_size_params(&b.badge_size);
567        let font_size = b
568            .style
569            .font_size_px_ctx(&measure_time_font_size_ctx(0.0), default_fs);
570        let ratio = font_size / default_fs;
571        let h_padding = h_pad * ratio;
572        let v_padding = v_pad * ratio;
573        let icon_extra = if b.icon.is_some() {
574            icon_size * ratio + 6.0 * ratio
575        } else {
576            0.0
577        };
578
579        let synthetic_style = synthesize_text_style(&b.style, font_size, "Inter");
580
581        Self {
582            text: TextIntrinsic::from_parts_with_wrap(&b.text, &synthetic_style, None, false),
583            h_padding,
584            v_padding,
585            icon_extra,
586            font_size,
587        }
588    }
589}
590
591impl IntrinsicMeasure for BadgeIntrinsic {
592    fn measure(
593        &self,
594        known: (Option<f32>, Option<f32>),
595        available: (AvailableSpace, AvailableSpace),
596    ) -> (f32, f32) {
597        let (tw, _th) = self.text.measure(known, available);
598        let w = self.h_padding * 2.0 + tw + self.icon_extra;
599        let h = self.v_padding * 2.0 + self.font_size * 1.3;
600        (w, h)
601    }
602}
603
604fn badge_size_params(s: &BadgeSize) -> (f32, f32, f32, f32) {
605    match s {
606        BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0),
607        BadgeSize::Md => (14.0, 12.0, 6.0, 18.0),
608        BadgeSize::Lg => (18.0, 16.0, 8.0, 22.0),
609    }
610}
611
612fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) -> CssStyle {
613    use rustmotion_core::css::Length;
614    let family = src
615        .font_family
616        .clone()
617        .unwrap_or_else(|| default_family.to_string());
618    CssStyle {
619        font_size: Some(Length::Px(font_size)),
620        font_family: Some(family),
621        font_weight: src.font_weight.clone(),
622        font_style: src.font_style,
623        letter_spacing: src.letter_spacing.clone(),
624        line_height: src.line_height.clone(),
625        ..CssStyle::default()
626    }
627}
628
629#[allow(dead_code)]
630fn _line_height_unused(_: Option<&LineHeight>) {}
631
632use crate::table::{Table, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO};
633
634pub struct TableIntrinsic {
635    row_height: f32,
636    row_count: usize,
637    total_width: f32,
638}
639
640impl TableIntrinsic {
641    pub fn from_table(t: &Table) -> Self {
642        let font_size = t
643            .style
644            .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TABLE_FONT_SIZE);
645        let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO;
646
647        let total_width = Self::compute_width(t, font_size);
648
649        Self {
650            row_height,
651            row_count: t.rows.len(),
652            total_width,
653        }
654    }
655
656    fn compute_width(t: &Table, font_size: f32) -> f32 {
657        if let Some(widths) = &t.column_widths {
658            if !widths.is_empty() {
659                return widths.iter().sum();
660            }
661        }
662        t.natural_column_widths(font_size).iter().sum()
663    }
664}
665
666impl IntrinsicMeasure for TableIntrinsic {
667    fn measure(
668        &self,
669        known: (Option<f32>, Option<f32>),
670        _available: (AvailableSpace, AvailableSpace),
671    ) -> (f32, f32) {
672        let w = known.0.unwrap_or(self.total_width);
673        let h = known
674            .1
675            .unwrap_or((1 + self.row_count) as f32 * self.row_height);
676        (w, h)
677    }
678}
679
680use crate::rich_text::{RichText, RichTextSpan};
681
682pub struct RichTextIntrinsic {
683    spans: Vec<RichTextSpan>,
684    style: CssStyle,
685    max_width: Option<f32>,
686}
687
688impl RichTextIntrinsic {
689    pub fn from_rich_text(rt: &RichText) -> Self {
690        Self {
691            spans: rt.spans.clone(),
692            style: rt.style.clone(),
693            max_width: rt.max_width,
694        }
695    }
696}
697
698impl IntrinsicMeasure for RichTextIntrinsic {
699    fn measure(
700        &self,
701        known: (Option<f32>, Option<f32>),
702        available: (AvailableSpace, AvailableSpace),
703    ) -> (f32, f32) {
704        let max_width = if let Some(w) = known.0 {
705            Some(w)
706        } else {
707            let avail_w = match available.0 {
708                AvailableSpace::Definite(w) => Some(w),
709                AvailableSpace::MaxContent => None,
710                AvailableSpace::MinContent => Some(0.0),
711            };
712            match (self.max_width, avail_w) {
713                (Some(a), Some(b)) => Some(a.min(b)),
714                (Some(a), None) => Some(a),
715                (None, Some(b)) => Some(b),
716                (None, None) => None,
717            }
718        };
719
720        let layout =
721            RichText::compute_layout(&self.spans, &self.style, 1920.0, 1080.0, max_width, -1.0);
722        let line_count = layout.lines.len().max(1) as f32;
723        (layout.max_width, line_count * layout.line_height)
724    }
725}
726
727use rustmotion_core::engine::animator::ResolvedCharAnimation;
728use rustmotion_core::engine::renderer::{draw_text_with_fallback, paint_from_hex};
729use rustmotion_core::schema::{CharAnimPreset, RotateOrigin, TextAlign, TextAnimGranularity};
730use skia_safe::{Canvas, Paint};
731
732pub fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
733    cfg.unit_progress(idx, time)
734}
735
736pub fn reflow_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
737    cfg.reflow_progress(idx, time)
738}
739
740fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option<Paint> {
741    let from = cfg.ink_from.as_deref()?;
742    let start = paint_from_hex(from).color();
743    let end = paint.color();
744    let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8;
745    let mut p = paint.clone();
746    p.set_color(skia_safe::Color::from_argb(
747        end.a(),
748        lerp(start.r(), end.r()),
749        lerp(start.g(), end.g()),
750        lerp(start.b(), end.b()),
751    ));
752    Some(p)
753}
754
755#[allow(clippy::too_many_arguments)]
756pub fn apply_text_anim_preset(
757    canvas: &Canvas,
758    text: &str,
759    font: &Font,
760    emoji_font: &Option<Font>,
761    paint: &Paint,
762    cursor_x: f32,
763    line_y: f32,
764    unit_width: f32,
765    letter_spacing: f32,
766    cfg: &ResolvedCharAnimation,
767    t: f32,
768    time: f64,
769    unit_idx: usize,
770    font_size: f32,
771) {
772    let preset = &cfg.preset;
773    let overshoot = cfg.overshoot;
774    let blur_radius = cfg.blur;
775    let center_x = cursor_x + unit_width / 2.0;
776    let center_y = line_y;
777
778    let inked = ink_paint(cfg, paint, t);
779    let paint = inked.as_ref().unwrap_or(paint);
780    if let Some(from) = cfg.scale_from {
781        if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) {
782            let s = from + (1.0 - from) * t.clamp(0.0, 1.0);
783            canvas.translate((center_x, center_y));
784            canvas.scale((s, s));
785            canvas.translate((-center_x, -center_y));
786        }
787    }
788    if let Some(from_deg) = cfg.rotate_from {
789        if !matches!(preset, CharAnimPreset::RotateIn) {
790            let angle = from_deg * (1.0 - t.clamp(0.0, 1.0));
791            let (px, py) = match cfg.rotate_origin {
792                RotateOrigin::Center => (center_x, center_y),
793                RotateOrigin::Edge => (cursor_x, center_y),
794            };
795            canvas.translate((px, py));
796            canvas.rotate(angle, None);
797            canvas.translate((-px, -py));
798        }
799    }
800    let scale_jitter_delta = cfg.scale_jitter_delta(unit_idx);
801    if scale_jitter_delta.abs() > 1e-6 {
802        let js = 1.0 + scale_jitter_delta * (1.0 - t.clamp(0.0, 1.0));
803        canvas.translate((center_x, center_y));
804        canvas.scale((js, js));
805        canvas.translate((-center_x, -center_y));
806    }
807    let baseline_jitter_delta = cfg.baseline_jitter_delta(unit_idx);
808    if baseline_jitter_delta.abs() > 1e-6 {
809        let jy = baseline_jitter_delta * font_size * (1.0 - t.clamp(0.0, 1.0));
810        canvas.translate((0.0, jy));
811    }
812
813    match preset {
814        CharAnimPreset::ScaleIn => {
815            let scale = if overshoot > 0.001 {
816                if t < 0.7 {
817                    let p = t / 0.7;
818                    p * (1.0 + overshoot)
819                } else {
820                    let p = (t - 0.7) / 0.3;
821                    (1.0 + overshoot) - overshoot * p
822                }
823            } else {
824                t
825            };
826            if scale < 0.001 {
827                return;
828            }
829            canvas.translate((center_x, center_y));
830            canvas.scale((scale, scale));
831            canvas.translate((-center_x, -center_y));
832            draw_text_with_fallback(
833                canvas,
834                text,
835                font,
836                emoji_font,
837                letter_spacing,
838                cursor_x,
839                line_y,
840                paint,
841            );
842        }
843        CharAnimPreset::FadeIn => {
844            let mut p = paint.clone();
845            p.set_alpha_f(t * paint.alpha_f());
846            draw_text_with_fallback(
847                canvas,
848                text,
849                font,
850                emoji_font,
851                letter_spacing,
852                cursor_x,
853                line_y,
854                &p,
855            );
856        }
857        CharAnimPreset::Wave => {
858            let wave_offset =
859                (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5);
860            let mut p = paint.clone();
861            p.set_alpha_f(t.min(1.0) * paint.alpha_f());
862            draw_text_with_fallback(
863                canvas,
864                text,
865                font,
866                emoji_font,
867                letter_spacing,
868                cursor_x,
869                line_y + wave_offset,
870                &p,
871            );
872        }
873        CharAnimPreset::Bounce => {
874            let peak = 1.0 + overshoot.max(0.3);
875            let scale = if t < 0.5 {
876                t * 2.0 * peak
877            } else {
878                peak - (peak - 1.0) * ((t - 0.5) * 2.0)
879            };
880            let scale = scale.max(0.001);
881            canvas.translate((center_x, center_y));
882            canvas.scale((scale, scale));
883            canvas.translate((-center_x, -center_y));
884            draw_text_with_fallback(
885                canvas,
886                text,
887                font,
888                emoji_font,
889                letter_spacing,
890                cursor_x,
891                line_y,
892                paint,
893            );
894        }
895        CharAnimPreset::RotateIn => {
896            let angle = (1.0 - t) * -90.0;
897            let mut p = paint.clone();
898            p.set_alpha_f(t * paint.alpha_f());
899            canvas.translate((center_x, center_y));
900            canvas.rotate(angle, None);
901            canvas.translate((-center_x, -center_y));
902            draw_text_with_fallback(
903                canvas,
904                text,
905                font,
906                emoji_font,
907                letter_spacing,
908                cursor_x,
909                line_y,
910                &p,
911            );
912        }
913        CharAnimPreset::SlideUp => {
914            let travel = (1.0 - t) * font_size * 0.8 * cfg.distance;
915            let (dx, dy) = cfg.direction.offset(travel);
916            let mut p = paint.clone();
917            p.set_alpha_f(t * paint.alpha_f());
918            draw_text_with_fallback(
919                canvas,
920                text,
921                font,
922                emoji_font,
923                letter_spacing,
924                cursor_x + dx,
925                line_y + dy,
926                &p,
927            );
928        }
929        CharAnimPreset::BlurIn => {
930            let tt = t.clamp(0.0, 1.0);
931            let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance;
932            let (dx, dy) = cfg.direction.offset(travel);
933            let sigma = ((1.0 - tt) * blur_radius).max(0.0);
934            let mut p = paint.clone();
935            p.set_alpha_f(tt * paint.alpha_f());
936            if sigma > 0.05 {
937                if let Some(filter) = skia_safe::image_filters::blur(
938                    (sigma, sigma),
939                    skia_safe::TileMode::Clamp,
940                    None,
941                    None,
942                ) {
943                    p.set_image_filter(filter);
944                }
945            }
946            draw_text_with_fallback(
947                canvas,
948                text,
949                font,
950                emoji_font,
951                letter_spacing,
952                cursor_x + dx,
953                line_y + dy,
954                &p,
955            );
956        }
957    }
958}
959
960#[allow(clippy::too_many_arguments)]
961struct ReflowWordToken {
962    text: String,
963    is_space: bool,
964    full_width: f32,
965}
966
967fn tokenize_reflow_words(
968    line: &str,
969    font: &Font,
970    emoji_font: &Option<Font>,
971    letter_spacing: f32,
972) -> Vec<ReflowWordToken> {
973    let mut toks = Vec::new();
974    let mut chars = line.chars().peekable();
975    while chars.peek().is_some() {
976        let mut spaces = String::new();
977        while let Some(&c) = chars.peek() {
978            if c.is_whitespace() {
979                spaces.push(c);
980                chars.next();
981            } else {
982                break;
983            }
984        }
985        if !spaces.is_empty() {
986            let full_width = measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
987            toks.push(ReflowWordToken {
988                text: spaces,
989                is_space: true,
990                full_width,
991            });
992        }
993        let mut word = String::new();
994        while let Some(&c) = chars.peek() {
995            if c.is_whitespace() {
996                break;
997            }
998            word.push(c);
999            chars.next();
1000        }
1001        if !word.is_empty() {
1002            let full_width = measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
1003            toks.push(ReflowWordToken {
1004                text: word,
1005                is_space: false,
1006                full_width,
1007            });
1008        }
1009    }
1010    toks
1011}
1012
1013fn reflowed_word_width(
1014    tok: &ReflowWordToken,
1015    idx: usize,
1016    char_anim: &ResolvedCharAnimation,
1017    time: f64,
1018) -> f32 {
1019    if tok.is_space {
1020        tok.full_width
1021    } else {
1022        tok.full_width * reflow_progress(char_anim, idx, time)
1023    }
1024}
1025
1026#[allow(clippy::too_many_arguments)]
1027fn paint_reflowing_word_line(
1028    canvas: &Canvas,
1029    font: &Font,
1030    emoji_font: &Option<Font>,
1031    paint: &Paint,
1032    letter_spacing: f32,
1033    align: &TextAlign,
1034    align_width: f32,
1035    line_y: f32,
1036    line: &str,
1037    char_anim: &ResolvedCharAnimation,
1038    time: f64,
1039    base_idx: usize,
1040) -> usize {
1041    let toks = tokenize_reflow_words(line, font, emoji_font, letter_spacing);
1042
1043    let mut word_counter = 0usize;
1044    let effective_width: f32 = toks
1045        .iter()
1046        .map(|tok| {
1047            if tok.is_space {
1048                tok.full_width
1049            } else {
1050                let idx = base_idx + word_counter;
1051                word_counter += 1;
1052                reflowed_word_width(tok, idx, char_anim, time)
1053            }
1054        })
1055        .sum();
1056
1057    let line_x = match align {
1058        TextAlign::Left => 0.0,
1059        TextAlign::Center => (align_width - effective_width) / 2.0,
1060        TextAlign::Right => align_width - effective_width,
1061    };
1062
1063    let mut cursor_x = line_x;
1064    word_counter = 0;
1065    for tok in &toks {
1066        if tok.is_space {
1067            draw_text_with_fallback(
1068                canvas, &tok.text, font, emoji_font, 0.0, cursor_x, line_y, paint,
1069            );
1070            cursor_x += tok.full_width;
1071            continue;
1072        }
1073        let idx = base_idx + word_counter;
1074        word_counter += 1;
1075        let t = unit_progress(char_anim, idx, time);
1076        let draw_width = reflowed_word_width(tok, idx, char_anim, time);
1077
1078        canvas.save();
1079        apply_text_anim_preset(
1080            canvas,
1081            &tok.text,
1082            font,
1083            emoji_font,
1084            paint,
1085            cursor_x,
1086            line_y,
1087            draw_width,
1088            letter_spacing,
1089            char_anim,
1090            t,
1091            time,
1092            idx,
1093            font.size(),
1094        );
1095        canvas.restore();
1096
1097        cursor_x += draw_width;
1098    }
1099    base_idx + word_counter
1100}
1101
1102#[allow(clippy::too_many_arguments)]
1103fn paint_reflowing_char_line(
1104    canvas: &Canvas,
1105    font: &Font,
1106    emoji_font: &Option<Font>,
1107    paint: &Paint,
1108    letter_spacing: f32,
1109    align: &TextAlign,
1110    align_width: f32,
1111    line_y: f32,
1112    line: &str,
1113    char_anim: &ResolvedCharAnimation,
1114    time: f64,
1115    base_idx: usize,
1116) -> usize {
1117    let chars_list: Vec<char> = line.chars().collect();
1118    let widths: Vec<f32> = chars_list
1119        .iter()
1120        .map(|ch| {
1121            let (w, _) = font.measure_str(ch.to_string(), None);
1122            w + letter_spacing
1123        })
1124        .collect();
1125
1126    let effective_width: f32 = widths
1127        .iter()
1128        .enumerate()
1129        .map(|(i, w)| w * reflow_progress(char_anim, base_idx + i, time))
1130        .sum();
1131
1132    let line_x = match align {
1133        TextAlign::Left => 0.0,
1134        TextAlign::Center => (align_width - effective_width) / 2.0,
1135        TextAlign::Right => align_width - effective_width,
1136    };
1137
1138    let mut cursor_x = line_x;
1139    for (i, ch) in chars_list.iter().enumerate() {
1140        let idx = base_idx + i;
1141        let ch_str = ch.to_string();
1142        let t = unit_progress(char_anim, idx, time);
1143        let draw_width = widths[i] * reflow_progress(char_anim, idx, time);
1144
1145        canvas.save();
1146        apply_text_anim_preset(
1147            canvas,
1148            &ch_str,
1149            font,
1150            emoji_font,
1151            paint,
1152            cursor_x,
1153            line_y,
1154            draw_width,
1155            0.0,
1156            char_anim,
1157            t,
1158            time,
1159            idx,
1160            font.size(),
1161        );
1162        canvas.restore();
1163
1164        cursor_x += draw_width;
1165    }
1166    base_idx + chars_list.len()
1167}
1168
1169pub fn render_char_animation(
1170    canvas: &Canvas,
1171    font: &Font,
1172    emoji_font: &Option<Font>,
1173    paint: &Paint,
1174    letter_spacing: f32,
1175    align: TextAlign,
1176    align_width: f32,
1177    line_height_val: f32,
1178    baseline_offset: f32,
1179    lines: &[String],
1180    char_anim: &ResolvedCharAnimation,
1181    time: f64,
1182) {
1183    let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
1184    let mut global_unit_idx = 0usize;
1185
1186    for (line_idx, line) in lines.iter().enumerate() {
1187        if line.is_empty() {
1188            continue;
1189        }
1190
1191        let line_y = line_idx as f32 * line_height_val + baseline_offset;
1192
1193        if char_anim.reflow {
1194            global_unit_idx = if is_word_mode {
1195                paint_reflowing_word_line(
1196                    canvas,
1197                    font,
1198                    emoji_font,
1199                    paint,
1200                    letter_spacing,
1201                    &align,
1202                    align_width,
1203                    line_y,
1204                    line,
1205                    char_anim,
1206                    time,
1207                    global_unit_idx,
1208                )
1209            } else {
1210                paint_reflowing_char_line(
1211                    canvas,
1212                    font,
1213                    emoji_font,
1214                    paint,
1215                    letter_spacing,
1216                    &align,
1217                    align_width,
1218                    line_y,
1219                    line,
1220                    char_anim,
1221                    time,
1222                    global_unit_idx,
1223                )
1224            };
1225            continue;
1226        }
1227
1228        let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
1229        let line_x = match align {
1230            TextAlign::Left => 0.0,
1231            TextAlign::Center => (align_width - advance_width) / 2.0,
1232            TextAlign::Right => align_width - advance_width,
1233        };
1234
1235        if is_word_mode {
1236            let mut cursor_x = line_x;
1237            let mut chars = line.chars().peekable();
1238
1239            while chars.peek().is_some() {
1240                let mut spaces = String::new();
1241                while let Some(&c) = chars.peek() {
1242                    if c.is_whitespace() {
1243                        spaces.push(c);
1244                        chars.next();
1245                    } else {
1246                        break;
1247                    }
1248                }
1249                if !spaces.is_empty() {
1250                    let space_w =
1251                        measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
1252                    draw_text_with_fallback(
1253                        canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint,
1254                    );
1255                    cursor_x += space_w;
1256                }
1257
1258                let mut word = String::new();
1259                while let Some(&c) = chars.peek() {
1260                    if c.is_whitespace() {
1261                        break;
1262                    }
1263                    word.push(c);
1264                    chars.next();
1265                }
1266                if word.is_empty() {
1267                    continue;
1268                }
1269
1270                let word_width =
1271                    measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
1272
1273                let t = unit_progress(char_anim, global_unit_idx, time);
1274
1275                canvas.save();
1276                apply_text_anim_preset(
1277                    canvas,
1278                    &word,
1279                    font,
1280                    emoji_font,
1281                    paint,
1282                    cursor_x,
1283                    line_y,
1284                    word_width,
1285                    letter_spacing,
1286                    char_anim,
1287                    t,
1288                    time,
1289                    global_unit_idx,
1290                    font.size(),
1291                );
1292                canvas.restore();
1293
1294                cursor_x += word_width;
1295                global_unit_idx += 1;
1296            }
1297        } else {
1298            let mut cursor_x = line_x;
1299            for ch in line.chars() {
1300                let ch_str = ch.to_string();
1301                let (ch_width, _) = font.measure_str(&ch_str, None);
1302                let ch_width = ch_width + letter_spacing;
1303
1304                let t = unit_progress(char_anim, global_unit_idx, time);
1305
1306                canvas.save();
1307                apply_text_anim_preset(
1308                    canvas,
1309                    &ch_str,
1310                    font,
1311                    emoji_font,
1312                    paint,
1313                    cursor_x,
1314                    line_y,
1315                    ch_width,
1316                    0.0,
1317                    char_anim,
1318                    t,
1319                    time,
1320                    global_unit_idx,
1321                    font.size(),
1322                );
1323                canvas.restore();
1324
1325                cursor_x += ch_width;
1326                global_unit_idx += 1;
1327            }
1328        }
1329    }
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335    use rustmotion_core::css::style::CssStyle;
1336    use rustmotion_core::css::Length;
1337    use rustmotion_core::engine::box_tree::AvailableSpace;
1338
1339    #[test]
1340    fn measure_returns_positive_size_for_non_empty_text() {
1341        let text = Text {
1342            content: "Hello World".into(),
1343            max_width: None,
1344            timing: Default::default(),
1345            style: CssStyle {
1346                font_size: Some(Length::Px(32.0)),
1347                ..Default::default()
1348            },
1349            timeline: Vec::new(),
1350            stagger: None,
1351            text_shadow: None,
1352            stroke: None,
1353            text_background: None,
1354            caret: None,
1355            states: Vec::new(),
1356            swap: None,
1357            morph: None,
1358        };
1359        let m = TextIntrinsic::from_text(&text);
1360        let (w, h) = m.measure(
1361            (None, None),
1362            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1363        );
1364        assert!(w > 0.0, "width should be > 0, got {}", w);
1365        assert!(
1366            h > 30.0,
1367            "height should be roughly font_size * line_height, got {}",
1368            h
1369        );
1370    }
1371
1372    #[test]
1373    fn wrapping_grows_height_when_max_width_constrained() {
1374        let text = Text {
1375            content: "the quick brown fox jumps over the lazy dog".into(),
1376            max_width: None,
1377            timing: Default::default(),
1378            style: CssStyle {
1379                font_size: Some(Length::Px(20.0)),
1380                ..Default::default()
1381            },
1382            timeline: Vec::new(),
1383            stagger: None,
1384            text_shadow: None,
1385            stroke: None,
1386            text_background: None,
1387            caret: None,
1388            states: Vec::new(),
1389            swap: None,
1390            morph: None,
1391        };
1392        let m = TextIntrinsic::from_text(&text);
1393        let (_w_unwrapped, h_unwrapped) = m.measure(
1394            (None, None),
1395            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1396        );
1397        let (_w_wrapped, h_wrapped) = m.measure(
1398            (None, None),
1399            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1400        );
1401        assert!(
1402            h_wrapped > h_unwrapped,
1403            "wrapped height ({}) should exceed unwrapped ({})",
1404            h_wrapped,
1405            h_unwrapped,
1406        );
1407    }
1408
1409    #[test]
1410    fn empty_text_has_zero_width_but_one_line_height() {
1411        let text = Text {
1412            content: "".into(),
1413            max_width: None,
1414            timing: Default::default(),
1415            style: CssStyle {
1416                font_size: Some(Length::Px(24.0)),
1417                ..Default::default()
1418            },
1419            timeline: Vec::new(),
1420            stagger: None,
1421            text_shadow: None,
1422            stroke: None,
1423            text_background: None,
1424            caret: None,
1425            states: Vec::new(),
1426            swap: None,
1427            morph: None,
1428        };
1429        let m = TextIntrinsic::from_text(&text);
1430        let (w, h) = m.measure(
1431            (None, None),
1432            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1433        );
1434        assert_eq!(w, 0.0);
1435        assert!(h > 0.0);
1436    }
1437
1438    fn nowrap_text(content: &str, white_space: Option<WhiteSpace>) -> Text {
1439        Text {
1440            content: content.into(),
1441            max_width: None,
1442            timing: Default::default(),
1443            style: CssStyle {
1444                font_size: Some(Length::Px(20.0)),
1445                white_space,
1446                ..Default::default()
1447            },
1448            timeline: Vec::new(),
1449            stagger: None,
1450            text_shadow: None,
1451            stroke: None,
1452            text_background: None,
1453            caret: None,
1454            states: Vec::new(),
1455            swap: None,
1456            morph: None,
1457        }
1458    }
1459
1460    #[test]
1461    fn nowrap_ignores_a_constrained_width_and_stays_one_line() {
1462        let text = nowrap_text(
1463            "the quick brown fox jumps over the lazy dog",
1464            Some(WhiteSpace::Nowrap),
1465        );
1466        let m = TextIntrinsic::from_text(&text);
1467        let (w_unconstrained, h_unconstrained) = m.measure(
1468            (None, None),
1469            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1470        );
1471        let (w_constrained, h_constrained) = m.measure(
1472            (None, None),
1473            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1474        );
1475        assert!(
1476            w_constrained > 80.0,
1477            "nowrap must ignore the 80px constraint, got width {}",
1478            w_constrained
1479        );
1480        assert_eq!(
1481            w_constrained, w_unconstrained,
1482            "nowrap width must equal the natural (unconstrained) width regardless of available space"
1483        );
1484        assert_eq!(
1485            h_constrained, h_unconstrained,
1486            "nowrap must always report a single line's height, constrained or not"
1487        );
1488    }
1489
1490    #[test]
1491    fn pre_disables_wrap_exactly_like_nowrap() {
1492        let text = nowrap_text("this string is too long to fit", Some(WhiteSpace::Pre));
1493        let m = TextIntrinsic::from_text(&text);
1494        let (w, _h) = m.measure(
1495            (None, None),
1496            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1497        );
1498        assert!(
1499            w > 80.0,
1500            "white-space: pre must also ignore the width constraint, got {}",
1501            w
1502        );
1503    }
1504
1505    #[test]
1506    fn normal_white_space_still_wraps_at_a_constrained_width() {
1507        let wrapped = nowrap_text(
1508            "the quick brown fox jumps over the lazy dog",
1509            Some(WhiteSpace::Normal),
1510        );
1511        let unset = nowrap_text("the quick brown fox jumps over the lazy dog", None);
1512        for text in [wrapped, unset] {
1513            let m = TextIntrinsic::from_text(&text);
1514            let (w, _h) = m.measure(
1515                (None, None),
1516                (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1517            );
1518            assert!(
1519                w <= 80.0 + 0.5,
1520                "white-space: normal (or unset) must still wrap at an 80px constraint, got {}",
1521                w
1522            );
1523        }
1524    }
1525
1526    fn span(text: &str) -> RichTextSpan {
1527        RichTextSpan {
1528            text: text.into(),
1529            color: None,
1530            font_size: None,
1531            font_weight: None,
1532            font_family: None,
1533            font_style: None,
1534            letter_spacing: None,
1535            background: None,
1536            padding: None,
1537            border_radius: None,
1538            rotation: None,
1539        }
1540    }
1541
1542    #[test]
1543    fn rich_text_intrinsic_is_non_zero_without_explicit_size() {
1544        let spans = vec![span("Hello "), span("world")];
1545        let style = CssStyle {
1546            font_size: Some(Length::Px(32.0)),
1547            ..Default::default()
1548        };
1549        let intrinsic = RichTextIntrinsic {
1550            spans,
1551            style,
1552            max_width: None,
1553        };
1554        let (w, h) = intrinsic.measure(
1555            (None, None),
1556            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1557        );
1558        assert!(w > 0.0, "rich_text natural width must be > 0, got {}", w);
1559        assert!(h > 0.0, "rich_text natural height must be > 0, got {}", h);
1560    }
1561
1562    #[test]
1563    fn rich_text_intrinsic_wraps_a_single_long_span_internally() {
1564        let spans = vec![span(
1565            "the quick brown fox jumps over the lazy dog and keeps going",
1566        )];
1567        let style = CssStyle {
1568            font_size: Some(Length::Px(24.0)),
1569            ..Default::default()
1570        };
1571        let intrinsic = RichTextIntrinsic {
1572            spans,
1573            style,
1574            max_width: None,
1575        };
1576        let (_w_unconstrained, h_unconstrained) = intrinsic.measure(
1577            (None, None),
1578            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1579        );
1580        let (w_constrained, h_constrained) = intrinsic.measure(
1581            (None, None),
1582            (AvailableSpace::Definite(150.0), AvailableSpace::MaxContent),
1583        );
1584        assert!(
1585            w_constrained <= 150.0 + 0.5,
1586            "wrapped width must fit the 150px constraint, got {}",
1587            w_constrained
1588        );
1589        assert!(
1590            h_constrained > h_unconstrained,
1591            "constraining width must add lines (wrap within the single span): {} vs {}",
1592            h_constrained,
1593            h_unconstrained
1594        );
1595    }
1596
1597    #[test]
1598    fn rich_text_intrinsic_matches_compute_layout_used_by_the_painter() {
1599        let spans = vec![span("Total: "), span("42"), span(" items")];
1600        let style = CssStyle::default();
1601        let intrinsic = RichTextIntrinsic {
1602            spans: spans.clone(),
1603            style: style.clone(),
1604            max_width: None,
1605        };
1606        let (w, h) = intrinsic.measure(
1607            (None, None),
1608            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1609        );
1610        let layout = RichText::compute_layout(&spans, &style, 1920.0, 1080.0, None, -1.0);
1611        assert_eq!(w, layout.max_width);
1612        assert_eq!(h, layout.lines.len().max(1) as f32 * layout.line_height);
1613    }
1614
1615    #[test]
1616    fn gradient_text_intrinsic_ignores_constrained_width_when_nowrap() {
1617        let gt = GradientText {
1618            content: "the quick brown fox jumps over the lazy dog".into(),
1619            colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1620            stops: None,
1621            angle: 90.0,
1622            animate_angle: false,
1623            speed: 0.5,
1624            timing: Default::default(),
1625            style: CssStyle {
1626                font_size: Some(Length::Px(20.0)),
1627                white_space: Some(WhiteSpace::Nowrap),
1628                ..Default::default()
1629            },
1630            timeline: Vec::new(),
1631            stagger: None,
1632        };
1633        let m = GradientTextIntrinsic::from_gradient_text(&gt);
1634        let (w, h) = m.measure(
1635            (None, None),
1636            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1637        );
1638        assert!(
1639            w > 80.0,
1640            "nowrap gradient_text must ignore the 80px constraint, got {}",
1641            w
1642        );
1643        let (_, h_unconstrained) = m.measure(
1644            (None, None),
1645            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1646        );
1647        assert_eq!(h, h_unconstrained);
1648    }
1649
1650    #[test]
1651    fn gradient_text_intrinsic_wraps_by_default() {
1652        let gt = GradientText {
1653            content: "the quick brown fox jumps over the lazy dog".into(),
1654            colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
1655            stops: None,
1656            angle: 90.0,
1657            animate_angle: false,
1658            speed: 0.5,
1659            timing: Default::default(),
1660            style: CssStyle {
1661                font_size: Some(Length::Px(20.0)),
1662                ..Default::default()
1663            },
1664            timeline: Vec::new(),
1665            stagger: None,
1666        };
1667        let m = GradientTextIntrinsic::from_gradient_text(&gt);
1668        let (_w, h_unconstrained) = m.measure(
1669            (None, None),
1670            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1671        );
1672        let (w_constrained, h_constrained) = m.measure(
1673            (None, None),
1674            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1675        );
1676        assert!(w_constrained <= 80.0 + 0.5);
1677        assert!(h_constrained > h_unconstrained);
1678    }
1679
1680    #[test]
1681    fn caption_intrinsic_ignores_constrained_width_when_nowrap() {
1682        let caption = Caption {
1683            words: "the quick brown fox jumps over the lazy dog"
1684                .split_whitespace()
1685                .map(|w| rustmotion_core::schema::CaptionWord {
1686                    text: w.to_string(),
1687                    start: 0.0,
1688                    end: 10.0,
1689                })
1690                .collect(),
1691            active_color: "#FFFF00".into(),
1692            mode: Default::default(),
1693            max_width: Some(80.0),
1694            pill_color: None,
1695            style: CssStyle {
1696                font_size: Some(Length::Px(20.0)),
1697                white_space: Some(WhiteSpace::Nowrap),
1698                ..Default::default()
1699            },
1700            timing: Default::default(),
1701            timeline: Vec::new(),
1702            stagger: None,
1703        };
1704        let m = CaptionIntrinsic::from_caption(&caption);
1705        let (w, _h) = m.measure(
1706            (None, None),
1707            (AvailableSpace::Definite(80.0), AvailableSpace::MaxContent),
1708        );
1709        assert!(
1710            w > 80.0,
1711            "nowrap caption intrinsic must ignore the 80px constraint, got {}",
1712            w
1713        );
1714    }
1715
1716    fn text_with_style(content: &str, style: CssStyle) -> Text {
1717        Text {
1718            content: content.into(),
1719            max_width: None,
1720            timing: Default::default(),
1721            style,
1722            timeline: Vec::new(),
1723            stagger: None,
1724            text_shadow: None,
1725            stroke: None,
1726            text_background: None,
1727            caret: None,
1728            states: Vec::new(),
1729            swap: None,
1730            morph: None,
1731        }
1732    }
1733
1734    #[test]
1735    fn line_height_percent_no_longer_collapses_the_box_to_zero_height() {
1736        use rustmotion_core::css::units::LengthPercentage;
1737        let text = text_with_style(
1738            "VISIBLE?",
1739            CssStyle {
1740                font_size: Some(Length::Px(60.0)),
1741                line_height: Some(LineHeight::Length(LengthPercentage::String("150%".into()))),
1742                ..Default::default()
1743            },
1744        );
1745        let m = TextIntrinsic::from_text(&text);
1746        let (_w, h) = m.measure(
1747            (None, None),
1748            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1749        );
1750        assert!(
1751            (h - 90.0).abs() < 0.5,
1752            "line-height: 150% of a 60px font-size must resolve to 90px (own font-size, per \
1753             CSS), got {h}"
1754        );
1755    }
1756
1757    #[test]
1758    fn line_height_em_no_longer_collapses_the_box_to_zero_height() {
1759        use rustmotion_core::css::units::LengthPercentage;
1760        let text = text_with_style(
1761            "VISIBLE?",
1762            CssStyle {
1763                font_size: Some(Length::Px(60.0)),
1764                line_height: Some(LineHeight::Length(LengthPercentage::String("1.5em".into()))),
1765                ..Default::default()
1766            },
1767        );
1768        let m = TextIntrinsic::from_text(&text);
1769        let (_w, h) = m.measure(
1770            (None, None),
1771            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1772        );
1773        assert!(
1774            (h - 90.0).abs() < 0.5,
1775            "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}"
1776        );
1777        let numeric = text_with_style(
1778            "VISIBLE?",
1779            CssStyle {
1780                font_size: Some(Length::Px(60.0)),
1781                line_height: Some(LineHeight::Number(1.5)),
1782                ..Default::default()
1783            },
1784        );
1785        let (_w, h_numeric) = TextIntrinsic::from_text(&numeric).measure(
1786            (None, None),
1787            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1788        );
1789        assert_eq!(h, h_numeric);
1790    }
1791
1792    #[test]
1793    fn letter_spacing_em_matches_the_equivalent_px_measurement() {
1794        let em_style = CssStyle {
1795            font_size: Some(Length::Px(200.0)),
1796            letter_spacing: Some(Length::String("1.2em".into())),
1797            white_space: Some(WhiteSpace::Nowrap),
1798            ..Default::default()
1799        };
1800        let px_style = CssStyle {
1801            font_size: Some(Length::Px(200.0)),
1802            letter_spacing: Some(Length::Px(240.0)),
1803            white_space: Some(WhiteSpace::Nowrap),
1804            ..Default::default()
1805        };
1806        let w_em = TextIntrinsic::from_text(&text_with_style("TRACKING", em_style))
1807            .measure(
1808                (None, None),
1809                (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1810            )
1811            .0;
1812        let w_px = TextIntrinsic::from_text(&text_with_style("TRACKING", px_style))
1813            .measure(
1814                (None, None),
1815                (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1816            )
1817            .0;
1818        assert!(
1819            (w_em - w_px).abs() < 1.0,
1820            "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \
1821             240px value: em={w_em}, px={w_px}"
1822        );
1823        let w_zero_tracking = TextIntrinsic::from_text(&text_with_style(
1824            "TRACKING",
1825            CssStyle {
1826                font_size: Some(Length::Px(200.0)),
1827                white_space: Some(WhiteSpace::Nowrap),
1828                ..Default::default()
1829            },
1830        ))
1831        .measure(
1832            (None, None),
1833            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1834        )
1835        .0;
1836        assert!(
1837            w_em > w_zero_tracking + 100.0,
1838            "em tracking must measurably widen the line versus zero tracking: em={w_em}, \
1839             zero={w_zero_tracking}"
1840        );
1841    }
1842
1843    #[test]
1844    fn shrink_to_fit_is_a_noop_when_content_already_fits() {
1845        let calls = std::cell::RefCell::new(Vec::new());
1846        let size = shrink_to_fit(48.0, 12.0, Some(200.0), Some(100.0), |s| {
1847            calls.borrow_mut().push(s);
1848            (150.0, 80.0)
1849        });
1850        assert_eq!(size, 48.0);
1851        assert_eq!(
1852            *calls.borrow(),
1853            vec![48.0],
1854            "must measure only once (at the requested size) when it already fits"
1855        );
1856    }
1857
1858    #[test]
1859    fn shrink_to_fit_is_a_noop_when_nothing_to_fit_against() {
1860        let size = shrink_to_fit(48.0, 12.0, None, None, |_| (99999.0, 99999.0));
1861        assert_eq!(size, 48.0);
1862    }
1863
1864    #[test]
1865    fn shrink_to_fit_finds_a_size_that_fits_the_width_target() {
1866        let target = 100.0;
1867        let size = shrink_to_fit(120.0, 5.0, Some(target), None, |s| (s * 2.0, 10.0));
1868        assert!(size < 120.0, "must have shrunk, got {size}");
1869        assert!(size * 2.0 <= target + 0.5, "resolved size must fit: {size}");
1870        assert!(
1871            (size + 1.0) * 2.0 > target + 0.5,
1872            "resolved size should be close to the fitting boundary, got {size}"
1873        );
1874    }
1875
1876    #[test]
1877    fn shrink_to_fit_respects_both_axes_jointly() {
1878        let size = shrink_to_fit(100.0, 5.0, Some(1000.0), Some(20.0), |s| (s, s * 2.0));
1879        assert!(size * 2.0 <= 20.5, "must respect the height target: {size}");
1880        assert!(
1881            (size + 0.5) * 2.0 > 20.5,
1882            "should converge close to the height boundary, got {size}"
1883        );
1884    }
1885
1886    #[test]
1887    fn shrink_to_fit_never_returns_below_the_floor() {
1888        let size = shrink_to_fit(120.0, 20.0, Some(10.0), None, |s| (s * 5.0, 10.0));
1889        assert_eq!(size, 20.0, "must stop exactly at the floor, not lower");
1890    }
1891
1892    #[test]
1893    fn shrink_to_fit_is_deterministic_across_repeated_calls() {
1894        let run = || shrink_to_fit(90.0, 10.0, Some(137.0), Some(64.0), |s| (s * 1.7, s * 0.9));
1895        let a = run();
1896        let b = run();
1897        assert_eq!(a, b);
1898    }
1899
1900    fn inter_typeface() -> Typeface {
1901        typeface_with_fallback("Inter", SkFontStyle::normal()).expect("Inter resolves in tests")
1902    }
1903
1904    #[test]
1905    fn resolve_text_autofit_shrinks_to_fit_a_width_target() {
1906        let typeface = inter_typeface();
1907        let content = "A very long headline that will not fit in this box";
1908        let requested = 80.0;
1909        let box_width = 300.0;
1910        let (fs, ls, lh) = resolve_text_autofit(
1911            content,
1912            &typeface,
1913            requested,
1914            0.0,
1915            requested * 1.3,
1916            false,
1917            Some(box_width),
1918            None,
1919        );
1920        assert!(fs < requested, "must shrink, got {fs}");
1921        assert!(
1922            fs >= TEXT_AUTOFIT_MIN_FONT_PX - 0.01,
1923            "must not shrink past the calibrated floor, got {fs}"
1924        );
1925        let (w, _) = wrap_and_measure(content, &typeface, fs, None, ls, lh);
1926        assert!(
1927            w <= box_width + 0.5,
1928            "resolved size must actually fit: w={w}, target={box_width}"
1929        );
1930    }
1931
1932    #[test]
1933    fn resolve_text_autofit_is_a_noop_when_it_already_fits() {
1934        let typeface = inter_typeface();
1935        let (fs, ls, lh) = resolve_text_autofit(
1936            "hi",
1937            &typeface,
1938            24.0,
1939            1.0,
1940            30.0,
1941            true,
1942            Some(1000.0),
1943            Some(1000.0),
1944        );
1945        assert_eq!(fs, 24.0);
1946        assert_eq!(ls, 1.0);
1947        assert_eq!(lh, 30.0);
1948    }
1949
1950    #[test]
1951    fn resolve_text_autofit_never_goes_below_the_calibrated_floor() {
1952        let typeface = inter_typeface();
1953        let (fs, _, _) = resolve_text_autofit(
1954            "This sentence is far too long for a ten pixel wide box",
1955            &typeface,
1956            80.0,
1957            0.0,
1958            104.0,
1959            true,
1960            Some(10.0),
1961            Some(10.0),
1962        );
1963        assert!(
1964            (fs - TEXT_AUTOFIT_MIN_FONT_PX).abs() < 0.01,
1965            "expected exactly the floor ({TEXT_AUTOFIT_MIN_FONT_PX}), got {fs}"
1966        );
1967    }
1968
1969    #[test]
1970    fn resolve_text_autofit_rescales_letter_spacing_and_line_height_proportionally() {
1971        let typeface = inter_typeface();
1972        let (fs, ls, lh) = resolve_text_autofit(
1973            "SHRINK ME PLEASE, THIS LINE IS QUITE LONG",
1974            &typeface,
1975            100.0,
1976            5.0,
1977            130.0,
1978            false,
1979            Some(150.0),
1980            None,
1981        );
1982        assert!(fs < 100.0, "sanity: must have shrunk, got {fs}");
1983        let ratio = fs / 100.0;
1984        assert!((ls - 5.0 * ratio).abs() < 1e-3);
1985        assert!((lh - 130.0 * ratio).abs() < 1e-3);
1986    }
1987
1988    fn autofit_text(content: &str, font_size: f32) -> Text {
1989        Text {
1990            content: content.into(),
1991            max_width: None,
1992            timing: Default::default(),
1993            style: CssStyle {
1994                font_size: Some(Length::Px(font_size)),
1995                text_autofit: Some(true),
1996                white_space: Some(WhiteSpace::Nowrap),
1997                ..Default::default()
1998            },
1999            timeline: Vec::new(),
2000            stagger: None,
2001            text_shadow: None,
2002            stroke: None,
2003            text_background: None,
2004            caret: None,
2005            states: Vec::new(),
2006            swap: None,
2007            morph: None,
2008        }
2009    }
2010
2011    #[test]
2012    fn text_intrinsic_shrinks_when_autofit_is_on_and_the_box_is_too_narrow() {
2013        let text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
2014        let m = TextIntrinsic::from_text(&text);
2015        let (w_unconstrained, _) = m.measure(
2016            (None, None),
2017            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2018        );
2019        let target = w_unconstrained / 2.0;
2020        let (w_constrained, _) = m.measure(
2021            (None, None),
2022            (AvailableSpace::Definite(target), AvailableSpace::MaxContent),
2023        );
2024        assert!(
2025            w_constrained <= target + 0.5,
2026            "autofit must shrink the nowrap line to fit {target}px, got {w_constrained}"
2027        );
2028        assert!(
2029            w_constrained < w_unconstrained,
2030            "must have actually shrunk from the natural width ({w_unconstrained}), got {w_constrained}"
2031        );
2032    }
2033
2034    #[test]
2035    fn text_intrinsic_ignores_autofit_target_when_the_flag_is_off() {
2036        let mut text = autofit_text("the quick brown fox jumps over the lazy dog", 60.0);
2037        text.style.text_autofit = None;
2038        let m = TextIntrinsic::from_text(&text);
2039        let (w, _) = m.measure(
2040            (None, None),
2041            (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
2042        );
2043        assert!(
2044            w > 200.0,
2045            "without text-autofit, nowrap must still bleed past the box exactly as before, got {w}"
2046        );
2047    }
2048
2049    #[test]
2050    fn text_intrinsic_autofit_still_overflows_when_even_the_floor_does_not_fit() {
2051        let text = autofit_text(
2052            "This is an extremely long sentence that will not fit no matter how much the font shrinks",
2053            80.0,
2054        );
2055        let m = TextIntrinsic::from_text(&text);
2056        let (w, _) = m.measure(
2057            (None, None),
2058            (AvailableSpace::Definite(5.0), AvailableSpace::MaxContent),
2059        );
2060        assert!(
2061            w > 5.0,
2062            "must not silently report a fit that never actually happened, got {w}"
2063        );
2064    }
2065
2066    #[test]
2067    fn caption_intrinsic_never_autofits_even_if_style_declares_it() {
2068        let caption = Caption {
2069            words: "the quick brown fox jumps over the lazy dog"
2070                .split_whitespace()
2071                .map(|w| rustmotion_core::schema::CaptionWord {
2072                    text: w.to_string(),
2073                    start: 0.0,
2074                    end: 10.0,
2075                })
2076                .collect(),
2077            active_color: "#FFFF00".into(),
2078            mode: Default::default(),
2079            max_width: None,
2080            pill_color: None,
2081            style: CssStyle {
2082                font_size: Some(Length::Px(60.0)),
2083                text_autofit: Some(true),
2084                white_space: Some(WhiteSpace::Nowrap),
2085                ..Default::default()
2086            },
2087            timing: Default::default(),
2088            timeline: Vec::new(),
2089            stagger: None,
2090        };
2091        let m = CaptionIntrinsic::from_caption(&caption);
2092        let (w_unconstrained, _) = m.measure(
2093            (None, None),
2094            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2095        );
2096        let (w_constrained, _) = m.measure(
2097            (None, None),
2098            (AvailableSpace::Definite(200.0), AvailableSpace::MaxContent),
2099        );
2100        assert_eq!(
2101            w_constrained, w_unconstrained,
2102            "caption must ignore text-autofit entirely (nowrap bleeds exactly as before)"
2103        );
2104    }
2105
2106    fn plain_text(content: &str, font_size: f32) -> Text {
2107        Text {
2108            content: content.into(),
2109            max_width: None,
2110            timing: Default::default(),
2111            style: CssStyle {
2112                font_size: Some(Length::Px(font_size)),
2113                ..Default::default()
2114            },
2115            timeline: Vec::new(),
2116            stagger: None,
2117            text_shadow: None,
2118            stroke: None,
2119            text_background: None,
2120            caret: None,
2121            states: Vec::new(),
2122            swap: None,
2123            morph: None,
2124        }
2125    }
2126
2127    #[test]
2128    fn text_metrics_reports_glyph_count_matching_content_for_one_line() {
2129        let text = plain_text("Sentence", 32.0);
2130        let metrics = TextIntrinsic::from_text(&text)
2131            .text_metrics(500.0)
2132            .expect("host must have a fallback typeface");
2133        assert_eq!(metrics.glyphs.len(), "Sentence".chars().count());
2134        assert!(metrics.text_width > 0.0);
2135        assert!(metrics.cap_height > 0.0);
2136        assert!(metrics.ascender > 0.0);
2137    }
2138
2139    #[test]
2140    fn text_metrics_last_glyph_sits_before_where_a_detached_char_would_go() {
2141        let text = plain_text("Sentence", 32.0);
2142        let metrics = TextIntrinsic::from_text(&text).text_metrics(500.0).unwrap();
2143        let last = *metrics.glyphs.last().unwrap();
2144        let detached_question_mark_x = last.x + last.width;
2145        assert!(detached_question_mark_x > last.x);
2146        assert!(detached_question_mark_x <= metrics.text_width + 0.5);
2147    }
2148
2149    #[test]
2150    fn text_metrics_width_matches_measure_when_unwrapped() {
2151        let text = plain_text("no wrap needed", 24.0);
2152        let intrinsic = TextIntrinsic::from_text(&text);
2153        let (measured_w, _measured_h) = intrinsic.measure(
2154            (None, None),
2155            (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
2156        );
2157        let metrics = intrinsic.text_metrics(10_000.0).unwrap();
2158        assert!(
2159            (metrics.text_width - measured_w).abs() < 0.5,
2160            "text_metrics width {} should match measure() width {}",
2161            metrics.text_width,
2162            measured_w
2163        );
2164    }
2165
2166    #[test]
2167    fn text_metrics_centers_glyphs_when_text_align_is_center() {
2168        let mut text = plain_text("Hi", 32.0);
2169        text.style.text_align = Some(rustmotion_core::css::style::TextAlign::Center);
2170        let intrinsic = TextIntrinsic::from_text(&text);
2171        let centered = intrinsic.text_metrics(400.0).unwrap();
2172        let left = plain_text("Hi", 32.0);
2173        let left_metrics = TextIntrinsic::from_text(&left).text_metrics(400.0).unwrap();
2174        assert!(
2175            centered.glyphs[0].x > left_metrics.glyphs[0].x,
2176            "centered first glyph ({}) should start further right than left-aligned ({})",
2177            centered.glyphs[0].x,
2178            left_metrics.glyphs[0].x
2179        );
2180    }
2181
2182    #[test]
2183    fn component_text_metrics_downcasts_text() {
2184        let text = plain_text("Hello", 28.0);
2185        let provider = ComponentTextMetrics;
2186        let payload: &(dyn std::any::Any + Send + Sync) = &text;
2187        let metrics = provider
2188            .text_metrics(payload, 500.0)
2189            .expect("Text must resolve through ComponentTextMetrics");
2190        assert_eq!(metrics.glyphs.len(), "Hello".chars().count());
2191    }
2192
2193    #[test]
2194    fn component_text_metrics_downcasts_gradient_text() {
2195        let gt = GradientText {
2196            content: "Gradient".into(),
2197            colors: vec!["#3B82F6".into(), "#8B5CF6".into()],
2198            stops: None,
2199            angle: 90.0,
2200            animate_angle: false,
2201            speed: 0.5,
2202            timing: Default::default(),
2203            style: CssStyle {
2204                font_size: Some(Length::Px(28.0)),
2205                ..Default::default()
2206            },
2207            timeline: Vec::new(),
2208            stagger: None,
2209        };
2210        let provider = ComponentTextMetrics;
2211        let payload: &(dyn std::any::Any + Send + Sync) = &gt;
2212        let metrics = provider
2213            .text_metrics(payload, 500.0)
2214            .expect("GradientText must resolve through ComponentTextMetrics");
2215        assert_eq!(metrics.glyphs.len(), "Gradient".chars().count());
2216    }
2217
2218    #[test]
2219    fn component_text_metrics_returns_none_for_a_non_text_component() {
2220        let payload: &(dyn std::any::Any + Send + Sync) = &42i32;
2221        assert!(ComponentTextMetrics.text_metrics(payload, 500.0).is_none());
2222    }
2223}