Skip to main content

rustmotion_components/
rich_text.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle, Point, RRect, Rect, Typeface};
4
5use rustmotion_core::css::style::{
6    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
7    TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
8};
9use rustmotion_core::css::CssStyle;
10use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation};
11use rustmotion_core::engine::layout_pass::BoxLayout;
12use rustmotion_core::engine::renderer::{
13    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
14    typeface_with_fallback,
15};
16use rustmotion_core::schema::{
17    FontStyleType, FontWeight, TextAlign, TextAnimGranularity, TimelineStep,
18};
19use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
20
21/// Padding around a pill span's background box, in px on each side. `left`
22/// and `right` grow the token's advance width, so following spans shift
23/// over instead of overlapping the pill. `top` and `bottom` only grow the
24/// box — they never change the line's height.
25#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
26pub struct RichTextSpanPadding {
27    /// Padding above the glyph run, in px.
28    #[serde(default)]
29    pub top: f32,
30    /// Padding to the right of the glyph run, in px. Added to the line's advance.
31    #[serde(default)]
32    pub right: f32,
33    /// Padding below the glyph run, in px.
34    #[serde(default)]
35    pub bottom: f32,
36    /// Padding to the left of the glyph run, in px. Added to the line's advance.
37    #[serde(default)]
38    pub left: f32,
39}
40
41/// A single styled span within a rich_text component.
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct RichTextSpan {
44    pub text: String,
45    #[serde(default)]
46    pub color: Option<String>,
47    #[serde(default, rename = "font-size")]
48    pub font_size: Option<f32>,
49    #[serde(default, rename = "font-weight")]
50    pub font_weight: Option<FontWeight>,
51    #[serde(default, rename = "font-family")]
52    pub font_family: Option<String>,
53    #[serde(default, rename = "font-style")]
54    pub font_style: Option<FontStyleType>,
55    #[serde(default, rename = "letter-spacing")]
56    pub letter_spacing: Option<f32>,
57    /// Background colour (hex) painted behind this span's glyph run, as a
58    /// pill. Absent means no box — the span paints glyphs only, and
59    /// `padding`/`border-radius`/`rotation` are then inert.
60    #[serde(default)]
61    pub background: Option<String>,
62    /// Padding around the pill box. Ignored when `background` is absent.
63    #[serde(default)]
64    pub padding: Option<RichTextSpanPadding>,
65    /// Corner radius of the pill box, in px. Ignored when `background` is absent.
66    #[serde(default, rename = "border-radius")]
67    pub border_radius: Option<f32>,
68    /// Rotation of the pill box and its glyphs together, in degrees, about
69    /// the box's own centre. Does not affect layout. Ignored when
70    /// `background` is absent.
71    #[serde(default)]
72    pub rotation: Option<f32>,
73}
74
75impl RichTextSpan {
76    fn pill_padding(&self) -> RichTextSpanPadding {
77        self.padding.unwrap_or_default()
78    }
79}
80
81/// Rich text component: renders multiple styled spans on the same line(s).
82#[derive(Debug, Serialize, Deserialize, JsonSchema)]
83pub struct RichText {
84    pub spans: Vec<RichTextSpan>,
85    #[serde(default)]
86    pub max_width: Option<f32>,
87    #[serde(flatten)]
88    pub timing: TimingConfig,
89    #[serde(default)]
90    pub style: CssStyle,
91    #[serde(default)]
92    pub timeline: Vec<TimelineStep>,
93    #[serde(default)]
94    pub stagger: Option<f32>,
95}
96
97rustmotion_core::impl_traits!(RichText {
98    Animatable => animation,
99    Timed => timing,
100    Styled => style,
101});
102
103fn make_font(
104    family: &str,
105    weight: &FontWeight,
106    font_style_type: &FontStyleType,
107    size: f32,
108) -> Option<Font> {
109    let slant = match font_style_type {
110        FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
111        FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
112        FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
113    };
114    let weight_val = match weight {
115        FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
116        FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
117        FontWeight::Weight(w) => skia_safe::font_style::Weight::from(*w as i32),
118    };
119    let skia_style = FontStyle::new(weight_val, skia_safe::font_style::Width::NORMAL, slant);
120    let typeface = typeface_with_fallback(family, skia_style).ok()?;
121    Some(Font::from_typeface(typeface, size))
122}
123
124struct SpanFontInfo {
125    font: Font,
126    color: String,
127    letter_spacing: f32,
128}
129
130fn resolve_span_fonts(
131    spans: &[RichTextSpan],
132    style: &CssStyle,
133    default_size: f32,
134    default_letter_spacing: f32,
135) -> Vec<Option<SpanFontInfo>> {
136    let default_color = style.color_str_or("#FFFFFF");
137    let default_family = style.font_family_or("Inter");
138    let default_weight = match &style.font_weight {
139        Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => FontWeight::Bold,
140        Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
141        Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
142        _ => FontWeight::Normal,
143    };
144    let default_font_style = match style.font_style {
145        Some(CssFontStyle::Italic) => FontStyleType::Italic,
146        Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
147        _ => FontStyleType::Normal,
148    };
149
150    spans
151        .iter()
152        .map(|span| {
153            let size = span.font_size.unwrap_or(default_size);
154            let family = span.font_family.as_deref().unwrap_or(default_family);
155            let weight = span.font_weight.as_ref().unwrap_or(&default_weight);
156            let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
157            let color = span.color.as_deref().unwrap_or(default_color).to_string();
158            let letter_spacing = span.letter_spacing.unwrap_or(default_letter_spacing);
159            make_font(family, weight, fstyle, size).map(|font| SpanFontInfo {
160                font,
161                color,
162                letter_spacing,
163            })
164        })
165        .collect()
166}
167
168pub struct RichTextToken {
169    pub span_idx: usize,
170    pub text: String,
171    pub x: f32,
172    pub width: f32,
173}
174
175pub struct RichTextLine {
176    pub tokens: Vec<RichTextToken>,
177    pub width: f32,
178}
179
180pub struct RichTextLayout {
181    pub lines: Vec<RichTextLine>,
182    pub max_width: f32,
183    pub line_height: f32,
184    pub max_ascent: f32,
185    pub max_descent: f32,
186}
187
188impl RichText {
189    pub fn compute_layout(
190        spans: &[RichTextSpan],
191        style: &CssStyle,
192        viewport_width: f32,
193        viewport_height: f32,
194        wrap_width: Option<f32>,
195        visible_chars_progress: f32,
196    ) -> RichTextLayout {
197        let base_ctx = crate::intrinsic::font_size_ctx(
198            viewport_width,
199            viewport_height,
200            wrap_width.unwrap_or(0.0),
201        );
202        let (default_size, default_letter_spacing, line_height_val) =
203            style.typography_px_ctx(&base_ctx, 48.0);
204        let span_fonts = resolve_span_fonts(spans, style, default_size, default_letter_spacing);
205        let emoji_tf = emoji_typeface();
206
207        let texts: Vec<String> = if visible_chars_progress >= 0.0 {
208            let total_chars: usize = spans.iter().map(|s| s.text.chars().count()).sum();
209            let visible =
210                ((visible_chars_progress * total_chars as f32).round() as usize).min(total_chars);
211            let mut remaining = visible;
212            spans
213                .iter()
214                .map(|s| {
215                    let char_count = s.text.chars().count();
216                    if remaining >= char_count {
217                        remaining -= char_count;
218                        s.text.clone()
219                    } else if remaining == 0 {
220                        String::new()
221                    } else {
222                        let truncated: String = s.text.chars().take(remaining).collect();
223                        remaining = 0;
224                        truncated
225                    }
226                })
227                .collect()
228        } else {
229            spans.iter().map(|s| s.text.clone()).collect()
230        };
231
232        struct Tok {
233            span_idx: usize,
234            text: String,
235            space_before: bool,
236        }
237        let literal_whitespace = matches!(
238            style.white_space,
239            Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
240        );
241        let mut tokens: Vec<Tok> = Vec::new();
242        if literal_whitespace {
243            for (span_idx, text) in texts.iter().enumerate() {
244                if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
245                    continue;
246                }
247                tokens.push(Tok {
248                    span_idx,
249                    text: text.clone(),
250                    space_before: false,
251                });
252            }
253        } else {
254            let mut prev_trailing_ws = true;
255            for (span_idx, text) in texts.iter().enumerate() {
256                if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
257                    continue;
258                }
259                let starts_ws = text.chars().next().is_some_and(char::is_whitespace);
260                for (wi, w) in text.split_whitespace().enumerate() {
261                    let space_before = if tokens.is_empty() {
262                        false
263                    } else if wi > 0 {
264                        true
265                    } else {
266                        prev_trailing_ws || starts_ws
267                    };
268                    tokens.push(Tok {
269                        span_idx,
270                        text: w.to_string(),
271                        space_before,
272                    });
273                }
274                prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace);
275            }
276        }
277
278        let effective_wrap = if literal_whitespace {
279            f32::INFINITY
280        } else {
281            wrap_width.unwrap_or(f32::INFINITY)
282        };
283        let mut lines: Vec<RichTextLine> = vec![RichTextLine {
284            tokens: Vec::new(),
285            width: 0.0,
286        }];
287
288        for tok in &tokens {
289            let sf = span_fonts[tok.span_idx]
290                .as_ref()
291                .expect("font presence checked during tokenization");
292            let emoji_font = emoji_tf
293                .as_ref()
294                .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
295            let tok_width =
296                measure_text_with_fallback(&tok.text, &sf.font, &emoji_font, sf.letter_spacing);
297            let space_width = if tok.space_before {
298                measure_text_with_fallback(" ", &sf.font, &emoji_font, 0.0)
299            } else {
300                0.0
301            };
302
303            let current = lines.last_mut().unwrap();
304            let has_content = !current.tokens.is_empty();
305            let extra = if has_content { space_width } else { 0.0 };
306            let projected = current.width + extra + tok_width;
307
308            if projected > effective_wrap && has_content {
309                lines.push(RichTextLine {
310                    tokens: vec![RichTextToken {
311                        span_idx: tok.span_idx,
312                        text: tok.text.clone(),
313                        x: 0.0,
314                        width: tok_width,
315                    }],
316                    width: tok_width,
317                });
318            } else {
319                let x = current.width + extra;
320                current.tokens.push(RichTextToken {
321                    span_idx: tok.span_idx,
322                    text: tok.text.clone(),
323                    x,
324                    width: tok_width,
325                });
326                current.width = x + tok_width;
327            }
328        }
329
330        for line in &mut lines {
331            let mut extra = 0.0f32;
332            let n = line.tokens.len();
333            for i in 0..n {
334                let span_idx = line.tokens[i].span_idx;
335                let is_pill = spans.get(span_idx).is_some_and(|s| s.background.is_some());
336                if is_pill && (i == 0 || line.tokens[i - 1].span_idx != span_idx) {
337                    extra += spans[span_idx].pill_padding().left;
338                }
339                line.tokens[i].x += extra;
340                if is_pill && (i + 1 == n || line.tokens[i + 1].span_idx != span_idx) {
341                    extra += spans[span_idx].pill_padding().right;
342                }
343            }
344            line.width += extra;
345        }
346
347        let max_width = lines.iter().map(|l| l.width).fold(0.0f32, f32::max);
348        let max_ascent = span_fonts
349            .iter()
350            .flatten()
351            .map(|sf| {
352                let (_, m) = sf.font.metrics();
353                -m.ascent
354            })
355            .fold(0.0f32, f32::max);
356        let max_descent = span_fonts
357            .iter()
358            .flatten()
359            .map(|sf| {
360                let (_, m) = sf.font.metrics();
361                m.descent
362            })
363            .fold(0.0f32, f32::max);
364
365        RichTextLayout {
366            lines,
367            max_width,
368            line_height: line_height_val,
369            max_ascent,
370            max_descent,
371        }
372    }
373
374    fn paint(
375        &self,
376        canvas: &Canvas,
377        layout_width: f32,
378        props: &AnimatedProperties,
379        ctx: &PaintCtx,
380    ) {
381        let align = match self.style.text_align {
382            Some(CssTextAlign::Center) => TextAlign::Center,
383            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
384            _ => TextAlign::Left,
385        };
386
387        let wrap_width = if layout_width.is_finite() && layout_width > 0.0 {
388            match self.max_width {
389                Some(mw) => Some(mw.min(layout_width)),
390                None => Some(layout_width),
391            }
392        } else {
393            self.max_width
394        };
395
396        let layout = RichText::compute_layout(
397            &self.spans,
398            &self.style,
399            ctx.video_width as f32,
400            ctx.video_height as f32,
401            wrap_width,
402            props.visible_chars_progress,
403        );
404        if layout.lines.iter().all(|l| l.tokens.is_empty()) {
405            return;
406        }
407
408        let base_ctx = crate::intrinsic::font_size_ctx(
409            ctx.video_width as f32,
410            ctx.video_height as f32,
411            wrap_width.unwrap_or(0.0),
412        );
413        let (default_size, default_letter_spacing, _) =
414            self.style.typography_px_ctx(&base_ctx, 48.0);
415        let span_fonts = resolve_span_fonts(
416            &self.spans,
417            &self.style,
418            default_size,
419            default_letter_spacing,
420        );
421        let emoji_tf = emoji_typeface();
422
423        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
424            layout_width
425        } else {
426            layout.max_width
427        };
428
429        let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0;
430
431        if let Some(ref resolved) = props.char_animation {
432            render_rich_text_char_animation(
433                canvas,
434                &layout,
435                &self.spans,
436                &span_fonts,
437                &emoji_tf,
438                align,
439                align_width,
440                baseline_offset,
441                resolved,
442                ctx.time,
443            );
444            return;
445        }
446
447        for (line_idx, line) in layout.lines.iter().enumerate() {
448            let line_x_offset = match align {
449                TextAlign::Left => 0.0,
450                TextAlign::Center => (align_width - line.width) / 2.0,
451                TextAlign::Right => align_width - line.width,
452            };
453            let y = line_idx as f32 * layout.line_height + baseline_offset;
454
455            let mut i = 0;
456            while i < line.tokens.len() {
457                let span_idx = line.tokens[i].span_idx;
458                let mut j = i + 1;
459                while j < line.tokens.len() && line.tokens[j].span_idx == span_idx {
460                    j += 1;
461                }
462                let run = &line.tokens[i..j];
463
464                if self.spans[span_idx].background.is_some() {
465                    self.paint_pill_run(
466                        canvas,
467                        &self.spans[span_idx],
468                        run,
469                        &span_fonts,
470                        &emoji_tf,
471                        line_x_offset,
472                        y,
473                    );
474                } else {
475                    for tok in run {
476                        Self::paint_token(canvas, tok, &span_fonts, &emoji_tf, line_x_offset, y);
477                    }
478                }
479                i = j;
480            }
481        }
482    }
483
484    fn paint_token(
485        canvas: &Canvas,
486        tok: &RichTextToken,
487        span_fonts: &[Option<SpanFontInfo>],
488        emoji_tf: &Option<Typeface>,
489        line_x_offset: f32,
490        y: f32,
491    ) {
492        let sf = span_fonts[tok.span_idx]
493            .as_ref()
494            .expect("font presence matches compute_layout's tokenization");
495        let paint = paint_from_hex(&sf.color);
496        let emoji_font = emoji_tf
497            .as_ref()
498            .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
499
500        draw_text_with_fallback(
501            canvas,
502            &tok.text,
503            &sf.font,
504            &emoji_font,
505            sf.letter_spacing,
506            line_x_offset + tok.x,
507            y,
508            &paint,
509        );
510    }
511
512    #[allow(clippy::too_many_arguments)]
513    fn paint_pill_run(
514        &self,
515        canvas: &Canvas,
516        span: &RichTextSpan,
517        run: &[RichTextToken],
518        span_fonts: &[Option<SpanFontInfo>],
519        emoji_tf: &Option<Typeface>,
520        line_x_offset: f32,
521        y: f32,
522    ) {
523        let (Some(first), Some(last)) = (run.first(), run.last()) else {
524            return;
525        };
526        let sf = span_fonts[first.span_idx]
527            .as_ref()
528            .expect("font presence matches compute_layout's tokenization");
529        let (_, metrics) = sf.font.metrics();
530        let ascent = -metrics.ascent;
531        let descent = metrics.descent;
532        let padding = span.pill_padding();
533
534        let x0 = line_x_offset + first.x - padding.left;
535        let x1 = line_x_offset + last.x + last.width + padding.right;
536        let y0 = y - ascent - padding.top;
537        let y1 = y + descent + padding.bottom;
538        let rect = Rect::from_ltrb(x0, y0, x1, y1);
539        let radius = span.border_radius.unwrap_or(0.0).max(0.0);
540        let rrect = RRect::new_rect_xy(rect, radius, radius);
541        let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
542        let rotation = span.rotation.unwrap_or(0.0);
543
544        canvas.save();
545        if rotation != 0.0 {
546            canvas.rotate(rotation, Some(center));
547        }
548
549        let background = span
550            .background
551            .as_deref()
552            .expect("paint_pill_run is only called for spans with a background");
553        let bg_paint = paint_from_hex(background);
554        canvas.draw_rrect(rrect, &bg_paint);
555
556        for tok in run {
557            Self::paint_token(canvas, tok, span_fonts, emoji_tf, line_x_offset, y);
558        }
559
560        canvas.restore();
561    }
562}
563
564fn paint_pill_background(
565    canvas: &Canvas,
566    span: &RichTextSpan,
567    run: &[RichTextToken],
568    span_fonts: &[Option<SpanFontInfo>],
569    line_x_offset: f32,
570    y: f32,
571) {
572    let (Some(first), Some(last)) = (run.first(), run.last()) else {
573        return;
574    };
575    let sf = span_fonts[first.span_idx]
576        .as_ref()
577        .expect("font presence matches compute_layout's tokenization");
578    let (_, metrics) = sf.font.metrics();
579    let ascent = -metrics.ascent;
580    let descent = metrics.descent;
581    let padding = span.pill_padding();
582
583    let x0 = line_x_offset + first.x - padding.left;
584    let x1 = line_x_offset + last.x + last.width + padding.right;
585    let y0 = y - ascent - padding.top;
586    let y1 = y + descent + padding.bottom;
587    let rect = Rect::from_ltrb(x0, y0, x1, y1);
588    let radius = span.border_radius.unwrap_or(0.0).max(0.0);
589    let rrect = RRect::new_rect_xy(rect, radius, radius);
590    let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
591    let rotation = span.rotation.unwrap_or(0.0);
592
593    let background = span
594        .background
595        .as_deref()
596        .expect("paint_pill_background is only called for spans with a background");
597    let bg_paint = paint_from_hex(background);
598
599    canvas.save();
600    if rotation != 0.0 {
601        canvas.rotate(rotation, Some(center));
602    }
603    canvas.draw_rrect(rrect, &bg_paint);
604    canvas.restore();
605}
606
607#[allow(clippy::too_many_arguments)]
608fn render_rich_text_char_animation(
609    canvas: &Canvas,
610    layout: &RichTextLayout,
611    spans: &[RichTextSpan],
612    span_fonts: &[Option<SpanFontInfo>],
613    emoji_tf: &Option<Typeface>,
614    align: TextAlign,
615    align_width: f32,
616    baseline_offset: f32,
617    char_anim: &ResolvedCharAnimation,
618    time: f64,
619) {
620    let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
621    let mut global_unit_idx = 0usize;
622
623    for (line_idx, line) in layout.lines.iter().enumerate() {
624        if line.tokens.is_empty() {
625            continue;
626        }
627        let line_x_offset = match align {
628            TextAlign::Left => 0.0,
629            TextAlign::Center => (align_width - line.width) / 2.0,
630            TextAlign::Right => align_width - line.width,
631        };
632        let y = line_idx as f32 * layout.line_height + baseline_offset;
633
634        let mut i = 0;
635        while i < line.tokens.len() {
636            let span_idx = line.tokens[i].span_idx;
637            let mut j = i + 1;
638            while j < line.tokens.len() && line.tokens[j].span_idx == span_idx {
639                j += 1;
640            }
641            let run = &line.tokens[i..j];
642            if spans[span_idx].background.is_some() {
643                paint_pill_background(canvas, &spans[span_idx], run, span_fonts, line_x_offset, y);
644            }
645            i = j;
646        }
647
648        for tok in &line.tokens {
649            let sf = span_fonts[tok.span_idx]
650                .as_ref()
651                .expect("font presence matches compute_layout's tokenization");
652            let paint = paint_from_hex(&sf.color);
653            let emoji_font = emoji_tf
654                .as_ref()
655                .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
656
657            if is_word_mode {
658                let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time);
659                canvas.save();
660                crate::intrinsic::apply_text_anim_preset(
661                    canvas,
662                    &tok.text,
663                    &sf.font,
664                    &emoji_font,
665                    &paint,
666                    line_x_offset + tok.x,
667                    y,
668                    tok.width,
669                    sf.letter_spacing,
670                    char_anim,
671                    t,
672                    time,
673                    global_unit_idx,
674                    sf.font.size(),
675                );
676                canvas.restore();
677                global_unit_idx += 1;
678            } else {
679                let mut cursor_x = line_x_offset + tok.x;
680                for ch in tok.text.chars() {
681                    let ch_str = ch.to_string();
682                    let (ch_width, _) = sf.font.measure_str(&ch_str, None);
683                    let ch_width = ch_width + sf.letter_spacing;
684
685                    let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time);
686                    canvas.save();
687                    crate::intrinsic::apply_text_anim_preset(
688                        canvas,
689                        &ch_str,
690                        &sf.font,
691                        &emoji_font,
692                        &paint,
693                        cursor_x,
694                        y,
695                        ch_width,
696                        0.0,
697                        char_anim,
698                        t,
699                        time,
700                        global_unit_idx,
701                        sf.font.size(),
702                    );
703                    canvas.restore();
704
705                    cursor_x += ch_width;
706                    global_unit_idx += 1;
707                }
708            }
709        }
710    }
711}
712
713impl Painter for RichText {
714    fn paint_content(
715        &self,
716        canvas: &Canvas,
717        layout: &BoxLayout,
718        props: &AnimatedProperties,
719        ctx: &PaintCtx,
720    ) {
721        self.paint(canvas, layout.width, props, ctx);
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use rustmotion_core::css::CssStyle;
729    use rustmotion_core::schema::{
730        AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity,
731    };
732
733    fn span(text: &str) -> RichTextSpan {
734        RichTextSpan {
735            text: text.into(),
736            color: None,
737            font_size: None,
738            font_weight: None,
739            font_family: None,
740            font_style: None,
741            letter_spacing: None,
742            background: None,
743            padding: None,
744            border_radius: None,
745            rotation: None,
746        }
747    }
748
749    fn pill_span(text: &str, background: &str, padding: RichTextSpanPadding) -> RichTextSpan {
750        RichTextSpan {
751            background: Some(background.into()),
752            padding: Some(padding),
753            ..span(text)
754        }
755    }
756
757    fn style(font_px: f32) -> CssStyle {
758        CssStyle {
759            font_size: Some(rustmotion_core::css::Length::Px(font_px)),
760            ..Default::default()
761        }
762    }
763
764    #[test]
765    fn single_long_span_wraps_into_multiple_lines_at_constrained_width() {
766        let spans = vec![span(
767            "the quick brown fox jumps over the lazy dog and keeps going",
768        )];
769        let s = style(24.0);
770        let unconstrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
771        assert_eq!(
772            unconstrained.lines.len(),
773            1,
774            "unconstrained width must fit on one line"
775        );
776
777        let constrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(150.0), -1.0);
778        assert!(
779            constrained.lines.len() > 1,
780            "a single long span must wrap into multiple lines at 150px, got {} line(s)",
781            constrained.lines.len()
782        );
783        for line in &constrained.lines {
784            assert!(
785                line.width <= 150.0 + 0.5,
786                "each wrapped line must fit the constraint, got {}",
787                line.width
788            );
789        }
790    }
791
792    #[test]
793    fn spans_glue_without_extra_space_when_source_has_none() {
794        let glued = vec![span("Total:"), span("42"), span(" items")];
795        let s = style(20.0);
796        let layout = RichText::compute_layout(&glued, &s, 1920.0, 1080.0, None, -1.0);
797        assert_eq!(layout.lines.len(), 1);
798        let tokens = &layout.lines[0].tokens;
799        assert_eq!(
800            tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(),
801            vec!["Total:", "42", "items"]
802        );
803        assert_eq!(tokens[1].x, tokens[0].width, "no space between glued spans");
804        assert!(
805            tokens[2].x > tokens[1].x + tokens[1].width,
806            "a space must separate '42' and 'items' (source had a leading space)"
807        );
808    }
809
810    #[test]
811    fn typewriter_truncation_hides_tail_tokens() {
812        let spans = vec![span("Hello "), span("world")];
813        let s = style(20.0);
814        let full = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
815        let half = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.5);
816        let none = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.0);
817
818        let full_tokens: usize = full.lines.iter().map(|l| l.tokens.len()).sum();
819        let half_tokens: usize = half.lines.iter().map(|l| l.tokens.len()).sum();
820        let none_tokens: usize = none.lines.iter().map(|l| l.tokens.len()).sum();
821
822        assert!(full_tokens >= half_tokens);
823        assert_eq!(none_tokens, 0, "progress 0.0 must show nothing");
824        assert!(
825            half_tokens >= 1,
826            "progress 0.5 must show at least one token"
827        );
828    }
829
830    #[test]
831    fn empty_spans_produce_one_empty_line_not_a_panic() {
832        let spans: Vec<RichTextSpan> = vec![];
833        let s = style(20.0);
834        let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
835        assert_eq!(layout.lines.len(), 1);
836        assert_eq!(layout.max_width, 0.0);
837    }
838
839    #[test]
840    fn white_space_pre_keeps_leading_spaces_as_a_single_literal_token() {
841        let spans = vec![span("    AB")];
842        let mut s = style(20.0);
843        s.white_space = Some(CssWhiteSpace::Pre);
844        let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(80.0), -1.0);
845
846        assert_eq!(layout.lines.len(), 1, "pre must not wrap onto extra lines");
847        let tokens = &layout.lines[0].tokens;
848        assert_eq!(
849            tokens.len(),
850            1,
851            "pre must not split the span into words, losing the run of spaces"
852        );
853        assert_eq!(
854            tokens[0].text, "    AB",
855            "white-space: pre must keep the leading spaces verbatim"
856        );
857    }
858
859    fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
860        let snapshot = surface.image_snapshot();
861        let info = skia_safe::ImageInfo::new(
862            (width, height),
863            skia_safe::ColorType::RGBA8888,
864            skia_safe::AlphaType::Premul,
865            None,
866        );
867        let mut buf = vec![0u8; (width * height * 4) as usize];
868        let ok = snapshot.read_pixels(
869            &info,
870            &mut buf,
871            (width * 4) as usize,
872            skia_safe::IPoint::new(0, 0),
873            skia_safe::image::CachingHint::Disallow,
874        );
875        assert!(ok, "pixel read should succeed");
876        (0..(width * height) as usize)
877            .map(|i| buf[i * 4 + 3])
878            .collect()
879    }
880
881    fn min_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
882        for x in 0..surface_width {
883            for y in 0..height {
884                if grid[(y * surface_width + x) as usize] > 0 {
885                    return Some(x);
886                }
887            }
888        }
889        None
890    }
891
892    fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
893        for y in 0..height {
894            for x in 0..surface_width {
895                if grid[(y * surface_width + x) as usize] > 0 {
896                    return Some(y);
897                }
898            }
899        }
900        None
901    }
902
903    fn test_ctx() -> PaintCtx {
904        PaintCtx {
905            time: 0.0,
906            scenario_time: 0.0,
907            scene_duration: 1.0,
908            frame_index: 0,
909            fps: 30,
910            video_width: 900,
911            video_height: 400,
912            stagger_offset: 0.0,
913        }
914    }
915
916    #[test]
917    fn white_space_pre_preserves_leading_spaces_at_the_pixel_level() {
918        let plain = RichText {
919            spans: vec![span("AB")],
920            max_width: None,
921            timing: Default::default(),
922            style: style(60.0),
923            timeline: Vec::new(),
924            stagger: None,
925        };
926        let padded = RichText {
927            spans: vec![span("    AB")],
928            max_width: None,
929            timing: Default::default(),
930            style: CssStyle {
931                white_space: Some(CssWhiteSpace::Pre),
932                ..style(60.0)
933            },
934            timeline: Vec::new(),
935            stagger: None,
936        };
937
938        const W: i32 = 400;
939        const H: i32 = 150;
940        let ctx = test_ctx();
941        let props = AnimatedProperties::default();
942
943        let plain_x = {
944            let mut surface =
945                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
946            plain.paint(surface.canvas(), W as f32, &props, &ctx);
947            let grid = alpha_grid(&mut surface, W, H);
948            min_ink_x(&grid, W, H).expect("plain text paints")
949        };
950        let padded_x = {
951            let mut surface =
952                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
953            padded.paint(surface.canvas(), W as f32, &props, &ctx);
954            let grid = alpha_grid(&mut surface, W, H);
955            min_ink_x(&grid, W, H).expect("padded text paints")
956        };
957
958        assert!(
959            padded_x > plain_x + 20,
960            "white-space: pre must preserve the 4 leading spaces, shifting first ink right \
961             (plain first ink at {plain_x}, padded first ink at {padded_x})"
962        );
963    }
964
965    #[test]
966    fn baseline_offset_matches_the_text_components_formula() {
967        let content = "Hamburgefonts";
968        let font_px = 72.0;
969        let s = style(font_px);
970        let rt = RichText {
971            spans: vec![span(content)],
972            max_width: None,
973            timing: Default::default(),
974            style: s.clone(),
975            timeline: Vec::new(),
976            stagger: None,
977        };
978
979        const W: i32 = 900;
980        const H: i32 = 200;
981        let ctx = test_ctx();
982        let props = AnimatedProperties::default();
983
984        let rich_text_top = {
985            let mut surface =
986                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
987            rt.paint(surface.canvas(), W as f32, &props, &ctx);
988            let grid = alpha_grid(&mut surface, W, H);
989            min_ink_y(&grid, W, H).expect("rich_text must paint ink")
990        };
991
992        let typeface =
993            typeface_with_fallback("Inter", FontStyle::default()).expect("typeface resolves");
994        let font = Font::from_typeface(typeface, font_px);
995        let (_, m) = font.metrics();
996        let ascent = -m.ascent;
997        let descent = m.descent;
998        let base_ctx = crate::intrinsic::font_size_ctx(1920.0, 1080.0, 0.0);
999        let (_, _, line_height) = s.typography_px_ctx(&base_ctx, 48.0);
1000        let expected_baseline = (line_height + ascent - descent) / 2.0;
1001
1002        let reference_top = {
1003            let mut surface =
1004                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1005            let paint = paint_from_hex("#FFFFFF");
1006            draw_text_with_fallback(
1007                surface.canvas(),
1008                content,
1009                &font,
1010                &None,
1011                0.0,
1012                0.0,
1013                expected_baseline,
1014                &paint,
1015            );
1016            let grid = alpha_grid(&mut surface, W, H);
1017            min_ink_y(&grid, W, H).expect("reference draw must paint ink")
1018        };
1019
1020        assert!(
1021            (rich_text_top - reference_top).abs() <= 1,
1022            "rich_text's baseline must be computed as (line_height + ascent - descent) / 2, the \
1023             same formula text.rs uses — got top {rich_text_top}, expected {reference_top} \
1024             (formula gave baseline {expected_baseline})"
1025        );
1026    }
1027
1028    fn pixel_rgba(
1029        surface: &mut skia_safe::Surface,
1030        w: i32,
1031        h: i32,
1032        x: f32,
1033        y: f32,
1034    ) -> (u8, u8, u8, u8) {
1035        let snapshot = surface.image_snapshot();
1036        let info = skia_safe::ImageInfo::new(
1037            (w, h),
1038            skia_safe::ColorType::RGBA8888,
1039            skia_safe::AlphaType::Unpremul,
1040            None,
1041        );
1042        let mut buf = vec![0u8; (w * h * 4) as usize];
1043        let ok = snapshot.read_pixels(
1044            &info,
1045            &mut buf,
1046            (w * 4) as usize,
1047            skia_safe::IPoint::new(0, 0),
1048            skia_safe::image::CachingHint::Disallow,
1049        );
1050        assert!(ok, "pixel read should succeed");
1051        let ix = x.round() as i32;
1052        let iy = y.round() as i32;
1053        let idx = ((iy * w + ix) * 4) as usize;
1054        (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])
1055    }
1056
1057    fn any_pixel_near_white(surface: &mut skia_safe::Surface, w: i32, h: i32) -> bool {
1058        let snapshot = surface.image_snapshot();
1059        let info = skia_safe::ImageInfo::new(
1060            (w, h),
1061            skia_safe::ColorType::RGBA8888,
1062            skia_safe::AlphaType::Unpremul,
1063            None,
1064        );
1065        let mut buf = vec![0u8; (w * h * 4) as usize];
1066        let ok = snapshot.read_pixels(
1067            &info,
1068            &mut buf,
1069            (w * 4) as usize,
1070            skia_safe::IPoint::new(0, 0),
1071            skia_safe::image::CachingHint::Disallow,
1072        );
1073        assert!(ok, "pixel read should succeed");
1074        buf.as_chunks::<4>()
1075            .0
1076            .iter()
1077            .any(|p| p[0] > 200 && p[1] > 200 && p[2] > 200 && p[3] > 200)
1078    }
1079
1080    #[test]
1081    fn pill_padding_grows_the_line_advance_and_shifts_the_following_span() {
1082        let s = style(40.0);
1083        let padding = RichTextSpanPadding {
1084            top: 4.0,
1085            right: 16.0,
1086            bottom: 6.0,
1087            left: 16.0,
1088        };
1089
1090        let plain = vec![span("tag"), span(" rest")];
1091        let with_pill = vec![pill_span("tag", "#1F6FEB", padding), span(" rest")];
1092
1093        let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, None, -1.0);
1094        let pill_layout = RichText::compute_layout(&with_pill, &s, 1920.0, 1080.0, None, -1.0);
1095
1096        let plain_rest_x = plain_layout.lines[0].tokens[1].x;
1097        let pill_rest_x = pill_layout.lines[0].tokens[1].x;
1098        let expected_shift = padding.left + padding.right;
1099
1100        assert!(
1101            (pill_rest_x - plain_rest_x - expected_shift).abs() < 0.5,
1102            "a pill's horizontal padding must be added to the line's advance, moving the \
1103             following span over by left+right padding: plain x={plain_rest_x}, pill \
1104             x={pill_rest_x}, expected shift {expected_shift}"
1105        );
1106    }
1107
1108    #[test]
1109    fn a_pill_that_wraps_gets_its_own_padding_per_line_fragment() {
1110        let s = style(40.0);
1111        let padding = RichTextSpanPadding {
1112            top: 4.0,
1113            right: 16.0,
1114            bottom: 4.0,
1115            left: 16.0,
1116        };
1117        const WRAP: f32 = 100.0;
1118
1119        let plain = vec![span("aaaa bbbb")];
1120        let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, Some(WRAP), -1.0);
1121        assert_eq!(
1122            plain_layout.lines.len(),
1123            2,
1124            "the reference text must wrap into two lines for this test to be meaningful"
1125        );
1126
1127        let pill = vec![pill_span("aaaa bbbb", "#1F6FEB", padding)];
1128        let pill_layout = RichText::compute_layout(&pill, &s, 1920.0, 1080.0, Some(WRAP), -1.0);
1129        assert_eq!(
1130            pill_layout.lines.len(),
1131            2,
1132            "padding must not change where the raw text wraps"
1133        );
1134
1135        assert!(
1136            (pill_layout.lines[0].tokens[0].x - padding.left).abs() < 0.5,
1137            "the first line's fragment must get its own left padding, got x={}",
1138            pill_layout.lines[0].tokens[0].x
1139        );
1140        assert!(
1141            (pill_layout.lines[1].tokens[0].x - padding.left).abs() < 0.5,
1142            "the second line's fragment must ALSO get its own left padding — \
1143             box-decoration-break: clone — got x={}",
1144            pill_layout.lines[1].tokens[0].x
1145        );
1146
1147        let expected_extra = padding.left + padding.right;
1148        assert!(
1149            (pill_layout.lines[0].width - plain_layout.lines[0].width - expected_extra).abs() < 0.5,
1150            "line 1's fragment must grow by its own left+right padding"
1151        );
1152        assert!(
1153            (pill_layout.lines[1].width - plain_layout.lines[1].width - expected_extra).abs() < 0.5,
1154            "line 2's fragment must ALSO grow by its own left+right padding"
1155        );
1156    }
1157
1158    #[test]
1159    fn pill_background_paints_behind_the_glyphs_not_in_front() {
1160        let padding = RichTextSpanPadding {
1161            top: 4.0,
1162            right: 16.0,
1163            bottom: 4.0,
1164            left: 16.0,
1165        };
1166        let s = CssStyle {
1167            color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1168            ..style(60.0)
1169        };
1170        let spans = vec![pill_span("Tag", "#000000", padding)];
1171        let rt = RichText {
1172            spans: spans.clone(),
1173            max_width: None,
1174            timing: Default::default(),
1175            style: s.clone(),
1176            timeline: Vec::new(),
1177            stagger: None,
1178        };
1179
1180        const W: i32 = 400;
1181        const H: i32 = 200;
1182        let ctx = test_ctx();
1183        let props = AnimatedProperties::default();
1184
1185        let layout = RichText::compute_layout(
1186            &spans,
1187            &s,
1188            ctx.video_width as f32,
1189            ctx.video_height as f32,
1190            Some(W as f32),
1191            -1.0,
1192        );
1193        let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0;
1194        let token = &layout.lines[0].tokens[0];
1195        let box_x0 = token.x - padding.left;
1196        let box_y0 = baseline_offset - layout.max_ascent - padding.top;
1197
1198        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1199        {
1200            let canvas = surface.canvas();
1201            canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0));
1202            rt.paint(canvas, W as f32, &props, &ctx);
1203        }
1204
1205        let inside_padding = pixel_rgba(&mut surface, W, H, box_x0 + 4.0, box_y0 + 4.0);
1206        assert_eq!(
1207            inside_padding,
1208            (0, 0, 0, 255),
1209            "just inside the pill's padding, clear of any glyph, must show the pill's own \
1210             background colour, got {inside_padding:?}"
1211        );
1212
1213        assert!(
1214            any_pixel_near_white(&mut surface, W, H),
1215            "the glyphs must still paint their own (white) colour on top of the pill's black \
1216             background — a background painted in front of the text would leave no white pixel"
1217        );
1218    }
1219
1220    #[test]
1221    fn pill_rotation_turns_the_box_and_text_without_touching_layout() {
1222        let s = style(50.0);
1223        let padding = RichTextSpanPadding {
1224            top: 6.0,
1225            right: 10.0,
1226            bottom: 6.0,
1227            left: 10.0,
1228        };
1229        let flat = vec![pill_span("Hi", "#1F6FEB", padding)];
1230        let mut rotated_spans = flat.clone();
1231        rotated_spans[0].rotation = Some(45.0);
1232
1233        let flat_layout = RichText::compute_layout(&flat, &s, 1920.0, 1080.0, None, -1.0);
1234        let rotated_layout =
1235            RichText::compute_layout(&rotated_spans, &s, 1920.0, 1080.0, None, -1.0);
1236        assert_eq!(
1237            flat_layout.lines[0].tokens[0].x, rotated_layout.lines[0].tokens[0].x,
1238            "rotation must not move the token — it is a paint-only transform"
1239        );
1240        assert_eq!(
1241            flat_layout.lines[0].width, rotated_layout.lines[0].width,
1242            "rotation must not change the line's advance"
1243        );
1244
1245        const W: i32 = 300;
1246        const H: i32 = 200;
1247        let ctx = test_ctx();
1248        let props = AnimatedProperties::default();
1249        let baseline_offset =
1250            (flat_layout.line_height + flat_layout.max_ascent - flat_layout.max_descent) / 2.0;
1251        let token = &flat_layout.lines[0].tokens[0];
1252        let box_x1 = token.x + token.width + padding.right;
1253        let box_y1 = baseline_offset + flat_layout.max_descent + padding.bottom;
1254
1255        let render_at = |rotation: Option<f32>| {
1256            let mut spans = flat.clone();
1257            spans[0].rotation = rotation;
1258            let rt = RichText {
1259                spans,
1260                max_width: None,
1261                timing: Default::default(),
1262                style: s.clone(),
1263                timeline: Vec::new(),
1264                stagger: None,
1265            };
1266            let mut surface =
1267                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1268            {
1269                let canvas = surface.canvas();
1270                canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0));
1271                rt.paint(canvas, W as f32, &props, &ctx);
1272            }
1273            surface
1274        };
1275
1276        let mut unrotated = render_at(None);
1277        let corner_flat = pixel_rgba(&mut unrotated, W, H, box_x1 - 2.0, box_y1 - 2.0);
1278        assert_eq!(
1279            corner_flat,
1280            (0x1F, 0x6F, 0xEB, 255),
1281            "unrotated: the bottom-right corner of the pill box must be filled, got {corner_flat:?}"
1282        );
1283
1284        let mut rotated = render_at(Some(45.0));
1285        let corner_rotated = pixel_rgba(&mut rotated, W, H, box_x1 - 2.0, box_y1 - 2.0);
1286        assert_ne!(
1287            corner_rotated, corner_flat,
1288            "rotating the pill 45° about its own centre must move its corner away from the \
1289             unrotated position, got the same pixel {corner_rotated:?} at both"
1290        );
1291    }
1292
1293    fn richtext_with_char_anim(
1294        spans: Vec<RichTextSpan>,
1295        effect: AnimationEffect,
1296        font_px: f32,
1297    ) -> RichText {
1298        RichText {
1299            spans,
1300            max_width: None,
1301            timing: Default::default(),
1302            style: CssStyle {
1303                font_size: Some(rustmotion_core::css::Length::Px(font_px)),
1304                white_space: Some(CssWhiteSpace::Nowrap),
1305                animation: vec![effect],
1306                ..Default::default()
1307            },
1308            timeline: Vec::new(),
1309            stagger: None,
1310        }
1311    }
1312
1313    fn props_for(rt: &RichText) -> AnimatedProperties {
1314        AnimatedProperties {
1315            char_animation: rustmotion_core::engine::animator::extract_effects(&rt.style.animation)
1316                .char_animation,
1317            ..Default::default()
1318        }
1319    }
1320
1321    fn ctx_at(time: f64, video_width: u32, video_height: u32) -> PaintCtx {
1322        PaintCtx {
1323            time,
1324            scenario_time: time,
1325            scene_duration: 2.0,
1326            frame_index: 0,
1327            fps: 30,
1328            video_width,
1329            video_height,
1330            stagger_offset: 0.0,
1331        }
1332    }
1333
1334    fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
1335        for y in y0..y1 {
1336            for x in x0..x1 {
1337                if grid[(y * surface_width + x) as usize] > 0 {
1338                    return true;
1339                }
1340            }
1341        }
1342        false
1343    }
1344
1345    fn soft_pixel_fraction(
1346        grid: &[u8],
1347        surface_width: i32,
1348        x0: i32,
1349        x1: i32,
1350        y0: i32,
1351        y1: i32,
1352    ) -> f32 {
1353        let mut inked = 0u32;
1354        let mut soft = 0u32;
1355        for y in y0..y1 {
1356            for x in x0..x1 {
1357                let a = grid[(y * surface_width + x) as usize];
1358                if a > 0 {
1359                    inked += 1;
1360                    if a < 250 {
1361                        soft += 1;
1362                    }
1363                }
1364            }
1365        }
1366        if inked == 0 {
1367            return 0.0;
1368        }
1369        soft as f32 / inked as f32
1370    }
1371
1372    fn inter_font_px(px: f32) -> Font {
1373        let typeface = typeface_with_fallback("Inter", FontStyle::default()).expect("resolves");
1374        Font::from_typeface(typeface, px)
1375    }
1376
1377    #[test]
1378    fn char_blur_in_animates_a_rich_text_word_instead_of_painting_it_sharp_immediately() {
1379        let font_px = 100.0;
1380        let rt = richtext_with_char_anim(
1381            vec![span("BLUR")],
1382            AnimationEffect::CharBlurIn(CharAnimationTiming {
1383                delay: 0.0,
1384                duration: 0.5,
1385                stagger: 0.03,
1386                granularity: TextAnimGranularity::Word,
1387                easing: EasingType::Linear,
1388                ..Default::default()
1389            }),
1390            font_px,
1391        );
1392
1393        const W: i32 = 700;
1394        const H: i32 = 220;
1395        let props = props_for(&rt);
1396
1397        let render_at = |t: f64| -> Vec<u8> {
1398            let mut surface =
1399                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1400            {
1401                let canvas = surface.canvas();
1402                rt.paint(canvas, W as f32, &props, &ctx_at(t, W as u32, H as u32));
1403            }
1404            alpha_grid(&mut surface, W, H)
1405        };
1406
1407        let early = render_at(0.15);
1408        let settled = render_at(1.0);
1409
1410        assert!(
1411            has_ink_in(&early, W, 0, W, 0, H),
1412            "the word must have started painting by t=0.15 — a char_* preset on rich_text used \
1413             to be silently ignored and paint the word fully sharp from frame 0"
1414        );
1415
1416        let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1417        let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1418
1419        assert!(
1420            early_soft > settled_soft + 0.15,
1421            "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1422             settled fraction ({settled_soft:.3}) — rich_text must actually blur while animating, \
1423             not just render the sharp glyph unconditionally"
1424        );
1425        assert!(
1426            settled_soft < 0.25,
1427            "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1428        );
1429    }
1430
1431    #[test]
1432    fn char_blur_in_word_stagger_carries_across_a_span_boundary() {
1433        let font_px = 90.0;
1434        let rt = richtext_with_char_anim(
1435            vec![span("ONE "), span("TWO")],
1436            AnimationEffect::CharBlurIn(CharAnimationTiming {
1437                delay: 0.5,
1438                duration: 0.3,
1439                stagger: 0.6,
1440                granularity: TextAnimGranularity::Word,
1441                easing: EasingType::Linear,
1442                blur: Some(16.0),
1443                ..Default::default()
1444            }),
1445            font_px,
1446        );
1447
1448        const W: i32 = 900;
1449        const H: i32 = 180;
1450        let props = props_for(&rt);
1451        let font = inter_font_px(font_px);
1452        let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1453
1454        let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1455        {
1456            let canvas = before.canvas();
1457            rt.paint(canvas, W as f32, &props, &ctx_at(0.1, W as u32, H as u32));
1458        }
1459        let before_grid = alpha_grid(&mut before, W, H);
1460        assert!(
1461            !has_ink_in(&before_grid, W, 0, W, 0, H),
1462            "nothing should paint before `delay` has elapsed"
1463        );
1464
1465        let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1466        {
1467            let canvas = mid.canvas();
1468            rt.paint(canvas, W as f32, &props, &ctx_at(0.65, W as u32, H as u32));
1469        }
1470        let mid_grid = alpha_grid(&mut mid, W, H);
1471        assert!(
1472            has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1473            "the first span's word should show ink by t=0.65 (mid-reveal)"
1474        );
1475        assert!(
1476            !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1477            "the SECOND span's word (a different RichTextSpan, starting at delay+stagger=1.1s) \
1478             must still be fully invisible at t=0.65 — the stagger index must run across the span \
1479             boundary rather than resetting per span"
1480        );
1481    }
1482
1483    #[test]
1484    fn ink_from_converges_to_each_spans_own_colour_not_a_shared_default() {
1485        let font_px = 90.0;
1486        let spans = vec![
1487            RichTextSpan {
1488                color: Some("#00FF00".into()),
1489                ..span("AAAA")
1490            },
1491            RichTextSpan {
1492                color: Some("#0000FF".into()),
1493                ..span(" BBBB")
1494            },
1495        ];
1496        let rt = richtext_with_char_anim(
1497            spans.clone(),
1498            AnimationEffect::CharFadeIn(CharAnimationTiming {
1499                delay: 0.0,
1500                duration: 1.0,
1501                stagger: 0.0,
1502                granularity: TextAnimGranularity::Word,
1503                easing: EasingType::Linear,
1504                ink_from: Some("#FF0000".into()),
1505                ..Default::default()
1506            }),
1507            font_px,
1508        );
1509
1510        const W: i32 = 900;
1511        const H: i32 = 200;
1512        let props = props_for(&rt);
1513
1514        let layout = RichText::compute_layout(&spans, &rt.style, W as f32, H as f32, None, -1.0);
1515        let win = |idx: usize| {
1516            let tok = &layout.lines[0].tokens[idx];
1517            (tok.x as i32, (tok.x + tok.width) as i32)
1518        };
1519        let (w1_x0, w1_x1) = win(0);
1520        let (w2_x0, w2_x1) = win(1);
1521
1522        let mean_rgb = |time: f64, x0: i32, x1: i32| -> (f32, f32, f32) {
1523            let mut surface =
1524                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1525            {
1526                let canvas = surface.canvas();
1527                rt.paint(canvas, W as f32, &props, &ctx_at(time, W as u32, H as u32));
1528            }
1529            let snapshot = surface.image_snapshot();
1530            let info = skia_safe::ImageInfo::new(
1531                (W, H),
1532                skia_safe::ColorType::RGBA8888,
1533                skia_safe::AlphaType::Unpremul,
1534                None,
1535            );
1536            let mut buf = vec![0u8; (W * H * 4) as usize];
1537            assert!(snapshot.read_pixels(
1538                &info,
1539                &mut buf,
1540                (W * 4) as usize,
1541                skia_safe::IPoint::new(0, 0),
1542                skia_safe::image::CachingHint::Disallow,
1543            ));
1544            let mut sum = (0u64, 0u64, 0u64);
1545            let mut alpha_sum = 0u64;
1546            for y in 0..H {
1547                for x in x0.max(0)..x1.min(W) {
1548                    let i = ((y * W + x) * 4) as usize;
1549                    let a = buf[i + 3] as u64;
1550                    sum.0 += buf[i] as u64 * a;
1551                    sum.1 += buf[i + 1] as u64 * a;
1552                    sum.2 += buf[i + 2] as u64 * a;
1553                    alpha_sum += a;
1554                }
1555            }
1556            assert!(
1557                alpha_sum > 0,
1558                "some inked pixels must exist in this window at t={time}"
1559            );
1560            (
1561                sum.0 as f32 / alpha_sum as f32,
1562                sum.1 as f32 / alpha_sum as f32,
1563                sum.2 as f32 / alpha_sum as f32,
1564            )
1565        };
1566
1567        let (r1_early, g1_early, b1_early) = mean_rgb(0.05, w1_x0, w1_x1);
1568        let (r2_early, g2_early, b2_early) = mean_rgb(0.05, w2_x0, w2_x1);
1569        assert!(
1570            r1_early > g1_early && r1_early > b1_early,
1571            "span 1 should read close to ink_from (red) early on, got rgb=({r1_early},{g1_early},{b1_early})"
1572        );
1573        assert!(
1574            r2_early > g2_early && r2_early > b2_early,
1575            "span 2 should read close to ink_from (red) early on, got rgb=({r2_early},{g2_early},{b2_early})"
1576        );
1577
1578        let (r1, g1, b1) = mean_rgb(5.0, w1_x0, w1_x1);
1579        let (r2, g2, b2) = mean_rgb(5.0, w2_x0, w2_x1);
1580        assert!(
1581            g1 > r1 && g1 > b1,
1582            "settled span 1 must converge to ITS OWN colour (green), got rgb=({r1},{g1},{b1})"
1583        );
1584        assert!(
1585            b2 > r2 && b2 > g2,
1586            "settled span 2 must converge to ITS OWN colour (blue), not span 1's — a shared \
1587             default would fail this, got rgb=({r2},{g2},{b2})"
1588        );
1589    }
1590}