Skip to main content

rustmotion_components/
rich_text.rs

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