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