Skip to main content

rustmotion_components/
text.rs

1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Font, FontStyle, Paint, PaintStyle, Rect};
5
6use rustmotion_core::css::style::{
7    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
8    TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
9};
10use rustmotion_core::css::CssStyle;
11use rustmotion_core::engine::animator::AnimatedProperties;
12use rustmotion_core::engine::layout_pass::BoxLayout;
13use rustmotion_core::engine::renderer::{
14    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
15    typeface_with_fallback, wrap_text_with_tracking,
16};
17use rustmotion_core::schema::{
18    CaretConfig, CaretShape, FontStyleType, FontWeight, Stroke, TextAlign, TextBackground,
19    TextShadow, TextState, TextSwapConfig, TimelineStep,
20};
21use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
22
23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
24pub struct Text {
25    pub content: String,
26    #[serde(default)]
27    pub max_width: Option<f32>,
28    #[serde(flatten)]
29    pub timing: TimingConfig,
30    #[serde(default)]
31    pub style: CssStyle,
32    #[serde(default)]
33    pub timeline: Vec<TimelineStep>,
34    #[serde(default)]
35    pub stagger: Option<f32>,
36    #[serde(default, rename = "text-shadow")]
37    pub text_shadow: Option<TextShadow>,
38    #[serde(default)]
39    pub stroke: Option<Stroke>,
40    #[serde(default, rename = "text-background")]
41    pub text_background: Option<TextBackground>,
42    /// A caret pinned to the reveal head of a `typewriter` animation.
43    /// See [`CaretConfig`].
44    #[serde(default)]
45    pub caret: Option<CaretConfig>,
46    /// Later labels this text swaps to. See [`TextState`].
47    #[serde(default)]
48    pub states: Vec<TextState>,
49    /// How the crossing between `states` is animated. See [`TextSwapConfig`].
50    #[serde(default)]
51    pub swap: Option<TextSwapConfig>,
52    /// A letter-by-letter transition between two `states`, instead of the
53    /// whole-label rise-and-blur `swap` performs. See [`TextMorphConfig`].
54    #[serde(default)]
55    pub morph: Option<TextMorphConfig>,
56}
57
58rustmotion_core::impl_traits!(Text {
59    Animatable => animation,
60    Timed => timing,
61    Styled => style,
62});
63
64/// How unmatched glyphs behave during a [`TextMorphConfig`] transition —
65/// characters present in the incoming label with no identical counterpart in
66/// the outgoing one.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
68#[serde(rename_all = "snake_case")]
69pub enum TextMorphUnmatched {
70    /// The unmatched glyph simply fades in (or out) at its resting position.
71    #[default]
72    Fade,
73    /// The unmatched glyph cycles through deterministic random characters
74    /// before settling on the real one, instead of just fading in blank.
75    Scramble,
76}
77
78/// A letter-by-letter morph between two `states` labels: identical
79/// characters are paired left to right and slide from their old position to
80/// their new one; characters with no pair fade (or, with `unmatched:
81/// "scramble"`, cycle through placeholder glyphs before settling).
82///
83/// This is a different crossing than [`TextSwapConfig`]: `swap` treats each
84/// label as one rigid block (rise + blur); `morph` treats it as a bag of
85/// glyphs that rearranges itself. Setting both is not meaningful — `morph`
86/// takes over the transition window whenever it applies, `swap` is only
87/// consulted outside it.
88#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
89#[serde(deny_unknown_fields)]
90pub struct TextMorphConfig {
91    /// How long the morph takes (seconds).
92    #[serde(default = "default_morph_duration")]
93    pub duration: f64,
94    /// Behaviour of glyphs that have no identical counterpart in the other label.
95    #[serde(default)]
96    pub unmatched: TextMorphUnmatched,
97    /// Seed for the deterministic scramble sequence. Same seed, same scramble.
98    #[serde(default)]
99    pub seed: u32,
100}
101
102impl Default for TextMorphConfig {
103    fn default() -> Self {
104        Self {
105            duration: default_morph_duration(),
106            unmatched: TextMorphUnmatched::default(),
107            seed: 0,
108        }
109    }
110}
111
112fn default_morph_duration() -> f64 {
113    0.6
114}
115
116impl Text {
117    pub fn all_labels(&self) -> impl Iterator<Item = &str> {
118        std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str()))
119    }
120
121    fn label_at(&self, time: f64) -> &str {
122        self.states
123            .iter()
124            .rfind(|s| s.at <= time)
125            .map(|s| s.content.as_str())
126            .unwrap_or(&self.content)
127    }
128
129    fn active_swap(&self, time: f64) -> Option<ActiveSwap> {
130        let cfg = self.swap.as_ref()?;
131        if cfg.duration <= 0.0 {
132            return None;
133        }
134        let (idx, state) = self
135            .states
136            .iter()
137            .enumerate()
138            .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
139        let from = if idx == 0 {
140            self.content.clone()
141        } else {
142            self.states[idx - 1].content.clone()
143        };
144        Some(ActiveSwap {
145            from,
146            to: state.content.clone(),
147            progress: ((time - state.at) / cfg.duration) as f32,
148            distance: cfg.distance,
149            blur: cfg.blur,
150        })
151    }
152
153    fn active_morph(&self, time: f64) -> Option<ActiveMorph> {
154        let cfg = self.morph.as_ref()?;
155        if cfg.duration <= 0.0 {
156            return None;
157        }
158        let (idx, state) = self
159            .states
160            .iter()
161            .enumerate()
162            .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
163        let from = if idx == 0 {
164            self.content.clone()
165        } else {
166            self.states[idx - 1].content.clone()
167        };
168        Some(ActiveMorph {
169            from,
170            to: state.content.clone(),
171            progress: ((time - state.at) / cfg.duration) as f32,
172            unmatched: cfg.unmatched,
173            seed: cfg.seed,
174        })
175    }
176
177    fn paint(
178        &self,
179        canvas: &Canvas,
180        layout_width: f32,
181        content_height: Option<f32>,
182        time: f64,
183        props: &AnimatedProperties,
184        ctx: &PaintCtx,
185    ) -> Result<()> {
186        let base_ctx = crate::intrinsic::font_size_ctx(
187            ctx.video_width as f32,
188            ctx.video_height as f32,
189            layout_width.max(0.0),
190        );
191        let (mut font_size, mut letter_spacing, mut line_height_val) =
192            self.style.typography_px_ctx(&base_ctx, 48.0);
193        if props.letter_spacing.is_finite() {
194            letter_spacing = props.letter_spacing;
195        }
196        let color = props
197            .color
198            .as_deref()
199            .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
200        let font_family = self.style.font_family_or("Inter");
201        let font_weight = match &self.style.font_weight {
202            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
203                FontWeight::Bold
204            }
205            Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
206            Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
207            _ => FontWeight::Normal,
208        };
209        let font_style_type = match self.style.font_style {
210            Some(CssFontStyle::Italic) => FontStyleType::Italic,
211            Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
212            _ => FontStyleType::Normal,
213        };
214        let align = match self.style.text_align {
215            Some(CssTextAlign::Center) => TextAlign::Center,
216            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
217            _ => TextAlign::Left,
218        };
219
220        let slant = match font_style_type {
221            FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
222            FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
223            FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
224        };
225        let weight = match font_weight {
226            FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
227            FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
228            FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
229        };
230        let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
231
232        let typeface = typeface_with_fallback(font_family, skia_font_style)?;
233
234        let nowrap = matches!(
235            self.style.white_space,
236            Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
237        );
238        let box_width = if layout_width.is_finite() && layout_width > 0.0 {
239            Some(match self.max_width {
240                Some(mw) => mw.min(layout_width),
241                None => layout_width,
242            })
243        } else {
244            self.max_width
245        };
246
247        if matches!(self.style.text_autofit, Some(true)) {
248            let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
249            let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
250                &self.content,
251                &typeface,
252                font_size,
253                letter_spacing,
254                line_height_val,
255                !nowrap,
256                box_width,
257                declared_height,
258            );
259            font_size = fs;
260            letter_spacing = ls;
261            line_height_val = lh;
262        }
263
264        let type_ctx = rustmotion_core::css::units::LengthContext {
265            font_size,
266            ..base_ctx
267        };
268
269        let font = Font::from_typeface(typeface, font_size);
270        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
271        let paint = paint_from_hex(color);
272
273        let wrap_width = if nowrap { None } else { box_width };
274
275        let label = self.label_at(time);
276        let content = if props.visible_chars_progress >= 0.0 {
277            let chars: Vec<char> = label.chars().collect();
278            let visible = (props.visible_chars_progress * chars.len() as f32).round() as usize;
279            let visible = visible.min(chars.len());
280            if visible == 0 && self.caret.is_none() {
281                return Ok(());
282            }
283            chars[..visible].iter().collect::<String>()
284        } else {
285            label.to_string()
286        };
287
288        let lines =
289            wrap_text_with_tracking(&content, &font, &emoji_font, wrap_width, letter_spacing);
290        let (_, metrics) = font.metrics();
291        let ascent = -metrics.ascent;
292        let descent = metrics.descent;
293        let baseline_offset = (line_height_val + ascent - descent) / 2.0;
294
295        let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
296            vec![s.clone()]
297        } else if let Some(list) = &self.style.text_shadow {
298            list.iter().map(|s| s.to_schema(&type_ctx)).collect()
299        } else {
300            Vec::new()
301        };
302        let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows
303            .iter()
304            .map(|shadow| {
305                let mut p = paint_from_hex(&shadow.color);
306                if shadow.blur > 0.01 {
307                    if let Some(filter) = skia_safe::image_filters::blur(
308                        (shadow.blur, shadow.blur),
309                        skia_safe::TileMode::Clamp,
310                        None,
311                        None,
312                    ) {
313                        p.set_image_filter(filter);
314                    }
315                }
316                (p, shadow.offset_x, shadow.offset_y)
317            })
318            .collect();
319
320        let stroke_paint = self.stroke.as_ref().map(|stroke| {
321            let mut p = paint_from_hex(&stroke.color);
322            p.set_style(PaintStyle::Stroke);
323            p.set_stroke_width(stroke.width);
324            p
325        });
326
327        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
328            layout_width
329        } else {
330            let mut max_w = 0.0f32;
331            for line in &lines {
332                let w = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
333                max_w = max_w.max(w);
334            }
335            max_w
336        };
337
338        if let Some(ref resolved) = props.char_animation {
339            crate::intrinsic::render_char_animation(
340                canvas,
341                &font,
342                &emoji_font,
343                &paint,
344                letter_spacing,
345                align,
346                align_width,
347                line_height_val,
348                baseline_offset,
349                &lines,
350                resolved,
351                time,
352            );
353            return Ok(());
354        }
355
356        if let Some(morph) = self.active_morph(time) {
357            paint_morph(
358                canvas,
359                &morph,
360                &font,
361                &emoji_font,
362                &paint,
363                letter_spacing,
364                wrap_width,
365                align,
366                align_width,
367                line_height_val,
368                baseline_offset,
369                time,
370            );
371            return Ok(());
372        }
373
374        if let Some(swap) = self.active_swap(time) {
375            for (label, offset_y, blur, alpha) in swap.labels() {
376                let label_lines =
377                    wrap_text_with_tracking(&label, &font, &emoji_font, wrap_width, letter_spacing);
378                let mut p = paint.clone();
379                p.set_alpha_f(alpha * paint.alpha_f());
380                if blur > 0.05 {
381                    if let Some(filter) = skia_safe::image_filters::blur(
382                        (blur, blur),
383                        skia_safe::TileMode::Clamp,
384                        None,
385                        None,
386                    ) {
387                        p.set_image_filter(filter);
388                    }
389                }
390                draw_text_lines(
391                    canvas,
392                    &label_lines,
393                    &font,
394                    &emoji_font,
395                    &p,
396                    &shadow_paints,
397                    stroke_paint.as_ref(),
398                    self.text_background.as_ref(),
399                    letter_spacing,
400                    &align,
401                    align_width,
402                    line_height_val,
403                    baseline_offset,
404                    offset_y,
405                );
406            }
407            return Ok(());
408        }
409
410        draw_text_lines(
411            canvas,
412            &lines,
413            &font,
414            &emoji_font,
415            &paint,
416            &shadow_paints,
417            stroke_paint.as_ref(),
418            self.text_background.as_ref(),
419            letter_spacing,
420            &align,
421            align_width,
422            line_height_val,
423            baseline_offset,
424            0.0,
425        );
426
427        if let Some(caret) = &self.caret {
428            let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0;
429            if !(done && caret.hide_when_done) {
430                let last = lines.len().saturating_sub(1);
431                let line = lines.last().map(String::as_str).unwrap_or("");
432                let advance_width =
433                    measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
434                let x = match align {
435                    TextAlign::Left => 0.0,
436                    TextAlign::Center => (align_width - advance_width) / 2.0,
437                    TextAlign::Right => align_width - advance_width,
438                };
439                let baseline = last as f32 * line_height_val + baseline_offset;
440                draw_caret(
441                    canvas,
442                    caret,
443                    x + advance_width,
444                    baseline,
445                    &font,
446                    &paint,
447                    time,
448                );
449            }
450        }
451
452        Ok(())
453    }
454}
455
456#[allow(clippy::too_many_arguments)]
457fn draw_text_lines(
458    canvas: &Canvas,
459    lines: &[String],
460    font: &Font,
461    emoji_font: &Option<Font>,
462    paint: &Paint,
463    shadow_paints: &[(Paint, f32, f32)],
464    stroke_paint: Option<&Paint>,
465    text_background: Option<&TextBackground>,
466    letter_spacing: f32,
467    align: &TextAlign,
468    align_width: f32,
469    line_height_val: f32,
470    baseline_offset: f32,
471    offset_y: f32,
472) {
473    for (i, line) in lines.iter().enumerate() {
474        if line.is_empty() {
475            continue;
476        }
477
478        let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
479
480        let x = match align {
481            TextAlign::Left => 0.0,
482            TextAlign::Center => (align_width - advance_width) / 2.0,
483            TextAlign::Right => align_width - advance_width,
484        };
485        let y = i as f32 * line_height_val + baseline_offset + offset_y;
486
487        if let Some(bg) = text_background {
488            let bg_paint = paint_from_hex(&bg.color);
489            let (_, font_rect) = font.measure_str(line, None);
490            let bg_rect = Rect::from_xywh(
491                x - bg.padding + font_rect.left,
492                y + font_rect.top - bg.padding / 2.0,
493                advance_width + bg.padding * 2.0,
494                -font_rect.top + font_rect.bottom + bg.padding,
495            );
496            if bg.corner_radius > 0.0 {
497                let rrect =
498                    skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius);
499                canvas.draw_rrect(rrect, &bg_paint);
500            } else {
501                canvas.draw_rect(bg_rect, &bg_paint);
502            }
503        }
504
505        for (sp, ox, oy) in shadow_paints.iter().rev() {
506            draw_text_with_fallback(
507                canvas,
508                line,
509                font,
510                emoji_font,
511                letter_spacing,
512                x + ox,
513                y + oy,
514                sp,
515            );
516        }
517
518        if let Some(sp) = stroke_paint {
519            draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp);
520        }
521        draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint);
522    }
523}
524
525struct ActiveSwap {
526    from: String,
527    to: String,
528    progress: f32,
529    distance: f32,
530    blur: f32,
531}
532
533impl ActiveSwap {
534    fn labels(&self) -> [(String, f32, f32, f32); 2] {
535        let p = self.progress.clamp(0.0, 1.0);
536        [
537            (
538                self.from.clone(),
539                -self.distance * p,
540                self.blur * p,
541                1.0 - p,
542            ),
543            (
544                self.to.clone(),
545                self.distance * (1.0 - p),
546                self.blur * (1.0 - p),
547                p,
548            ),
549        ]
550    }
551}
552
553struct ActiveMorph {
554    from: String,
555    to: String,
556    progress: f32,
557    unmatched: TextMorphUnmatched,
558    seed: u32,
559}
560
561fn smoothstep(t: f32) -> f32 {
562    let t = t.clamp(0.0, 1.0);
563    t * t * (3.0 - 2.0 * t)
564}
565
566fn char_positions(
567    lines: &[String],
568    font: &Font,
569    emoji_font: &Option<Font>,
570    letter_spacing: f32,
571    align: &TextAlign,
572    align_width: f32,
573    line_height_val: f32,
574    baseline_offset: f32,
575) -> Vec<(f32, f32)> {
576    let mut out = Vec::new();
577    for (line_idx, line) in lines.iter().enumerate() {
578        let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
579        let line_x = match align {
580            TextAlign::Left => 0.0,
581            TextAlign::Center => (align_width - advance_width) / 2.0,
582            TextAlign::Right => align_width - advance_width,
583        };
584        let y = line_idx as f32 * line_height_val + baseline_offset;
585        let mut cursor_x = line_x;
586        for ch in line.chars() {
587            out.push((cursor_x, y));
588            let ch_str = ch.to_string();
589            let ch_width = measure_text_with_fallback(&ch_str, font, emoji_font, letter_spacing);
590            cursor_x += ch_width;
591        }
592    }
593    out
594}
595
596fn lcs_pairs(from: &[char], to: &[char]) -> Vec<(usize, usize)> {
597    let n = from.len();
598    let m = to.len();
599    let mut dp = vec![vec![0u32; m + 1]; n + 1];
600    for i in (0..n).rev() {
601        for j in (0..m).rev() {
602            dp[i][j] = if from[i] == to[j] {
603                dp[i + 1][j + 1] + 1
604            } else {
605                dp[i + 1][j].max(dp[i][j + 1])
606            };
607        }
608    }
609    let mut pairs = Vec::new();
610    let (mut i, mut j) = (0usize, 0usize);
611    while i < n && j < m {
612        if from[i] == to[j] {
613            pairs.push((i, j));
614            i += 1;
615            j += 1;
616        } else if dp[i + 1][j] >= dp[i][j + 1] {
617            i += 1;
618        } else {
619            j += 1;
620        }
621    }
622    pairs
623}
624
625const SCRAMBLE_POOL: &[char] = &[
626    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
627    'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#', '%',
628    '&', '*', '?',
629];
630
631fn scrambled_char(seed: u32, idx: usize, time: f64) -> char {
632    let bucket = (time * 20.0) as u64;
633    let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (seed as u64);
634    h ^= bucket.wrapping_mul(0xD1B5_4A32_D192_ED03);
635    h ^= h >> 33;
636    h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD);
637    h ^= h >> 33;
638    let pool_idx = (h % SCRAMBLE_POOL.len() as u64) as usize;
639    SCRAMBLE_POOL[pool_idx]
640}
641
642#[allow(clippy::too_many_arguments)]
643fn paint_morph(
644    canvas: &Canvas,
645    morph: &ActiveMorph,
646    font: &Font,
647    emoji_font: &Option<Font>,
648    paint: &Paint,
649    letter_spacing: f32,
650    wrap_width: Option<f32>,
651    align: TextAlign,
652    align_width: f32,
653    line_height_val: f32,
654    baseline_offset: f32,
655    time: f64,
656) {
657    let from_lines =
658        wrap_text_with_tracking(&morph.from, font, emoji_font, wrap_width, letter_spacing);
659    let to_lines = wrap_text_with_tracking(&morph.to, font, emoji_font, wrap_width, letter_spacing);
660
661    let from_positions = char_positions(
662        &from_lines,
663        font,
664        emoji_font,
665        letter_spacing,
666        &align,
667        align_width,
668        line_height_val,
669        baseline_offset,
670    );
671    let to_positions = char_positions(
672        &to_lines,
673        font,
674        emoji_font,
675        letter_spacing,
676        &align,
677        align_width,
678        line_height_val,
679        baseline_offset,
680    );
681
682    let from_chars: Vec<char> = from_lines.iter().flat_map(|l| l.chars()).collect();
683    let to_chars: Vec<char> = to_lines.iter().flat_map(|l| l.chars()).collect();
684
685    let pairs = lcs_pairs(&from_chars, &to_chars);
686    let mut matched_from = vec![false; from_chars.len()];
687    let mut matched_to = vec![false; to_chars.len()];
688    for &(i, j) in &pairs {
689        matched_from[i] = true;
690        matched_to[j] = true;
691    }
692
693    let p = smoothstep(morph.progress);
694
695    for &(i, j) in &pairs {
696        let (fx, fy) = from_positions[i];
697        let (tx, ty) = to_positions[j];
698        let x = fx + (tx - fx) * p;
699        let y = fy + (ty - fy) * p;
700        draw_text_with_fallback(
701            canvas,
702            &to_chars[j].to_string(),
703            font,
704            emoji_font,
705            0.0,
706            x,
707            y,
708            paint,
709        );
710    }
711
712    for (i, &(x, y)) in from_positions.iter().enumerate() {
713        if matched_from[i] {
714            continue;
715        }
716        let alpha = 1.0 - p;
717        if alpha <= 0.001 {
718            continue;
719        }
720        let mut ap = paint.clone();
721        ap.set_alpha_f(alpha * paint.alpha_f());
722        draw_text_with_fallback(
723            canvas,
724            &from_chars[i].to_string(),
725            font,
726            emoji_font,
727            0.0,
728            x,
729            y,
730            &ap,
731        );
732    }
733
734    for (j, &(x, y)) in to_positions.iter().enumerate() {
735        if matched_to[j] {
736            continue;
737        }
738        let alpha = p;
739        if alpha <= 0.001 {
740            continue;
741        }
742        let mut ap = paint.clone();
743        ap.set_alpha_f(alpha * paint.alpha_f());
744        let glyph = match morph.unmatched {
745            TextMorphUnmatched::Fade => to_chars[j],
746            TextMorphUnmatched::Scramble => {
747                if p < 0.7 {
748                    scrambled_char(morph.seed, j, time)
749                } else {
750                    to_chars[j]
751                }
752            }
753        };
754        draw_text_with_fallback(canvas, &glyph.to_string(), font, emoji_font, 0.0, x, y, &ap);
755    }
756}
757
758fn draw_caret(
759    canvas: &Canvas,
760    cfg: &CaretConfig,
761    x: f32,
762    baseline: f32,
763    font: &Font,
764    text_paint: &Paint,
765    time: f64,
766) {
767    if cfg.blink > 0.0 {
768        let phase = (time / cfg.blink as f64).rem_euclid(1.0);
769        if phase >= 0.5 {
770            return;
771        }
772    }
773
774    let (_, metrics) = font.metrics();
775    let ascent = -metrics.ascent;
776    let descent = metrics.descent;
777    let size = font.size();
778
779    let (width, gap) = match cfg.shape {
780        CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05),
781        CaretShape::Block => (size * 0.55, size * 0.04),
782    };
783
784    let mut paint = match &cfg.color {
785        Some(hex) => paint_from_hex(hex),
786        None => text_paint.clone(),
787    };
788    paint.set_style(PaintStyle::Fill);
789    paint.set_anti_alias(true);
790    paint.set_image_filter(None);
791
792    canvas.draw_rect(
793        Rect::from_xywh(x + gap, baseline - ascent, width, ascent + descent),
794        &paint,
795    );
796}
797
798impl Painter for Text {
799    fn paint_content(
800        &self,
801        canvas: &Canvas,
802        layout: &BoxLayout,
803        props: &AnimatedProperties,
804        ctx: &PaintCtx,
805    ) {
806        let (_, _, _, content_height) = layout.content_box();
807        let content_height =
808            (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
809        let _ = self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use crate::intrinsic::TextIntrinsic;
817    use rustmotion_core::css::style::CssStyle;
818    use rustmotion_core::css::Length;
819    use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
820    use rustmotion_core::schema::{
821        AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection, TextAnimGranularity,
822    };
823
824    fn make_text(content: &str, white_space: Option<CssWhiteSpace>) -> Text {
825        Text {
826            content: content.into(),
827            max_width: None,
828            timing: Default::default(),
829            style: CssStyle {
830                font_size: Some(Length::Px(28.0)),
831                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
832                white_space,
833                ..Default::default()
834            },
835            timeline: Vec::new(),
836            stagger: None,
837            text_shadow: None,
838            stroke: None,
839            text_background: None,
840            caret: None,
841            states: Vec::new(),
842            swap: None,
843            morph: None,
844        }
845    }
846
847    fn test_ctx() -> PaintCtx {
848        PaintCtx {
849            time: 0.0,
850            scenario_time: 0.0,
851            scene_duration: 1.0,
852            frame_index: 0,
853            fps: 30,
854            video_width: 600,
855            video_height: 200,
856            stagger_offset: 0.0,
857        }
858    }
859
860    fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
861        let snapshot = surface.image_snapshot();
862        let info = skia_safe::ImageInfo::new(
863            (width, height),
864            skia_safe::ColorType::RGBA8888,
865            skia_safe::AlphaType::Premul,
866            None,
867        );
868        let mut buf = vec![0u8; (width * height * 4) as usize];
869        let ok = snapshot.read_pixels(
870            &info,
871            &mut buf,
872            (width * 4) as usize,
873            skia_safe::IPoint::new(0, 0),
874            skia_safe::image::CachingHint::Disallow,
875        );
876        assert!(ok, "pixel read should succeed");
877        (0..(width * height) as usize)
878            .map(|i| buf[i * 4 + 3])
879            .collect()
880    }
881
882    fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
883        for y in y0..y1 {
884            for x in x0..x1 {
885                if grid[(y * surface_width + x) as usize] > 0 {
886                    return true;
887                }
888            }
889        }
890        false
891    }
892
893    #[test]
894    fn nowrap_paints_a_single_line_past_the_layout_width() {
895        let text = make_text(
896            "the quick brown fox jumps over the lazy dog",
897            Some(CssWhiteSpace::Nowrap),
898        );
899        const W: i32 = 600;
900        const H: i32 = 200;
901        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
902        let canvas = surface.canvas();
903        let ctx = test_ctx();
904        let props = AnimatedProperties::default();
905        text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
906            .expect("paint succeeds");
907        let grid = alpha_grid(&mut surface, W, H);
908
909        assert!(
910            has_ink_in(&grid, W, 300, W, 0, 45),
911            "nowrap text must paint past its 80px box on line 1 (scanned x∈[300,600), y∈[0,45))"
912        );
913
914        assert!(
915            !has_ink_in(&grid, W, 0, W, 55, H),
916            "nowrap text must stay on a single line; found ink on what would be line 2"
917        );
918    }
919
920    #[test]
921    fn normal_white_space_wraps_within_the_layout_width() {
922        let text = make_text("the quick brown fox jumps over the lazy dog", None);
923        const W: i32 = 600;
924        const H: i32 = 200;
925        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
926        let canvas = surface.canvas();
927        let ctx = test_ctx();
928        let props = AnimatedProperties::default();
929        text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
930            .expect("paint succeeds");
931        let grid = alpha_grid(&mut surface, W, H);
932
933        assert!(
934            !has_ink_in(&grid, W, 300, W, 0, 45),
935            "wrapped text must not reach x∈[300,600) on line 1 within an 80px box"
936        );
937        assert!(
938            has_ink_in(&grid, W, 0, W, 55, H),
939            "wrapped text must spill onto a second line within the box width"
940        );
941    }
942
943    #[test]
944    fn rem_font_size_paints_visible_ink() {
945        let text = Text {
946            content: "HELLO".into(),
947            max_width: None,
948            timing: Default::default(),
949            style: CssStyle {
950                font_size: Some(Length::String("2rem".into())),
951                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
952                ..Default::default()
953            },
954            timeline: Vec::new(),
955            stagger: None,
956            text_shadow: None,
957            stroke: None,
958            text_background: None,
959            caret: None,
960            states: Vec::new(),
961            swap: None,
962            morph: None,
963        };
964        const W: i32 = 400;
965        const H: i32 = 200;
966        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
967        let canvas = surface.canvas();
968        let ctx = test_ctx();
969        let props = AnimatedProperties::default();
970        text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
971            .expect("paint succeeds");
972        let grid = alpha_grid(&mut surface, W, H);
973
974        assert!(
975            has_ink_in(&grid, W, 0, W, 0, 60),
976            "font-size: 2rem must paint visible ink (32px glyphs), got none"
977        );
978    }
979
980    #[test]
981    fn vh_font_size_paints_visible_ink_scaled_to_the_real_viewport() {
982        let text = Text {
983            content: "HI".into(),
984            max_width: None,
985            timing: Default::default(),
986            style: CssStyle {
987                font_size: Some(Length::String("20vh".into())),
988                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
989                ..Default::default()
990            },
991            timeline: Vec::new(),
992            stagger: None,
993            text_shadow: None,
994            stroke: None,
995            text_background: None,
996            caret: None,
997            states: Vec::new(),
998            swap: None,
999            morph: None,
1000        };
1001        const W: i32 = 400;
1002        const H: i32 = 200;
1003        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1004        let canvas = surface.canvas();
1005        let ctx = test_ctx();
1006        let props = AnimatedProperties::default();
1007        text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1008            .expect("paint succeeds");
1009        let grid = alpha_grid(&mut surface, W, H);
1010
1011        assert!(
1012            has_ink_in(&grid, W, 0, W, 0, 70),
1013            "font-size: 20vh (40px against a 200px-tall test viewport) must paint visible ink"
1014        );
1015    }
1016
1017    #[test]
1018    fn a_translucent_color_alpha_channel_survives_to_the_painted_pixels() {
1019        let mut text = make_text("A", Some(CssWhiteSpace::Nowrap));
1020        text.style.font_size = Some(Length::Px(120.0));
1021        text.style.color = Some(rustmotion_core::css::style::Color::String(
1022            "#FFFFFF12".into(),
1023        ));
1024
1025        const W: i32 = 300;
1026        const H: i32 = 200;
1027        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1028        let canvas = surface.canvas();
1029        let ctx = test_ctx();
1030        let props = AnimatedProperties::default();
1031        text.paint(canvas, W as f32, None, 0.0, &props, &ctx)
1032            .expect("paint succeeds");
1033        let grid = alpha_grid(&mut surface, W, H);
1034
1035        let max_alpha = grid.iter().copied().max().unwrap_or(0);
1036        assert!(max_alpha > 0, "the glyph must paint some ink");
1037        assert!(
1038            max_alpha <= 40,
1039            "style.color's alpha channel (0x12 = 18/255, about 7%) must survive to the painted \
1040             pixels instead of being forced fully opaque; got max alpha {max_alpha}"
1041        );
1042    }
1043
1044    fn soft_pixel_fraction(
1045        grid: &[u8],
1046        surface_width: i32,
1047        x0: i32,
1048        x1: i32,
1049        y0: i32,
1050        y1: i32,
1051    ) -> f32 {
1052        let mut inked = 0u32;
1053        let mut soft = 0u32;
1054        for y in y0..y1 {
1055            for x in x0..x1 {
1056                let a = grid[(y * surface_width + x) as usize];
1057                if a > 0 {
1058                    inked += 1;
1059                    if a < 250 {
1060                        soft += 1;
1061                    }
1062                }
1063            }
1064        }
1065        if inked == 0 {
1066            return 0.0;
1067        }
1068        soft as f32 / inked as f32
1069    }
1070
1071    fn props_for(text: &Text) -> AnimatedProperties {
1072        AnimatedProperties {
1073            char_animation: rustmotion_core::engine::animator::extract_effects(
1074                &text.style.animation,
1075            )
1076            .char_animation,
1077            ..Default::default()
1078        }
1079    }
1080
1081    fn inter_font(px: f32) -> Font {
1082        let style = FontStyle::new(
1083            skia_safe::font_style::Weight::NORMAL,
1084            skia_safe::font_style::Width::NORMAL,
1085            skia_safe::font_style::Slant::Upright,
1086        );
1087        let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves");
1088        Font::from_typeface(typeface, px)
1089    }
1090
1091    #[test]
1092    fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() {
1093        let mut text = make_text("BLUR", None);
1094        text.style.font_size = Some(Length::Px(100.0));
1095        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1096        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1097            delay: 0.0,
1098            duration: 0.5,
1099            stagger: 0.03,
1100            granularity: TextAnimGranularity::Word,
1101            easing: EasingType::Linear,
1102            ..Default::default()
1103        })];
1104
1105        const W: i32 = 700;
1106        const H: i32 = 220;
1107        let ctx = test_ctx();
1108        let props = props_for(&text);
1109
1110        let render_at = |t: f64| -> Vec<u8> {
1111            let mut surface =
1112                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1113            {
1114                let canvas = surface.canvas();
1115                text.paint(canvas, W as f32, None, t, &props, &ctx)
1116                    .expect("paint succeeds");
1117            }
1118            alpha_grid(&mut surface, W, H)
1119        };
1120
1121        let early = render_at(0.15);
1122        let settled = render_at(1.0);
1123
1124        assert!(
1125            has_ink_in(&early, W, 0, W, 0, H),
1126            "the word must have started painting by t=0.15"
1127        );
1128
1129        let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1130        let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1131
1132        assert!(
1133            early_soft > settled_soft + 0.15,
1134            "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1135             settled fraction ({settled_soft:.3}) — the word should read as blurred while \
1136             animating and sharp at rest"
1137        );
1138        assert!(
1139            settled_soft < 0.25,
1140            "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1141        );
1142    }
1143
1144    #[test]
1145    fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() {
1146        const FONT_PX: f32 = 90.0;
1147        let mut text = make_text("FIRST               SECOND", None);
1148        text.style.font_size = Some(Length::Px(FONT_PX));
1149        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1150        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1151            delay: 0.0,
1152            duration: 0.4,
1153            stagger: 0.2,
1154            granularity: TextAnimGranularity::Word,
1155            easing: EasingType::Linear,
1156            blur: Some(18.0),
1157            ..Default::default()
1158        })];
1159
1160        const W: i32 = 1400;
1161        const H: i32 = 180;
1162        let ctx = test_ctx();
1163        let props = props_for(&text);
1164
1165        let font = inter_font(FONT_PX);
1166        let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0);
1167        let gap_w = measure_text_with_fallback("               ", &font, &None, 0.0);
1168        let margin = (gap_w / 3.0).max(40.0);
1169        let gap_x0 = (first_w + margin) as i32;
1170        let gap_x1 = (first_w + gap_w - margin) as i32;
1171        assert!(
1172            gap_x1 > gap_x0,
1173            "test setup: the space run must measure to a real gap (got [{gap_x0},{gap_x1}))"
1174        );
1175
1176        for &t in &[0.05_f64, 0.35, 1.0] {
1177            let mut surface =
1178                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1179            {
1180                let canvas = surface.canvas();
1181                text.paint(canvas, W as f32, None, t, &props, &ctx)
1182                    .expect("paint succeeds");
1183            }
1184            let grid = alpha_grid(&mut surface, W, H);
1185            assert!(
1186                !has_ink_in(&grid, W, gap_x0, gap_x1, 0, H),
1187                "inter-word gap [{gap_x0},{gap_x1}) must stay empty at t={t}"
1188            );
1189        }
1190    }
1191
1192    #[test]
1193    fn char_blur_in_honors_word_delay_and_stagger() {
1194        const FONT_PX: f32 = 90.0;
1195        let mut text = make_text("ONE TWO", None);
1196        text.style.font_size = Some(Length::Px(FONT_PX));
1197        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1198        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1199            delay: 0.5,
1200            duration: 0.3,
1201            stagger: 0.6,
1202            granularity: TextAnimGranularity::Word,
1203            easing: EasingType::Linear,
1204            blur: Some(16.0),
1205            ..Default::default()
1206        })];
1207
1208        const W: i32 = 900;
1209        const H: i32 = 180;
1210        let ctx = test_ctx();
1211        let props = props_for(&text);
1212        let font = inter_font(FONT_PX);
1213        let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1214
1215        let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1216        {
1217            let canvas = before.canvas();
1218            text.paint(canvas, W as f32, None, 0.1, &props, &ctx)
1219                .expect("paint succeeds");
1220        }
1221        let before_grid = alpha_grid(&mut before, W, H);
1222        assert!(
1223            !has_ink_in(&before_grid, W, 0, W, 0, H),
1224            "nothing should paint before `delay` has elapsed"
1225        );
1226
1227        let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1228        {
1229            let canvas = mid.canvas();
1230            text.paint(canvas, W as f32, None, 0.65, &props, &ctx)
1231                .expect("paint succeeds");
1232        }
1233        let mid_grid = alpha_grid(&mut mid, W, H);
1234        assert!(
1235            has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1236            "word 1 should show ink by t=0.65 (mid-reveal)"
1237        );
1238        assert!(
1239            !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1240            "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65"
1241        );
1242    }
1243
1244    fn autofit_text(content: &str, font_size: f32, white_space: Option<CssWhiteSpace>) -> Text {
1245        let mut t = make_text(content, white_space);
1246        t.style.font_size = Some(Length::Px(font_size));
1247        t.style.text_autofit = Some(true);
1248        t
1249    }
1250
1251    fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1252        let mut max_x: Option<i32> = None;
1253        for y in 0..height {
1254            for x in (0..surface_width).rev() {
1255                if grid[(y * surface_width + x) as usize] > 0 {
1256                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
1257                    break;
1258                }
1259            }
1260        }
1261        max_x
1262    }
1263
1264    fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1265        for y in (0..height).rev() {
1266            for x in 0..surface_width {
1267                if grid[(y * surface_width + x) as usize] > 0 {
1268                    return Some(y);
1269                }
1270            }
1271        }
1272        None
1273    }
1274
1275    #[test]
1276    fn measure_and_paint_agree_on_a_shrunk_nowrap_line() {
1277        let text = autofit_text(
1278            "the quick brown fox jumps over the lazy dog",
1279            90.0,
1280            Some(CssWhiteSpace::Nowrap),
1281        );
1282        const BOX_W: f32 = 300.0;
1283        const BOX_H: f32 = 60.0;
1284
1285        let (measured_w, _measured_h) = TextIntrinsic::from_text(&text).measure(
1286            (None, None),
1287            (
1288                AvailableSpace::Definite(BOX_W),
1289                AvailableSpace::Definite(BOX_H),
1290            ),
1291        );
1292        assert!(
1293            measured_w <= BOX_W + 0.5,
1294            "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
1295        );
1296
1297        const W: i32 = 900;
1298        const H: i32 = 300;
1299        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1300        {
1301            let canvas = surface.canvas();
1302            let ctx = test_ctx();
1303            let props = AnimatedProperties::default();
1304            text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1305                .expect("paint succeeds");
1306        }
1307        let grid = alpha_grid(&mut surface, W, H);
1308        let ink_right = max_ink_x(&grid, W, H).expect("text must paint some ink");
1309
1310        assert!(
1311            (ink_right as f32) <= measured_w + 3.0,
1312            "painted ink (right edge {ink_right}) must not exceed the box TextIntrinsic reserved \
1313             ({measured_w}) — a wider paint than measure is exactly the class of bug this \
1314             workstream exists to close"
1315        );
1316        assert!(
1317            (ink_right as f32) >= measured_w - 15.0,
1318            "painted ink (right edge {ink_right}) should land close to what TextIntrinsic \
1319             measured ({measured_w}); a big gap would mean the two disagree on the resolved \
1320             font size in the other direction (paint drawing much smaller than reserved)"
1321        );
1322    }
1323
1324    #[test]
1325    fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() {
1326        let text = autofit_text(
1327            "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer",
1328            60.0,
1329            None,
1330        );
1331        const BOX_W: f32 = 300.0;
1332        const BOX_H: f32 = 90.0;
1333
1334        let (_measured_w, measured_h) = TextIntrinsic::from_text(&text).measure(
1335            (None, None),
1336            (
1337                AvailableSpace::Definite(BOX_W),
1338                AvailableSpace::Definite(BOX_H),
1339            ),
1340        );
1341        assert!(
1342            measured_h <= BOX_H + 0.5,
1343            "TextIntrinsic itself must report a fit once autofit is on, got {measured_h}"
1344        );
1345
1346        const W: i32 = 500;
1347        const H: i32 = 400;
1348        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1349        {
1350            let canvas = surface.canvas();
1351            let ctx = test_ctx();
1352            let props = AnimatedProperties::default();
1353            text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1354                .expect("paint succeeds");
1355        }
1356        let grid = alpha_grid(&mut surface, W, H);
1357        let ink_bottom = max_ink_y(&grid, W, H).expect("text must paint some ink");
1358
1359        assert!(
1360            (ink_bottom as f32) <= measured_h + 6.0,
1361            "painted ink (bottom edge {ink_bottom}) must not exceed the box TextIntrinsic \
1362             reserved ({measured_h})"
1363        );
1364    }
1365
1366    #[test]
1367    fn autofit_size_is_stable_across_frames_for_fixed_content() {
1368        let text = autofit_text(
1369            "the quick brown fox jumps over the lazy dog",
1370            90.0,
1371            Some(CssWhiteSpace::Nowrap),
1372        );
1373        const W: i32 = 900;
1374        const H: i32 = 300;
1375        let ctx = test_ctx();
1376        let props = AnimatedProperties::default();
1377
1378        let render_at = |t: f64| -> Vec<u8> {
1379            let mut surface =
1380                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1381            {
1382                let canvas = surface.canvas();
1383                text.paint(canvas, 250.0, Some(60.0), t, &props, &ctx)
1384                    .expect("paint succeeds");
1385            }
1386            alpha_grid(&mut surface, W, H)
1387        };
1388
1389        let frame_a = render_at(0.0);
1390        let frame_b = render_at(0.9);
1391        assert_eq!(
1392            frame_a, frame_b,
1393            "fixed content in a fixed box must render byte-identically regardless of ctx.time — \
1394             a per-frame drift here is exactly what the temporal-stability requirement forbids"
1395        );
1396    }
1397
1398    #[test]
1399    fn autofit_size_does_not_drift_during_a_typewriter_reveal() {
1400        let text = autofit_text(
1401            "the quick brown fox jumps over the lazy dog",
1402            90.0,
1403            Some(CssWhiteSpace::Nowrap),
1404        );
1405        const W: i32 = 900;
1406        const H: i32 = 300;
1407        let ctx = test_ctx();
1408
1409        let render_at = |progress: f32| -> Vec<u8> {
1410            let mut surface =
1411                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1412            let props = AnimatedProperties {
1413                visible_chars_progress: progress,
1414                ..Default::default()
1415            };
1416            {
1417                let canvas = surface.canvas();
1418                text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1419                    .expect("paint succeeds");
1420            }
1421            alpha_grid(&mut surface, W, H)
1422        };
1423
1424        let early = render_at(0.3);
1425        let full = render_at(1.0);
1426
1427        let early_bottom = max_ink_y(&early, W, H).expect("some ink must paint at 30% reveal");
1428        let full_bottom = max_ink_y(&full, W, H).expect("some ink must paint at full reveal");
1429        assert_eq!(
1430            early_bottom, full_bottom,
1431            "the resolved font size (line height, hence vertical ink footprint) must not change \
1432             as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}"
1433        );
1434
1435        let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal");
1436        let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal");
1437        assert!(
1438            early_right < full_right,
1439            "test setup: 30% reveal should show measurably less horizontal ink than the full \
1440             line (early={early_right}, full={full_right})"
1441        );
1442    }
1443
1444    #[test]
1445    fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
1446        let text = make_text(
1447            "the quick brown fox jumps over the lazy dog",
1448            Some(CssWhiteSpace::Nowrap),
1449        );
1450        const W: i32 = 900;
1451        const H: i32 = 300;
1452        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1453        let canvas = surface.canvas();
1454        let ctx = test_ctx();
1455        let props = AnimatedProperties::default();
1456        text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1457            .expect("paint succeeds");
1458        let grid = alpha_grid(&mut surface, W, H);
1459        assert!(
1460            has_ink_in(&grid, W, 260, W, 0, 45),
1461            "without text-autofit, nowrap must still bleed past its box exactly as before"
1462        );
1463    }
1464
1465    fn swapping_text(swap: Option<TextSwapConfig>) -> Text {
1466        let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap));
1467        text.style.font_size = Some(Length::Px(48.0));
1468        text.states = vec![TextState {
1469            at: 1.0,
1470            content: "Saved".into(),
1471        }];
1472        text.swap = swap;
1473        text
1474    }
1475
1476    fn render_plain(text: &Text, time: f64) -> Vec<u8> {
1477        let mut surface =
1478            skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1479        {
1480            let canvas = surface.canvas();
1481            text.paint(
1482                canvas,
1483                CARET_W as f32,
1484                None,
1485                time,
1486                &AnimatedProperties::default(),
1487                &test_ctx(),
1488            )
1489            .expect("paint succeeds");
1490        }
1491        alpha_grid(&mut surface, CARET_W, CARET_H)
1492    }
1493
1494    #[test]
1495    fn states_cut_over_at_their_own_time_without_a_swap_config() {
1496        let text = swapping_text(None);
1497        let before = render_plain(&text, 0.5);
1498        let after = render_plain(&text, 1.5);
1499
1500        let width_of = |g: &[u8]| max_ink_x(g, CARET_W, CARET_H).unwrap_or(0);
1501        assert!(
1502            width_of(&before) > width_of(&after) + 20,
1503            "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \
1504             have changed at t=1.0"
1505        );
1506        assert_eq!(
1507            render_plain(&text, 1.01),
1508            render_plain(&text, 1.5),
1509            "without a `swap`, the new label must be fully in place immediately"
1510        );
1511    }
1512
1513    #[test]
1514    fn a_swap_puts_both_labels_on_screen_at_once() {
1515        let text = swapping_text(Some(TextSwapConfig::default()));
1516
1517        let rows_with_ink = |time: f64| -> usize {
1518            let grid = render_plain(&text, time);
1519            (0..CARET_H)
1520                .filter(|&y| (0..CARET_W).any(|x| grid[(y * CARET_W + x) as usize] > 0))
1521                .count()
1522        };
1523
1524        let settled = rows_with_ink(0.5);
1525        let mid = rows_with_ink(1.0 + 0.45 / 2.0);
1526        assert!(
1527            mid > settled,
1528            "mid-swap, the two offset labels should span more rows than one settled label \
1529             (settled={settled}, mid={mid})"
1530        );
1531    }
1532
1533    #[test]
1534    fn a_finished_swap_settles_on_the_incoming_label_alone() {
1535        let swapped = swapping_text(Some(TextSwapConfig::default()));
1536        let cut = swapping_text(None);
1537        assert_eq!(
1538            render_plain(&swapped, 2.0),
1539            render_plain(&cut, 2.0),
1540            "once the swap window has passed, the frame must match a plain cut exactly"
1541        );
1542    }
1543
1544    #[test]
1545    fn the_box_is_measured_for_the_widest_label_not_the_first() {
1546        let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1547        short_first.states = vec![TextState {
1548            at: 1.0,
1549            content: "Saving draft".into(),
1550        }];
1551        let only_short = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1552
1553        let measure = |t: &Text| {
1554            TextIntrinsic::from_text(t)
1555                .measure(
1556                    (None, None),
1557                    (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1558                )
1559                .0
1560        };
1561
1562        assert!(
1563            measure(&short_first) > measure(&only_short) + 10.0,
1564            "the reserved width must cover the longest label the text can show \
1565             (with states={}, without={})",
1566            measure(&short_first),
1567            measure(&only_short)
1568        );
1569    }
1570
1571    const CARET_W: i32 = 900;
1572    const CARET_H: i32 = 160;
1573
1574    fn typewriter_text(caret: Option<CaretConfig>) -> Text {
1575        let mut text = make_text("HELLO WORLD", Some(CssWhiteSpace::Nowrap));
1576        text.style.font_size = Some(Length::Px(64.0));
1577        text.caret = caret;
1578        text
1579    }
1580
1581    fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec<u8> {
1582        let props = AnimatedProperties {
1583            visible_chars_progress: progress,
1584            ..AnimatedProperties::default()
1585        };
1586        let mut surface =
1587            skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1588        {
1589            let canvas = surface.canvas();
1590            text.paint(canvas, CARET_W as f32, None, time, &props, &test_ctx())
1591                .expect("paint succeeds");
1592        }
1593        alpha_grid(&mut surface, CARET_W, CARET_H)
1594    }
1595
1596    #[test]
1597    fn the_caret_follows_the_reveal_head_instead_of_standing_still() {
1598        let text = typewriter_text(Some(CaretConfig {
1599            blink: 0.0,
1600            ..Default::default()
1601        }));
1602        let plain = typewriter_text(None);
1603
1604        let caret_x = |progress: f32| -> i32 {
1605            let with = render_reveal(&text, progress, 0.0);
1606            let without = render_reveal(&plain, progress, 0.0);
1607            let with_right = max_ink_x(&with, CARET_W, CARET_H).expect("caret paints");
1608            let without_right = max_ink_x(&without, CARET_W, CARET_H).unwrap_or(0);
1609            assert!(
1610                with_right > without_right,
1611                "the caret should extend past the last revealed glyph \
1612                 (with={with_right}, without={without_right}) at progress {progress}"
1613            );
1614            with_right
1615        };
1616
1617        let early = caret_x(0.25);
1618        let late = caret_x(0.75);
1619        assert!(
1620            late > early + 40,
1621            "the caret should have travelled with the reveal head (early={early}, late={late})"
1622        );
1623    }
1624
1625    #[test]
1626    fn the_caret_blinks_off_for_half_of_each_period() {
1627        let text = typewriter_text(Some(CaretConfig {
1628            blink: 1.0,
1629            ..Default::default()
1630        }));
1631        let plain = typewriter_text(None);
1632
1633        let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0);
1634        let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0));
1635
1636        let on = right_edge(&render_reveal(&text, 0.5, 0.1));
1637        let off = right_edge(&render_reveal(&text, 0.5, 0.6));
1638        assert!(on > baseline, "caret should be visible at phase 0.1");
1639        assert_eq!(
1640            off, baseline,
1641            "caret should be blinked out at phase 0.6, leaving only the text's own ink"
1642        );
1643    }
1644
1645    #[test]
1646    fn hide_when_done_removes_the_caret_once_the_reveal_finishes() {
1647        let hiding = typewriter_text(Some(CaretConfig {
1648            blink: 0.0,
1649            hide_when_done: true,
1650            ..Default::default()
1651        }));
1652        let staying = typewriter_text(Some(CaretConfig {
1653            blink: 0.0,
1654            ..Default::default()
1655        }));
1656        let plain = typewriter_text(None);
1657
1658        let right_edge = |t: &Text, progress: f32| {
1659            max_ink_x(&render_reveal(t, progress, 0.0), CARET_W, CARET_H).unwrap_or(0)
1660        };
1661        let text_edge = right_edge(&plain, 1.0);
1662
1663        assert_eq!(
1664            right_edge(&hiding, 1.0),
1665            text_edge,
1666            "with hide_when_done, a finished reveal must leave no caret behind"
1667        );
1668        assert!(
1669            right_edge(&staying, 1.0) > text_edge,
1670            "without hide_when_done, the caret parks at the end of the text"
1671        );
1672    }
1673
1674    #[test]
1675    fn the_caret_is_there_before_the_first_character_is() {
1676        let text = typewriter_text(Some(CaretConfig {
1677            blink: 0.0,
1678            ..Default::default()
1679        }));
1680        let grid = render_reveal(&text, 0.0, 0.0);
1681        assert!(
1682            has_ink_in(&grid, CARET_W, 0, CARET_W, 0, CARET_H),
1683            "the caret must be painting at 0% reveal, before any glyph"
1684        );
1685    }
1686
1687    const TUNING_W: i32 = 520;
1688    const TUNING_H: i32 = 360;
1689
1690    fn tuned_slide_up(timing: CharAnimationTiming) -> Text {
1691        let mut text = make_text("GO", None);
1692        text.style.font_size = Some(Length::Px(90.0));
1693        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1694        text.style.animation = vec![AnimationEffect::CharSlideUp(timing)];
1695        text
1696    }
1697
1698    fn render_alpha(text: &Text, time: f64) -> Vec<u8> {
1699        let mut surface =
1700            skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)).expect("raster surface");
1701        {
1702            let canvas = surface.canvas();
1703            text.paint(
1704                canvas,
1705                TUNING_W as f32,
1706                None,
1707                time,
1708                &props_for(text),
1709                &test_ctx(),
1710            )
1711            .expect("paint succeeds");
1712        }
1713        alpha_grid(&mut surface, TUNING_W, TUNING_H)
1714    }
1715
1716    fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1717        (0..height)
1718            .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0))
1719    }
1720
1721    #[test]
1722    fn direction_down_starts_the_unit_above_its_line_instead_of_below() {
1723        let base = || CharAnimationTiming {
1724            duration: 1.0,
1725            stagger: 0.0,
1726            granularity: TextAnimGranularity::Word,
1727            easing: EasingType::Linear,
1728            distance: Some(0.5),
1729            ..Default::default()
1730        };
1731        let up = tuned_slide_up(CharAnimationTiming {
1732            direction: TextAnimDirection::Up,
1733            ..base()
1734        });
1735        let down = tuned_slide_up(CharAnimationTiming {
1736            direction: TextAnimDirection::Down,
1737            ..base()
1738        });
1739
1740        let up_grid = render_alpha(&up, 0.5);
1741        let down_grid = render_alpha(&down, 0.5);
1742
1743        let up_top = min_ink_y(&up_grid, TUNING_W, TUNING_H).expect("up-travelling word paints");
1744        let down_top =
1745            min_ink_y(&down_grid, TUNING_W, TUNING_H).expect("down-travelling word paints");
1746
1747        assert!(
1748            down_top < up_top - 10,
1749            "at the same instant, a `down` unit should sit clearly higher on the canvas than an \
1750             `up` one (down_top={down_top}, up_top={up_top})"
1751        );
1752    }
1753
1754    #[test]
1755    fn distance_scales_how_far_the_unit_travels() {
1756        let base = || CharAnimationTiming {
1757            duration: 1.0,
1758            stagger: 0.0,
1759            granularity: TextAnimGranularity::Word,
1760            easing: EasingType::Linear,
1761            ..Default::default()
1762        };
1763        let close = tuned_slide_up(CharAnimationTiming {
1764            distance: Some(0.25),
1765            ..base()
1766        });
1767        let far = tuned_slide_up(CharAnimationTiming {
1768            distance: Some(1.0),
1769            ..base()
1770        });
1771
1772        let close_top =
1773            min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints");
1774        let far_top =
1775            min_ink_y(&render_alpha(&far, 0.5), TUNING_W, TUNING_H).expect("far word paints");
1776
1777        assert!(
1778            far_top > close_top + 10,
1779            "a `distance: 1.0` unit should still be further below its line than a `0.25` one at \
1780             the same instant (far_top={far_top}, close_top={close_top})"
1781        );
1782    }
1783
1784    #[test]
1785    fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() {
1786        let settled = |timing: CharAnimationTiming| {
1787            let grid = render_alpha(&tuned_slide_up(timing), 5.0);
1788            min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints")
1789        };
1790        let base = || CharAnimationTiming {
1791            duration: 1.0,
1792            stagger: 0.0,
1793            granularity: TextAnimGranularity::Word,
1794            easing: EasingType::Linear,
1795            ..Default::default()
1796        };
1797
1798        let plain = settled(base());
1799        let downward = settled(CharAnimationTiming {
1800            direction: TextAnimDirection::Down,
1801            distance: Some(1.85),
1802            ..base()
1803        });
1804        let sideways = settled(CharAnimationTiming {
1805            direction: TextAnimDirection::Right,
1806            distance: Some(1.85),
1807            ..base()
1808        });
1809
1810        assert_eq!(
1811            plain, downward,
1812            "a settled `down` unit must land on its line"
1813        );
1814        assert_eq!(
1815            plain, sideways,
1816            "a settled `right` unit must land on its line"
1817        );
1818    }
1819
1820    #[test]
1821    fn scale_from_shrinks_the_unit_at_the_start_and_releases_it_by_the_end() {
1822        let timing = CharAnimationTiming {
1823            duration: 1.0,
1824            stagger: 0.0,
1825            granularity: TextAnimGranularity::Word,
1826            easing: EasingType::Linear,
1827            distance: Some(0.0),
1828            scale_from: Some(0.5),
1829            ..Default::default()
1830        };
1831        let text = tuned_slide_up(timing);
1832
1833        let ink_width = |time: f64| -> i32 {
1834            let grid = render_alpha(&text, time);
1835            let left = (0..TUNING_W)
1836                .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
1837            let right = (0..TUNING_W)
1838                .rev()
1839                .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
1840            match (left, right) {
1841                (Some(l), Some(r)) => r - l,
1842                _ => 0,
1843            }
1844        };
1845
1846        let early = ink_width(0.35);
1847        let settled = ink_width(5.0);
1848        assert!(early > 0, "the word must be painting by t=0.35");
1849        assert!(
1850            (early as f32) < settled as f32 * 0.9,
1851            "a `scale_from: 0.5` unit should still be visibly narrower than its settled self \
1852             early on (early={early}px, settled={settled}px)"
1853        );
1854    }
1855
1856    #[test]
1857    fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() {
1858        let mut text = make_text("INK", None);
1859        text.style.font_size = Some(Length::Px(90.0));
1860        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1861        text.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
1862        text.style.animation = vec![AnimationEffect::CharScaleIn(CharAnimationTiming {
1863            duration: 1.0,
1864            stagger: 0.0,
1865            granularity: TextAnimGranularity::Word,
1866            easing: EasingType::Linear,
1867            overshoot: Some(0.0),
1868            ink_from: Some("#FF0000".into()),
1869            ..Default::default()
1870        })];
1871
1872        let mean_green = |time: f64| -> f32 {
1873            let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H))
1874                .expect("raster surface");
1875            {
1876                let canvas = surface.canvas();
1877                text.paint(
1878                    canvas,
1879                    TUNING_W as f32,
1880                    None,
1881                    time,
1882                    &props_for(&text),
1883                    &test_ctx(),
1884                )
1885                .expect("paint succeeds");
1886            }
1887            let snapshot = surface.image_snapshot();
1888            let info = skia_safe::ImageInfo::new(
1889                (TUNING_W, TUNING_H),
1890                skia_safe::ColorType::RGBA8888,
1891                skia_safe::AlphaType::Unpremul,
1892                None,
1893            );
1894            let mut buf = vec![0u8; (TUNING_W * TUNING_H * 4) as usize];
1895            assert!(snapshot.read_pixels(
1896                &info,
1897                &mut buf,
1898                (TUNING_W * 4) as usize,
1899                skia_safe::IPoint::new(0, 0),
1900                skia_safe::image::CachingHint::Disallow,
1901            ));
1902            let (weighted, alpha_sum) =
1903                (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| {
1904                    let alpha = buf[i * 4 + 3] as u64;
1905                    (s + buf[i * 4 + 1] as u64 * alpha, a + alpha)
1906                });
1907            assert!(alpha_sum > 0, "some inked pixels must exist at t={time}");
1908            weighted as f32 / alpha_sum as f32
1909        };
1910
1911        let early = mean_green(0.1);
1912        let mid = mean_green(0.5);
1913        let settled = mean_green(5.0);
1914
1915        assert!(
1916            early < 60.0,
1917            "at 10% the word should read nearly pure red (mean green {early:.1})"
1918        );
1919        assert!(
1920            mid > early + 40.0 && mid < settled - 40.0,
1921            "at 50% the word should be halfway between its start colour and the text colour \
1922             (early={early:.1}, mid={mid:.1}, settled={settled:.1})"
1923        );
1924        assert!(
1925            settled > 250.0,
1926            "once settled the word must be the text's own white, not a tint of it \
1927             (mean green {settled:.1})"
1928        );
1929    }
1930
1931    fn morphing_text(from: &str, to: &str, morph: Option<TextMorphConfig>) -> Text {
1932        let mut text = make_text(from, Some(CssWhiteSpace::Nowrap));
1933        text.style.font_size = Some(Length::Px(90.0));
1934        text.states = vec![TextState {
1935            at: 1.0,
1936            content: to.into(),
1937        }];
1938        text.morph = morph;
1939        text
1940    }
1941
1942    #[test]
1943    fn lcs_pairs_matches_identical_characters_left_to_right() {
1944        let from: Vec<char> = "AX".chars().collect();
1945        let to: Vec<char> = "YA".chars().collect();
1946        assert_eq!(
1947            lcs_pairs(&from, &to),
1948            vec![(0, 1)],
1949            "the shared 'A' must be paired even though it moves from index 0 to index 1"
1950        );
1951    }
1952
1953    #[test]
1954    fn scrambled_char_is_deterministic_and_varies_across_time() {
1955        let a = scrambled_char(4, 2, 0.31);
1956        let b = scrambled_char(4, 2, 0.31);
1957        assert_eq!(
1958            a, b,
1959            "the same seed/index/time must always scramble to the same glyph"
1960        );
1961
1962        let distinct: std::collections::HashSet<char> = (0..30)
1963            .map(|i| scrambled_char(4, 2, i as f64 * 0.05))
1964            .collect();
1965        assert!(
1966            distinct.len() > 1,
1967            "the scramble must vary as time advances, not freeze on one glyph"
1968        );
1969    }
1970
1971    #[test]
1972    fn morph_looks_different_from_a_hard_cut_mid_transition() {
1973        let morphed = morphing_text(
1974            "AX",
1975            "YA",
1976            Some(TextMorphConfig {
1977                duration: 1.0,
1978                unmatched: TextMorphUnmatched::Fade,
1979                seed: 0,
1980            }),
1981        );
1982        let cut = morphing_text("AX", "YA", None);
1983
1984        assert_ne!(
1985            render_plain(&morphed, 1.5),
1986            render_plain(&cut, 1.5),
1987            "50% through the morph, the frame must differ from an immediate hard cut to the \
1988             final label — the matched letter should still be travelling and the unmatched \
1989             letters should still be fading, not already fully settled"
1990        );
1991    }
1992
1993    #[test]
1994    fn morph_keeps_progressing_between_two_instants_in_the_window() {
1995        let morphed = morphing_text(
1996            "AX",
1997            "YA",
1998            Some(TextMorphConfig {
1999                duration: 1.0,
2000                unmatched: TextMorphUnmatched::Fade,
2001                seed: 0,
2002            }),
2003        );
2004
2005        assert_ne!(
2006            render_plain(&morphed, 1.1),
2007            render_plain(&morphed, 1.9),
2008            "the morph must keep changing across its window, not snap to one position and hold"
2009        );
2010    }
2011
2012    #[test]
2013    fn scramble_mode_paints_a_glyph_other_than_the_target_before_settling() {
2014        let seed = 3u32;
2015        let idx = 0usize;
2016        let visibly_faded_in_but_before_the_settle_threshold = 20..60;
2017        let sample_time = visibly_faded_in_but_before_the_settle_threshold
2018            .map(|i| 1.0 + i as f64 * 0.01)
2019            .find(|&t| scrambled_char(seed, idx, t) != 'B')
2020            .expect("at least one sampled instant must scramble to something other than 'B'");
2021
2022        let fade = morphing_text(
2023            "A",
2024            "B",
2025            Some(TextMorphConfig {
2026                duration: 1.0,
2027                unmatched: TextMorphUnmatched::Fade,
2028                seed,
2029            }),
2030        );
2031        let scramble = morphing_text(
2032            "A",
2033            "B",
2034            Some(TextMorphConfig {
2035                duration: 1.0,
2036                unmatched: TextMorphUnmatched::Scramble,
2037                seed,
2038            }),
2039        );
2040
2041        const W: i32 = 400;
2042        const H: i32 = 200;
2043        let ctx = test_ctx();
2044        let props = AnimatedProperties::default();
2045
2046        let render = |text: &Text| -> Vec<u8> {
2047            let mut surface =
2048                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
2049            text.paint(surface.canvas(), W as f32, None, sample_time, &props, &ctx)
2050                .expect("paint succeeds");
2051            alpha_grid(&mut surface, W, H)
2052        };
2053
2054        let fade_grid = render(&fade);
2055        let scramble_grid = render(&scramble);
2056
2057        assert_ne!(
2058            fade_grid, scramble_grid,
2059            "at t={sample_time}, `unmatched: scramble` must paint a different glyph than \
2060             `unmatched: fade` — scramble is supposed to cycle through placeholder characters \
2061             instead of just fading the real one in"
2062        );
2063    }
2064
2065    #[test]
2066    fn morph_settles_on_a_plain_cut_once_the_window_has_passed() {
2067        let morphed = morphing_text(
2068            "AX",
2069            "YA",
2070            Some(TextMorphConfig {
2071                duration: 1.0,
2072                unmatched: TextMorphUnmatched::Fade,
2073                seed: 0,
2074            }),
2075        );
2076        let cut = morphing_text("AX", "YA", None);
2077
2078        assert_eq!(
2079            render_plain(&morphed, 2.0),
2080            render_plain(&cut, 2.0),
2081            "once the morph window has passed, the frame must match a plain cut to the final label"
2082        );
2083    }
2084
2085    fn painted_width(letter_spacing: f32) -> usize {
2086        let mut text = make_text("IIIII", None);
2087        text.style.font_size = Some(Length::Px(40.0));
2088        let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap();
2089        let props = AnimatedProperties {
2090            letter_spacing,
2091            ..Default::default()
2092        };
2093        let _ = text.paint(surface.canvas(), 600.0, None, 0.0, &props, &test_ctx());
2094
2095        let info = skia_safe::ImageInfo::new(
2096            (600, 120),
2097            skia_safe::ColorType::RGBA8888,
2098            skia_safe::AlphaType::Unpremul,
2099            None,
2100        );
2101        let mut buf = vec![0u8; 600 * 120 * 4];
2102        surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0));
2103
2104        let mut lo = usize::MAX;
2105        let mut hi = 0usize;
2106        for y in 0..120 {
2107            for x in 0..600 {
2108                if buf[(y * 600 + x) * 4 + 3] > 60 {
2109                    lo = lo.min(x);
2110                    hi = hi.max(x);
2111                }
2112            }
2113        }
2114        if lo == usize::MAX {
2115            0
2116        } else {
2117            hi - lo
2118        }
2119    }
2120
2121    #[test]
2122    fn an_animated_letter_spacing_widens_the_painted_run() {
2123        let tight = painted_width(0.0);
2124        let loose = painted_width(16.0);
2125        assert!(tight > 0, "sanity: the tight run paints something");
2126        assert!(
2127            loose > tight + 40,
2128            "a keyframed letter_spacing must move the glyphs apart: tight={tight}px, \
2129             loose={loose}px over five glyphs at 16px extra each"
2130        );
2131    }
2132
2133    #[test]
2134    fn an_absent_letter_spacing_leaves_the_declared_one_alone() {
2135        let declared = {
2136            let mut text = make_text("IIIII", None);
2137            text.style.font_size = Some(Length::Px(40.0));
2138            text.style.letter_spacing = Some(Length::Px(16.0));
2139            let mut surface = skia_safe::surfaces::raster_n32_premul((600, 120)).unwrap();
2140            let _ = text.paint(
2141                surface.canvas(),
2142                600.0,
2143                None,
2144                0.0,
2145                &AnimatedProperties::default(),
2146                &test_ctx(),
2147            );
2148            let info = skia_safe::ImageInfo::new(
2149                (600, 120),
2150                skia_safe::ColorType::RGBA8888,
2151                skia_safe::AlphaType::Unpremul,
2152                None,
2153            );
2154            let mut buf = vec![0u8; 600 * 120 * 4];
2155            surface.read_pixels(&info, &mut buf, 600 * 4, (0, 0));
2156            buf
2157        };
2158        assert!(
2159            declared.iter().any(|b| *b > 60),
2160            "with no animated value, style.letter-spacing must still be honoured — the \
2161             animated field defaults to NaN precisely so absent means absent"
2162        );
2163    }
2164
2165    fn render_pipeline(
2166        component_json: serde_json::Value,
2167        box_x: f32,
2168        box_y: f32,
2169        w: i32,
2170        h: i32,
2171        time: f64,
2172        scene_duration: f64,
2173    ) -> Vec<u8> {
2174        use crate::box_builder::{build_scene_with_anim, BuildAnimationCtx};
2175        use crate::legacy_dispatch::LegacyPaintDispatcher;
2176        use crate::{ChildComponent, Component, PositionMode};
2177        use rustmotion_core::css::taffy_bridge::ConversionContext;
2178        use rustmotion_core::engine::layout_pass::run_layout;
2179        use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
2180
2181        let component: Component = serde_json::from_value(component_json).expect("deserialize");
2182        let child = ChildComponent {
2183            id: None,
2184            component,
2185            position: Some(PositionMode::Absolute { x: box_x, y: box_y }),
2186            x: None,
2187            y: None,
2188            z_index: None,
2189            bleed: false,
2190        };
2191        let children = vec![child];
2192
2193        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
2194        let canvas = surface.canvas();
2195        canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0));
2196
2197        let built = build_scene_with_anim(
2198            &children,
2199            (w as f32, h as f32),
2200            BuildAnimationCtx {
2201                time,
2202                scenario_time: time,
2203                scene_duration,
2204                fps: 30,
2205            },
2206        );
2207        let layout = run_layout(
2208            &built.root,
2209            (w as f32, h as f32),
2210            &ConversionContext::default(),
2211        );
2212        let dispatcher = LegacyPaintDispatcher::for_scene(&built);
2213        let frame = PaintFrame {
2214            light: Default::default(),
2215            time,
2216            scenario_time: time,
2217            frame_index: (time * 30.0) as u32,
2218            fps: 30,
2219            video_width: w as u32,
2220            video_height: h as u32,
2221            scene_duration,
2222            camera: None,
2223        };
2224        paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
2225
2226        let row_bytes = w as usize * 4;
2227        let mut pixels = vec![0u8; row_bytes * h as usize];
2228        let info = skia_safe::ImageInfo::new(
2229            (w, h),
2230            skia_safe::ColorType::RGBA8888,
2231            skia_safe::AlphaType::Premul,
2232            None,
2233        );
2234        surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
2235        pixels
2236    }
2237
2238    fn ink_x_range(pixels: &[u8], w: i32, h: i32) -> Option<(i32, i32)> {
2239        let mut lo = None;
2240        let mut hi = None;
2241        for y in 0..h {
2242            for x in 0..w {
2243                let idx = ((y * w + x) * 4 + 3) as usize;
2244                if pixels[idx] > 0 {
2245                    lo = Some(lo.map_or(x, |m: i32| m.min(x)));
2246                    hi = Some(hi.map_or(x, |m: i32| m.max(x)));
2247                }
2248            }
2249        }
2250        lo.zip(hi)
2251    }
2252
2253    #[test]
2254    fn rotate_from_tilts_a_unit_and_widens_its_footprint_mid_animation() {
2255        const W: i32 = 400;
2256        const H: i32 = 220;
2257
2258        let json_for = |rotate_from: Option<f64>| {
2259            let mut anim = serde_json::json!({
2260                "name": "char_fade_in",
2261                "granularity": "char",
2262                "duration": 1.0,
2263                "stagger": 0.0,
2264            });
2265            if let Some(r) = rotate_from {
2266                anim["rotate_from"] = serde_json::json!(r);
2267            }
2268            serde_json::json!({
2269                "type": "text",
2270                "content": "I",
2271                "style": {
2272                    "width": 300, "height": 200,
2273                    "font-size": 120,
2274                    "color": "#ffffff",
2275                    "white-space": "nowrap",
2276                    "animation": [anim]
2277                }
2278            })
2279        };
2280
2281        let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0);
2282        let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0);
2283
2284        let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2285        let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink");
2286
2287        let plain_width = plain_hi - plain_lo;
2288        let tilted_width = tilted_hi - tilted_lo;
2289
2290        assert!(
2291            tilted_width > plain_width + 20,
2292            "rotate_from must visibly tilt the unit mid-animation, widening its horizontal \
2293             footprint: plain width={plain_width}px, tilted width={tilted_width}px"
2294        );
2295    }
2296
2297    #[test]
2298    fn rotate_from_has_settled_to_upright_by_the_end_of_the_units_own_animation() {
2299        const W: i32 = 400;
2300        const H: i32 = 220;
2301
2302        let json_for = |rotate_from: Option<f64>| {
2303            let mut anim = serde_json::json!({
2304                "name": "char_fade_in",
2305                "granularity": "char",
2306                "duration": 1.0,
2307                "stagger": 0.0,
2308            });
2309            if let Some(r) = rotate_from {
2310                anim["rotate_from"] = serde_json::json!(r);
2311            }
2312            serde_json::json!({
2313                "type": "text",
2314                "content": "I",
2315                "style": {
2316                    "width": 300, "height": 200,
2317                    "font-size": 120,
2318                    "color": "#ffffff",
2319                    "white-space": "nowrap",
2320                    "animation": [anim]
2321                }
2322            })
2323        };
2324
2325        let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 1.0, 1.0);
2326        let settled = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 1.0, 1.0);
2327
2328        let plain_range = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2329        let settled_range = ink_x_range(&settled, W, H).expect("settled glyph paints ink");
2330
2331        assert_eq!(
2332            plain_range, settled_range,
2333            "rotate_from must straighten to upright by the end of the unit's own animation, \
2334             landing at the same horizontal footprint as no rotation at all"
2335        );
2336    }
2337
2338    #[test]
2339    fn scale_jitter_perturbs_a_units_born_size_and_settles_away_by_the_end() {
2340        const W: i32 = 300;
2341        const H: i32 = 200;
2342
2343        let json_for = |scale_jitter: Option<f64>| {
2344            let mut anim = serde_json::json!({
2345                "name": "char_fade_in",
2346                "granularity": "char",
2347                "duration": 1.0,
2348                "stagger": 0.0,
2349                "seed": 7,
2350            });
2351            if let Some(s) = scale_jitter {
2352                anim["scale_jitter"] = serde_json::json!(s);
2353            }
2354            serde_json::json!({
2355                "type": "text",
2356                "content": "O",
2357                "style": {
2358                    "width": 200, "height": 150,
2359                    "font-size": 100,
2360                    "color": "#ffffff",
2361                    "white-space": "nowrap",
2362                    "animation": [anim]
2363                }
2364            })
2365        };
2366
2367        let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0);
2368        let jittered_mid = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 0.5, 1.0);
2369        assert_ne!(
2370            plain_mid, jittered_mid,
2371            "scale_jitter must visibly perturb a unit's size mid-animation"
2372        );
2373
2374        let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0);
2375        let jittered_end = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 1.0, 1.0);
2376        let plain_range = ink_x_range(&plain_end, W, H).expect("plain glyph paints ink");
2377        let jittered_range = ink_x_range(&jittered_end, W, H).expect("jittered glyph paints ink");
2378        assert_eq!(
2379            plain_range, jittered_range,
2380            "scale_jitter must settle away by the end of the unit's own animation, landing at \
2381             the same footprint as no jitter at all — letters born at different sizes, then \
2382             aligning"
2383        );
2384    }
2385
2386    #[test]
2387    fn baseline_jitter_perturbs_a_units_born_baseline_and_settles_away_by_the_end() {
2388        const W: i32 = 300;
2389        const H: i32 = 200;
2390
2391        let json_for = |baseline_jitter: Option<f64>| {
2392            let mut anim = serde_json::json!({
2393                "name": "char_fade_in",
2394                "granularity": "char",
2395                "duration": 1.0,
2396                "stagger": 0.0,
2397                "seed": 11,
2398            });
2399            if let Some(b) = baseline_jitter {
2400                anim["baseline_jitter"] = serde_json::json!(b);
2401            }
2402            serde_json::json!({
2403                "type": "text",
2404                "content": "O",
2405                "style": {
2406                    "width": 200, "height": 150,
2407                    "font-size": 100,
2408                    "color": "#ffffff",
2409                    "white-space": "nowrap",
2410                    "animation": [anim]
2411                }
2412            })
2413        };
2414
2415        let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0);
2416        let jittered_mid = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 0.5, 1.0);
2417        assert_ne!(
2418            plain_mid, jittered_mid,
2419            "baseline_jitter must visibly perturb a unit's baseline mid-animation"
2420        );
2421
2422        let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0);
2423        let jittered_end = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 1.0, 1.0);
2424        assert_eq!(
2425            plain_end, jittered_end,
2426            "baseline_jitter must settle away by the end of the unit's own animation, landing \
2427             on the same laid-out baseline as no jitter at all"
2428        );
2429    }
2430
2431    #[test]
2432    fn reflow_keeps_a_centered_line_centred_while_it_is_being_written() {
2433        const W: i32 = 900;
2434        const H: i32 = 200;
2435        const BOX_X: f32 = 0.0;
2436        const BOX_W: f32 = 700.0;
2437        let box_center = BOX_X + BOX_W / 2.0;
2438
2439        let json_for = |reflow: bool| {
2440            serde_json::json!({
2441                "type": "text",
2442                "content": "reflowing centered text",
2443                "style": {
2444                    "width": BOX_W, "height": 100,
2445                    "font-size": 48,
2446                    "color": "#ffffff",
2447                    "text-align": "center",
2448                    "white-space": "nowrap",
2449                    "animation": [{
2450                        "name": "char_fade_in",
2451                        "granularity": "char",
2452                        "duration": 0.05,
2453                        "stagger": 0.05,
2454                        "reflow": reflow,
2455                    }]
2456                }
2457            })
2458        };
2459
2460        let sample_time = 0.5;
2461        let no_reflow = render_pipeline(json_for(false), BOX_X, 40.0, W, H, sample_time, 2.0);
2462        let reflowed = render_pipeline(json_for(true), BOX_X, 40.0, W, H, sample_time, 2.0);
2463
2464        let (lo_a, hi_a) =
2465            ink_x_range(&no_reflow, W, H).expect("no-reflow must paint some ink by this time");
2466        let (lo_b, hi_b) =
2467            ink_x_range(&reflowed, W, H).expect("reflow must paint some ink by this time");
2468
2469        let center_no_reflow = (lo_a + hi_a) as f32 / 2.0;
2470        let center_reflow = (lo_b + hi_b) as f32 / 2.0;
2471
2472        assert!(
2473            (center_reflow - box_center).abs() < 20.0,
2474            "reflow must keep the growing centred line's ink centred on the box: box \
2475             center={box_center}, got ink center={center_reflow}"
2476        );
2477        assert!(
2478            center_no_reflow < box_center - 40.0,
2479            "control: without reflow, a partially revealed centred line must still read left \
2480             of the box center, since every unit reserves its final (fully revealed) position \
2481             from frame one and only the early units have appeared yet — box \
2482             center={box_center}, got ink center={center_no_reflow}"
2483        );
2484    }
2485
2486    #[test]
2487    fn rotate_from_also_reaches_gradient_text_through_the_shared_render_path() {
2488        const W: i32 = 400;
2489        const H: i32 = 220;
2490
2491        let json_for = |rotate_from: Option<f64>| {
2492            let mut anim = serde_json::json!({
2493                "name": "char_fade_in",
2494                "granularity": "char",
2495                "duration": 1.0,
2496                "stagger": 0.0,
2497            });
2498            if let Some(r) = rotate_from {
2499                anim["rotate_from"] = serde_json::json!(r);
2500            }
2501            serde_json::json!({
2502                "type": "gradient_text",
2503                "content": "I",
2504                "colors": ["#FF4FB0", "#4B5BFF"],
2505                "style": {
2506                    "width": 300, "height": 200,
2507                    "font-size": 120,
2508                    "white-space": "nowrap",
2509                    "animation": [anim]
2510                }
2511            })
2512        };
2513
2514        let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0);
2515        let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0);
2516
2517        let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink");
2518        let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink");
2519
2520        let plain_width = plain_hi - plain_lo;
2521        let tilted_width = tilted_hi - tilted_lo;
2522
2523        assert!(
2524            tilted_width > plain_width + 20,
2525            "rotate_from must also tilt a gradient_text unit through the shared \
2526             intrinsic::render_char_animation path, without any change to gradient_text.rs \
2527             itself: plain width={plain_width}px, tilted width={tilted_width}px"
2528        );
2529    }
2530}