Skip to main content

rustmotion_components/
intrinsic.rs

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