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::engine::animator::ease;
7
8use rustmotion_core::css::style::{
9    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
10    TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace,
11};
12use rustmotion_core::css::CssStyle;
13use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation};
14use rustmotion_core::engine::layout_pass::BoxLayout;
15use rustmotion_core::engine::renderer::{
16    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
17    typeface_with_fallback, wrap_text_with_tracking,
18};
19use rustmotion_core::schema::{
20    CaretConfig, CaretShape, CharAnimPreset, FontStyleType, FontWeight, Stroke, TextAlign,
21    TextAnimGranularity, TextBackground, TextShadow, TextState, TextSwapConfig, TimelineStep,
22};
23use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
24
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct Text {
27    pub content: String,
28    #[serde(default)]
29    pub max_width: Option<f32>,
30    #[serde(flatten)]
31    pub timing: TimingConfig,
32    #[serde(default)]
33    pub style: CssStyle,
34    #[serde(default)]
35    pub timeline: Vec<TimelineStep>,
36    #[serde(default)]
37    pub stagger: Option<f32>,
38    #[serde(default, rename = "text-shadow")]
39    pub text_shadow: Option<TextShadow>,
40    #[serde(default)]
41    pub stroke: Option<Stroke>,
42    #[serde(default, rename = "text-background")]
43    pub text_background: Option<TextBackground>,
44    /// A caret pinned to the reveal head of a `typewriter` animation.
45    /// See [`CaretConfig`].
46    #[serde(default)]
47    pub caret: Option<CaretConfig>,
48    /// Later labels this text swaps to. See [`TextState`].
49    #[serde(default)]
50    pub states: Vec<TextState>,
51    /// How the crossing between `states` is animated. See [`TextSwapConfig`].
52    #[serde(default)]
53    pub swap: Option<TextSwapConfig>,
54}
55
56rustmotion_core::impl_traits!(Text {
57    Animatable => animation,
58    Timed => timing,
59    Styled => style,
60});
61
62/// Eased progress (0..1) of unit `idx` at `time`, honouring the config's
63/// deterministic jitter.
64fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 {
65    let unit_start = cfg.unit_start(idx);
66    let unit_end = unit_start + cfg.duration as f64;
67    let raw_t = if time <= unit_start {
68        0.0
69    } else if time >= unit_end {
70        1.0
71    } else {
72        (time - unit_start) / (unit_end - unit_start)
73    };
74    ease(raw_t, &cfg.easing) as f32
75}
76
77/// The paint a unit draws with at progress `t`: the base paint, tinted from
78/// `ink_from` towards the text's own colour when the config asks for it.
79///
80/// `None` means "use the base paint unchanged" — worth keeping distinct from
81/// a clone, since the caller may already be mutating its own copy.
82fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option<Paint> {
83    let from = cfg.ink_from.as_deref()?;
84    let start = paint_from_hex(from).color();
85    let end = paint.color();
86    let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8;
87    let mut p = paint.clone();
88    p.set_color(skia_safe::Color::from_argb(
89        end.a(),
90        lerp(start.r(), end.r()),
91        lerp(start.g(), end.g()),
92        lerp(start.b(), end.b()),
93    ));
94    Some(p)
95}
96
97/// Apply a text animation preset to a single unit (char or word).
98/// Returns the text draw position adjustments and paint modifications.
99fn apply_text_anim_preset(
100    canvas: &Canvas,
101    text: &str,
102    font: &Font,
103    emoji_font: &Option<Font>,
104    paint: &Paint,
105    cursor_x: f32,
106    line_y: f32,
107    unit_width: f32,
108    // The tracking the *cursor* was advanced with. Drawing at 0 while the
109    // advance carries a negative value makes the glyphs overrun their slot and
110    // swallow the inter-word space — visible only once the overrun approaches
111    // a space's width, i.e. at small sizes or long words.
112    letter_spacing: f32,
113    cfg: &ResolvedCharAnimation,
114    t: f32,
115    time: f64,
116    unit_idx: usize,
117    font_size: f32,
118) {
119    let preset = &cfg.preset;
120    let overshoot = cfg.overshoot;
121    let blur_radius = cfg.blur;
122    let center_x = cursor_x + unit_width / 2.0;
123    let center_y = line_y;
124
125    // `ink_from` and `scale_from` are cross-cutting: they compose with
126    // whatever the preset itself does rather than replacing it, so they are
127    // resolved once here instead of inside each arm.
128    let inked = ink_paint(cfg, paint, t);
129    let paint = inked.as_ref().unwrap_or(paint);
130    if let Some(from) = cfg.scale_from {
131        // The scale-driven presets own their scale curve outright; stacking a
132        // second one on top would fight it rather than compose with it.
133        if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) {
134            let s = from + (1.0 - from) * t.clamp(0.0, 1.0);
135            canvas.translate((center_x, center_y));
136            canvas.scale((s, s));
137            canvas.translate((-center_x, -center_y));
138        }
139    }
140
141    match preset {
142        CharAnimPreset::ScaleIn => {
143            // 0→(1+overshoot) at 70%, then settle to 1.0
144            let scale = if overshoot > 0.001 {
145                if t < 0.7 {
146                    let p = t / 0.7;
147                    p * (1.0 + overshoot)
148                } else {
149                    let p = (t - 0.7) / 0.3;
150                    (1.0 + overshoot) - overshoot * p
151                }
152            } else {
153                t
154            };
155            if scale < 0.001 {
156                return;
157            }
158            canvas.translate((center_x, center_y));
159            canvas.scale((scale, scale));
160            canvas.translate((-center_x, -center_y));
161            draw_text_with_fallback(
162                canvas,
163                text,
164                font,
165                emoji_font,
166                letter_spacing,
167                cursor_x,
168                line_y,
169                paint,
170            );
171        }
172        CharAnimPreset::FadeIn => {
173            let mut p = paint.clone();
174            p.set_alpha_f(t * paint.alpha_f());
175            draw_text_with_fallback(
176                canvas,
177                text,
178                font,
179                emoji_font,
180                letter_spacing,
181                cursor_x,
182                line_y,
183                &p,
184            );
185        }
186        CharAnimPreset::Wave => {
187            let wave_offset =
188                (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5);
189            let mut p = paint.clone();
190            p.set_alpha_f(t.min(1.0) * paint.alpha_f());
191            draw_text_with_fallback(
192                canvas,
193                text,
194                font,
195                emoji_font,
196                letter_spacing,
197                cursor_x,
198                line_y + wave_offset,
199                &p,
200            );
201        }
202        CharAnimPreset::Bounce => {
203            let peak = 1.0 + overshoot.max(0.3); // bounce always overshoots, min 0.3
204            let scale = if t < 0.5 {
205                t * 2.0 * peak
206            } else {
207                peak - (peak - 1.0) * ((t - 0.5) * 2.0)
208            };
209            let scale = scale.max(0.001);
210            canvas.translate((center_x, center_y));
211            canvas.scale((scale, scale));
212            canvas.translate((-center_x, -center_y));
213            draw_text_with_fallback(
214                canvas,
215                text,
216                font,
217                emoji_font,
218                letter_spacing,
219                cursor_x,
220                line_y,
221                paint,
222            );
223        }
224        CharAnimPreset::RotateIn => {
225            let angle = (1.0 - t) * -90.0;
226            let mut p = paint.clone();
227            p.set_alpha_f(t * paint.alpha_f());
228            canvas.translate((center_x, center_y));
229            canvas.rotate(angle, None);
230            canvas.translate((-center_x, -center_y));
231            draw_text_with_fallback(
232                canvas,
233                text,
234                font,
235                emoji_font,
236                letter_spacing,
237                cursor_x,
238                line_y,
239                &p,
240            );
241        }
242        CharAnimPreset::SlideUp => {
243            // Despite the name, the travel axis is `direction`'s to choose —
244            // `up` (the default) is what the preset has always done.
245            let travel = (1.0 - t) * font_size * 0.8 * cfg.distance;
246            let (dx, dy) = cfg.direction.offset(travel);
247            let mut p = paint.clone();
248            p.set_alpha_f(t * paint.alpha_f());
249            draw_text_with_fallback(
250                canvas,
251                text,
252                font,
253                emoji_font,
254                letter_spacing,
255                cursor_x + dx,
256                line_y + dy,
257                &p,
258            );
259        }
260        CharAnimPreset::BlurIn => {
261            // One continuous progress value `t` drives all three
262            // components at once (blur settle, upward drift, opacity
263            // ramp) rather than sequencing them as separate effects.
264            let tt = t.clamp(0.0, 1.0);
265            let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance;
266            let (dx, dy) = cfg.direction.offset(travel);
267            let sigma = ((1.0 - tt) * blur_radius).max(0.0);
268            let mut p = paint.clone();
269            p.set_alpha_f(tt * paint.alpha_f());
270            if sigma > 0.05 {
271                if let Some(filter) = skia_safe::image_filters::blur(
272                    (sigma, sigma),
273                    skia_safe::TileMode::Clamp,
274                    None,
275                    None,
276                ) {
277                    p.set_image_filter(filter);
278                }
279            }
280            draw_text_with_fallback(
281                canvas,
282                text,
283                font,
284                emoji_font,
285                letter_spacing,
286                cursor_x + dx,
287                line_y + dy,
288                &p,
289            );
290        }
291    }
292}
293
294/// Render text with per-character or per-word animation.
295fn render_char_animation(
296    canvas: &Canvas,
297    _content: &str,
298    font: &Font,
299    emoji_font: &Option<Font>,
300    paint: &Paint,
301    letter_spacing: f32,
302    align: TextAlign,
303    align_width: f32,
304    line_height_val: f32,
305    baseline_offset: f32,
306    lines: &[String],
307    char_anim: &ResolvedCharAnimation,
308    time: f64,
309) {
310    let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
311    let mut global_unit_idx = 0usize;
312
313    for (line_idx, line) in lines.iter().enumerate() {
314        if line.is_empty() {
315            continue;
316        }
317
318        let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
319        let line_x = match align {
320            TextAlign::Left => 0.0,
321            TextAlign::Center => (align_width - advance_width) / 2.0,
322            TextAlign::Right => align_width - advance_width,
323        };
324        let line_y = line_idx as f32 * line_height_val + baseline_offset;
325
326        if is_word_mode {
327            // Per-word animation: split line into words and spaces
328            let mut cursor_x = line_x;
329            let mut chars = line.chars().peekable();
330
331            while chars.peek().is_some() {
332                // Collect leading spaces
333                let mut spaces = String::new();
334                while let Some(&c) = chars.peek() {
335                    if c.is_whitespace() {
336                        spaces.push(c);
337                        chars.next();
338                    } else {
339                        break;
340                    }
341                }
342                if !spaces.is_empty() {
343                    let space_w =
344                        measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing);
345                    // Draw spaces without animation
346                    draw_text_with_fallback(
347                        canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint,
348                    );
349                    cursor_x += space_w;
350                }
351
352                // Collect the word
353                let mut word = String::new();
354                while let Some(&c) = chars.peek() {
355                    if c.is_whitespace() {
356                        break;
357                    }
358                    word.push(c);
359                    chars.next();
360                }
361                if word.is_empty() {
362                    continue;
363                }
364
365                let word_width =
366                    measure_text_with_fallback(&word, font, emoji_font, letter_spacing);
367
368                // Calculate animation progress for this word
369                let t = unit_progress(char_anim, global_unit_idx, time);
370
371                canvas.save();
372                apply_text_anim_preset(
373                    canvas,
374                    &word,
375                    font,
376                    emoji_font,
377                    paint,
378                    cursor_x,
379                    line_y,
380                    word_width,
381                    letter_spacing,
382                    char_anim,
383                    t,
384                    time,
385                    global_unit_idx,
386                    font.size(),
387                );
388                canvas.restore();
389
390                cursor_x += word_width;
391                global_unit_idx += 1;
392            }
393        } else {
394            // Per-character animation (original behavior)
395            let mut cursor_x = line_x;
396            for ch in line.chars() {
397                let ch_str = ch.to_string();
398                let (ch_width, _) = font.measure_str(&ch_str, None);
399                let ch_width = ch_width + letter_spacing;
400
401                let t = unit_progress(char_anim, global_unit_idx, time);
402
403                canvas.save();
404                apply_text_anim_preset(
405                    canvas,
406                    &ch_str,
407                    font,
408                    emoji_font,
409                    paint,
410                    cursor_x,
411                    line_y,
412                    ch_width,
413                    // Single characters carry no internal tracking, so this is
414                    // 0 by construction — passed explicitly rather than left
415                    // to a default, since the word path above needs the real
416                    // value and the two must not drift apart.
417                    0.0,
418                    char_anim,
419                    t,
420                    time,
421                    global_unit_idx,
422                    font.size(),
423                );
424                canvas.restore();
425
426                cursor_x += ch_width;
427                global_unit_idx += 1;
428            }
429        }
430    }
431}
432
433impl Text {
434    /// Every label this text can display, in order — `content` followed by
435    /// each state's.
436    ///
437    /// Used for measurement: a box sized for the first label alone would be
438    /// overrun the moment the text swapped to a longer one, and the geometry
439    /// validator would have signed off on it.
440    pub fn all_labels(&self) -> impl Iterator<Item = &str> {
441        std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str()))
442    }
443
444    /// The label showing at `time`: the last state whose `at` has passed, or
445    /// `content` before any of them.
446    fn label_at(&self, time: f64) -> &str {
447        self.states
448            .iter()
449            .rfind(|s| s.at <= time)
450            .map(|s| s.content.as_str())
451            .unwrap_or(&self.content)
452    }
453
454    /// The swap in progress at `time`, if any.
455    ///
456    /// `None` when the text declares no `swap` config, even if it declares
457    /// `states`: the labels then simply cut over at each `at`, which is a
458    /// legitimate (if abrupt) choice and the one the field's absence asks
459    /// for.
460    fn active_swap(&self, time: f64) -> Option<ActiveSwap> {
461        let cfg = self.swap.as_ref()?;
462        if cfg.duration <= 0.0 {
463            return None;
464        }
465        let (idx, state) = self
466            .states
467            .iter()
468            .enumerate()
469            .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?;
470        let from = if idx == 0 {
471            self.content.clone()
472        } else {
473            self.states[idx - 1].content.clone()
474        };
475        Some(ActiveSwap {
476            from,
477            to: state.content.clone(),
478            progress: ((time - state.at) / cfg.duration) as f32,
479            distance: cfg.distance,
480            blur: cfg.blur,
481        })
482    }
483
484    fn paint(
485        &self,
486        canvas: &Canvas,
487        layout_width: f32,
488        content_height: Option<f32>,
489        time: f64,
490        props: &AnimatedProperties,
491        ctx: &PaintCtx,
492    ) -> Result<()> {
493        // `font-size` itself, plus `letter-spacing`/`line-height`'s `em`/`%`
494        // (relative to this element's *own*, just-resolved font-size) are
495        // now all resolved together against a real `LengthContext` (real
496        // viewport dims from `ctx`) via `typography_px_ctx`, which re-derives
497        // the right base between the two steps (lot B, wave S — this used to
498        // stop at the context-free `font_size_px_or`, so `rem`/`vw`/`vh`
499        // font-size silently fell back to 0px with only a loud warning).
500        //
501        // `em`/`%` *on `font-size` itself* are the one case this still
502        // doesn't get right: per CSS they're relative to the *parent's*
503        // actual computed font-size, but `cascade.rs` inherits `font-size`
504        // down the tree as a raw, unresolved `Length`, not a resolved px
505        // value (see the module note on `CssStyle::font_size_px_ctx`) — no
506        // caller here can supply the real cascaded value, so `base_ctx`
507        // below uses the CSS root default (16px) as the best available
508        // stand-in. `rem` (always relative to a fixed root, not a per-
509        // ancestor chain) and `vw`/`vh` (relative to the real viewport,
510        // available here via `ctx`) do not have this problem.
511        let base_ctx = crate::intrinsic::font_size_ctx(
512            ctx.video_width as f32,
513            ctx.video_height as f32,
514            layout_width.max(0.0),
515        );
516        let (mut font_size, mut letter_spacing, mut line_height_val) =
517            self.style.typography_px_ctx(&base_ctx, 48.0);
518        // Animated color (timeline style-state transitions) overrides the
519        // static style color.
520        let color = props
521            .color
522            .as_deref()
523            .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
524        let font_family = self.style.font_family_or("Inter");
525        let font_weight = match &self.style.font_weight {
526            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
527                FontWeight::Bold
528            }
529            Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
530            Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
531            _ => FontWeight::Normal,
532        };
533        let font_style_type = match self.style.font_style {
534            Some(CssFontStyle::Italic) => FontStyleType::Italic,
535            Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
536            _ => FontStyleType::Normal,
537        };
538        let align = match self.style.text_align {
539            Some(CssTextAlign::Center) => TextAlign::Center,
540            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
541            _ => TextAlign::Left,
542        };
543
544        let slant = match font_style_type {
545            FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
546            FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
547            FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
548        };
549        let weight = match font_weight {
550            FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
551            FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
552            FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
553        };
554        let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
555
556        let typeface = typeface_with_fallback(font_family, skia_font_style)?;
557
558        // The box's own resolved width — computed here (ahead of the
559        // `white-space: nowrap` wrap decision below) because `text-autofit`
560        // needs it as its width-fit target *regardless* of nowrap: a nowrap
561        // line still shrinks to fit this box once `text-autofit` is on (see
562        // `CssStyle::text_autofit`'s doc comment), it just never breaks
563        // across lines while doing it.
564        let nowrap = matches!(
565            self.style.white_space,
566            Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
567        );
568        let box_width = if layout_width.is_finite() && layout_width > 0.0 {
569            Some(match self.max_width {
570                Some(mw) => mw.min(layout_width),
571                None => layout_width,
572            })
573        } else {
574            self.max_width
575        };
576
577        // `text-autofit`: resolve the *actual* font-size/letter-spacing/
578        // line-height used for the rest of this function — the identical
579        // computation `TextIntrinsic::measure` runs for this same node (see
580        // `resolve_text_autofit`'s doc comment for the parity argument).
581        // Must happen before `type_ctx`/the final `Font` are built below so
582        // both reflect the resolved (possibly shrunk) size, not the
583        // requested one.
584        if matches!(self.style.text_autofit, Some(true)) {
585            let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite());
586            let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit(
587                &self.content,
588                &typeface,
589                font_size,
590                letter_spacing,
591                line_height_val,
592                !nowrap,
593                box_width,
594                declared_height,
595            );
596            font_size = fs;
597            letter_spacing = ls;
598            line_height_val = lh;
599        }
600
601        // This element's *own* resolved font-size as the `em`/`%` base —
602        // needed below for `text-shadow` (its blur/offset are relative to
603        // the shadow owner's own font-size, same rule as letter-spacing/
604        // line-height, not the parent-proxy `base_ctx` above). Built from
605        // the post-autofit `font_size` so a shrunk headline's shadow shrinks
606        // with it instead of using the pre-shrink em/% base.
607        let type_ctx = rustmotion_core::css::units::LengthContext {
608            font_size,
609            ..base_ctx
610        };
611
612        let font = Font::from_typeface(typeface, font_size);
613        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
614        let mut paint = paint_from_hex(color);
615        paint.set_alpha_f(1.0);
616
617        // Use the box width as the wrapping constraint (computed above, as
618        // `box_width`, ahead of the autofit step). M1: `white-space:
619        // nowrap|pre` disables wrapping entirely — the line may then exceed
620        // `layout_width`. That's the point: it makes the property mean
621        // something, and it's exactly the condition the geometry
622        // validator's `unwrappable_text_overflow` check (which re-measures
623        // via `TextIntrinsic::from_text`, now wrap-aware too) assumes the
624        // renderer can produce.
625        let wrap_width = if nowrap { None } else { box_width };
626
627        // Apply typewriter effect: limit visible characters based on animation progress
628        let label = self.label_at(time);
629        let content = if props.visible_chars_progress >= 0.0 {
630            let chars: Vec<char> = label.chars().collect();
631            let visible = (props.visible_chars_progress * chars.len() as f32).round() as usize;
632            let visible = visible.min(chars.len());
633            if visible == 0 && self.caret.is_none() {
634                return Ok(());
635            }
636            // With a caret, an empty reveal still has something to paint: the
637            // caret itself, sitting where the first character is about to
638            // appear. Bailing out here would make it pop into existence
639            // alongside that character instead of waiting for it.
640            chars[..visible].iter().collect::<String>()
641        } else {
642            label.to_string()
643        };
644
645        // Tracking-aware wrap (issue #125 §1): the fit test now measures
646        // with this element's real `letter_spacing`, matching the
647        // measurements below (`align_width`, per-line `advance_width`) that
648        // already used it — the box this wraps for and the pixels painted
649        // into it now agree.
650        let lines =
651            wrap_text_with_tracking(&content, &font, &emoji_font, wrap_width, letter_spacing);
652        let (_, metrics) = font.metrics();
653        let ascent = -metrics.ascent;
654        let descent = metrics.descent;
655        let baseline_offset = (line_height_val + ascent - descent) / 2.0;
656
657        // Prepare optional shadow and stroke paints. The component-level
658        // `text-shadow` field wins; otherwise the CSS `style.text-shadow`
659        // list is bridged (it used to be parsed and silently dropped).
660        let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
661            vec![s.clone()]
662        } else if let Some(list) = &self.style.text_shadow {
663            list.iter().map(|s| s.to_schema(&type_ctx)).collect()
664        } else {
665            Vec::new()
666        };
667        let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows
668            .iter()
669            .map(|shadow| {
670                let mut p = paint_from_hex(&shadow.color);
671                if shadow.blur > 0.01 {
672                    if let Some(filter) = skia_safe::image_filters::blur(
673                        (shadow.blur, shadow.blur),
674                        skia_safe::TileMode::Clamp,
675                        None,
676                        None,
677                    ) {
678                        p.set_image_filter(filter);
679                    }
680                }
681                (p, shadow.offset_x, shadow.offset_y)
682            })
683            .collect();
684
685        let stroke_paint = self.stroke.as_ref().map(|stroke| {
686            let mut p = paint_from_hex(&stroke.color);
687            p.set_style(PaintStyle::Stroke);
688            p.set_stroke_width(stroke.width);
689            p
690        });
691
692        // Compute alignment width
693        let align_width = if layout_width.is_finite() && layout_width > 0.0 {
694            layout_width
695        } else {
696            let mut max_w = 0.0f32;
697            for line in &lines {
698                let w = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
699                max_w = max_w.max(w);
700            }
701            max_w
702        };
703
704        // Per-character animation mode (via style.animation char_* presets).
705        // All seven presets — `char_blur_in` included since it was routed
706        // through `extract_effects` like its siblings — arrive here already
707        // resolved, with container-level stagger folded into `delay`.
708        if let Some(ref resolved) = props.char_animation {
709            render_char_animation(
710                canvas,
711                &content,
712                &font,
713                &emoji_font,
714                &paint,
715                letter_spacing,
716                align,
717                align_width,
718                line_height_val,
719                baseline_offset,
720                &lines,
721                resolved,
722                time,
723            );
724            return Ok(());
725        }
726
727        // A state swap has two labels on screen at once, each with its own
728        // travel, blur and opacity. Everything above — font, alignment,
729        // metrics, decorations — is shared between them; only the label text
730        // and the motion differ.
731        if let Some(swap) = self.active_swap(time) {
732            for (label, offset_y, blur, alpha) in swap.labels() {
733                let label_lines =
734                    wrap_text_with_tracking(&label, &font, &emoji_font, wrap_width, letter_spacing);
735                let mut p = paint.clone();
736                p.set_alpha_f(alpha * paint.alpha_f());
737                if blur > 0.05 {
738                    if let Some(filter) = skia_safe::image_filters::blur(
739                        (blur, blur),
740                        skia_safe::TileMode::Clamp,
741                        None,
742                        None,
743                    ) {
744                        p.set_image_filter(filter);
745                    }
746                }
747                draw_text_lines(
748                    canvas,
749                    &label_lines,
750                    &font,
751                    &emoji_font,
752                    &p,
753                    &shadow_paints,
754                    stroke_paint.as_ref(),
755                    self.text_background.as_ref(),
756                    letter_spacing,
757                    &align,
758                    align_width,
759                    line_height_val,
760                    baseline_offset,
761                    offset_y,
762                );
763            }
764            return Ok(());
765        }
766
767        draw_text_lines(
768            canvas,
769            &lines,
770            &font,
771            &emoji_font,
772            &paint,
773            &shadow_paints,
774            stroke_paint.as_ref(),
775            self.text_background.as_ref(),
776            letter_spacing,
777            &align,
778            align_width,
779            line_height_val,
780            baseline_offset,
781            0.0,
782        );
783
784        // The caret rides the reveal head: the end of the last line that has
785        // been revealed so far, which is where the next character will land.
786        if let Some(caret) = &self.caret {
787            let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0;
788            if !(done && caret.hide_when_done) {
789                let last = lines.len().saturating_sub(1);
790                let line = lines.last().map(String::as_str).unwrap_or("");
791                let advance_width =
792                    measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
793                let x = match align {
794                    TextAlign::Left => 0.0,
795                    TextAlign::Center => (align_width - advance_width) / 2.0,
796                    TextAlign::Right => align_width - advance_width,
797                };
798                let baseline = last as f32 * line_height_val + baseline_offset;
799                draw_caret(
800                    canvas,
801                    caret,
802                    x + advance_width,
803                    baseline,
804                    &font,
805                    &paint,
806                    time,
807                );
808            }
809        }
810
811        Ok(())
812    }
813}
814
815/// Draw already-wrapped `lines` with their background, shadows, stroke and
816/// fill, shifted down by `offset_y`.
817///
818/// Shared by the plain draw and by each half of a state swap, so a swapping
819/// label keeps the decorations (`text-background`, `text-shadow`, `stroke`)
820/// the same text has when it is not swapping.
821#[allow(clippy::too_many_arguments)]
822fn draw_text_lines(
823    canvas: &Canvas,
824    lines: &[String],
825    font: &Font,
826    emoji_font: &Option<Font>,
827    paint: &Paint,
828    shadow_paints: &[(Paint, f32, f32)],
829    stroke_paint: Option<&Paint>,
830    text_background: Option<&TextBackground>,
831    letter_spacing: f32,
832    align: &TextAlign,
833    align_width: f32,
834    line_height_val: f32,
835    baseline_offset: f32,
836    offset_y: f32,
837) {
838    for (i, line) in lines.iter().enumerate() {
839        if line.is_empty() {
840            continue;
841        }
842
843        let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing);
844
845        let x = match align {
846            TextAlign::Left => 0.0,
847            TextAlign::Center => (align_width - advance_width) / 2.0,
848            TextAlign::Right => align_width - advance_width,
849        };
850        let y = i as f32 * line_height_val + baseline_offset + offset_y;
851
852        // Draw background highlight behind text
853        if let Some(bg) = text_background {
854            let bg_paint = paint_from_hex(&bg.color);
855            let (_, font_rect) = font.measure_str(line, None);
856            let bg_rect = Rect::from_xywh(
857                x - bg.padding + font_rect.left,
858                y + font_rect.top - bg.padding / 2.0,
859                advance_width + bg.padding * 2.0,
860                -font_rect.top + font_rect.bottom + bg.padding,
861            );
862            if bg.corner_radius > 0.0 {
863                let rrect =
864                    skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius);
865                canvas.draw_rrect(rrect, &bg_paint);
866            } else {
867                canvas.draw_rect(bg_rect, &bg_paint);
868            }
869        }
870
871        // Draw shadows — reverse order so the first CSS shadow ends on top.
872        for (sp, ox, oy) in shadow_paints.iter().rev() {
873            draw_text_with_fallback(
874                canvas,
875                line,
876                font,
877                emoji_font,
878                letter_spacing,
879                x + ox,
880                y + oy,
881                sp,
882            );
883        }
884
885        if let Some(sp) = stroke_paint {
886            draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp);
887        }
888        draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint);
889    }
890}
891
892/// A state swap in progress: the label leaving, the label arriving, and how
893/// far through the crossing we are.
894struct ActiveSwap {
895    from: String,
896    to: String,
897    progress: f32,
898    distance: f32,
899    blur: f32,
900}
901
902impl ActiveSwap {
903    /// `(label, offset_y, blur_sigma, alpha)` for each of the two labels.
904    ///
905    /// The outgoing label leaves upwards and the incoming one arrives from
906    /// below, so the pair reads as one value moving up a slot rather than as
907    /// two labels passing each other.
908    fn labels(&self) -> [(String, f32, f32, f32); 2] {
909        let p = self.progress.clamp(0.0, 1.0);
910        [
911            (
912                self.from.clone(),
913                -self.distance * p,
914                self.blur * p,
915                1.0 - p,
916            ),
917            (
918                self.to.clone(),
919                self.distance * (1.0 - p),
920                self.blur * (1.0 - p),
921                p,
922            ),
923        ]
924    }
925}
926
927/// Paint a caret whose left edge sits at `x`, aligned to the text `baseline`.
928fn draw_caret(
929    canvas: &Canvas,
930    cfg: &CaretConfig,
931    x: f32,
932    baseline: f32,
933    font: &Font,
934    text_paint: &Paint,
935    time: f64,
936) {
937    // A blink is a square wave over one full period, so `blink: 1.0` reads as
938    // "on for half a second, off for half a second" rather than as a rate
939    // nobody can predict from the number.
940    if cfg.blink > 0.0 {
941        let phase = (time / cfg.blink as f64).rem_euclid(1.0);
942        if phase >= 0.5 {
943            return;
944        }
945    }
946
947    let (_, metrics) = font.metrics();
948    let ascent = -metrics.ascent;
949    let descent = metrics.descent;
950    let size = font.size();
951
952    let (width, gap) = match cfg.shape {
953        // Proportional to the type size: a 3px rule that reads as a caret at
954        // 24px is a hairline at 120px.
955        CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05),
956        // Roughly one character cell, the terminal look.
957        CaretShape::Block => (size * 0.55, size * 0.04),
958    };
959
960    let mut paint = match &cfg.color {
961        Some(hex) => paint_from_hex(hex),
962        None => text_paint.clone(),
963    };
964    paint.set_style(PaintStyle::Fill);
965    paint.set_anti_alias(true);
966    // A caret is a solid mark, not a ghost: it must not inherit a stroke or
967    // image filter the text set up for itself.
968    paint.set_image_filter(None);
969
970    canvas.draw_rect(
971        Rect::from_xywh(x + gap, baseline - ascent, width, ascent + descent),
972        &paint,
973    );
974}
975
976impl Painter for Text {
977    fn paint_content(
978        &self,
979        canvas: &Canvas,
980        layout: &BoxLayout,
981        props: &AnimatedProperties,
982        ctx: &PaintCtx,
983    ) {
984        // `text-autofit`'s height-fit target: the box's own content-box
985        // height, exactly as taffy resolved it for this frame's layout —
986        // `None` when it isn't a positive, finite number (an intrinsically-
987        // sized box that grew to fit its content, i.e. nothing to shrink
988        // for on this axis; see `CssStyle::text_autofit`'s doc comment).
989        let (_, _, _, content_height) = layout.content_box();
990        let content_height =
991            (content_height > 0.0 && content_height.is_finite()).then_some(content_height);
992        let _ = self.paint(canvas, layout.width, content_height, ctx.time, props, ctx);
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use crate::intrinsic::TextIntrinsic;
1000    use rustmotion_core::css::style::CssStyle;
1001    use rustmotion_core::css::Length;
1002    use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
1003    use rustmotion_core::schema::{
1004        AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection,
1005    };
1006
1007    fn make_text(content: &str, white_space: Option<CssWhiteSpace>) -> Text {
1008        Text {
1009            content: content.into(),
1010            max_width: None,
1011            timing: Default::default(),
1012            style: CssStyle {
1013                font_size: Some(Length::Px(28.0)),
1014                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1015                white_space,
1016                ..Default::default()
1017            },
1018            timeline: Vec::new(),
1019            stagger: None,
1020            text_shadow: None,
1021            stroke: None,
1022            text_background: None,
1023            caret: None,
1024            states: Vec::new(),
1025            swap: None,
1026        }
1027    }
1028
1029    fn test_ctx() -> PaintCtx {
1030        PaintCtx {
1031            time: 0.0,
1032            scenario_time: 0.0,
1033            scene_duration: 1.0,
1034            frame_index: 0,
1035            fps: 30,
1036            video_width: 600,
1037            video_height: 200,
1038            stagger_offset: 0.0,
1039        }
1040    }
1041
1042    /// Reads back the full alpha channel of the surface as a `width × height`
1043    /// row-major byte grid.
1044    fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec<u8> {
1045        let snapshot = surface.image_snapshot();
1046        let info = skia_safe::ImageInfo::new(
1047            (width, height),
1048            skia_safe::ColorType::RGBA8888,
1049            skia_safe::AlphaType::Premul,
1050            None,
1051        );
1052        let mut buf = vec![0u8; (width * height * 4) as usize];
1053        let ok = snapshot.read_pixels(
1054            &info,
1055            &mut buf,
1056            (width * 4) as usize,
1057            skia_safe::IPoint::new(0, 0),
1058            skia_safe::image::CachingHint::Disallow,
1059        );
1060        assert!(ok, "pixel read should succeed");
1061        (0..(width * height) as usize)
1062            .map(|i| buf[i * 4 + 3])
1063            .collect()
1064    }
1065
1066    /// Does any pixel in `[x0, x1) × [y0, y1)` have non-zero alpha? Scanning
1067    /// a region rather than a single exact pixel avoids flaking on the gap
1068    /// between two glyphs or on a space character.
1069    fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool {
1070        for y in y0..y1 {
1071            for x in x0..x1 {
1072                if grid[(y * surface_width + x) as usize] > 0 {
1073                    return true;
1074                }
1075            }
1076        }
1077        false
1078    }
1079
1080    #[test]
1081    fn nowrap_paints_a_single_line_past_the_layout_width() {
1082        // M1 render-level proof: a `white-space: nowrap` line stays on one
1083        // line and its glyphs visibly extend past `layout_width` — the box
1084        // it was allocated.
1085        let text = make_text(
1086            "the quick brown fox jumps over the lazy dog",
1087            Some(CssWhiteSpace::Nowrap),
1088        );
1089        const W: i32 = 600;
1090        const H: i32 = 200;
1091        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1092        let canvas = surface.canvas();
1093        let ctx = test_ctx();
1094        let props = AnimatedProperties::default();
1095        text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
1096            .expect("paint succeeds");
1097        let grid = alpha_grid(&mut surface, W, H);
1098
1099        // Far past the 80px box, on the first line's height band: nowrap
1100        // must have painted ink there (the line didn't break at 80px).
1101        assert!(
1102            has_ink_in(&grid, W, 300, W, 0, 45),
1103            "nowrap text must paint past its 80px box on line 1 (scanned x∈[300,600), y∈[0,45))"
1104        );
1105
1106        // Nothing should be on a *second* line — nowrap never word-wraps
1107        // (only literal newlines would start a new line, and there are
1108        // none here), so all ink stays within the first line's height band.
1109        assert!(
1110            !has_ink_in(&grid, W, 0, W, 55, H),
1111            "nowrap text must stay on a single line; found ink on what would be line 2"
1112        );
1113    }
1114
1115    #[test]
1116    fn normal_white_space_wraps_within_the_layout_width() {
1117        // Contrast case: default wrapping keeps ink within the box on the
1118        // first line, and instead spills onto additional lines below.
1119        let text = make_text("the quick brown fox jumps over the lazy dog", None);
1120        const W: i32 = 600;
1121        const H: i32 = 200;
1122        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1123        let canvas = surface.canvas();
1124        let ctx = test_ctx();
1125        let props = AnimatedProperties::default();
1126        text.paint(canvas, 80.0, None, 0.0, &props, &ctx)
1127            .expect("paint succeeds");
1128        let grid = alpha_grid(&mut surface, W, H);
1129
1130        assert!(
1131            !has_ink_in(&grid, W, 300, W, 0, 45),
1132            "wrapped text must not reach x∈[300,600) on line 1 within an 80px box"
1133        );
1134        assert!(
1135            has_ink_in(&grid, W, 0, W, 55, H),
1136            "wrapped text must spill onto a second line within the box width"
1137        );
1138    }
1139
1140    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
1141
1142    #[test]
1143    fn rem_font_size_paints_visible_ink() {
1144        // Reproduction: `font-size: "2rem"` used to resolve to 0px (the
1145        // context-free `font_size_px_or` cannot resolve `rem`), so
1146        // `TextIntrinsic` measured a 0-height box and `paint_pass`'s
1147        // `height <= 0.0` guard skipped painting this node entirely —
1148        // `validate` reported success with only a warning.
1149        let text = Text {
1150            content: "HELLO".into(),
1151            max_width: None,
1152            timing: Default::default(),
1153            style: CssStyle {
1154                font_size: Some(Length::String("2rem".into())),
1155                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1156                ..Default::default()
1157            },
1158            timeline: Vec::new(),
1159            stagger: None,
1160            text_shadow: None,
1161            stroke: None,
1162            text_background: None,
1163            caret: None,
1164            states: Vec::new(),
1165            swap: None,
1166        };
1167        const W: i32 = 400;
1168        const H: i32 = 200;
1169        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1170        let canvas = surface.canvas();
1171        let ctx = test_ctx();
1172        let props = AnimatedProperties::default();
1173        text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1174            .expect("paint succeeds");
1175        let grid = alpha_grid(&mut surface, W, H);
1176
1177        // 2rem against the 16px CSS root default = 32px — comfortably tall
1178        // enough to show up in the first 60 rows.
1179        assert!(
1180            has_ink_in(&grid, W, 0, W, 0, 60),
1181            "font-size: 2rem must paint visible ink (32px glyphs), got none"
1182        );
1183    }
1184
1185    #[test]
1186    fn vh_font_size_paints_visible_ink_scaled_to_the_real_viewport() {
1187        // `vh` needs the real per-frame viewport (`ctx.video_height`), not
1188        // just a fixed root size — a different resolution path from `rem`.
1189        // `test_ctx()` sets `video_height: 200`, so `20vh` = 40px.
1190        let text = Text {
1191            content: "HI".into(),
1192            max_width: None,
1193            timing: Default::default(),
1194            style: CssStyle {
1195                font_size: Some(Length::String("20vh".into())),
1196                color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
1197                ..Default::default()
1198            },
1199            timeline: Vec::new(),
1200            stagger: None,
1201            text_shadow: None,
1202            stroke: None,
1203            text_background: None,
1204            caret: None,
1205            states: Vec::new(),
1206            swap: None,
1207        };
1208        const W: i32 = 400;
1209        const H: i32 = 200;
1210        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1211        let canvas = surface.canvas();
1212        let ctx = test_ctx();
1213        let props = AnimatedProperties::default();
1214        text.paint(canvas, 300.0, None, 0.0, &props, &ctx)
1215            .expect("paint succeeds");
1216        let grid = alpha_grid(&mut surface, W, H);
1217
1218        assert!(
1219            has_ink_in(&grid, W, 0, W, 0, 70),
1220            "font-size: 20vh (40px against a 200px-tall test viewport) must paint visible ink"
1221        );
1222    }
1223
1224    // ─── char_blur_in ───────────────────────────────────────────────────
1225
1226    /// Fraction of "inked" pixels (alpha > 0) in the region that are
1227    /// partially transparent (0 < alpha < 250) rather than solid. A sharp
1228    /// glyph is mostly solid fill with a thin antialiased edge, so this
1229    /// fraction is low. A heavily blurred glyph is a soft gradient
1230    /// wherever it has any ink at all, so this fraction is high.
1231    fn soft_pixel_fraction(
1232        grid: &[u8],
1233        surface_width: i32,
1234        x0: i32,
1235        x1: i32,
1236        y0: i32,
1237        y1: i32,
1238    ) -> f32 {
1239        let mut inked = 0u32;
1240        let mut soft = 0u32;
1241        for y in y0..y1 {
1242            for x in x0..x1 {
1243                let a = grid[(y * surface_width + x) as usize];
1244                if a > 0 {
1245                    inked += 1;
1246                    if a < 250 {
1247                        soft += 1;
1248                    }
1249                }
1250            }
1251        }
1252        if inked == 0 {
1253            return 0.0;
1254        }
1255        soft as f32 / inked as f32
1256    }
1257
1258    /// Resolve `style.animation` the way the engine does before it paints, so
1259    /// a char-animation test exercises the real wiring
1260    /// (`extract_effects` → `props.char_animation`) instead of a
1261    /// painter-private lookup.
1262    fn props_for(text: &Text) -> AnimatedProperties {
1263        AnimatedProperties {
1264            char_animation: rustmotion_core::engine::animator::extract_effects(
1265                &text.style.animation,
1266            )
1267            .char_animation,
1268            ..Default::default()
1269        }
1270    }
1271
1272    /// Build the same `Font` the renderer would build for `family`/`px`, so
1273    /// tests can measure exact word boundaries instead of guessing pixel
1274    /// coordinates.
1275    fn inter_font(px: f32) -> Font {
1276        let style = FontStyle::new(
1277            skia_safe::font_style::Weight::NORMAL,
1278            skia_safe::font_style::Width::NORMAL,
1279            skia_safe::font_style::Slant::Upright,
1280        );
1281        let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves");
1282        Font::from_typeface(typeface, px)
1283    }
1284
1285    #[test]
1286    fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() {
1287        // Render-level proof that char_blur_in actually blurs: a single
1288        // word must read as measurably softer mid-reveal than once
1289        // settled. Also exercises the DEFAULT_CHAR_BLUR_SIGMA fallback
1290        // (`blur: None`).
1291        let mut text = make_text("BLUR", None);
1292        text.style.font_size = Some(Length::Px(100.0));
1293        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1294        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1295            delay: 0.0,
1296            duration: 0.5,
1297            stagger: 0.03,
1298            granularity: TextAnimGranularity::Word,
1299            easing: EasingType::Linear,
1300            ..Default::default()
1301        })];
1302
1303        const W: i32 = 700;
1304        const H: i32 = 220;
1305        let ctx = test_ctx();
1306        let props = props_for(&text);
1307
1308        let render_at = |t: f64| -> Vec<u8> {
1309            let mut surface =
1310                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1311            {
1312                let canvas = surface.canvas();
1313                text.paint(canvas, W as f32, None, t, &props, &ctx)
1314                    .expect("paint succeeds");
1315            }
1316            alpha_grid(&mut surface, W, H)
1317        };
1318
1319        let early = render_at(0.15); // raw progress 0.15/0.5 = 0.3 into the reveal
1320        let settled = render_at(1.0); // long past duration: sigma → 0, alpha → 1
1321
1322        assert!(
1323            has_ink_in(&early, W, 0, W, 0, H),
1324            "the word must have started painting by t=0.15"
1325        );
1326
1327        let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H);
1328        let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H);
1329
1330        assert!(
1331            early_soft > settled_soft + 0.15,
1332            "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \
1333             settled fraction ({settled_soft:.3}) — the word should read as blurred while \
1334             animating and sharp at rest"
1335        );
1336        assert!(
1337            settled_soft < 0.25,
1338            "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})"
1339        );
1340    }
1341
1342    #[test]
1343    fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() {
1344        // The word-mode char path draws inter-word whitespace unanimated
1345        // at full opacity (see render_char_animation) — harmless for the
1346        // six existing opacity-only presets since a space glyph has no
1347        // ink. Pin down that this holds for char_blur_in too: each word's
1348        // blur filter is scoped to that word's own draw call, so it must
1349        // never smear ink into the gap between words.
1350        //
1351        // Note this is *not* the same as "a blurred word's own halo never
1352        // reaches near the gap" — a Gaussian blur legitimately spreads a
1353        // word's own ink a few sigma past its sharp glyph edge, which is
1354        // correct behaviour, not smearing into whitespace. So this checks
1355        // the gap's true center (rendering evidence: crates/.../issue-118
1356        // render proof measured the same distinction against real render
1357        // output — a word's halo fades to background well within a third
1358        // of a multi-space gap).
1359        const FONT_PX: f32 = 90.0;
1360        let mut text = make_text("FIRST               SECOND", None); // 15 spaces
1361        text.style.font_size = Some(Length::Px(FONT_PX));
1362        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1363        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1364            delay: 0.0,
1365            duration: 0.4,
1366            stagger: 0.2,
1367            granularity: TextAnimGranularity::Word,
1368            easing: EasingType::Linear,
1369            blur: Some(18.0),
1370            ..Default::default()
1371        })];
1372
1373        const W: i32 = 1400;
1374        const H: i32 = 180;
1375        let ctx = test_ctx();
1376        let props = props_for(&text);
1377
1378        let font = inter_font(FONT_PX);
1379        let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0);
1380        let gap_w = measure_text_with_fallback("               ", &font, &None, 0.0);
1381        let margin = (gap_w / 3.0).max(40.0);
1382        let gap_x0 = (first_w + margin) as i32;
1383        let gap_x1 = (first_w + gap_w - margin) as i32;
1384        assert!(
1385            gap_x1 > gap_x0,
1386            "test setup: the space run must measure to a real gap (got [{gap_x0},{gap_x1}))"
1387        );
1388
1389        for &t in &[0.05_f64, 0.35, 1.0] {
1390            let mut surface =
1391                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1392            {
1393                let canvas = surface.canvas();
1394                text.paint(canvas, W as f32, None, t, &props, &ctx)
1395                    .expect("paint succeeds");
1396            }
1397            let grid = alpha_grid(&mut surface, W, H);
1398            assert!(
1399                !has_ink_in(&grid, W, gap_x0, gap_x1, 0, H),
1400                "inter-word gap [{gap_x0},{gap_x1}) must stay empty at t={t}"
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn char_blur_in_honors_word_delay_and_stagger() {
1407        // With a stagger larger than the per-word duration, word 2 must
1408        // not have started at all while word 1 is mid-reveal, and nothing
1409        // should paint before `delay` has elapsed either.
1410        const FONT_PX: f32 = 90.0;
1411        let mut text = make_text("ONE TWO", None);
1412        text.style.font_size = Some(Length::Px(FONT_PX));
1413        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1414        text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming {
1415            delay: 0.5,
1416            duration: 0.3,
1417            stagger: 0.6,
1418            granularity: TextAnimGranularity::Word,
1419            easing: EasingType::Linear,
1420            blur: Some(16.0),
1421            ..Default::default()
1422        })];
1423
1424        const W: i32 = 900;
1425        const H: i32 = 180;
1426        let ctx = test_ctx();
1427        let props = props_for(&text);
1428        let font = inter_font(FONT_PX);
1429        let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32;
1430
1431        // Before `delay`, nothing should paint at all.
1432        let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1433        {
1434            let canvas = before.canvas();
1435            text.paint(canvas, W as f32, None, 0.1, &props, &ctx)
1436                .expect("paint succeeds");
1437        }
1438        let before_grid = alpha_grid(&mut before, W, H);
1439        assert!(
1440            !has_ink_in(&before_grid, W, 0, W, 0, H),
1441            "nothing should paint before `delay` has elapsed"
1442        );
1443
1444        // t=0.65s: word 1's local progress is (0.65-0.5)/0.3 = 0.5 (mid
1445        // reveal), word 2 only starts at delay+stagger=1.1s.
1446        let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1447        {
1448            let canvas = mid.canvas();
1449            text.paint(canvas, W as f32, None, 0.65, &props, &ctx)
1450                .expect("paint succeeds");
1451        }
1452        let mid_grid = alpha_grid(&mut mid, W, H);
1453        assert!(
1454            has_ink_in(&mid_grid, W, 0, word1_end, 0, H),
1455            "word 1 should show ink by t=0.65 (mid-reveal)"
1456        );
1457        // Leave enough clearance past word 1's sharp-edge measurement for
1458        // its *own* Gaussian halo (a real, expected effect of blurring
1459        // that word — see the analogous note in the whitespace-gap test
1460        // above), so this only catches an actual word-2 leak.
1461        assert!(
1462            !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H),
1463            "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65"
1464        );
1465    }
1466
1467    // ─── text-autofit ───────────────────────────────────────────────────
1468
1469    fn autofit_text(content: &str, font_size: f32, white_space: Option<CssWhiteSpace>) -> Text {
1470        let mut t = make_text(content, white_space);
1471        t.style.font_size = Some(Length::Px(font_size));
1472        t.style.text_autofit = Some(true);
1473        t
1474    }
1475
1476    /// Rightmost painted column across the whole surface — the horizontal
1477    /// extent of whatever ink was actually drawn.
1478    fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1479        let mut max_x: Option<i32> = None;
1480        for y in 0..height {
1481            for x in (0..surface_width).rev() {
1482                if grid[(y * surface_width + x) as usize] > 0 {
1483                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
1484                    break;
1485                }
1486            }
1487        }
1488        max_x
1489    }
1490
1491    /// Bottommost painted row across the whole surface — the vertical
1492    /// extent of whatever ink was actually drawn.
1493    fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
1494        for y in (0..height).rev() {
1495            for x in 0..surface_width {
1496                if grid[(y * surface_width + x) as usize] > 0 {
1497                    return Some(y);
1498                }
1499            }
1500        }
1501        None
1502    }
1503
1504    #[test]
1505    fn measure_and_paint_agree_on_a_shrunk_nowrap_line() {
1506        // Trap #1 — the one the brief calls the only one that can ruin this
1507        // work: `TextIntrinsic::measure` and `Text::paint` must resolve to
1508        // the *same* font size for the same node, or the box the layout
1509        // engine reserves stops matching what actually gets painted. Both
1510        // delegate to `resolve_text_autofit` with identical inputs (see its
1511        // doc comment); this proves that agreement operationally, on the
1512        // real render path, not by re-deriving the expected size by hand
1513        // (which would only test this test's own arithmetic).
1514        let text = autofit_text(
1515            "the quick brown fox jumps over the lazy dog",
1516            90.0,
1517            Some(CssWhiteSpace::Nowrap),
1518        );
1519        // 300px comfortably clears this sentence's floor-fit width (~252px
1520        // — this string never reads shorter than the calibrated legibility
1521        // floor allows), so the box is reachable by shrinking alone,
1522        // distinct from the separate floor-behaviour tests in
1523        // `intrinsic.rs`.
1524        const BOX_W: f32 = 300.0;
1525        const BOX_H: f32 = 60.0;
1526
1527        let (measured_w, _measured_h) = TextIntrinsic::from_text(&text).measure(
1528            (None, None),
1529            (
1530                AvailableSpace::Definite(BOX_W),
1531                AvailableSpace::Definite(BOX_H),
1532            ),
1533        );
1534        // Sanity: at 90px this line would never fit a 300px box unshrunk —
1535        // proves the shrink path is actually exercised here.
1536        assert!(
1537            measured_w <= BOX_W + 0.5,
1538            "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}"
1539        );
1540
1541        const W: i32 = 900;
1542        const H: i32 = 300;
1543        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1544        {
1545            let canvas = surface.canvas();
1546            let ctx = test_ctx();
1547            let props = AnimatedProperties::default();
1548            text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1549                .expect("paint succeeds");
1550        }
1551        let grid = alpha_grid(&mut surface, W, H);
1552        let ink_right = max_ink_x(&grid, W, H).expect("text must paint some ink");
1553
1554        assert!(
1555            (ink_right as f32) <= measured_w + 3.0,
1556            "painted ink (right edge {ink_right}) must not exceed the box TextIntrinsic reserved \
1557             ({measured_w}) — a wider paint than measure is exactly the class of bug this \
1558             workstream exists to close"
1559        );
1560        assert!(
1561            (ink_right as f32) >= measured_w - 15.0,
1562            "painted ink (right edge {ink_right}) should land close to what TextIntrinsic \
1563             measured ({measured_w}); a big gap would mean the two disagree on the resolved \
1564             font size in the other direction (paint drawing much smaller than reserved)"
1565        );
1566    }
1567
1568    #[test]
1569    fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() {
1570        // Same agreement proof as above, on the height axis with wrapping
1571        // on: a paragraph whose box has an explicit height too short for
1572        // its natural (unshrunk) line count.
1573        let text = autofit_text(
1574            "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer",
1575            60.0,
1576            None,
1577        );
1578        const BOX_W: f32 = 300.0;
1579        const BOX_H: f32 = 90.0;
1580
1581        let (_measured_w, measured_h) = TextIntrinsic::from_text(&text).measure(
1582            (None, None),
1583            (
1584                AvailableSpace::Definite(BOX_W),
1585                AvailableSpace::Definite(BOX_H),
1586            ),
1587        );
1588        assert!(
1589            measured_h <= BOX_H + 0.5,
1590            "TextIntrinsic itself must report a fit once autofit is on, got {measured_h}"
1591        );
1592
1593        const W: i32 = 500;
1594        const H: i32 = 400;
1595        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1596        {
1597            let canvas = surface.canvas();
1598            let ctx = test_ctx();
1599            let props = AnimatedProperties::default();
1600            text.paint(canvas, BOX_W, Some(BOX_H), 0.0, &props, &ctx)
1601                .expect("paint succeeds");
1602        }
1603        let grid = alpha_grid(&mut surface, W, H);
1604        let ink_bottom = max_ink_y(&grid, W, H).expect("text must paint some ink");
1605
1606        assert!(
1607            (ink_bottom as f32) <= measured_h + 6.0,
1608            "painted ink (bottom edge {ink_bottom}) must not exceed the box TextIntrinsic \
1609             reserved ({measured_h})"
1610        );
1611    }
1612
1613    #[test]
1614    fn autofit_size_is_stable_across_frames_for_fixed_content() {
1615        // Trap #2: nothing in the resolution may depend on `ctx.time` for
1616        // fixed content — rendering it at two different times, same box,
1617        // must be byte-identical.
1618        let text = autofit_text(
1619            "the quick brown fox jumps over the lazy dog",
1620            90.0,
1621            Some(CssWhiteSpace::Nowrap),
1622        );
1623        const W: i32 = 900;
1624        const H: i32 = 300;
1625        let ctx = test_ctx();
1626        let props = AnimatedProperties::default();
1627
1628        let render_at = |t: f64| -> Vec<u8> {
1629            let mut surface =
1630                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1631            {
1632                let canvas = surface.canvas();
1633                text.paint(canvas, 250.0, Some(60.0), t, &props, &ctx)
1634                    .expect("paint succeeds");
1635            }
1636            alpha_grid(&mut surface, W, H)
1637        };
1638
1639        let frame_a = render_at(0.0);
1640        let frame_b = render_at(0.9);
1641        assert_eq!(
1642            frame_a, frame_b,
1643            "fixed content in a fixed box must render byte-identically regardless of ctx.time — \
1644             a per-frame drift here is exactly what the temporal-stability requirement forbids"
1645        );
1646    }
1647
1648    #[test]
1649    fn autofit_size_does_not_drift_during_a_typewriter_reveal() {
1650        // Trap #2's named example: a typewriter reveal
1651        // (`visible_chars_progress`) must not make the resolved font size
1652        // drift as more characters become visible — `resolve_text_autofit`
1653        // is always fed the full, untruncated content, never the
1654        // reveal-in-progress view (see its doc comment). Proof: the line's
1655        // vertical footprint (driven by line-height, hence font size) must
1656        // be identical at 30% and 100% reveal, even though the horizontal
1657        // extent legitimately differs (fewer glyphs are visible yet).
1658        let text = autofit_text(
1659            "the quick brown fox jumps over the lazy dog",
1660            90.0,
1661            Some(CssWhiteSpace::Nowrap),
1662        );
1663        const W: i32 = 900;
1664        const H: i32 = 300;
1665        let ctx = test_ctx();
1666
1667        let render_at = |progress: f32| -> Vec<u8> {
1668            let mut surface =
1669                skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1670            let props = AnimatedProperties {
1671                visible_chars_progress: progress,
1672                ..Default::default()
1673            };
1674            {
1675                let canvas = surface.canvas();
1676                text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1677                    .expect("paint succeeds");
1678            }
1679            alpha_grid(&mut surface, W, H)
1680        };
1681
1682        let early = render_at(0.3);
1683        let full = render_at(1.0);
1684
1685        let early_bottom = max_ink_y(&early, W, H).expect("some ink must paint at 30% reveal");
1686        let full_bottom = max_ink_y(&full, W, H).expect("some ink must paint at full reveal");
1687        assert_eq!(
1688            early_bottom, full_bottom,
1689            "the resolved font size (line height, hence vertical ink footprint) must not change \
1690             as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}"
1691        );
1692
1693        // And the visible width at 30% must be meaningfully narrower than
1694        // the full line — otherwise this test would not actually be
1695        // exercising a partial reveal at all.
1696        let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal");
1697        let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal");
1698        assert!(
1699            early_right < full_right,
1700            "test setup: 30% reveal should show measurably less horizontal ink than the full \
1701             line (early={early_right}, full={full_right})"
1702        );
1703    }
1704
1705    #[test]
1706    fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() {
1707        // Backward compatibility: a scenario that does not declare
1708        // `text-autofit` must render exactly as it did before this feature
1709        // existed, even now that `content_height` is threaded through —
1710        // the render-level twin of
1711        // `intrinsic::tests::text_intrinsic_ignores_autofit_target_when_the_flag_is_off`.
1712        let text = make_text(
1713            "the quick brown fox jumps over the lazy dog",
1714            Some(CssWhiteSpace::Nowrap),
1715        );
1716        const W: i32 = 900;
1717        const H: i32 = 300;
1718        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
1719        let canvas = surface.canvas();
1720        let ctx = test_ctx();
1721        let props = AnimatedProperties::default();
1722        text.paint(canvas, 250.0, Some(60.0), 0.0, &props, &ctx)
1723            .expect("paint succeeds");
1724        let grid = alpha_grid(&mut surface, W, H);
1725        assert!(
1726            has_ink_in(&grid, W, 260, W, 0, 45),
1727            "without text-autofit, nowrap must still bleed past its box exactly as before"
1728        );
1729    }
1730
1731    // ─── Text state swap ──────────────────────────────────────────────────────
1732
1733    fn swapping_text(swap: Option<TextSwapConfig>) -> Text {
1734        let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap));
1735        text.style.font_size = Some(Length::Px(48.0));
1736        text.states = vec![TextState {
1737            at: 1.0,
1738            content: "Saved".into(),
1739        }];
1740        text.swap = swap;
1741        text
1742    }
1743
1744    fn render_plain(text: &Text, time: f64) -> Vec<u8> {
1745        let mut surface =
1746            skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1747        {
1748            let canvas = surface.canvas();
1749            text.paint(
1750                canvas,
1751                CARET_W as f32,
1752                None,
1753                time,
1754                &AnimatedProperties::default(),
1755                &test_ctx(),
1756            )
1757            .expect("paint succeeds");
1758        }
1759        alpha_grid(&mut surface, CARET_W, CARET_H)
1760    }
1761
1762    #[test]
1763    fn states_cut_over_at_their_own_time_without_a_swap_config() {
1764        // `states` on its own is a hard cut: abrupt, but it is exactly what
1765        // omitting `swap` asks for, and it must not silently animate.
1766        let text = swapping_text(None);
1767        let before = render_plain(&text, 0.5);
1768        let after = render_plain(&text, 1.5);
1769
1770        let width_of = |g: &[u8]| max_ink_x(g, CARET_W, CARET_H).unwrap_or(0);
1771        assert!(
1772            width_of(&before) > width_of(&after) + 20,
1773            "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \
1774             have changed at t=1.0"
1775        );
1776        // A cut has exactly one label on screen at each instant, so the frame
1777        // right after the boundary equals the settled one.
1778        assert_eq!(
1779            render_plain(&text, 1.01),
1780            render_plain(&text, 1.5),
1781            "without a `swap`, the new label must be fully in place immediately"
1782        );
1783    }
1784
1785    #[test]
1786    fn a_swap_puts_both_labels_on_screen_at_once() {
1787        let text = swapping_text(Some(TextSwapConfig::default()));
1788
1789        // Just before, only the outgoing label; mid-window, both; well after,
1790        // only the incoming one. "Both" shows up as ink covering more rows
1791        // than either label alone occupies, since they are offset vertically.
1792        let rows_with_ink = |time: f64| -> usize {
1793            let grid = render_plain(&text, time);
1794            (0..CARET_H)
1795                .filter(|&y| (0..CARET_W).any(|x| grid[(y * CARET_W + x) as usize] > 0))
1796                .count()
1797        };
1798
1799        let settled = rows_with_ink(0.5);
1800        let mid = rows_with_ink(1.0 + 0.45 / 2.0);
1801        assert!(
1802            mid > settled,
1803            "mid-swap, the two offset labels should span more rows than one settled label \
1804             (settled={settled}, mid={mid})"
1805        );
1806    }
1807
1808    #[test]
1809    fn a_finished_swap_settles_on_the_incoming_label_alone() {
1810        let swapped = swapping_text(Some(TextSwapConfig::default()));
1811        let cut = swapping_text(None);
1812        // Past `at + duration` the animated version must be indistinguishable
1813        // from a plain cut — no residual offset, blur or ghost of the old
1814        // label parked behind the new one.
1815        assert_eq!(
1816            render_plain(&swapped, 2.0),
1817            render_plain(&cut, 2.0),
1818            "once the swap window has passed, the frame must match a plain cut exactly"
1819        );
1820    }
1821
1822    #[test]
1823    fn the_box_is_measured_for_the_widest_label_not_the_first() {
1824        // A box sized for "Saved" would be overrun the instant the text
1825        // swapped back to "Saving draft" — and the geometry validator, which
1826        // measures through this same intrinsic, would have signed off on it.
1827        let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1828        short_first.states = vec![TextState {
1829            at: 1.0,
1830            content: "Saving draft".into(),
1831        }];
1832        let only_short = make_text("Saved", Some(CssWhiteSpace::Nowrap));
1833
1834        let measure = |t: &Text| {
1835            TextIntrinsic::from_text(t)
1836                .measure(
1837                    (None, None),
1838                    (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
1839                )
1840                .0
1841        };
1842
1843        assert!(
1844            measure(&short_first) > measure(&only_short) + 10.0,
1845            "the reserved width must cover the longest label the text can show \
1846             (with states={}, without={})",
1847            measure(&short_first),
1848            measure(&only_short)
1849        );
1850    }
1851
1852    // ─── Typewriter caret ─────────────────────────────────────────────────────
1853
1854    const CARET_W: i32 = 900;
1855    const CARET_H: i32 = 160;
1856
1857    fn typewriter_text(caret: Option<CaretConfig>) -> Text {
1858        let mut text = make_text("HELLO WORLD", Some(CssWhiteSpace::Nowrap));
1859        text.style.font_size = Some(Length::Px(64.0));
1860        text.caret = caret;
1861        text
1862    }
1863
1864    /// Render a `visible_chars_progress` reveal at `progress`, at `time`.
1865    fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec<u8> {
1866        let props = AnimatedProperties {
1867            visible_chars_progress: progress,
1868            ..AnimatedProperties::default()
1869        };
1870        let mut surface =
1871            skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface");
1872        {
1873            let canvas = surface.canvas();
1874            text.paint(canvas, CARET_W as f32, None, time, &props, &test_ctx())
1875                .expect("paint succeeds");
1876        }
1877        alpha_grid(&mut surface, CARET_W, CARET_H)
1878    }
1879
1880    #[test]
1881    fn the_caret_follows_the_reveal_head_instead_of_standing_still() {
1882        // The whole reason this is a field on `text` rather than a separate
1883        // `cursor` component placed next to it: a hand-placed caret stays
1884        // put while the text grows out from under it.
1885        let text = typewriter_text(Some(CaretConfig {
1886            blink: 0.0,
1887            ..Default::default()
1888        }));
1889        let plain = typewriter_text(None);
1890
1891        // Rightmost ink, with and without the caret: the difference is the
1892        // caret's own position.
1893        let caret_x = |progress: f32| -> i32 {
1894            let with = render_reveal(&text, progress, 0.0);
1895            let without = render_reveal(&plain, progress, 0.0);
1896            let with_right = max_ink_x(&with, CARET_W, CARET_H).expect("caret paints");
1897            let without_right = max_ink_x(&without, CARET_W, CARET_H).unwrap_or(0);
1898            assert!(
1899                with_right > without_right,
1900                "the caret should extend past the last revealed glyph \
1901                 (with={with_right}, without={without_right}) at progress {progress}"
1902            );
1903            with_right
1904        };
1905
1906        let early = caret_x(0.25);
1907        let late = caret_x(0.75);
1908        assert!(
1909            late > early + 40,
1910            "the caret should have travelled with the reveal head (early={early}, late={late})"
1911        );
1912    }
1913
1914    #[test]
1915    fn the_caret_blinks_off_for_half_of_each_period() {
1916        let text = typewriter_text(Some(CaretConfig {
1917            blink: 1.0,
1918            ..Default::default()
1919        }));
1920        let plain = typewriter_text(None);
1921
1922        let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0);
1923        let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0));
1924
1925        // First half of the period: caret visible, so ink extends past the
1926        // text. Second half: it must be gone, i.e. back to the text's own
1927        // right edge.
1928        let on = right_edge(&render_reveal(&text, 0.5, 0.1));
1929        let off = right_edge(&render_reveal(&text, 0.5, 0.6));
1930        assert!(on > baseline, "caret should be visible at phase 0.1");
1931        assert_eq!(
1932            off, baseline,
1933            "caret should be blinked out at phase 0.6, leaving only the text's own ink"
1934        );
1935    }
1936
1937    #[test]
1938    fn hide_when_done_removes_the_caret_once_the_reveal_finishes() {
1939        let hiding = typewriter_text(Some(CaretConfig {
1940            blink: 0.0,
1941            hide_when_done: true,
1942            ..Default::default()
1943        }));
1944        let staying = typewriter_text(Some(CaretConfig {
1945            blink: 0.0,
1946            ..Default::default()
1947        }));
1948        let plain = typewriter_text(None);
1949
1950        let right_edge = |t: &Text, progress: f32| {
1951            max_ink_x(&render_reveal(t, progress, 0.0), CARET_W, CARET_H).unwrap_or(0)
1952        };
1953        let text_edge = right_edge(&plain, 1.0);
1954
1955        assert_eq!(
1956            right_edge(&hiding, 1.0),
1957            text_edge,
1958            "with hide_when_done, a finished reveal must leave no caret behind"
1959        );
1960        assert!(
1961            right_edge(&staying, 1.0) > text_edge,
1962            "without hide_when_done, the caret parks at the end of the text"
1963        );
1964    }
1965
1966    #[test]
1967    fn the_caret_is_there_before_the_first_character_is() {
1968        // At 0% reveal there is no text yet, but a typewriter that starts
1969        // with a blank frame and pops both caret and first letter together
1970        // reads as a glitch rather than as typing.
1971        let text = typewriter_text(Some(CaretConfig {
1972            blink: 0.0,
1973            ..Default::default()
1974        }));
1975        let grid = render_reveal(&text, 0.0, 0.0);
1976        assert!(
1977            has_ink_in(&grid, CARET_W, 0, CARET_W, 0, CARET_H),
1978            "the caret must be painting at 0% reveal, before any glyph"
1979        );
1980    }
1981
1982    // ─── Char-animation tuning (direction / distance / scale_from / ink_from) ──
1983
1984    const TUNING_W: i32 = 520;
1985    const TUNING_H: i32 = 360;
1986
1987    /// A single 90px word carrying `timing` as a `char_slide_up` effect.
1988    fn tuned_slide_up(timing: CharAnimationTiming) -> Text {
1989        let mut text = make_text("GO", None);
1990        text.style.font_size = Some(Length::Px(90.0));
1991        text.style.white_space = Some(CssWhiteSpace::Nowrap);
1992        text.style.animation = vec![AnimationEffect::CharSlideUp(timing)];
1993        text
1994    }
1995
1996    fn render_alpha(text: &Text, time: f64) -> Vec<u8> {
1997        let mut surface =
1998            skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)).expect("raster surface");
1999        {
2000            let canvas = surface.canvas();
2001            text.paint(
2002                canvas,
2003                TUNING_W as f32,
2004                None,
2005                time,
2006                &props_for(text),
2007                &test_ctx(),
2008            )
2009            .expect("paint succeeds");
2010        }
2011        alpha_grid(&mut surface, TUNING_W, TUNING_H)
2012    }
2013
2014    /// Topmost inked row, i.e. how high on the canvas the glyphs sit.
2015    fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option<i32> {
2016        (0..height)
2017            .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0))
2018    }
2019
2020    #[test]
2021    fn direction_down_starts_the_unit_above_its_line_instead_of_below() {
2022        // `char_slide_up` used to hardcode a downward starting offset. With
2023        // `direction: "down"` the same preset has to start *above* the line
2024        // and fall — the "letters cascading from the top" look. Sampled
2025        // mid-travel, where the two are furthest apart.
2026        let base = || CharAnimationTiming {
2027            duration: 1.0,
2028            stagger: 0.0,
2029            granularity: TextAnimGranularity::Word,
2030            easing: EasingType::Linear,
2031            distance: Some(0.5),
2032            ..Default::default()
2033        };
2034        let up = tuned_slide_up(CharAnimationTiming {
2035            direction: TextAnimDirection::Up,
2036            ..base()
2037        });
2038        let down = tuned_slide_up(CharAnimationTiming {
2039            direction: TextAnimDirection::Down,
2040            ..base()
2041        });
2042
2043        let up_grid = render_alpha(&up, 0.5);
2044        let down_grid = render_alpha(&down, 0.5);
2045
2046        let up_top = min_ink_y(&up_grid, TUNING_W, TUNING_H).expect("up-travelling word paints");
2047        let down_top =
2048            min_ink_y(&down_grid, TUNING_W, TUNING_H).expect("down-travelling word paints");
2049
2050        assert!(
2051            down_top < up_top - 10,
2052            "at the same instant, a `down` unit should sit clearly higher on the canvas than an \
2053             `up` one (down_top={down_top}, up_top={up_top})"
2054        );
2055    }
2056
2057    #[test]
2058    fn distance_scales_how_far_the_unit_travels() {
2059        let base = || CharAnimationTiming {
2060            duration: 1.0,
2061            stagger: 0.0,
2062            granularity: TextAnimGranularity::Word,
2063            easing: EasingType::Linear,
2064            ..Default::default()
2065        };
2066        let close = tuned_slide_up(CharAnimationTiming {
2067            distance: Some(0.25),
2068            ..base()
2069        });
2070        let far = tuned_slide_up(CharAnimationTiming {
2071            distance: Some(1.0),
2072            ..base()
2073        });
2074
2075        // Same instant, same preset: the only difference is how far each has
2076        // left to travel, which shows up as how far below its line it sits.
2077        let close_top =
2078            min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints");
2079        let far_top =
2080            min_ink_y(&render_alpha(&far, 0.5), TUNING_W, TUNING_H).expect("far word paints");
2081
2082        assert!(
2083            far_top > close_top + 10,
2084            "a `distance: 1.0` unit should still be further below its line than a `0.25` one at \
2085             the same instant (far_top={far_top}, close_top={close_top})"
2086        );
2087    }
2088
2089    #[test]
2090    fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() {
2091        // Whatever route it took, a unit's resting position is its laid-out
2092        // one — otherwise the tuning knobs would silently move the finished
2093        // frame, which is the frame that has to match the layout.
2094        let settled = |timing: CharAnimationTiming| {
2095            let grid = render_alpha(&tuned_slide_up(timing), 5.0);
2096            min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints")
2097        };
2098        let base = || CharAnimationTiming {
2099            duration: 1.0,
2100            stagger: 0.0,
2101            granularity: TextAnimGranularity::Word,
2102            easing: EasingType::Linear,
2103            ..Default::default()
2104        };
2105
2106        let plain = settled(base());
2107        let downward = settled(CharAnimationTiming {
2108            direction: TextAnimDirection::Down,
2109            distance: Some(1.85),
2110            ..base()
2111        });
2112        let sideways = settled(CharAnimationTiming {
2113            direction: TextAnimDirection::Right,
2114            distance: Some(1.85),
2115            ..base()
2116        });
2117
2118        assert_eq!(
2119            plain, downward,
2120            "a settled `down` unit must land on its line"
2121        );
2122        assert_eq!(
2123            plain, sideways,
2124            "a settled `right` unit must land on its line"
2125        );
2126    }
2127
2128    #[test]
2129    fn scale_from_shrinks_the_unit_at_the_start_and_releases_it_by_the_end() {
2130        let timing = CharAnimationTiming {
2131            duration: 1.0,
2132            stagger: 0.0,
2133            granularity: TextAnimGranularity::Word,
2134            easing: EasingType::Linear,
2135            // Isolate the scale: no travel to move the ink around.
2136            distance: Some(0.0),
2137            scale_from: Some(0.5),
2138            ..Default::default()
2139        };
2140        let text = tuned_slide_up(timing);
2141
2142        let ink_width = |time: f64| -> i32 {
2143            let grid = render_alpha(&text, time);
2144            let left = (0..TUNING_W)
2145                .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
2146            let right = (0..TUNING_W)
2147                .rev()
2148                .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0));
2149            match (left, right) {
2150                (Some(l), Some(r)) => r - l,
2151                _ => 0,
2152            }
2153        };
2154
2155        let early = ink_width(0.35);
2156        let settled = ink_width(5.0);
2157        assert!(early > 0, "the word must be painting by t=0.35");
2158        assert!(
2159            (early as f32) < settled as f32 * 0.9,
2160            "a `scale_from: 0.5` unit should still be visibly narrower than its settled self \
2161             early on (early={early}px, settled={settled}px)"
2162        );
2163    }
2164
2165    #[test]
2166    fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() {
2167        // `char_scale_in` is the one preset that leaves alpha alone, so the
2168        // measurement reads the colour ramp instead of a fade.
2169        let mut text = make_text("INK", None);
2170        text.style.font_size = Some(Length::Px(90.0));
2171        text.style.white_space = Some(CssWhiteSpace::Nowrap);
2172        text.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
2173        text.style.animation = vec![AnimationEffect::CharScaleIn(CharAnimationTiming {
2174            duration: 1.0,
2175            stagger: 0.0,
2176            granularity: TextAnimGranularity::Word,
2177            easing: EasingType::Linear,
2178            overshoot: Some(0.0),
2179            // Pure red start → the green channel is the whole measurement.
2180            ink_from: Some("#FF0000".into()),
2181            ..Default::default()
2182        })];
2183
2184        // Mean green over inked pixels: 0 at pure red, 255 once white.
2185        let mean_green = |time: f64| -> f32 {
2186            let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H))
2187                .expect("raster surface");
2188            {
2189                let canvas = surface.canvas();
2190                text.paint(
2191                    canvas,
2192                    TUNING_W as f32,
2193                    None,
2194                    time,
2195                    &props_for(&text),
2196                    &test_ctx(),
2197                )
2198                .expect("paint succeeds");
2199            }
2200            let snapshot = surface.image_snapshot();
2201            let info = skia_safe::ImageInfo::new(
2202                (TUNING_W, TUNING_H),
2203                skia_safe::ColorType::RGBA8888,
2204                skia_safe::AlphaType::Unpremul,
2205                None,
2206            );
2207            let mut buf = vec![0u8; (TUNING_W * TUNING_H * 4) as usize];
2208            assert!(snapshot.read_pixels(
2209                &info,
2210                &mut buf,
2211                (TUNING_W * 4) as usize,
2212                skia_safe::IPoint::new(0, 0),
2213                skia_safe::image::CachingHint::Disallow,
2214            ));
2215            // Weight by alpha rather than requiring `alpha == 255`: the fill
2216            // colour is uniform across a unit regardless of AA coverage, so
2217            // this reads the same value it would with an opaque-only filter
2218            // — but it stays correct once `char_scale_in` has shrunk the
2219            // glyph enough (early in its ramp) that no single pixel is fully
2220            // covered.
2221            let (weighted, alpha_sum) =
2222                (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| {
2223                    let alpha = buf[i * 4 + 3] as u64;
2224                    (s + buf[i * 4 + 1] as u64 * alpha, a + alpha)
2225                });
2226            assert!(alpha_sum > 0, "some inked pixels must exist at t={time}");
2227            weighted as f32 / alpha_sum as f32
2228        };
2229
2230        let early = mean_green(0.1);
2231        let mid = mean_green(0.5);
2232        let settled = mean_green(5.0);
2233
2234        assert!(
2235            early < 60.0,
2236            "at 10% the word should read nearly pure red (mean green {early:.1})"
2237        );
2238        assert!(
2239            mid > early + 40.0 && mid < settled - 40.0,
2240            "at 50% the word should be halfway between its start colour and the text colour \
2241             (early={early:.1}, mid={mid:.1}, settled={settled:.1})"
2242        );
2243        assert!(
2244            settled > 250.0,
2245            "once settled the word must be the text's own white, not a tint of it \
2246             (mean green {settled:.1})"
2247        );
2248    }
2249}