Skip to main content

rustmotion_components/
box_builder.rs

1use std::sync::Arc;
2
3use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize};
4use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP};
5use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
6use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId};
7use rustmotion_core::engine::deps::NodeRef;
8use rustmotion_core::expr::Scope;
9use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig};
10
11use crate::callout::ArrowDirection as CalloutArrowDirection;
12use crate::chart::ChartType;
13use crate::divider::DividerDirection;
14use crate::mockup::MockupDevice;
15use crate::skeleton::SkeletonVariant;
16use crate::stepper::StepperOrientation;
17use crate::timeline::TimelineDirection;
18use crate::tooltip::TooltipArrow;
19use crate::{ChildComponent, Component};
20
21#[derive(Debug, Clone, Copy)]
22pub struct BuildAnimationCtx {
23    pub time: f64,
24    pub scenario_time: f64,
25    pub scene_duration: f64,
26    pub fps: u32,
27}
28
29const ARROW_BBOX_PADDING: f32 = 16.0;
30
31pub struct BuiltScene<'a> {
32    pub root: BoxNode,
33    pub components: Vec<Option<&'a ChildComponent>>,
34    pub stagger_delays: Vec<f64>,
35    pub time_params: Vec<(f64, f64)>,
36    pub ghost_principal: Vec<(NodeId, NodeId)>,
37}
38
39pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> {
40    build_scene_with_root(children, viewport, default_root_css(viewport))
41}
42
43pub fn build_scene_with_root<'a>(
44    children: &'a [ChildComponent],
45    viewport: (f32, f32),
46    root_css: CssStyle,
47) -> BuiltScene<'a> {
48    build_scene_from_refs(children.iter(), viewport, root_css, None)
49}
50
51pub fn build_scene_at_time<'a>(
52    children: &'a [ChildComponent],
53    viewport: (f32, f32),
54    root_css: CssStyle,
55    anim: BuildAnimationCtx,
56) -> BuiltScene<'a> {
57    build_scene_from_refs(children.iter(), viewport, root_css, Some(anim))
58}
59
60pub fn build_scene_with_anim<'a>(
61    children: &'a [ChildComponent],
62    viewport: (f32, f32),
63    anim: BuildAnimationCtx,
64) -> BuiltScene<'a> {
65    build_scene_from_refs(
66        children.iter(),
67        viewport,
68        default_root_css(viewport),
69        Some(anim),
70    )
71}
72
73pub fn build_scene_from_refs<'a, I>(
74    children: I,
75    viewport: (f32, f32),
76    root_css: CssStyle,
77    anim: Option<BuildAnimationCtx>,
78) -> BuiltScene<'a>
79where
80    I: IntoIterator<Item = &'a ChildComponent>,
81{
82    build_scene_from_refs_with_scope(children, viewport, root_css, anim, None)
83}
84
85pub fn build_scene_from_refs_with_scope<'a, I>(
86    children: I,
87    viewport: (f32, f32),
88    root_css: CssStyle,
89    anim: Option<BuildAnimationCtx>,
90    outer_scope: Option<&dyn Scope>,
91) -> BuiltScene<'a>
92where
93    I: IntoIterator<Item = &'a ChildComponent>,
94{
95    build_scene_from_refs_with_scope_quiet(children, viewport, root_css, anim, outer_scope, true)
96}
97
98pub fn build_scene_from_refs_with_scope_quiet<'a, I>(
99    children: I,
100    viewport: (f32, f32),
101    mut root_css: CssStyle,
102    anim: Option<BuildAnimationCtx>,
103    outer_scope: Option<&dyn Scope>,
104    warn_unresolved: bool,
105) -> BuiltScene<'a>
106where
107    I: IntoIterator<Item = &'a ChildComponent>,
108{
109    let mut components: Vec<Option<&'a ChildComponent>> = vec![None];
110    let mut stagger_delays: Vec<f64> = vec![0.0];
111    let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)];
112    let mut ghost_principal: Vec<(NodeId, NodeId)> = Vec::new();
113    let mut next_id: NodeId = 1;
114
115    let mut child_boxes = Vec::new();
116    for (i, c) in children.into_iter().enumerate() {
117        child_boxes.extend(build_child(
118            c,
119            &mut components,
120            &mut stagger_delays,
121            &mut time_params,
122            &mut ghost_principal,
123            &mut next_id,
124            anim,
125            format!("/children/{i}"),
126            0.0,
127            (1.0, 0.0),
128            &root_css,
129            viewport,
130            outer_scope,
131            warn_unresolved,
132        ));
133    }
134
135    root_css.width = Some(CSize::Length(CLP::Px(viewport.0)));
136    root_css.height = Some(CSize::Length(CLP::Px(viewport.1)));
137
138    let root = BoxNode {
139        id: 0,
140        kind: BoxKind::Container,
141        css: root_css,
142        children: child_boxes,
143        intrinsic: None,
144        source_path: None,
145        window: None,
146    };
147
148    BuiltScene {
149        root,
150        components,
151        stagger_delays,
152        time_params,
153        ghost_principal,
154    }
155}
156
157pub fn collect_node_refs<'a, I>(children: I) -> Vec<(String, Vec<NodeRef>)>
158where
159    I: IntoIterator<Item = &'a ChildComponent>,
160{
161    let mut out = Vec::new();
162    for child in children {
163        collect_node_refs_into(child, &mut out);
164    }
165    out
166}
167
168fn collect_node_refs_into(child: &ChildComponent, out: &mut Vec<(String, Vec<NodeRef>)>) {
169    if let Some(id) = &child.id {
170        out.push((
171            id.clone(),
172            component_style(&child.component).expr.node_refs.clone(),
173        ));
174    }
175    for c in container_children_of(&child.component) {
176        collect_node_refs_into(c, out);
177    }
178}
179
180fn container_children_of(component: &Component) -> &[ChildComponent] {
181    match component {
182        Component::Container(c) => &c.children,
183        _ => &[],
184    }
185}
186
187pub fn scene_uses_node_refs<'a, I>(children: I) -> bool
188where
189    I: IntoIterator<Item = &'a ChildComponent>,
190{
191    children.into_iter().any(scene_uses_node_refs_in)
192}
193
194fn scene_uses_node_refs_in(child: &ChildComponent) -> bool {
195    if !component_style(&child.component).expr.node_refs.is_empty() {
196        return true;
197    }
198    container_children_of(&child.component)
199        .iter()
200        .any(scene_uses_node_refs_in)
201}
202
203fn default_root_css(viewport: (f32, f32)) -> CssStyle {
204    CssStyle {
205        display: Some(rustmotion_core::css::style::Display::Flex),
206        flex_direction: Some(rustmotion_core::css::style::FlexDirection::Column),
207        width: Some(CSize::Length(CLP::Px(viewport.0))),
208        height: Some(CSize::Length(CLP::Px(viewport.1))),
209        ..Default::default()
210    }
211}
212
213fn detect_ghost_effects(
214    effects: &[AnimationEffect],
215) -> (Option<MotionBlurConfig>, Option<TrailConfig>) {
216    let mut mb: Option<MotionBlurConfig> = None;
217    let mut tr: Option<TrailConfig> = None;
218    for e in effects {
219        match e {
220            AnimationEffect::MotionBlur(c) if mb.is_none() => mb = Some(c.clone()),
221            AnimationEffect::Trail(c) if tr.is_none() => tr = Some(c.clone()),
222            _ => {}
223        }
224    }
225    (mb, tr)
226}
227
228fn ghost_css_for(
229    child: &ChildComponent,
230    parent_css: &CssStyle,
231    extra_delay: f64,
232    scene_duration: f64,
233    ghost_time: f64,
234    ghost_opacity_scale: f32,
235) -> CssStyle {
236    let mut css = component_css(&child.component);
237    css.position = Some(Position::Absolute);
238    if let Some((x, y)) = child.absolute_position() {
239        css.left = Some(CLP::Px(x));
240        css.top = Some(CLP::Px(y));
241    }
242    if let Some(z) = child.z_index {
243        css.z_index = Some(z);
244    }
245    rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
246    if let Some(animatable) = child.component.as_animatable() {
247        let steps = animatable.timeline_steps();
248        if steps.iter().any(|s| s.style.is_some()) {
249            let skip_opacity = css.transition.is_some();
250            apply_style_states(&mut css, steps, ghost_time - extra_delay, skip_opacity);
251            let overrides = resolve_transition_css_overrides(
252                child.component.as_styled().style_config(),
253                steps,
254                ghost_time - extra_delay,
255            );
256            if let Some(br) = overrides.border_radius {
257                css.border_radius = Some(br);
258            }
259            if let Some(bg) = overrides.background {
260                css.background = Some(bg);
261            }
262        }
263    }
264    if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) {
265        let props = resolve_props_for_effects(&ghost_effects, ghost_time, scene_duration);
266        apply_animated_props(&mut css, &props);
267        apply_glow_effect(&mut css, &ghost_effects);
268        carry_paint_pass_effects(&mut css, &ghost_effects);
269        apply_directional_blur_props(&mut css, &props);
270    }
271    apply_pointer_path_transform(&mut css, &child.component, ghost_time);
272    let base_opacity = css.opacity.unwrap_or(1.0);
273    css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0));
274    css
275}
276
277#[allow(clippy::too_many_arguments)]
278fn build_one_ghost<'a>(
279    child: &'a ChildComponent,
280    components: &mut Vec<Option<&'a ChildComponent>>,
281    stagger_delays: &mut Vec<f64>,
282    time_params: &mut Vec<(f64, f64)>,
283    ghost_principal: &mut Vec<(NodeId, NodeId)>,
284    next_id: &mut NodeId,
285    anim: Option<BuildAnimationCtx>,
286    actx: BuildAnimationCtx,
287    stagger_delay: f64,
288    extra_delay: f64,
289    time_remap: (f64, f64),
290    parent_css: &CssStyle,
291    path: &str,
292    viewport: (f32, f32),
293    outer_scope: Option<&dyn Scope>,
294    warn_unresolved: bool,
295    offset: f64,
296    ghost_opacity_scale: f32,
297) -> BoxNode {
298    let ghost_time = actx.time - offset;
299    let ghost_time_remap = (time_remap.0, time_remap.1 - offset);
300    let ghost_css = ghost_css_for(
301        child,
302        parent_css,
303        extra_delay,
304        actx.scene_duration,
305        ghost_time,
306        ghost_opacity_scale,
307    );
308    let ghost_intrinsic = component_intrinsic(&child.component, &ghost_css);
309
310    let ghost_id = *next_id;
311    *next_id += 1;
312    components.push(Some(child));
313    stagger_delays.push(extra_delay);
314    time_params.push(ghost_time_remap);
315
316    let ghost_children = container_children(
317        &child.component,
318        components,
319        stagger_delays,
320        time_params,
321        ghost_principal,
322        next_id,
323        anim,
324        path,
325        stagger_delay,
326        ghost_time_remap,
327        &ghost_css,
328        viewport,
329        outer_scope,
330        warn_unresolved,
331    );
332
333    BoxNode {
334        id: ghost_id,
335        kind: BoxKind::Ghost(Arc::new(ghost_id)),
336        css: ghost_css,
337        children: ghost_children,
338        intrinsic: ghost_intrinsic,
339        source_path: None,
340        window: None,
341    }
342}
343
344#[allow(clippy::too_many_arguments)]
345fn build_ghosts<'a>(
346    child: &'a ChildComponent,
347    components: &mut Vec<Option<&'a ChildComponent>>,
348    stagger_delays: &mut Vec<f64>,
349    time_params: &mut Vec<(f64, f64)>,
350    ghost_principal: &mut Vec<(NodeId, NodeId)>,
351    next_id: &mut NodeId,
352    anim: Option<BuildAnimationCtx>,
353    actx: BuildAnimationCtx,
354    stagger_delay: f64,
355    extra_delay: f64,
356    time_remap: (f64, f64),
357    effects: &[AnimationEffect],
358    parent_css: &CssStyle,
359    path: &str,
360    viewport: (f32, f32),
361    outer_scope: Option<&dyn Scope>,
362    warn_unresolved: bool,
363) -> Vec<BoxNode> {
364    let (mb, tr) = detect_ghost_effects(effects);
365
366    enum Strategy {
367        MotionBlur {
368            samples: u32,
369            shutter_window: f64,
370        },
371        Trail {
372            copies: u32,
373            spacing: f64,
374            falloff: f32,
375        },
376    }
377    let strategy = if let Some(mc) = mb {
378        if mc.mode == rustmotion_core::schema::MotionBlurMode::Smear {
379            return Vec::new();
380        }
381        let samples = mc.samples.clamp(1, 16);
382        if samples <= 1 {
383            return Vec::new();
384        }
385        let shutter_window = mc.shutter / actx.fps.max(1) as f64;
386        Strategy::MotionBlur {
387            samples,
388            shutter_window,
389        }
390    } else if let Some(tc) = tr {
391        let copies = tc.copies.clamp(1, 12);
392        Strategy::Trail {
393            copies,
394            spacing: tc.spacing,
395            falloff: tc.falloff,
396        }
397    } else {
398        return Vec::new();
399    };
400
401    let mut ghosts = Vec::new();
402
403    match strategy {
404        Strategy::MotionBlur {
405            samples,
406            shutter_window,
407        } => {
408            let ghost_opacity_scale = 1.0 / (samples + 1) as f32;
409            for i in 1..=samples {
410                let offset = i as f64 * shutter_window / samples as f64;
411                ghosts.push(build_one_ghost(
412                    child,
413                    components,
414                    stagger_delays,
415                    time_params,
416                    ghost_principal,
417                    next_id,
418                    anim,
419                    actx,
420                    stagger_delay,
421                    extra_delay,
422                    time_remap,
423                    parent_css,
424                    path,
425                    viewport,
426                    outer_scope,
427                    warn_unresolved,
428                    offset,
429                    ghost_opacity_scale,
430                ));
431            }
432        }
433        Strategy::Trail {
434            copies,
435            spacing,
436            falloff,
437        } => {
438            let mut trail_nodes = Vec::with_capacity(copies as usize);
439            for i in 1..=copies {
440                let offset = i as f64 * spacing;
441                let ghost_opacity_scale = falloff.powi(i as i32);
442                trail_nodes.push(build_one_ghost(
443                    child,
444                    components,
445                    stagger_delays,
446                    time_params,
447                    ghost_principal,
448                    next_id,
449                    anim,
450                    actx,
451                    stagger_delay,
452                    extra_delay,
453                    time_remap,
454                    parent_css,
455                    path,
456                    viewport,
457                    outer_scope,
458                    warn_unresolved,
459                    offset,
460                    ghost_opacity_scale,
461                ));
462            }
463            trail_nodes.reverse();
464            ghosts = trail_nodes;
465        }
466    }
467
468    ghosts
469}
470
471#[allow(clippy::too_many_arguments)]
472fn build_child<'a>(
473    child: &'a ChildComponent,
474    components: &mut Vec<Option<&'a ChildComponent>>,
475    stagger_delays: &mut Vec<f64>,
476    time_params: &mut Vec<(f64, f64)>,
477    ghost_principal: &mut Vec<(NodeId, NodeId)>,
478    next_id: &mut NodeId,
479    anim: Option<BuildAnimationCtx>,
480    path: String,
481    stagger_delay: f64,
482    time_remap: (f64, f64),
483    parent_css: &CssStyle,
484    viewport: (f32, f32),
485    outer_scope: Option<&dyn Scope>,
486    warn_unresolved: bool,
487) -> Vec<BoxNode> {
488    let local_actx = anim.map(|a| {
489        let (scale, shift) = time_remap;
490        BuildAnimationCtx {
491            time: a.time * scale + shift,
492            scenario_time: a.scenario_time,
493            scene_duration: a.scene_duration,
494            fps: a.fps,
495        }
496    });
497
498    let anim_delay = stagger_delay
499        + child
500            .component
501            .as_timed()
502            .and_then(|t| t.timing().0)
503            .unwrap_or(0.0);
504
505    let mut ghosts: Vec<BoxNode> = Vec::new();
506    if let Some(actx) = local_actx {
507        if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) {
508            ghosts = build_ghosts(
509                child,
510                components,
511                stagger_delays,
512                time_params,
513                ghost_principal,
514                next_id,
515                anim,
516                actx,
517                stagger_delay,
518                anim_delay,
519                time_remap,
520                &effects,
521                parent_css,
522                &path,
523                viewport,
524                outer_scope,
525                warn_unresolved,
526            );
527        }
528    }
529
530    let id = *next_id;
531    *next_id += 1;
532    components.push(Some(child));
533    stagger_delays.push(anim_delay);
534    time_params.push(time_remap);
535    for g in &ghosts {
536        ghost_principal.push((g.id, id));
537    }
538
539    let mut css = component_css(&child.component);
540
541    if let Some((x, y)) = child.absolute_position() {
542        css.position = Some(Position::Absolute);
543        css.left = Some(CLP::Px(x));
544        css.top = Some(CLP::Px(y));
545    }
546    if let Some(z) = child.z_index {
547        css.z_index = Some(z);
548    }
549
550    rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
551
552    if let Some(animatable) = child.component.as_animatable() {
553        let steps = animatable.timeline_steps();
554        if steps.iter().any(|s| s.style.is_some()) {
555            let t = local_actx.map(|a| a.time).unwrap_or(0.0);
556            let skip_opacity = css.transition.is_some();
557            apply_style_states(&mut css, steps, t - anim_delay, skip_opacity);
558            let overrides = resolve_transition_css_overrides(
559                child.component.as_styled().style_config(),
560                steps,
561                t - anim_delay,
562            );
563            if let Some(br) = overrides.border_radius {
564                css.border_radius = Some(br);
565            }
566            if let Some(bg) = overrides.background {
567                css.background = Some(bg);
568            }
569        }
570    }
571
572    if let Some(actx) = local_actx {
573        if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) {
574            let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration);
575            apply_animated_props(&mut css, &props);
576            apply_glow_effect(&mut css, &effects);
577            carry_paint_pass_effects(&mut css, &effects);
578            apply_directional_blur_props(&mut css, &props);
579            apply_motion_blur_smear(
580                &mut css,
581                &effects,
582                &child.component,
583                anim_delay,
584                actx.time,
585                actx.scene_duration,
586                actx.fps,
587            );
588        }
589        apply_pointer_path_transform(&mut css, &child.component, actx.time);
590        resolve_computed_style(
591            &mut css,
592            &path,
593            actx,
594            viewport,
595            outer_scope,
596            warn_unresolved,
597        );
598    }
599
600    if let Some(ar) = css.audio_reactive.take() {
601        use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag};
602        use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
603
604        if let Some(actx) = local_actx {
605            let cache = audio_analysis_cache();
606            let analysis_opt = if let Some(ref src) = ar.track {
607                cache.get(src).map(|r| r.clone())
608            } else {
609                cache.iter().next().map(|r| r.value().clone())
610            };
611
612            let raw = if let Some(analysis) = analysis_opt {
613                match &ar.source {
614                    AudioSource::Amplitude(AudioSourceTag::Amplitude) => {
615                        analysis.amplitude_smoothed(actx.scenario_time, ar.smoothing_frames)
616                    }
617                    AudioSource::Band { band } => {
618                        analysis.band_smoothed(actx.scenario_time, *band, ar.smoothing_frames)
619                    }
620                }
621            } else {
622                0.0
623            };
624
625            let lerped = ar.min as f32 + raw * (ar.max - ar.min) as f32;
626
627            match ar.property {
628                AudioReactiveProperty::Opacity => {
629                    let base = css.opacity.unwrap_or(1.0);
630                    css.opacity = Some(base * lerped.clamp(0.0, 1.0));
631                }
632                AudioReactiveProperty::Scale => {
633                    let s = lerped.max(0.0);
634                    let tx = css.transform.get_or_insert_with(Vec::new);
635                    tx.push(rustmotion_core::css::style::TransformFn::Scale { x: s, y: s });
636                }
637                AudioReactiveProperty::TranslateY => {
638                    use rustmotion_core::css::units::LengthPercentage;
639                    let tx = css.transform.get_or_insert_with(Vec::new);
640                    tx.push(rustmotion_core::css::style::TransformFn::TranslateY {
641                        y: LengthPercentage::Px(lerped),
642                    });
643                }
644                AudioReactiveProperty::Rotation => {
645                    let tx = css.transform.get_or_insert_with(Vec::new);
646                    tx.push(rustmotion_core::css::style::TransformFn::Rotate { deg: lerped });
647                }
648            }
649        }
650    }
651
652    let window = child.component.as_timed().and_then(|t| {
653        let (start, end) = t.timing();
654        (start.is_some() || end.is_some()).then_some({
655            let (scale, shift) = time_remap;
656            let to_global = |t_local: f64| -> f64 {
657                if scale.abs() < 1e-10 {
658                    t_local
659                } else {
660                    (t_local - shift) / scale
661                }
662            };
663            rustmotion_core::engine::box_tree::PaintWindow {
664                start: start.map(|s| to_global(s + stagger_delay)),
665                end: end.map(|e| to_global(e + stagger_delay)),
666            }
667        })
668    });
669
670    let children_boxes = container_children(
671        &child.component,
672        components,
673        stagger_delays,
674        time_params,
675        ghost_principal,
676        next_id,
677        anim,
678        &path,
679        stagger_delay,
680        time_remap,
681        &css,
682        viewport,
683        outer_scope,
684        warn_unresolved,
685    );
686    let intrinsic = component_intrinsic(&child.component, &css);
687
688    let principal = BoxNode {
689        id,
690        kind: BoxKind::Component(Arc::new(id)),
691        css,
692        children: children_boxes,
693        intrinsic,
694        source_path: Some(path),
695        window,
696    };
697
698    let mut result = ghosts;
699    result.push(principal);
700    result
701}
702
703fn resolve_computed_style(
704    css: &mut CssStyle,
705    path: &str,
706    actx: BuildAnimationCtx,
707    viewport: (f32, f32),
708    outer_scope: Option<&dyn Scope>,
709    warn_unresolved: bool,
710) {
711    if css.expr.is_empty() {
712        return;
713    }
714    let clock = rustmotion_core::css::FrameClock {
715        t: actx.time,
716        t_abs: actx.scenario_time,
717        duration: actx.scene_duration,
718        width: viewport.0 as f64,
719        height: viewport.1 as f64,
720        fps: actx.fps as f64,
721    };
722    let scope = rustmotion_core::css::ComposedScope {
723        clock,
724        outer: outer_scope,
725    };
726    match css.expr.resolve(&scope) {
727        Ok(props) => apply_animated_props(css, &props),
728        Err(e) => {
729            if warn_unresolved {
730                eprintln!(
731                    "warning: {path}: {e} — this property keeps its pre-expression value this frame"
732                );
733            }
734        }
735    }
736}
737
738pub fn effective_effects(
739    component: &Component,
740    extra_delay: f64,
741    t: f64,
742) -> Option<std::borrow::Cow<'_, [rustmotion_core::schema::AnimationEffect]>> {
743    let animatable = component.as_animatable()?;
744    let effects = animatable.animation_effects();
745    let steps = animatable.timeline_steps();
746    let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_));
747    let synthesized =
748        transition_keyframes(component.as_styled().style_config(), steps, smooth_color);
749    if steps.is_empty() && synthesized.is_empty() && extra_delay == 0.0 {
750        return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects));
751    }
752    let mut merged = effects.to_vec();
753    for step in steps.iter().filter(|s| s.at <= t - extra_delay) {
754        for effect in &step.animation {
755            let mut e = effect.clone();
756            e.shift_delay(step.at);
757            merged.push(e);
758        }
759    }
760    merged.extend(synthesized);
761    if extra_delay != 0.0 {
762        for e in &mut merged {
763            e.shift_delay(extra_delay);
764        }
765    }
766    (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged))
767}
768
769pub(crate) fn apply_style_states(
770    css: &mut CssStyle,
771    steps: &[rustmotion_core::schema::TimelineStep],
772    t: f64,
773    skip_opacity: bool,
774) {
775    let mut due: Vec<&rustmotion_core::schema::TimelineStep> = steps
776        .iter()
777        .filter(|s| s.style.is_some() && s.at <= t)
778        .collect();
779    if due.is_empty() {
780        return;
781    }
782    due.sort_by(|a, b| a.at.total_cmp(&b.at));
783
784    let Ok(serde_json::Value::Object(mut base)) = serde_json::to_value(&*css) else {
785        return;
786    };
787    for step in due {
788        let Some(style) = step.style.as_deref() else {
789            continue;
790        };
791        let Ok(serde_json::Value::Object(state)) = serde_json::to_value(style) else {
792            continue;
793        };
794        for (k, v) in state {
795            if v.is_null() {
796                continue;
797            }
798            if k == "animation" && v.as_array().is_some_and(|a| a.is_empty()) {
799                continue;
800            }
801            if skip_opacity && k == "opacity" {
802                continue;
803            }
804            base.insert(k, v);
805        }
806    }
807    let saved_expr = css.expr.clone();
808    if let Ok(mut merged) = serde_json::from_value::<CssStyle>(serde_json::Value::Object(base)) {
809        merged.expr = merged.expr.prefer(saved_expr);
810        *css = merged;
811    }
812}
813
814pub(crate) fn transition_keyframes(
815    base: &CssStyle,
816    steps: &[rustmotion_core::schema::TimelineStep],
817    smooth_color: bool,
818) -> Vec<rustmotion_core::schema::AnimationEffect> {
819    use rustmotion_core::schema::{
820        Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig,
821    };
822
823    let has_states = steps.iter().any(|s| s.style.is_some());
824    if !has_states {
825        return Vec::new();
826    }
827    let (duration, easing) = match base.transition.as_ref() {
828        Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()),
829        _ => (0.001, rustmotion_core::schema::EasingType::Linear),
830    };
831    let smooth_opacity = base.transition.is_some();
832
833    let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
834        steps.iter().filter(|s| s.style.is_some()).collect();
835    sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
836
837    let base_opacity = base.opacity.unwrap_or(1.0) as f64;
838    let mut opacity_kfs: Vec<Keyframe> = Vec::new();
839    let mut color_kfs: Vec<Keyframe> = Vec::new();
840    let mut prev_opacity = base_opacity;
841    let mut prev_color = base.color.as_ref().map(|c| c.to_css_string());
842
843    let kf_num = |time: f64, v: f64| Keyframe {
844        time,
845        value: KeyframeValue::Number(v),
846        easing: None,
847    };
848    let kf_color = |time: f64, c: String| Keyframe {
849        time,
850        value: KeyframeValue::Color(c),
851        easing: None,
852    };
853    let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
854        let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
855        let start = at.max(floor);
856        let mut from = from;
857        let mut to = to;
858        from.time = start;
859        to.time = to.time.max(start + 1e-6);
860        kfs.push(from);
861        kfs.push(to);
862    };
863
864    for step in sorted {
865        let style = step.style.as_deref().unwrap();
866        if smooth_opacity && base_opacity > 1e-6 {
867            if let Some(o) = style.opacity {
868                let target = o as f64;
869                if (target - prev_opacity).abs() > 1e-6 {
870                    push_pair(
871                        &mut opacity_kfs,
872                        step.at,
873                        kf_num(step.at, prev_opacity / base_opacity),
874                        kf_num(step.at + duration, target / base_opacity),
875                    );
876                    prev_opacity = target;
877                }
878            }
879        }
880        if smooth_color {
881            if let Some(c) = style.color.as_ref() {
882                let target = c.to_css_string();
883                if prev_color.as_ref() != Some(&target) {
884                    if let Some(from) = prev_color.clone() {
885                        push_pair(
886                            &mut color_kfs,
887                            step.at,
888                            kf_color(step.at, from),
889                            kf_color(step.at + duration, target.clone()),
890                        );
891                    }
892                    prev_color = Some(target);
893                }
894            }
895        }
896    }
897
898    let mut out = Vec::new();
899    let mut push_effect = |property: &str, keyframes: Vec<Keyframe>| {
900        if keyframes.is_empty() {
901            return;
902        }
903        out.push(AnimationEffect::Keyframes(KeyframesConfig {
904            keyframes: vec![Animation {
905                property: property.to_string(),
906                keyframes,
907                easing: easing.clone(),
908                spring: None,
909            }],
910            delay: 0.0,
911            duration: 0.0,
912            repeat: false,
913        }));
914    };
915    push_effect("opacity", opacity_kfs);
916    push_effect("color", color_kfs);
917    out
918}
919
920pub(crate) struct TransitionCssOverrides {
921    pub border_radius: Option<rustmotion_core::css::style::BorderRadius>,
922    pub background: Option<rustmotion_core::css::style::Background>,
923}
924
925pub(crate) fn resolve_transition_css_overrides(
926    base: &CssStyle,
927    steps: &[rustmotion_core::schema::TimelineStep],
928    t: f64,
929) -> TransitionCssOverrides {
930    use rustmotion_core::css::style::{Background, BorderRadius, Color};
931    use rustmotion_core::css::units::LengthPercentage as CssLP;
932    use rustmotion_core::engine::animator::resolve_keyframe_track;
933    use rustmotion_core::schema::{Animation, Keyframe, KeyframeValue};
934
935    let mut out = TransitionCssOverrides {
936        border_radius: None,
937        background: None,
938    };
939    let Some(tr) = base.transition.as_ref() else {
940        return out;
941    };
942    if tr.duration() <= 0.0 {
943        return out;
944    }
945    let duration = tr.duration();
946    let easing = tr.easing();
947
948    let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
949        steps.iter().filter(|s| s.style.is_some()).collect();
950    sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
951
952    let mut prev_radius = base
953        .border_radius
954        .as_ref()
955        .and_then(BorderRadius::absolute_px);
956    let mut prev_bg = base.background.as_ref().and_then(Background::solid_hex);
957    let mut radius_kfs: Vec<Keyframe> = Vec::new();
958    let mut bg_kfs: Vec<Keyframe> = Vec::new();
959
960    let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
961        let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
962        let start = at.max(floor);
963        let mut from = from;
964        let mut to = to;
965        from.time = start;
966        to.time = to.time.max(start + 1e-6);
967        kfs.push(from);
968        kfs.push(to);
969    };
970
971    for step in sorted {
972        let style = step.style.as_deref().unwrap();
973        if let Some(br) = style.border_radius.as_ref() {
974            match br.absolute_px() {
975                Some(target) => {
976                    if prev_radius != Some(target) {
977                        if let Some(from) = prev_radius {
978                            push_pair(
979                                &mut radius_kfs,
980                                step.at,
981                                Keyframe {
982                                    time: step.at,
983                                    value: KeyframeValue::Number(from as f64),
984                                    easing: None,
985                                },
986                                Keyframe {
987                                    time: step.at + duration,
988                                    value: KeyframeValue::Number(target as f64),
989                                    easing: None,
990                                },
991                            );
992                        }
993                        prev_radius = Some(target);
994                    }
995                }
996                None => prev_radius = None,
997            }
998        }
999        if let Some(bg) = style.background.as_ref() {
1000            match bg.solid_hex() {
1001                Some(target) => {
1002                    if prev_bg.as_deref() != Some(target.as_str()) {
1003                        if let Some(from) = prev_bg.clone() {
1004                            push_pair(
1005                                &mut bg_kfs,
1006                                step.at,
1007                                Keyframe {
1008                                    time: step.at,
1009                                    value: KeyframeValue::Color(from),
1010                                    easing: None,
1011                                },
1012                                Keyframe {
1013                                    time: step.at + duration,
1014                                    value: KeyframeValue::Color(target.clone()),
1015                                    easing: None,
1016                                },
1017                            );
1018                        }
1019                        prev_bg = Some(target);
1020                    }
1021                }
1022                None => prev_bg = None,
1023            }
1024        }
1025    }
1026
1027    if !radius_kfs.is_empty() {
1028        let anim = Animation {
1029            property: "border_radius".to_string(),
1030            keyframes: radius_kfs,
1031            easing: easing.clone(),
1032            spring: None,
1033        };
1034        if let KeyframeValue::Number(v) = resolve_keyframe_track(&anim, t) {
1035            out.border_radius = Some(BorderRadius::Uniform(CssLP::Px(v as f32)));
1036        }
1037    }
1038    if !bg_kfs.is_empty() {
1039        let anim = Animation {
1040            property: "background".to_string(),
1041            keyframes: bg_kfs,
1042            easing,
1043            spring: None,
1044        };
1045        if let KeyframeValue::Color(c) = resolve_keyframe_track(&anim, t) {
1046            out.background = Some(Background::Color(Color::String(c)));
1047        }
1048    }
1049    out
1050}
1051
1052fn carry_paint_pass_effects(
1053    css: &mut CssStyle,
1054    effects: &[rustmotion_core::schema::AnimationEffect],
1055) {
1056    use rustmotion_core::schema::AnimationEffect;
1057    if effects
1058        .iter()
1059        .any(|e| matches!(e, AnimationEffect::Shimmer(_)))
1060    {
1061        css.animation = effects
1062            .iter()
1063            .filter(|e| matches!(e, AnimationEffect::Shimmer(_)))
1064            .cloned()
1065            .collect();
1066    }
1067}
1068
1069fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect]) {
1070    use rustmotion_core::css::style::{Color, FilterFn};
1071    use rustmotion_core::css::units::Length;
1072    use rustmotion_core::engine::animator::find_glow_effect;
1073
1074    let Some(glow) = find_glow_effect(effects) else {
1075        return;
1076    };
1077
1078    let (r, g, b, a) = rustmotion_core::engine::renderer::parse_css_color(&glow.color)
1079        .unwrap_or((255, 255, 255, 255));
1080    let alpha = ((a as f32 / 255.0) * glow.intensity.max(0.0)).clamp(0.0, 1.0);
1081    let radius = glow.radius.max(0.0);
1082    if radius <= 0.0 || alpha <= 0.0 {
1083        return;
1084    }
1085
1086    let shadow = FilterFn::DropShadow {
1087        offset_x: Length::Px(0.0),
1088        offset_y: Length::Px(0.0),
1089        blur: Some(Length::Px(radius)),
1090        color: Some(Color::Rgba { r, g, b, a: alpha }),
1091    };
1092    css.filter.get_or_insert_with(Vec::new).push(shadow);
1093}
1094
1095fn apply_pointer_path_transform(css: &mut CssStyle, component: &Component, time: f64) {
1096    use rustmotion_core::css::style::TransformFn;
1097    use rustmotion_core::css::units::LengthPercentage as CssLP;
1098
1099    let Component::Pointer(p) = component else {
1100        return;
1101    };
1102    if p.path.is_empty() {
1103        return;
1104    }
1105    let (dx, dy) = crate::cursor::waypoint_offset(&p.path, time, p.click_duration, p.path_easing);
1106    css.transform
1107        .get_or_insert_with(Vec::new)
1108        .push(TransformFn::Translate {
1109            x: CssLP::Px(dx),
1110            y: CssLP::Px(dy),
1111        });
1112}
1113
1114fn apply_directional_blur_props(css: &mut CssStyle, props: &AnimatedProperties) {
1115    use rustmotion_core::css::style::FilterFn;
1116    use rustmotion_core::css::units::Length;
1117
1118    if props.blur_x <= 0.0 && props.blur_y <= 0.0 {
1119        return;
1120    }
1121    css.filter
1122        .get_or_insert_with(Vec::new)
1123        .push(FilterFn::Blur {
1124            radius: None,
1125            radius_x: (props.blur_x > 0.0).then_some(Length::Px(props.blur_x)),
1126            radius_y: (props.blur_y > 0.0).then_some(Length::Px(props.blur_y)),
1127        });
1128}
1129
1130#[allow(clippy::too_many_arguments)]
1131fn apply_motion_blur_smear(
1132    css: &mut CssStyle,
1133    effects: &[rustmotion_core::schema::AnimationEffect],
1134    component: &Component,
1135    extra_delay: f64,
1136    t: f64,
1137    scene_duration: f64,
1138    fps: u32,
1139) {
1140    use rustmotion_core::css::style::FilterFn;
1141    use rustmotion_core::css::units::Length;
1142    use rustmotion_core::schema::AnimationEffect;
1143    use rustmotion_core::schema::MotionBlurMode;
1144
1145    let Some(cfg) = effects.iter().find_map(|e| match e {
1146        AnimationEffect::MotionBlur(c) if c.mode == MotionBlurMode::Smear => Some(c),
1147        _ => None,
1148    }) else {
1149        return;
1150    };
1151
1152    let shutter_window = (cfg.shutter / fps.max(1) as f64).max(1e-6);
1153    let sample = |at: f64| -> (f32, f32) {
1154        match effective_effects(component, extra_delay, at) {
1155            Some(e) => {
1156                let p = resolve_props_for_effects(&e, at, scene_duration);
1157                (p.translate_x, p.translate_y)
1158            }
1159            None => (0.0, 0.0),
1160        }
1161    };
1162    let (x0, y0) = sample(t - shutter_window);
1163    let (x1, y1) = sample(t);
1164    let dx = x1 - x0;
1165    let dy = y1 - y0;
1166    let magnitude = dx.hypot(dy);
1167    if magnitude < 0.5 {
1168        return;
1169    }
1170    let angle = dy.atan2(dx).to_degrees();
1171    css.filter
1172        .get_or_insert_with(Vec::new)
1173        .push(FilterFn::DirectionalBlur {
1174            angle,
1175            radius: Length::Px(magnitude),
1176        });
1177}
1178
1179fn component_intrinsic(
1180    component: &Component,
1181    cascaded_css: &CssStyle,
1182) -> Option<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
1183    let cascaded_component = component.with_cascaded_style(cascaded_css);
1184    let component = cascaded_component.as_ref().unwrap_or(component);
1185
1186    use Component::*;
1187    match component {
1188        Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
1189        GradientText(t) => Some(Arc::new(
1190            crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t),
1191        )),
1192        Caption(c) => Some(Arc::new(crate::intrinsic::CaptionIntrinsic::from_caption(
1193            c,
1194        ))),
1195        Kbd(k) => Some(Arc::new(crate::intrinsic::KbdIntrinsic::from_kbd(k))),
1196        Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter(
1197            c,
1198        ))),
1199        NumberWheel(w) => Some(Arc::new(
1200            crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w),
1201        )),
1202        Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))),
1203        Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))),
1204        RichText(rt) => Some(Arc::new(
1205            crate::intrinsic::RichTextIntrinsic::from_rich_text(rt),
1206        )),
1207        _ => None,
1208    }
1209}
1210
1211#[allow(clippy::too_many_arguments)]
1212fn container_children<'a>(
1213    component: &'a Component,
1214    components: &mut Vec<Option<&'a ChildComponent>>,
1215    stagger_delays: &mut Vec<f64>,
1216    time_params: &mut Vec<(f64, f64)>,
1217    ghost_principal: &mut Vec<(NodeId, NodeId)>,
1218    next_id: &mut NodeId,
1219    anim: Option<BuildAnimationCtx>,
1220    parent_path: &str,
1221    inherited_delay: f64,
1222    time_remap: (f64, f64),
1223    parent_css: &CssStyle,
1224    viewport: (f32, f32),
1225    outer_scope: Option<&dyn Scope>,
1226    warn_unresolved: bool,
1227) -> Vec<BoxNode> {
1228    let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option<f32>, f64, f64) =
1229        match component {
1230            Component::Container(c) => (
1231                &c.children,
1232                c.stagger,
1233                c.time_scale.unwrap_or(1.0),
1234                c.time_offset.unwrap_or(0.0),
1235            ),
1236            _ => return Vec::new(),
1237        };
1238
1239    let child_scale = child_scale.max(1e-6);
1240
1241    let (scale_acc, shift_acc) = time_remap;
1242    let new_scale = child_scale * scale_acc;
1243    let new_shift = child_scale * (shift_acc - child_offset);
1244    let child_remap = (new_scale, new_shift);
1245
1246    let step = stagger.unwrap_or(0.0) as f64;
1247    let mut result = Vec::new();
1248    for (j, c) in children.iter().enumerate() {
1249        result.extend(build_child(
1250            c,
1251            components,
1252            stagger_delays,
1253            time_params,
1254            ghost_principal,
1255            next_id,
1256            anim,
1257            format!("{parent_path}/children/{j}"),
1258            inherited_delay + j as f64 * step,
1259            child_remap,
1260            parent_css,
1261            viewport,
1262            outer_scope,
1263            warn_unresolved,
1264        ));
1265    }
1266    result
1267}
1268
1269fn component_css(component: &Component) -> CssStyle {
1270    let mut css = component_style(component).clone();
1271    apply_default_display(component, &mut css);
1272    apply_intrinsic_overrides(component, &mut css);
1273    css
1274}
1275
1276fn apply_default_display(component: &Component, css: &mut CssStyle) {
1277    use rustmotion_core::css::style::Display;
1278    if css.display.is_some() {
1279        return;
1280    }
1281    if matches!(component, Component::Container(_)) {
1282        css.display = Some(if css.grid_template_columns.is_some() {
1283            Display::Grid
1284        } else {
1285            Display::Flex
1286        });
1287    }
1288}
1289
1290fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) -> f32 {
1291    use rustmotion_core::engine::renderer::{
1292        emoji_typeface, measure_text_with_fallback, subpixel_font, typeface_with_fallback,
1293    };
1294    let style = if bold {
1295        skia_safe::FontStyle::bold()
1296    } else {
1297        skia_safe::FontStyle::normal()
1298    };
1299    let Ok(typeface) = typeface_with_fallback(family, style) else {
1300        return 0.0;
1301    };
1302    let font = subpixel_font(typeface, font_size);
1303    let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
1304    measure_text_with_fallback(text, &font, &emoji_font, 0.0)
1305}
1306
1307fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
1308    use Component::*;
1309    match component {
1310        Text(t) => {
1311            let nowrap = matches!(
1312                t.style.white_space,
1313                Some(
1314                    rustmotion_core::css::style::WhiteSpace::Nowrap
1315                        | rustmotion_core::css::style::WhiteSpace::Pre
1316                )
1317            );
1318            if nowrap && css.min_width.is_none() {
1319                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1320            }
1321        }
1322        GradientText(t) => {
1323            let nowrap = matches!(
1324                t.style.white_space,
1325                Some(
1326                    rustmotion_core::css::style::WhiteSpace::Nowrap
1327                        | rustmotion_core::css::style::WhiteSpace::Pre
1328                )
1329            );
1330            if nowrap && css.min_width.is_none() {
1331                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1332            }
1333        }
1334        Caption(c) => {
1335            let nowrap = matches!(
1336                c.style.white_space,
1337                Some(
1338                    rustmotion_core::css::style::WhiteSpace::Nowrap
1339                        | rustmotion_core::css::style::WhiteSpace::Pre
1340                )
1341            );
1342            if nowrap && css.min_width.is_none() {
1343                css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1344            }
1345        }
1346        Divider(d) => match d.direction {
1347            DividerDirection::Horizontal => {
1348                if css.height.is_none() {
1349                    css.height = Some(CSize::Length(CLP::Px(d.thickness)));
1350                }
1351                if css.width.is_none() {
1352                    css.width = match d.length {
1353                        Some(l) => Some(CSize::Length(CLP::Px(l))),
1354                        None => Some(CSize::Length(CLP::String("100%".into()))),
1355                    };
1356                }
1357                if css.align_self.is_none() {
1358                    css.align_self = Some(AlignSelf::Stretch);
1359                }
1360            }
1361            DividerDirection::Vertical => {
1362                if css.width.is_none() {
1363                    css.width = Some(CSize::Length(CLP::Px(d.thickness)));
1364                }
1365                if css.height.is_none() {
1366                    css.height = match d.length {
1367                        Some(l) => Some(CSize::Length(CLP::Px(l))),
1368                        None => Some(CSize::Length(CLP::String("100%".into()))),
1369                    };
1370                }
1371            }
1372        },
1373        Line(l) => {
1374            let w = (l.x2 - l.x1).abs().max(1.0);
1375            let h = (l.y2 - l.y1).abs().max(1.0);
1376            if css.width.is_none() {
1377                css.width = Some(CSize::Length(CLP::Px(w)));
1378            }
1379            if css.height.is_none() {
1380                css.height = Some(CSize::Length(CLP::Px(h)));
1381            }
1382        }
1383        Arrow(a) => {
1384            let pad = ARROW_BBOX_PADDING + a.arrow_size.max(0.0);
1385            let w = (a.x2 - a.x1).abs().max(1.0) + pad;
1386            let h = (a.y2 - a.y1).abs().max(1.0) + pad;
1387            if css.width.is_none() {
1388                css.width = Some(CSize::Length(CLP::Px(w)));
1389            }
1390            if css.height.is_none() {
1391                css.height = Some(CSize::Length(CLP::Px(h)));
1392            }
1393        }
1394        Connector(c) => {
1395            let pad = ARROW_BBOX_PADDING + c.arrow_size.max(0.0);
1396            let w = (c.to.x - c.from.x).abs().max(1.0) + pad;
1397            let h = (c.to.y - c.from.y).abs().max(1.0) + pad;
1398            if css.width.is_none() {
1399                css.width = Some(CSize::Length(CLP::Px(w)));
1400            }
1401            if css.height.is_none() {
1402                css.height = Some(CSize::Length(CLP::Px(h)));
1403            }
1404        }
1405        Cursor(cur) => {
1406            if css.width.is_none() {
1407                css.width = Some(CSize::Length(CLP::Px(cur.width)));
1408            }
1409            if css.height.is_none() {
1410                css.height = Some(CSize::Length(CLP::Px(cur.height)));
1411            }
1412        }
1413        SuccessCheck(c) => {
1414            apply_default_size(css, c.size, c.size);
1415        }
1416        Pointer(p) => {
1417            if css.width.is_none() {
1418                css.width = Some(CSize::Length(CLP::Px(p.size * 0.6)));
1419            }
1420            if css.height.is_none() {
1421                css.height = Some(CSize::Length(CLP::Px(p.size)));
1422            }
1423        }
1424        Particle(_) => {
1425            if css.width.is_none() {
1426                css.width = Some(CSize::Length(CLP::String("100%".into())));
1427            }
1428            if css.height.is_none() {
1429                css.height = Some(CSize::Length(CLP::String("100%".into())));
1430            }
1431        }
1432        Emitter(_) => {
1433            if css.width.is_none() {
1434                css.width = Some(CSize::Length(CLP::String("100%".into())));
1435            }
1436            if css.height.is_none() {
1437                css.height = Some(CSize::Length(CLP::String("100%".into())));
1438            }
1439        }
1440        Switch(c) => {
1441            if css.width.is_none() {
1442                css.width = Some(CSize::Length(CLP::Px(c.width)));
1443            }
1444            if css.height.is_none() {
1445                css.height = Some(CSize::Length(CLP::Px(c.height)));
1446            }
1447        }
1448        Slider(c) => {
1449            if css.width.is_none() {
1450                css.width = Some(CSize::Length(CLP::Px(c.width)));
1451            }
1452            if css.height.is_none() {
1453                css.height = Some(CSize::Length(CLP::Px(c.height)));
1454            }
1455        }
1456        Progress(c) => {
1457            if css.width.is_none() {
1458                css.width = Some(CSize::Length(CLP::Px(c.width)));
1459            }
1460            if css.height.is_none() {
1461                css.height = Some(CSize::Length(CLP::Px(c.height)));
1462            }
1463        }
1464        List(c) => {
1465            if css.width.is_none() {
1466                css.width = Some(CSize::Length(CLP::Px(c.width)));
1467            }
1468            if css.height.is_none() {
1469                let font_size = c
1470                    .style
1471                    .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
1472                let line_height = font_size * 1.3;
1473                let n = c.items.len() as f32;
1474                let h = n * line_height + (n - 1.0).max(0.0) * c.gap;
1475                css.height = Some(CSize::Length(CLP::Px(h)));
1476            }
1477        }
1478        Timeline(c) => {
1479            if css.width.is_none() {
1480                css.width = Some(CSize::Length(CLP::Px(c.width)));
1481            }
1482            if css.height.is_none() {
1483                let r = c.node_radius;
1484                let h = match c.direction {
1485                    TimelineDirection::Horizontal => r * 2.0 + c.font_size * 2.5 + 24.0,
1486                    TimelineDirection::Vertical => {
1487                        let n = c.steps.len().max(1) as f32;
1488                        n * (r * 2.0 + 64.0)
1489                    }
1490                };
1491                css.height = Some(CSize::Length(CLP::Px(h)));
1492            }
1493        }
1494        Rating(c) => {
1495            if css.width.is_none() {
1496                let count = c.max as f32;
1497                let w = count * c.size + (count - 1.0).max(0.0) * c.gap;
1498                css.width = Some(CSize::Length(CLP::Px(w)));
1499            }
1500            if css.height.is_none() {
1501                css.height = Some(CSize::Length(CLP::Px(c.size)));
1502            }
1503        }
1504        Avatar(c) => {
1505            if css.width.is_none() {
1506                css.width = Some(CSize::Length(CLP::Px(c.size)));
1507            }
1508            if css.height.is_none() {
1509                css.height = Some(CSize::Length(CLP::Px(c.size)));
1510            }
1511        }
1512        AvatarGroup(c) => {
1513            if css.width.is_none() {
1514                let visible = c.visible_count() as f32;
1515                let extra = if c.overflow_count() > 0 { 1.0 } else { 0.0 };
1516                let total = visible + extra;
1517                let step = (c.size - c.overlap).max(0.0);
1518                let w = if total <= 0.0 {
1519                    0.0
1520                } else {
1521                    c.size + (total - 1.0) * step
1522                };
1523                css.width = Some(CSize::Length(CLP::Px(w)));
1524            }
1525            if css.height.is_none() {
1526                css.height = Some(CSize::Length(CLP::Px(c.size)));
1527            }
1528        }
1529        QrCode(c) => {
1530            if css.width.is_none() {
1531                css.width = Some(CSize::Length(CLP::Px(c.size)));
1532            }
1533            if css.height.is_none() {
1534                css.height = Some(CSize::Length(CLP::Px(c.size)));
1535            }
1536        }
1537        Countdown(c) if (css.width.is_none() || css.height.is_none()) => {
1538            let visible = [c.show_hours, c.show_minutes, c.show_seconds]
1539                .iter()
1540                .filter(|v| **v)
1541                .count() as f32;
1542            let box_w = c.digit_size * 0.75;
1543            let box_h = c.digit_size * 1.2;
1544            let w = (visible * 2.0 * box_w) + ((visible - 1.0).max(0.0) * c.gap);
1545            if css.width.is_none() {
1546                css.width = Some(CSize::Length(CLP::Px(w)));
1547            }
1548            if css.height.is_none() {
1549                css.height = Some(CSize::Length(CLP::Px(box_h)));
1550            }
1551        }
1552        AudioSpectrum(_) => {
1553            if css.width.is_none() {
1554                css.width = Some(CSize::Length(CLP::Px(400.0)));
1555            }
1556            if css.height.is_none() {
1557                css.height = Some(CSize::Length(CLP::Px(120.0)));
1558            }
1559        }
1560        Waveform(_) => {
1561            if css.width.is_none() {
1562                css.width = Some(CSize::Length(CLP::Px(400.0)));
1563            }
1564            if css.height.is_none() {
1565                css.height = Some(CSize::Length(CLP::Px(80.0)));
1566            }
1567        }
1568
1569        Callout(t) => {
1570            let font_size = t
1571                .style
1572                .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
1573            let family = t.style.font_family_or("Inter");
1574            let text_w = measure_text_line_width(&t.text, font_size, family, false);
1575            let h_pad = 12.0;
1576            let v_pad = 16.0;
1577            let line_h = font_size * 1.4;
1578            let (extra_w, extra_h) = match t.arrow_direction {
1579                CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0),
1580                CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size),
1581            };
1582            apply_default_size(
1583                css,
1584                text_w + h_pad * 2.0 + extra_w,
1585                line_h + v_pad + extra_h,
1586            );
1587        }
1588        Tooltip(t) => {
1589            let font_size = t.style.font_size_px_ctx(
1590                &crate::intrinsic::measure_time_font_size_ctx(0.0),
1591                t.font_size,
1592            );
1593            let family = t.style.font_family_or("Inter");
1594            let text_w = measure_text_line_width(&t.text, font_size, family, false);
1595            let h_pad = 12.0;
1596            let v_pad = 16.0;
1597            let line_h = font_size * 1.4;
1598            let (extra_w, extra_h) = match t.arrow {
1599                TooltipArrow::Left | TooltipArrow::Right => (t.arrow_size, 0.0),
1600                TooltipArrow::Top | TooltipArrow::Bottom | TooltipArrow::None => {
1601                    (0.0, t.arrow_size)
1602                }
1603            };
1604            apply_default_size(
1605                css,
1606                text_w + h_pad * 2.0 + extra_w,
1607                line_h + v_pad + extra_h,
1608            );
1609        }
1610        PillNav(p) => {
1611            let font_size = p
1612                .style
1613                .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0);
1614            let family = p.style.font_family_or("Inter");
1615            let h_pad = font_size * 1.2;
1616            let n = p.items.len() as f32;
1617            let labels_w: f32 = p
1618                .items
1619                .iter()
1620                .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0)
1621                .sum();
1622            let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
1623            apply_default_size(css, total_w, p.height);
1624        }
1625        Marquee(m) => {
1626            let font_size = m.style.font_size_px_ctx(
1627                &crate::intrinsic::measure_time_font_size_ctx(0.0),
1628                m.font_size,
1629            );
1630            apply_default_size(css, 800.0, font_size * 2.0);
1631        }
1632        Stepper(s) => {
1633            let n = (s.steps.len().max(1)) as f32;
1634            let has_desc = s.steps.iter().any(|st| st.description.is_some());
1635            const LABEL_FS: f32 = 14.0;
1636            const DESC_FS: f32 = 11.0;
1637            let max_label_w = s
1638                .steps
1639                .iter()
1640                .map(|st| measure_text_line_width(&st.label, LABEL_FS, "Inter", false))
1641                .fold(0.0_f32, f32::max);
1642            let max_desc_w = s
1643                .steps
1644                .iter()
1645                .filter_map(|st| st.description.as_deref())
1646                .map(|d| measure_text_line_width(d, DESC_FS, "Inter", false))
1647                .fold(0.0_f32, f32::max);
1648            match s.orientation {
1649                StepperOrientation::Horizontal => {
1650                    let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
1651                    let label_h = LABEL_FS * 1.3;
1652                    let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
1653                    let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
1654                    apply_default_size(css, per_step * n, h);
1655                }
1656                StepperOrientation::Vertical => {
1657                    let label_w = max_label_w.max(max_desc_w);
1658                    let w = s.node_size + 12.0 + label_w + 24.0;
1659                    let label_block = if has_desc {
1660                        LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
1661                    } else {
1662                        LABEL_FS * 1.3 + 8.0
1663                    };
1664                    let per_step = (s.node_size * 2.0).max(label_block);
1665                    apply_default_size(css, w, per_step * n);
1666                }
1667            }
1668        }
1669        TagCloud(tc) => {
1670            let n = tc.tags.len();
1671            if n > 0 {
1672                let min_w = tc.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
1673                let max_w = tc.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
1674                let range = (max_w - min_w).max(0.001);
1675                const H_GAP: f32 = 12.0;
1676                const V_GAP: f32 = 8.0;
1677                let total_w: f32 = tc
1678                    .tags
1679                    .iter()
1680                    .map(|t| {
1681                        let normalized = ((t.weight - min_w) / range) as f32;
1682                        let fs =
1683                            tc.min_font_size + normalized * (tc.max_font_size - tc.min_font_size);
1684                        measure_text_line_width(&t.text, fs, "Inter", true) + H_GAP
1685                    })
1686                    .sum();
1687                const CAP_W: f32 = 600.0;
1688                let box_w = total_w.clamp(CAP_W * 0.3, CAP_W);
1689                let lines = (total_w / box_w).ceil().max(1.0);
1690                let line_h = tc.max_font_size * 1.3;
1691                let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP;
1692                apply_default_size(css, box_w, box_h);
1693            }
1694        }
1695        Heatmap(h) => {
1696            let rows = h.data.len();
1697            let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0);
1698            let step = h.cell_size + h.cell_gap;
1699            let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1700            let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1701            apply_default_size(css, w, hh);
1702        }
1703        Sparkline(_) => {
1704            apply_default_size(css, 120.0, 40.0);
1705        }
1706        Stat(_) => {
1707            apply_default_size(css, 280.0, 180.0);
1708        }
1709        Gauge(g) => {
1710            const TARGET_RADIUS: f32 = 88.0;
1711            let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0);
1712            apply_default_size(css, size, size);
1713        }
1714        DotMap(_) => {
1715            apply_default_size(css, 640.0, 320.0);
1716        }
1717        Comparison(_) => {
1718            apply_default_size(css, 520.0, 280.0);
1719        }
1720        Treemap(_) => {
1721            apply_default_size(css, 416.0, 368.0);
1722        }
1723        Chart(c) => {
1724            let round = matches!(
1725                c.chart_type,
1726                ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar
1727            );
1728            let (dw, dh) = if round {
1729                (320.0, 320.0)
1730            } else {
1731                (400.0, 300.0)
1732            };
1733            apply_default_size(css, dw, dh);
1734        }
1735        Skeleton(s) => match s.variant {
1736            SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0),
1737            SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0),
1738            SkeletonVariant::Text => {
1739                let n = s.lines.max(1) as f32;
1740                let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
1741                apply_default_size(css, 240.0, h);
1742            }
1743        },
1744        Mockup(m) => {
1745            let (dw, dh) = match m.device {
1746                MockupDevice::Iphone | MockupDevice::Android => (320.0, 690.0),
1747                MockupDevice::Laptop => (640.0, 400.0),
1748                MockupDevice::Browser => (640.0, 360.0),
1749            };
1750            apply_default_size(css, dw, dh);
1751        }
1752        Icon(_) => {
1753            apply_default_size(css, 64.0, 64.0);
1754        }
1755        Svg(_) => {
1756            apply_default_size(css, 200.0, 200.0);
1757        }
1758        Shape(_) => {
1759            apply_default_size(css, 80.0, 80.0);
1760        }
1761        Image(_) => {
1762            apply_default_size(css, 400.0, 300.0);
1763        }
1764        Video(_) | Gif(_) => {
1765            apply_default_size(css, 400.0, 225.0);
1766        }
1767        Lottie(_) => {
1768            apply_default_size(css, 300.0, 300.0);
1769        }
1770
1771        _ => {}
1772    }
1773}
1774
1775fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) {
1776    let ratio = css.aspect_ratio.filter(|r| *r > 0.0);
1777    match (css.width.is_some(), css.height.is_some()) {
1778        (true, true) => {}
1779        (true, false) => {
1780            let h = fixed_px(css.width.as_ref())
1781                .zip(ratio)
1782                .map(|(w, r)| w / r)
1783                .unwrap_or(dh);
1784            css.height = Some(CSize::Length(CLP::Px(h)));
1785        }
1786        (false, true) => {
1787            let w = fixed_px(css.height.as_ref())
1788                .zip(ratio)
1789                .map(|(h, r)| h * r)
1790                .unwrap_or(dw);
1791            css.width = Some(CSize::Length(CLP::Px(w)));
1792        }
1793        (false, false) => {
1794            css.width = Some(CSize::Length(CLP::Px(dw)));
1795            let h = ratio.map(|r| dw / r).unwrap_or(dh);
1796            css.height = Some(CSize::Length(CLP::Px(h)));
1797        }
1798    }
1799}
1800
1801fn fixed_px(size: Option<&CSize>) -> Option<f32> {
1802    match size? {
1803        CSize::Length(lp) => match lp.try_parse()? {
1804            rustmotion_core::css::units::ParsedLength::Px(v) => Some(v),
1805            _ => None,
1806        },
1807        _ => None,
1808    }
1809}
1810
1811fn component_style(c: &Component) -> &CssStyle {
1812    use Component::*;
1813    match c {
1814        Text(c) => &c.style,
1815        Shape(c) => &c.style,
1816        Image(c) => &c.style,
1817        Icon(c) => &c.style,
1818        Svg(c) => &c.style,
1819        Video(c) => &c.style,
1820        Gif(c) => &c.style,
1821        Counter(c) => &c.style,
1822        Cursor(c) => &c.style,
1823        Caption(c) => &c.style,
1824        Connector(c) => &c.style,
1825        Avatar(c) => &c.style,
1826        AvatarGroup(c) => &c.style,
1827        Arrow(c) => &c.style,
1828        Badge(c) => &c.style,
1829        Callout(c) => &c.style,
1830        Chart(c) => &c.style,
1831        Comparison(c) => &c.style,
1832        Countdown(c) => &c.style,
1833        Divider(c) => &c.style,
1834        DotMap(c) => &c.style,
1835        Emitter(c) => &c.style,
1836        Gauge(c) => &c.style,
1837        GradientText(c) => &c.style,
1838        Heatmap(c) => &c.style,
1839        Kbd(c) => &c.style,
1840        Line(c) => &c.style,
1841        List(c) => &c.style,
1842        Lottie(c) => &c.style,
1843        Marquee(c) => &c.style,
1844        Mockup(c) => &c.style,
1845        Particle(c) => &c.style,
1846        PillNav(c) => &c.style,
1847        Progress(c) => &c.style,
1848        QrCode(c) => &c.style,
1849        NumberWheel(c) => &c.style,
1850        SuccessCheck(c) => &c.style,
1851        Pointer(c) => &c.style,
1852        Rating(c) => &c.style,
1853        Skeleton(c) => &c.style,
1854        Slider(c) => &c.style,
1855        Sparkline(c) => &c.style,
1856        Stat(c) => &c.style,
1857        Stepper(c) => &c.style,
1858        Switch(c) => &c.style,
1859        RichText(c) => &c.style,
1860        Table(c) => &c.style,
1861        TagCloud(c) => &c.style,
1862        Timeline(c) => &c.style,
1863        Tooltip(c) => &c.style,
1864        Treemap(c) => &c.style,
1865        Container(c) => &c.style,
1866        AudioSpectrum(c) => &c.style,
1867        Waveform(c) => &c.style,
1868    }
1869}
1870
1871pub fn component_kind(c: &Component) -> &'static str {
1872    use Component::*;
1873    match c {
1874        Text(_) => "text",
1875        Shape(_) => "shape",
1876        Image(_) => "image",
1877        Icon(_) => "icon",
1878        Svg(_) => "svg",
1879        Video(_) => "video",
1880        Gif(_) => "gif",
1881        Counter(_) => "counter",
1882        Cursor(_) => "cursor",
1883        Caption(_) => "caption",
1884        Connector(_) => "connector",
1885        Avatar(_) => "avatar",
1886        AvatarGroup(_) => "avatar_group",
1887        Arrow(_) => "arrow",
1888        Badge(_) => "badge",
1889        Callout(_) => "callout",
1890        Chart(_) => "chart",
1891        Comparison(_) => "comparison",
1892        Countdown(_) => "countdown",
1893        Divider(_) => "divider",
1894        DotMap(_) => "dot_map",
1895        Emitter(_) => "emitter",
1896        Gauge(_) => "gauge",
1897        GradientText(_) => "gradient_text",
1898        Heatmap(_) => "heatmap",
1899        Kbd(_) => "kbd",
1900        Line(_) => "line",
1901        List(_) => "list",
1902        Lottie(_) => "lottie",
1903        Marquee(_) => "marquee",
1904        Mockup(_) => "mockup",
1905        Particle(_) => "particle",
1906        PillNav(_) => "pill_nav",
1907        Progress(_) => "progress",
1908        QrCode(_) => "qr_code",
1909        NumberWheel(_) => "number_wheel",
1910        SuccessCheck(_) => "success_check",
1911        Pointer(_) => "pointer",
1912        Rating(_) => "rating",
1913        Skeleton(_) => "skeleton",
1914        Slider(_) => "slider",
1915        Sparkline(_) => "sparkline",
1916        Stat(_) => "stat",
1917        Stepper(_) => "stepper",
1918        Switch(_) => "switch",
1919        RichText(_) => "rich_text",
1920        Table(_) => "table",
1921        TagCloud(_) => "tag_cloud",
1922        Timeline(_) => "timeline",
1923        Tooltip(_) => "tooltip",
1924        Treemap(_) => "treemap",
1925        Container(_) => "div",
1926        AudioSpectrum(_) => "audio_spectrum",
1927        Waveform(_) => "waveform",
1928    }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933    use super::*;
1934
1935    #[test]
1936    fn a_timeline_step_leaves_the_value_alone_until_its_at() {
1937        let component: Component = serde_json::from_value(json!({
1938            "type": "shape",
1939            "shape": "circle",
1940            "fill": "#1EA2C2",
1941            "style": { "width": 120, "height": 120 },
1942            "timeline": [
1943                { "at": 3.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
1944                    { "property": "translate_x", "easing": "linear", "keyframes": [
1945                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 200.0 }] }] }] },
1946                { "at": 6.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
1947                    { "property": "translate_x", "easing": "linear", "keyframes": [
1948                        { "time": 0.0, "value": 200.0 }, { "time": 1.0, "value": 0.0 }] }] }] }
1949            ]
1950        }))
1951        .expect("component deserializes");
1952
1953        let tx = |t: f64| match effective_effects(&component, 0.0, t) {
1954            Some(effects) => resolve_props_for_effects(&effects, t, 9.0).translate_x as f64,
1955            None => AnimatedProperties::default().translate_x as f64,
1956        };
1957
1958        assert!(
1959            tx(0.5).abs() < 1.0,
1960            "before either step, translate_x is 0, got {}",
1961            tx(0.5)
1962        );
1963        assert!(
1964            (tx(3.5) - 100.0).abs() < 2.0,
1965            "halfway through step one, got {}",
1966            tx(3.5)
1967        );
1968        assert!(
1969            (tx(5.0) - 200.0).abs() < 1.0,
1970            "step one has ended and holds, got {}",
1971            tx(5.0)
1972        );
1973        assert!(
1974            (tx(6.5) - 100.0).abs() < 2.0,
1975            "halfway through step two, got {}",
1976            tx(6.5)
1977        );
1978        assert!(
1979            tx(8.0).abs() < 1.0,
1980            "step two has ended and holds, got {}",
1981            tx(8.0)
1982        );
1983    }
1984
1985    #[test]
1986    fn start_at_rebases_the_entrance_animation_clock() {
1987        let scene = vec![ChildComponent {
1988            id: None,
1989            component: serde_json::from_value(json!({
1990                "type": "shape",
1991                "shape": "rect",
1992                "fill": "#1EA2C2",
1993                "start_at": 2.0,
1994                "style": {
1995                    "width": 120, "height": 120,
1996                    "animation": [{ "name": "fade_in_down", "duration": 0.6 }]
1997                }
1998            }))
1999            .expect("component deserializes"),
2000            position: None,
2001            x: None,
2002            y: None,
2003            z_index: None,
2004            bleed: false,
2005        }];
2006
2007        let opacity_at = |t: f64| -> f32 {
2008            let built = build_scene_at_time(
2009                &scene,
2010                (400.0, 400.0),
2011                default_root_css((400.0, 400.0)),
2012                BuildAnimationCtx {
2013                    time: t,
2014                    scenario_time: t,
2015                    scene_duration: 6.0,
2016                    fps: 30,
2017                },
2018            );
2019            built.root.children[0].css.opacity.unwrap_or(1.0)
2020        };
2021
2022        assert!(
2023            opacity_at(2.0) < 0.3,
2024            "at start_at (2.0) the fade_in_down entrance should just be beginning, got opacity {}",
2025            opacity_at(2.0)
2026        );
2027        assert!(
2028            opacity_at(2.6) > 0.9,
2029            "0.6s after start_at (the entrance's own duration) it should have finished, got opacity {}",
2030            opacity_at(2.6)
2031        );
2032    }
2033
2034    #[test]
2035    fn start_at_rebases_an_exit_animation_declared_after_the_entrance() {
2036        let scene = vec![ChildComponent {
2037            id: None,
2038            component: serde_json::from_value(json!({
2039                "type": "shape",
2040                "shape": "rect",
2041                "fill": "#1EA2C2",
2042                "start_at": 2.0,
2043                "style": {
2044                    "width": 120, "height": 120,
2045                    "animation": [
2046                        { "name": "fade_in_down", "duration": 0.6 },
2047                        { "name": "fade_out_up", "delay": 0.85, "duration": 0.3 }
2048                    ]
2049                }
2050            }))
2051            .expect("component deserializes"),
2052            position: None,
2053            x: None,
2054            y: None,
2055            z_index: None,
2056            bleed: false,
2057        }];
2058
2059        let opacity_at = |t: f64| -> f32 {
2060            let built = build_scene_at_time(
2061                &scene,
2062                (400.0, 400.0),
2063                default_root_css((400.0, 400.0)),
2064                BuildAnimationCtx {
2065                    time: t,
2066                    scenario_time: t,
2067                    scene_duration: 6.0,
2068                    fps: 30,
2069                },
2070            );
2071            built.root.children[0].css.opacity.unwrap_or(1.0)
2072        };
2073
2074        assert!(
2075            opacity_at(2.5) > 0.5,
2076            "the exit's own delay (0.85s) has not elapsed since start_at (2.0), the node should \
2077             still be visible, got opacity {}",
2078            opacity_at(2.5)
2079        );
2080    }
2081
2082    use rustmotion_core::css::style::{
2083        CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize,
2084    };
2085    use rustmotion_core::css::taffy_bridge::ConversionContext;
2086    use rustmotion_core::css::units::LengthPercentage;
2087    use rustmotion_core::css::units::LengthPercentage as CLP;
2088    use rustmotion_core::engine::layout_pass::run_layout;
2089    use serde_json::json;
2090
2091    fn make_card(children: Vec<ChildComponent>, style: CssStyle) -> Component {
2092        Component::Container(crate::container::ContainerComponent {
2093            children,
2094            timing: Default::default(),
2095            style,
2096            timeline: Vec::new(),
2097            stagger: None,
2098            time_scale: None,
2099            time_offset: None,
2100        })
2101    }
2102
2103    fn make_shape(width: f32, height: f32) -> ChildComponent {
2104        ChildComponent {
2105            id: None,
2106            component: Component::Shape(crate::shape::Shape {
2107                shape: rustmotion_core::schema::ShapeType::Rect,
2108                text: None,
2109                timing: Default::default(),
2110                style: CssStyle {
2111                    width: Some(CSize::Length(CLP::Px(width))),
2112                    height: Some(CSize::Length(CLP::Px(height))),
2113                    ..Default::default()
2114                },
2115                timeline: Vec::new(),
2116                stagger: None,
2117                fill: None,
2118                stroke: None,
2119                draw_start: None,
2120                path_morph: None,
2121            }),
2122            position: None,
2123            x: None,
2124            y: None,
2125            z_index: None,
2126            bleed: false,
2127        }
2128    }
2129
2130    fn make_text(content: &str, style: CssStyle) -> ChildComponent {
2131        ChildComponent {
2132            id: None,
2133            component: Component::Text(crate::text::Text {
2134                content: content.to_string(),
2135                max_width: None,
2136                timing: Default::default(),
2137                style,
2138                timeline: Vec::new(),
2139                stagger: None,
2140                text_shadow: None,
2141                stroke: None,
2142                text_background: None,
2143                caret: None,
2144                states: Vec::new(),
2145                swap: None,
2146                morph: None,
2147            }),
2148            position: None,
2149            x: None,
2150            y: None,
2151            z_index: None,
2152            bleed: false,
2153        }
2154    }
2155
2156    #[test]
2157    fn empty_scene_has_only_root() {
2158        let built = build_scene(&[], (1920.0, 1080.0));
2159        assert_eq!(built.root.children.len(), 0);
2160        assert_eq!(built.components.len(), 1);
2161    }
2162
2163    #[test]
2164    fn component_kind_labels() {
2165        assert_eq!(component_kind(&make_shape(100.0, 50.0).component), "shape");
2166    }
2167
2168    #[test]
2169    fn build_child_records_source_path() {
2170        let card = make_card(
2171            vec![make_shape(10.0, 10.0), make_shape(10.0, 10.0)],
2172            CssStyle::default(),
2173        );
2174        let scene = vec![ChildComponent {
2175            id: None,
2176            component: card,
2177            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2178            x: None,
2179            y: None,
2180            z_index: None,
2181            bleed: false,
2182        }];
2183        let built = build_scene(&scene, (800.0, 600.0));
2184        let card_box = &built.root.children[0];
2185        assert_eq!(card_box.source_path.as_deref(), Some("/children/0"));
2186        assert_eq!(
2187            card_box.children[1].source_path.as_deref(),
2188            Some("/children/0/children/1")
2189        );
2190    }
2191
2192    #[test]
2193    fn flex_card_with_two_shapes_lays_out_vertically() {
2194        let card = make_card(
2195            vec![make_shape(200.0, 50.0), make_shape(200.0, 50.0)],
2196            CssStyle {
2197                display: Some(Display::Flex),
2198                flex_direction: Some(FlexDirection::Column),
2199                gap: Some(Gap::Uniform(LengthPercentage::Px(10.0))),
2200                padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2201                width: Some(CSize::Length(CLP::Px(300.0))),
2202                height: Some(CSize::Length(CLP::Px(200.0))),
2203                ..Default::default()
2204            },
2205        );
2206        let scene = vec![ChildComponent {
2207            id: None,
2208            component: card,
2209            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2210            x: None,
2211            y: None,
2212            z_index: None,
2213            bleed: false,
2214        }];
2215        let built = build_scene(&scene, (1920.0, 1080.0));
2216        assert_eq!(built.root.children.len(), 1);
2217        let card_box = &built.root.children[0];
2218        assert_eq!(card_box.css.display, Some(Display::Flex));
2219        assert_eq!(card_box.css.flex_direction, Some(FlexDirection::Column));
2220        assert_eq!(card_box.children.len(), 2);
2221
2222        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2223        let card_layout = layout.get(card_box.id).expect("card laid out");
2224        assert_eq!(card_layout.x, 0.0);
2225        assert_eq!(card_layout.y, 0.0);
2226        assert_eq!(card_layout.width, 300.0);
2227        assert_eq!(card_layout.height, 200.0);
2228
2229        let c1 = layout
2230            .get(card_box.children[0].id)
2231            .expect("shape 1 laid out");
2232        let c2 = layout
2233            .get(card_box.children[1].id)
2234            .expect("shape 2 laid out");
2235        assert_eq!(c1.x, 20.0);
2236        assert_eq!(c1.y, 20.0);
2237        assert_eq!(c2.x, 20.0);
2238        assert_eq!(c2.y, 80.0);
2239    }
2240
2241    #[test]
2242    fn absolute_child_uses_top_left() {
2243        let scene = vec![ChildComponent {
2244            id: None,
2245            component: Component::Shape(crate::shape::Shape {
2246                shape: rustmotion_core::schema::ShapeType::Rect,
2247                text: None,
2248                timing: Default::default(),
2249                style: CssStyle {
2250                    width: Some(CSize::Length(CLP::Px(100.0))),
2251                    height: Some(CSize::Length(CLP::Px(80.0))),
2252                    ..Default::default()
2253                },
2254                timeline: Vec::new(),
2255                stagger: None,
2256                fill: None,
2257                stroke: None,
2258                draw_start: None,
2259                path_morph: None,
2260            }),
2261            position: Some(crate::PositionMode::Absolute { x: 40.0, y: 30.0 }),
2262            x: None,
2263            y: None,
2264            z_index: None,
2265            bleed: false,
2266        }];
2267        let built = build_scene(&scene, (400.0, 400.0));
2268        let layout = run_layout(&built.root, (400.0, 400.0), &ConversionContext::default());
2269        let shape_id = built.root.children[0].id;
2270        let l = layout.get(shape_id).expect("shape laid out");
2271        assert_eq!(l.x, 40.0);
2272        assert_eq!(l.y, 30.0);
2273        assert_eq!(l.width, 100.0);
2274        assert_eq!(l.height, 80.0);
2275    }
2276
2277    #[test]
2278    fn horizontal_divider_stretches_to_parent_width() {
2279        let divider = ChildComponent {
2280            id: None,
2281            component: Component::Divider(crate::divider::Divider {
2282                direction: DividerDirection::Horizontal,
2283                thickness: 4.0,
2284                line_style: Default::default(),
2285                length: None,
2286                timing: Default::default(),
2287                style: CssStyle::default(),
2288                timeline: Vec::new(),
2289                stagger: None,
2290            }),
2291            position: None,
2292            x: None,
2293            y: None,
2294            z_index: None,
2295            bleed: false,
2296        };
2297        let scene = vec![divider];
2298        let built = build_scene(&scene, (800.0, 600.0));
2299        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2300        let id = built.root.children[0].id;
2301        let l = layout.get(id).expect("divider laid out");
2302        assert_eq!(l.height, 4.0);
2303        assert_eq!(l.width, 800.0);
2304    }
2305
2306    #[test]
2307    fn text_child_in_flex_card_gets_cosmic_intrinsic_size() {
2308        use crate::container::ContainerComponent;
2309        use crate::text::Text;
2310
2311        use rustmotion_core::css::units::Length;
2312
2313        let text = ChildComponent {
2314            id: None,
2315            component: Component::Text(Text {
2316                content: "Hello World".into(),
2317                max_width: None,
2318                timing: Default::default(),
2319                style: CssStyle {
2320                    font_size: Some(Length::Px(40.0)),
2321                    ..Default::default()
2322                },
2323                timeline: Vec::new(),
2324                stagger: None,
2325                text_shadow: None,
2326                stroke: None,
2327                text_background: None,
2328                caret: None,
2329                states: Vec::new(),
2330                swap: None,
2331                morph: None,
2332            }),
2333            position: None,
2334            x: None,
2335            y: None,
2336            z_index: None,
2337            bleed: false,
2338        };
2339
2340        let card = ChildComponent {
2341            id: None,
2342            component: Component::Container(ContainerComponent {
2343                children: vec![text],
2344                timing: Default::default(),
2345                style: CssStyle {
2346                    display: Some(Display::Flex),
2347                    flex_direction: Some(FlexDirection::Column),
2348                    padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2349                    ..Default::default()
2350                },
2351                timeline: Vec::new(),
2352                stagger: None,
2353                time_scale: None,
2354                time_offset: None,
2355            }),
2356            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2357            x: None,
2358            y: None,
2359            z_index: None,
2360            bleed: false,
2361        };
2362
2363        let scene = vec![card];
2364        let built = build_scene(&scene, (1920.0, 1080.0));
2365        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2366
2367        let card_id = built.root.children[0].id;
2368        let text_id = built.root.children[0].children[0].id;
2369        let text_layout = layout.get(text_id).expect("text laid out");
2370
2371        assert!(
2372            text_layout.width > 0.0,
2373            "text width should be > 0, got {}",
2374            text_layout.width
2375        );
2376        assert!(
2377            text_layout.height >= 40.0,
2378            "text height should be at least one line tall, got {}",
2379            text_layout.height
2380        );
2381
2382        let card_layout = layout.get(card_id).expect("card laid out");
2383        assert!(
2384            card_layout.height >= text_layout.height + 40.0 - 1.0,
2385            "card height ({}) should fit text + padding ({}+40)",
2386            card_layout.height,
2387            text_layout.height,
2388        );
2389    }
2390
2391    #[test]
2392    fn arrow_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2393        let arrow = ChildComponent {
2394            id: None,
2395            component: Component::Arrow(crate::arrow::Arrow {
2396                x1: 10.0,
2397                y1: 20.0,
2398                x2: 110.0,
2399                y2: 80.0,
2400                cp: None,
2401                cp1: None,
2402                cp2: None,
2403                curve: None,
2404                width: 4.0,
2405                color: "#fff".into(),
2406                arrow_end: true,
2407                arrow_start: false,
2408                arrow_size: 12.0,
2409                dashed: None,
2410                timing: Default::default(),
2411                style: CssStyle::default(),
2412                timeline: Vec::new(),
2413                stagger: None,
2414            }),
2415            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2416            x: None,
2417            y: None,
2418            z_index: None,
2419            bleed: false,
2420        };
2421        let scene = vec![arrow];
2422        let built = build_scene(&scene, (800.0, 600.0));
2423        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2424        let l = layout
2425            .get(built.root.children[0].id)
2426            .expect("arrow laid out");
2427        assert_eq!(l.width, 128.0);
2428        assert_eq!(l.height, 88.0);
2429    }
2430
2431    #[test]
2432    fn connector_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2433        let conn = ChildComponent {
2434            id: None,
2435            component: Component::Connector(crate::connector::Connector {
2436                from: crate::connector::ConnectorPoint { x: 50.0, y: 0.0 },
2437                to: crate::connector::ConnectorPoint { x: 150.0, y: 50.0 },
2438                routing: Default::default(),
2439                curvature: 0.4,
2440                width: 2.0,
2441                color: "#fff".into(),
2442                arrow_end: true,
2443                arrow_start: false,
2444                arrow_size: 10.0,
2445                dashed: None,
2446                timing: Default::default(),
2447                style: CssStyle::default(),
2448                timeline: Vec::new(),
2449                stagger: None,
2450            }),
2451            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2452            x: None,
2453            y: None,
2454            z_index: None,
2455            bleed: false,
2456        };
2457        let scene = vec![conn];
2458        let built = build_scene(&scene, (800.0, 600.0));
2459        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2460        let l = layout
2461            .get(built.root.children[0].id)
2462            .expect("connector laid out");
2463        assert_eq!(l.width, 126.0);
2464        assert_eq!(l.height, 76.0);
2465    }
2466
2467    #[test]
2468    fn counter_intrinsic_size_reserves_space_for_max_value() {
2469        use crate::counter::Counter;
2470
2471        use rustmotion_core::css::units::Length;
2472        let counter = ChildComponent {
2473            id: None,
2474            component: Component::Counter(Counter {
2475                duration: None,
2476                from: 0.0,
2477                to: 1234.0,
2478                decimals: 0,
2479                separator: None,
2480                prefix: None,
2481                suffix: None,
2482                easing: Default::default(),
2483                timing: Default::default(),
2484                style: CssStyle {
2485                    font_size: Some(Length::Px(64.0)),
2486                    ..Default::default()
2487                },
2488                timeline: Vec::new(),
2489                stagger: None,
2490                text_shadow: None,
2491                stroke: None,
2492            }),
2493            position: Some(crate::PositionMode::Absolute { x: 10.0, y: 20.0 }),
2494            x: None,
2495            y: None,
2496            z_index: None,
2497            bleed: false,
2498        };
2499        let scene = vec![counter];
2500        let built = build_scene(&scene, (800.0, 600.0));
2501        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2502        let id = built.root.children[0].id;
2503        let l = layout.get(id).expect("counter laid out");
2504        assert!(
2505            l.width > 0.0,
2506            "counter width should be > 0, got {}",
2507            l.width
2508        );
2509        assert!(
2510            l.height >= 60.0,
2511            "counter height should be ≥ ~one line ({}), got {}",
2512            64.0,
2513            l.height
2514        );
2515    }
2516
2517    #[test]
2518    fn badge_intrinsic_size_includes_padding_and_text() {
2519        use crate::badge::{Badge, BadgeSize, BadgeVariant};
2520
2521        let badge = ChildComponent {
2522            id: None,
2523            component: Component::Badge(Badge {
2524                text: "New".into(),
2525                icon: None,
2526                variant: BadgeVariant::Solid,
2527                badge_size: BadgeSize::Md,
2528                dot: false,
2529                dot_color: None,
2530                pulse: false,
2531                count: None,
2532                timing: Default::default(),
2533                style: CssStyle::default(),
2534                timeline: Vec::new(),
2535                stagger: None,
2536            }),
2537            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2538            x: None,
2539            y: None,
2540            z_index: None,
2541            bleed: false,
2542        };
2543        let scene = vec![badge];
2544        let built = build_scene(&scene, (400.0, 200.0));
2545        let layout = run_layout(&built.root, (400.0, 200.0), &ConversionContext::default());
2546        let id = built.root.children[0].id;
2547        let l = layout.get(id).expect("badge laid out");
2548        assert!(
2549            l.width > 24.0,
2550            "badge width should exceed padding alone, got {}",
2551            l.width
2552        );
2553        assert!(
2554            (l.height - 30.2).abs() < 2.0,
2555            "badge height should be ~30.2, got {}",
2556            l.height
2557        );
2558    }
2559
2560    #[test]
2561    fn line_intrinsic_size_matches_endpoint_bounding_box() {
2562        let line = ChildComponent {
2563            id: None,
2564            component: Component::Line(crate::line::Line {
2565                x1: 10.0,
2566                y1: 20.0,
2567                x2: 110.0,
2568                y2: 80.0,
2569                width: 2.0,
2570                color: "#fff".into(),
2571                dashed: None,
2572                timing: Default::default(),
2573                style: CssStyle::default(),
2574                timeline: Vec::new(),
2575                stagger: None,
2576            }),
2577            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2578            x: None,
2579            y: None,
2580            z_index: None,
2581            bleed: false,
2582        };
2583        let scene = vec![line];
2584        let built = build_scene(&scene, (800.0, 600.0));
2585        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2586        let id = built.root.children[0].id;
2587        let l = layout.get(id).expect("line laid out");
2588        assert_eq!(l.width, 100.0);
2589        assert_eq!(l.height, 60.0);
2590    }
2591
2592    #[test]
2593    fn rich_text_child_gets_a_non_zero_intrinsic_size() {
2594        use crate::rich_text::{RichText, RichTextSpan};
2595        use rustmotion_core::css::units::Length;
2596
2597        let rich_text = ChildComponent {
2598            id: None,
2599            component: Component::RichText(RichText {
2600                spans: vec![
2601                    RichTextSpan {
2602                        text: "Save ".into(),
2603                        color: None,
2604                        font_size: None,
2605                        font_weight: None,
2606                        font_family: None,
2607                        font_style: None,
2608                        letter_spacing: None,
2609                        background: None,
2610                        padding: None,
2611                        border_radius: None,
2612                        rotation: None,
2613                    },
2614                    RichTextSpan {
2615                        text: "40%".into(),
2616                        color: Some("#5C39EE".into()),
2617                        font_size: None,
2618                        font_weight: None,
2619                        font_family: None,
2620                        font_style: None,
2621                        letter_spacing: None,
2622                        background: None,
2623                        padding: None,
2624                        border_radius: None,
2625                        rotation: None,
2626                    },
2627                ],
2628                max_width: None,
2629                timing: Default::default(),
2630                style: CssStyle {
2631                    font_size: Some(Length::Px(40.0)),
2632                    ..Default::default()
2633                },
2634                timeline: Vec::new(),
2635                stagger: None,
2636            }),
2637            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2638            x: None,
2639            y: None,
2640            z_index: None,
2641            bleed: false,
2642        };
2643        let scene = vec![rich_text];
2644        let built = build_scene(&scene, (800.0, 600.0));
2645        let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2646        let id = built.root.children[0].id;
2647        let l = layout.get(id).expect("rich_text laid out");
2648        assert!(
2649            l.width > 0.0,
2650            "rich_text width should be > 0, got {}",
2651            l.width
2652        );
2653        assert!(
2654            l.height > 0.0,
2655            "rich_text height should be > 0, got {}",
2656            l.height
2657        );
2658    }
2659
2660    #[test]
2661    fn glow_effect_adds_a_coloured_drop_shadow_filter() {
2662        use rustmotion_core::css::style::{Color, FilterFn};
2663        use rustmotion_core::css::units::Length as CLength;
2664        use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2665
2666        let mut shape = make_shape(100.0, 100.0);
2667        if let Component::Shape(ref mut s) = shape.component {
2668            s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2669                color: "#5C39EE".to_string(),
2670                radius: 12.0,
2671                intensity: 1.0,
2672            })];
2673        }
2674        let anim = BuildAnimationCtx {
2675            time: 0.0,
2676            scenario_time: 0.0,
2677            scene_duration: 1.0,
2678            fps: 30,
2679        };
2680        let scene = [shape];
2681        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2682        let filters = built.root.children[0]
2683            .css
2684            .filter
2685            .as_ref()
2686            .expect("glow must add a filter list");
2687        let shadow = filters
2688            .iter()
2689            .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2690            .expect("a DropShadow filter must be present");
2691        let FilterFn::DropShadow { blur, color, .. } = shadow else {
2692            unreachable!()
2693        };
2694        match blur {
2695            Some(CLength::Px(px)) => {
2696                assert_eq!(*px, 12.0, "blur radius must match GlowConfig.radius")
2697            }
2698            other => panic!("expected Some(Length::Px(12.0)) blur, got {:?}", other),
2699        }
2700        match color {
2701            Some(Color::Rgba { r, g, b, a }) => {
2702                assert_eq!(*r, 0x5C);
2703                assert_eq!(*g, 0x39);
2704                assert_eq!(*b, 0xEE);
2705                assert!(*a > 0.0, "alpha must be > 0 for the glow to be visible");
2706            }
2707            other => panic!("expected Some(Color::Rgba{{..}}), got {:?}", other),
2708        }
2709    }
2710
2711    #[test]
2712    fn glow_effect_scales_alpha_by_intensity() {
2713        use rustmotion_core::css::style::{Color, FilterFn};
2714        use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2715
2716        let mut shape = make_shape(100.0, 100.0);
2717        if let Component::Shape(ref mut s) = shape.component {
2718            s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2719                color: "#FFFFFFFF".to_string(),
2720                radius: 10.0,
2721                intensity: 0.5,
2722            })];
2723        }
2724        let anim = BuildAnimationCtx {
2725            time: 0.0,
2726            scenario_time: 0.0,
2727            scene_duration: 1.0,
2728            fps: 30,
2729        };
2730        let scene = [shape];
2731        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2732        let filters = built.root.children[0].css.filter.as_ref().unwrap();
2733        let FilterFn::DropShadow { color, .. } = filters
2734            .iter()
2735            .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2736            .unwrap()
2737        else {
2738            unreachable!()
2739        };
2740        let Some(Color::Rgba { a, .. }) = color else {
2741            panic!("expected Rgba color");
2742        };
2743        assert!(
2744            (*a - 0.5).abs() < 1e-4,
2745            "intensity 0.5 on an opaque colour must scale alpha to ~0.5, got {}",
2746            a
2747        );
2748    }
2749
2750    #[test]
2751    fn no_glow_effect_leaves_filter_untouched() {
2752        let shape = make_shape(100.0, 100.0);
2753        let anim = BuildAnimationCtx {
2754            time: 0.0,
2755            scenario_time: 0.0,
2756            scene_duration: 1.0,
2757            fps: 30,
2758        };
2759        let scene = [shape];
2760        let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2761        assert!(built.root.children[0].css.filter.is_none());
2762    }
2763
2764    fn child_from_json(json: serde_json::Value) -> ChildComponent {
2765        let component: Component =
2766            serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}"));
2767        ChildComponent {
2768            id: None,
2769            component,
2770            position: None,
2771            x: None,
2772            y: None,
2773            z_index: None,
2774            bleed: false,
2775        }
2776    }
2777
2778    fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) {
2779        let card = make_card(vec![child_from_json(child_json)], CssStyle::default());
2780        let scene = vec![ChildComponent {
2781            id: None,
2782            component: card,
2783            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2784            x: None,
2785            y: None,
2786            z_index: None,
2787            bleed: false,
2788        }];
2789        let built = build_scene(&scene, (1920.0, 1080.0));
2790        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2791        let child_id = built.root.children[0].children[0].id;
2792        let l = layout.get(child_id).expect("child laid out");
2793        (l.width, l.height)
2794    }
2795
2796    #[test]
2797    fn all_23_unsized_components_get_a_positive_box_in_a_card() {
2798        let cases: &[(&str, serde_json::Value)] = &[
2799            ("callout", json!({"type":"callout","text":"Hello"})),
2800            (
2801                "chart",
2802                json!({"type":"chart","chart_type":"bar","data":[{"value":10}]}),
2803            ),
2804            ("comparison", json!({"type":"comparison"})),
2805            (
2806                "dot_map",
2807                json!({"type":"dot_map","points":[{"lat":10.0,"lng":10.0}]}),
2808            ),
2809            ("gauge", json!({"type":"gauge","value":50})),
2810            ("gif", json!({"type":"gif","src":"a.gif"})),
2811            ("heatmap", json!({"type":"heatmap","data":[[1.0,2.0]]})),
2812            ("icon", json!({"type":"icon","icon":"lucide:home"})),
2813            ("image", json!({"type":"image","src":"a.png"})),
2814            ("lottie", json!({"type":"lottie","data":"{}"})),
2815            ("marquee", json!({"type":"marquee","content":"hi"})),
2816            (
2817                "mockup",
2818                json!({"type":"mockup","device":"iphone","src":"a.png"}),
2819            ),
2820            ("pill_nav", json!({"type":"pill_nav","items":["A","B"]})),
2821            ("shape", json!({"type":"shape","shape":"rect"})),
2822            ("skeleton", json!({"type":"skeleton"})),
2823            (
2824                "skeleton_text",
2825                json!({"type":"skeleton","variant":"text","lines":3}),
2826            ),
2827            (
2828                "sparkline",
2829                json!({"type":"sparkline","data":[1.0,2.0,3.0]}),
2830            ),
2831            ("stat", json!({"type":"stat","value":"42"})),
2832            (
2833                "stepper",
2834                json!({"type":"stepper","steps":[{"label":"A"},{"label":"B"}]}),
2835            ),
2836            ("svg", json!({"type":"svg","data":"<svg></svg>"})),
2837            (
2838                "tag_cloud",
2839                json!({"type":"tag_cloud","tags":[{"text":"rust","weight":1.0}]}),
2840            ),
2841            ("tooltip", json!({"type":"tooltip","text":"hi"})),
2842            ("treemap", json!({"type":"treemap","data":[{"value":10.0}]})),
2843            ("video", json!({"type":"video","src":"a.mp4"})),
2844        ];
2845        for (name, json) in cases {
2846            let (w, h) = layout_in_auto_card(json.clone());
2847            assert!(w > 0.0, "{name}: width should be > 0, got {w}");
2848            assert!(h > 0.0, "{name}: height should be > 0, got {h}");
2849        }
2850    }
2851
2852    #[test]
2853    fn heatmap_intrinsic_size_matches_cell_grid_formula() {
2854        let (w, h) =
2855            layout_in_auto_card(json!({"type":"heatmap","data":[[1.0,2.0,3.0],[4.0,5.0,6.0]]}));
2856        assert_eq!(w, 48.0);
2857        assert_eq!(h, 31.0);
2858    }
2859
2860    #[test]
2861    fn sparkline_gets_the_documented_120x40_default() {
2862        let (w, h) = layout_in_auto_card(json!({"type":"sparkline","data":[1.0,2.0,3.0]}));
2863        assert_eq!(w, 120.0);
2864        assert_eq!(h, 40.0);
2865    }
2866
2867    #[test]
2868    fn stat_gets_the_documented_280x180_default() {
2869        let (w, h) = layout_in_auto_card(json!({"type":"stat","value":"42"}));
2870        assert_eq!(w, 280.0);
2871        assert_eq!(h, 180.0);
2872    }
2873
2874    #[test]
2875    fn gauge_default_size_is_square() {
2876        let (w, h) = layout_in_auto_card(json!({"type":"gauge","value":50}));
2877        assert_eq!(w, h);
2878        assert!(w > 0.0);
2879    }
2880
2881    #[test]
2882    fn pill_nav_height_promotes_its_own_declared_field() {
2883        let (_, h) = layout_in_auto_card(json!({"type":"pill_nav","items":["Overview"]}));
2884        assert_eq!(h, 44.0);
2885    }
2886
2887    #[test]
2888    fn callout_width_grows_with_its_own_text_content() {
2889        let (short_w, _) = layout_in_auto_card(json!({"type":"callout","text":"Hi"}));
2890        let (long_w, _) = layout_in_auto_card(
2891            json!({"type":"callout","text":"This is a much longer callout message"}),
2892        );
2893        assert!(
2894            long_w > short_w,
2895            "longer text should measure a wider box: short={short_w}, long={long_w}"
2896        );
2897    }
2898
2899    #[test]
2900    fn three_stats_in_a_flex_row_all_get_a_positive_width() {
2901        let stats = vec![
2902            child_from_json(json!({"type":"stat","value":"45.2K","label":"Users"})),
2903            child_from_json(json!({"type":"stat","value":"12%","label":"Growth"})),
2904            child_from_json(json!({"type":"stat","value":"$8.1M","label":"Revenue"})),
2905        ];
2906        let card = make_card(
2907            stats,
2908            CssStyle {
2909                display: Some(Display::Flex),
2910                flex_direction: Some(FlexDirection::Row),
2911                gap: Some(Gap::Uniform(LengthPercentage::Px(16.0))),
2912                ..Default::default()
2913            },
2914        );
2915        let scene = vec![ChildComponent {
2916            id: None,
2917            component: card,
2918            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2919            x: None,
2920            y: None,
2921            z_index: None,
2922            bleed: false,
2923        }];
2924        let built = build_scene(&scene, (1920.0, 1080.0));
2925        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2926        for (i, child) in built.root.children[0].children.iter().enumerate() {
2927            let l = layout.get(child.id).expect("stat laid out");
2928            assert!(
2929                l.width > 0.0,
2930                "stat #{i}: width should be > 0, got {}",
2931                l.width
2932            );
2933            assert!(
2934                l.height > 0.0,
2935                "stat #{i}: height should be > 0, got {}",
2936                l.height
2937            );
2938        }
2939    }
2940
2941    #[test]
2942    fn card_color_cascades_to_text_child_with_no_color_of_its_own() {
2943        use rustmotion_core::css::style::Color;
2944
2945        let card = make_card(
2946            vec![make_text("hello", CssStyle::default())],
2947            CssStyle {
2948                color: Some(Color::String("#ff0000".into())),
2949                ..Default::default()
2950            },
2951        );
2952        let scene = vec![ChildComponent {
2953            id: None,
2954            component: card,
2955            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2956            x: None,
2957            y: None,
2958            z_index: None,
2959            bleed: false,
2960        }];
2961        let built = build_scene(&scene, (800.0, 600.0));
2962        let text_box = &built.root.children[0].children[0];
2963        assert_eq!(
2964            text_box.css.color,
2965            Some(Color::String("#ff0000".into())),
2966            "text child declares no color of its own — it should inherit the card's"
2967        );
2968    }
2969
2970    #[test]
2971    fn text_own_color_wins_over_inherited_card_color() {
2972        use rustmotion_core::css::style::Color;
2973
2974        let card = make_card(
2975            vec![make_text(
2976                "hello",
2977                CssStyle {
2978                    color: Some(Color::String("#00ff00".into())),
2979                    ..Default::default()
2980                },
2981            )],
2982            CssStyle {
2983                color: Some(Color::String("#ff0000".into())),
2984                ..Default::default()
2985            },
2986        );
2987        let scene = vec![ChildComponent {
2988            id: None,
2989            component: card,
2990            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2991            x: None,
2992            y: None,
2993            z_index: None,
2994            bleed: false,
2995        }];
2996        let built = build_scene(&scene, (800.0, 600.0));
2997        let text_box = &built.root.children[0].children[0];
2998        assert_eq!(
2999            text_box.css.color,
3000            Some(Color::String("#00ff00".into())),
3001            "text child's own explicit color must win over the inherited card color"
3002        );
3003    }
3004
3005    #[test]
3006    fn card_display_does_not_cascade_to_text_child() {
3007        let card = make_card(
3008            vec![make_text("hello", CssStyle::default())],
3009            CssStyle {
3010                display: Some(Display::Flex),
3011                ..Default::default()
3012            },
3013        );
3014        let scene = vec![ChildComponent {
3015            id: None,
3016            component: card,
3017            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3018            x: None,
3019            y: None,
3020            z_index: None,
3021            bleed: false,
3022        }];
3023        let built = build_scene(&scene, (800.0, 600.0));
3024        let text_box = &built.root.children[0].children[0];
3025        assert_eq!(text_box.css.display, None);
3026    }
3027
3028    fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent {
3029        ChildComponent {
3030            id: None,
3031            component: Component::Shape(crate::shape::Shape {
3032                shape: rustmotion_core::schema::ShapeType::Rect,
3033                text: None,
3034                timing: Default::default(),
3035                style: CssStyle {
3036                    width: Some(CSize::Length(CLP::Px(width))),
3037                    aspect_ratio: Some(aspect_ratio),
3038                    ..Default::default()
3039                },
3040                timeline: Vec::new(),
3041                stagger: None,
3042                fill: None,
3043                stroke: None,
3044                draw_start: None,
3045                path_morph: None,
3046            }),
3047            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3048            x: None,
3049            y: None,
3050            z_index: None,
3051            bleed: false,
3052        }
3053    }
3054
3055    #[test]
3056    fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() {
3057        let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)];
3058        let built = build_scene(&scene, (1920.0, 1080.0));
3059        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3060        let l = layout
3061            .get(built.root.children[0].id)
3062            .expect("shape laid out");
3063        assert!(
3064            (l.width - 400.0).abs() < 1.0,
3065            "width should stay the author's explicit 400, got {}",
3066            l.width
3067        );
3068        assert!(
3069            (l.height - 225.0).abs() < 1.0,
3070            "height should derive from width/aspect-ratio (400/1.778=225), got {}",
3071            l.height
3072        );
3073    }
3074
3075    #[test]
3076    fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() {
3077        let scene = vec![make_aspect_shape_no_width(2.0)];
3078        let built = build_scene(&scene, (1920.0, 1080.0));
3079        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3080        let l = layout
3081            .get(built.root.children[0].id)
3082            .expect("shape laid out");
3083        assert!(
3084            (l.width - 80.0).abs() < 1.0,
3085            "width should keep the natural default (80), got {}",
3086            l.width
3087        );
3088        assert!(
3089            (l.height - 40.0).abs() < 1.0,
3090            "height should derive from the default width/aspect-ratio (80/2=40), got {}",
3091            l.height
3092        );
3093    }
3094
3095    fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent {
3096        ChildComponent {
3097            id: None,
3098            component: Component::Shape(crate::shape::Shape {
3099                shape: rustmotion_core::schema::ShapeType::Rect,
3100                text: None,
3101                timing: Default::default(),
3102                style: CssStyle {
3103                    aspect_ratio: Some(aspect_ratio),
3104                    ..Default::default()
3105                },
3106                timeline: Vec::new(),
3107                stagger: None,
3108                fill: None,
3109                stroke: None,
3110                draw_start: None,
3111                path_morph: None,
3112            }),
3113            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3114            x: None,
3115            y: None,
3116            z_index: None,
3117            bleed: false,
3118        }
3119    }
3120
3121    use crate::legacy_dispatch::LegacyPaintDispatcher;
3122    use rustmotion_core::css::style::{AlignItems, JustifyContent};
3123    use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
3124
3125    #[test]
3126    fn ghost_does_not_steal_a_flex_slot() {
3127        let root_css = CssStyle {
3128            display: Some(Display::Flex),
3129            flex_direction: Some(FlexDirection::Column),
3130            align_items: Some(AlignItems::Center),
3131            justify_content: Some(JustifyContent::Center),
3132            gap: Some(Gap::Uniform(CLP::Px(20.0))),
3133            width: Some(CSize::Length(CLP::Px(640.0))),
3134            height: Some(CSize::Length(CLP::Px(360.0))),
3135            ..Default::default()
3136        };
3137        let scene = vec![
3138            child_from_json(json!({
3139                "type": "shape", "shape": "rect", "fill": "#FF4FB0",
3140                "style": { "width": 200, "height": 60, "animation": [{ "name": "motion_blur" }] }
3141            })),
3142            child_from_json(json!({
3143                "type": "shape", "shape": "rect", "fill": "#4B5BFF",
3144                "style": { "width": 200, "height": 60 }
3145            })),
3146        ];
3147        let anim = BuildAnimationCtx {
3148            time: 10.0 / 30.0,
3149            scenario_time: 10.0 / 30.0,
3150            scene_duration: 1.0,
3151            fps: 30,
3152        };
3153        let built = build_scene_at_time(&scene, (640.0, 360.0), root_css, anim);
3154        let layout = run_layout(&built.root, (640.0, 360.0), &ConversionContext::default());
3155
3156        let principals: Vec<_> = built
3157            .root
3158            .children
3159            .iter()
3160            .filter(|n| matches!(n.kind, BoxKind::Component(_)))
3161            .collect();
3162        assert_eq!(
3163            principals.len(),
3164            2,
3165            "exactly the two real elements; ghosts must not be counted among them"
3166        );
3167
3168        let first = layout.get(principals[0].id).expect("first box laid out");
3169        let second = layout.get(principals[1].id).expect("second box laid out");
3170
3171        assert!(
3172            (first.y - 110.0).abs() < 1.0,
3173            "the motion-blurred box itself must sit at the flex-resolved position (110), got {}",
3174            first.y
3175        );
3176        assert!(
3177            (second.y - 190.0).abs() < 1.0,
3178            "a motion-blurred sibling must not push the next flex item down (expected 190, \
3179             regression of #66), got {}",
3180            second.y
3181        );
3182    }
3183
3184    #[test]
3185    fn ghost_of_a_container_carries_its_nested_child() {
3186        let container_component: Component = serde_json::from_value(json!({
3187            "type": "div",
3188            "style": {
3189                "width": 200, "height": 100,
3190                "background": "#FF00FF",
3191                "animation": [{ "name": "trail", "copies": 1, "spacing": 0.05, "falloff": 1.0 }]
3192            },
3193            "children": [{
3194                "type": "shape", "shape": "rect", "fill": "#00FF00",
3195                "position": "absolute", "x": 20, "y": 20,
3196                "style": { "width": 40, "height": 40 }
3197            }]
3198        }))
3199        .expect("container deserializes");
3200        let scene = vec![ChildComponent {
3201            id: None,
3202            component: container_component,
3203            position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3204            x: None,
3205            y: None,
3206            z_index: None,
3207            bleed: false,
3208        }];
3209
3210        let frame_time = 0.2;
3211        let anim = BuildAnimationCtx {
3212            time: frame_time,
3213            scenario_time: frame_time,
3214            scene_duration: 1.0,
3215            fps: 30,
3216        };
3217        let mut built = build_scene_at_time(
3218            &scene,
3219            (200.0, 100.0),
3220            default_root_css((200.0, 100.0)),
3221            anim,
3222        );
3223
3224        let ghost_index = built
3225            .root
3226            .children
3227            .iter()
3228            .position(|n| matches!(n.kind, BoxKind::Ghost(_)))
3229            .expect("a ghost was built for the trail effect");
3230        let ghost = built.root.children.remove(ghost_index);
3231        assert_eq!(
3232            ghost.children.len(),
3233            1,
3234            "the ghost of a container must carry the container's own children, not an empty subtree"
3235        );
3236
3237        let mini_root = BoxNode::container(default_root_css((200.0, 100.0)), vec![ghost]);
3238        let layout = run_layout(&mini_root, (200.0, 100.0), &ConversionContext::default());
3239
3240        let mut surface =
3241            skia_safe::surfaces::raster_n32_premul((200, 100)).expect("raster surface");
3242        let canvas = surface.canvas();
3243        let dispatcher = LegacyPaintDispatcher::for_scene(&built);
3244        let frame = PaintFrame {
3245            light: Default::default(),
3246            time: frame_time,
3247            scenario_time: frame_time,
3248            frame_index: 6,
3249            fps: 30,
3250            video_width: 200,
3251            video_height: 100,
3252            scene_duration: 1.0,
3253            camera: None,
3254        };
3255        paint_tree(canvas, &mini_root, &layout, &frame, &dispatcher);
3256
3257        let snapshot = surface.image_snapshot();
3258        let info = skia_safe::ImageInfo::new(
3259            (1, 1),
3260            skia_safe::ColorType::RGBA8888,
3261            skia_safe::AlphaType::Premul,
3262            None,
3263        );
3264        let mut buf = [0u8; 4];
3265        assert!(snapshot.read_pixels(
3266            &info,
3267            &mut buf,
3268            4,
3269            skia_safe::IPoint::new(40, 40),
3270            skia_safe::image::CachingHint::Disallow,
3271        ));
3272        assert!(
3273            buf[1] > 200 && buf[0] < 80 && buf[2] < 80,
3274            "the ghost must paint the nested green shape, not just the container's own \
3275             background; got {:?}",
3276            buf
3277        );
3278    }
3279
3280    #[test]
3281    fn ghost_of_a_measured_leaf_keeps_its_intrinsic_size() {
3282        let scene = vec![child_from_json(json!({
3283            "type": "text",
3284            "content": "TEXT",
3285            "position": "absolute", "x": 100, "y": 40,
3286            "style": {
3287                "font-size": 96, "color": "#FFFFFF",
3288                "animation": [
3289                    { "name": "keyframes", "keyframes": [{ "property": "translate_x", "easing": "linear", "keyframes": [
3290                        { "time": 0, "value": 1200 }, { "time": 1, "value": -1200 }] }] },
3291                    { "name": "motion_blur", "samples": 4, "shutter": 1.0 }
3292                ]
3293            }
3294        }))];
3295        let anim = BuildAnimationCtx {
3296            time: 0.5,
3297            scenario_time: 0.5,
3298            scene_duration: 1.0,
3299            fps: 30,
3300        };
3301        let built = build_scene_at_time(
3302            &scene,
3303            (1280.0, 360.0),
3304            default_root_css((1280.0, 360.0)),
3305            anim,
3306        );
3307        let layout = run_layout(&built.root, (1280.0, 360.0), &ConversionContext::default());
3308
3309        let ghosts: Vec<_> = built
3310            .root
3311            .children
3312            .iter()
3313            .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3314            .collect();
3315        assert!(
3316            !ghosts.is_empty(),
3317            "expected ghosts for a motion-blurred text node"
3318        );
3319        for g in &ghosts {
3320            assert!(
3321                g.intrinsic.is_some(),
3322                "a ghost of a measured component (text) needs its own intrinsic to size itself"
3323            );
3324            let l = layout.get(g.id).expect("ghost laid out");
3325            assert!(
3326                l.width > 0.0 && l.height > 0.0,
3327                "text ghost collapsed to a zero-size box, got {}x{}",
3328                l.width,
3329                l.height
3330            );
3331        }
3332    }
3333
3334    #[test]
3335    fn ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset() {
3336        let scene = vec![child_from_json(json!({
3337            "type": "pointer",
3338            "size": 220, "tone": "dark",
3339            "path": [
3340                { "time": 0.0, "x": 1900, "y": 1000 },
3341                { "time": 0.6, "x": 900, "y": 500 }
3342            ],
3343            "path_easing": "linear",
3344            "style": { "animation": [{ "name": "motion_blur", "samples": 4, "shutter": 3.0 }] }
3345        }))];
3346        let frame_time = 0.5;
3347        let anim = BuildAnimationCtx {
3348            time: frame_time,
3349            scenario_time: frame_time,
3350            scene_duration: 1.0,
3351            fps: 30,
3352        };
3353        let built = build_scene_at_time(
3354            &scene,
3355            (1920.0, 1080.0),
3356            default_root_css((1920.0, 1080.0)),
3357            anim,
3358        );
3359
3360        let (path, click_duration, path_easing) = match &scene[0].component {
3361            Component::Pointer(p) => (p.path.clone(), p.click_duration, p.path_easing),
3362            _ => panic!("expected a pointer component"),
3363        };
3364
3365        let ghost_ids: Vec<NodeId> = built
3366            .root
3367            .children
3368            .iter()
3369            .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3370            .map(|n| n.id)
3371            .collect();
3372        assert!(ghost_ids.len() >= 2, "expected several ghosts");
3373
3374        let offsets: Vec<(f32, f32)> = ghost_ids
3375            .iter()
3376            .map(|&id| {
3377                let (scale, shift) = built.time_params[id as usize];
3378                let local_time = frame_time * scale + shift;
3379                crate::cursor::waypoint_offset(&path, local_time, click_duration, path_easing)
3380            })
3381            .collect();
3382
3383        let distinct = offsets
3384            .windows(2)
3385            .any(|w| (w[0].0 - w[1].0).abs() > 1.0 || (w[0].1 - w[1].1).abs() > 1.0);
3386        assert!(
3387            distinct,
3388            "ghosts of a path-driven pointer must sample different points along the path, got {:?}",
3389            offsets
3390        );
3391
3392        let live_offset =
3393            crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing);
3394        assert!(
3395            offsets
3396                .iter()
3397                .all(|o| (o.0 - live_offset.0).abs() > 1.0 || (o.1 - live_offset.1).abs() > 1.0),
3398            "every ghost sampled the live pointer position instead of its own instant; \
3399             got {:?} vs live {:?}",
3400            offsets,
3401            live_offset
3402        );
3403    }
3404
3405    #[test]
3406    fn a_pointer_moved_by_path_actually_paints_ink_at_a_ghosts_own_position() {
3407        let scene = vec![child_from_json(json!({
3408            "type": "pointer",
3409            "size": 220, "tone": "dark",
3410            "path": [
3411                { "time": 0.0, "x": 1900, "y": 1000 },
3412                { "time": 0.6, "x": 900, "y": 500 }
3413            ],
3414            "path_easing": "linear",
3415            "style": { "animation": [{ "name": "motion_blur", "samples": 12, "shutter": 3.0 }] }
3416        }))];
3417        let frame_time = 0.5;
3418        let anim = BuildAnimationCtx {
3419            time: frame_time,
3420            scenario_time: frame_time,
3421            scene_duration: 1.0,
3422            fps: 30,
3423        };
3424        let built = build_scene_at_time(
3425            &scene,
3426            (1920.0, 1080.0),
3427            default_root_css((1920.0, 1080.0)),
3428            anim,
3429        );
3430        let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3431        let mut surface = paint_scene_at(&built, &layout, 1920, 1080, frame_time);
3432
3433        let (path, click_duration, path_easing) = match &scene[0].component {
3434            Component::Pointer(p) => (p.path.clone(), p.click_duration, p.path_easing),
3435            _ => panic!("expected a pointer component"),
3436        };
3437        let (fx, fy) = (0.1946_f32, 0.4390_f32);
3438        let size = 220.0_f32;
3439        let probe_for = |offset: (f32, f32)| {
3440            (
3441                (offset.0 + fx * size).round() as i32,
3442                (offset.1 + fy * size).round() as i32,
3443            )
3444        };
3445
3446        let live_offset =
3447            crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing);
3448        let live_probe = probe_for(live_offset);
3449        let live_alpha = read_pixel(&mut surface, live_probe.0, live_probe.1)[3];
3450        assert!(
3451            live_alpha > 200,
3452            "sanity check: the live, un-ghosted pointer must paint its own glyph at the \
3453             current path position; probed {:?}, alpha={live_alpha}",
3454            live_probe
3455        );
3456
3457        let ghost_id = built
3458            .root
3459            .children
3460            .iter()
3461            .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3462            .map(|n| n.id)
3463            .min_by(|&a, &b| {
3464                let ta = built.time_params[a as usize];
3465                let tb = built.time_params[b as usize];
3466                (frame_time * ta.0 + ta.1)
3467                    .partial_cmp(&(frame_time * tb.0 + tb.1))
3468                    .unwrap()
3469            })
3470            .expect("expected at least one ghost");
3471        let (scale, shift) = built.time_params[ghost_id as usize];
3472        let farthest_ghost_time = frame_time * scale + shift;
3473        let ghost_offset =
3474            crate::cursor::waypoint_offset(&path, farthest_ghost_time, click_duration, path_easing);
3475        let ghost_probe = probe_for(ghost_offset);
3476        let ghost_alpha = read_pixel(&mut surface, ghost_probe.0, ghost_probe.1)[3];
3477        assert!(
3478            ghost_alpha > 20,
3479            "a pointer moved by `path` must be ghosted along its trajectory just like one \
3480             moved by translate_x/y keyframes — the furthest motion_blur ghost (its own local \
3481             time {farthest_ghost_time}, opacity 1/13 of the live pointer) must paint ink at \
3482             its own point along the path ({ghost_offset:?}, live is {live_offset:?}), not \
3483             stay pixel-identical (fully transparent there) to the same pointer without \
3484             motion_blur; probed {:?}, alpha={ghost_alpha}",
3485            ghost_probe
3486        );
3487
3488        let empty_alpha = read_pixel(&mut surface, 100, 100)[3];
3489        assert_eq!(
3490            empty_alpha, 0,
3491            "sanity check: a point far from every sampled instant of the path must stay \
3492             untouched background"
3493        );
3494    }
3495
3496    #[test]
3497    fn animated_blur_x_becomes_a_directional_blur_filter() {
3498        use rustmotion_core::css::style::FilterFn;
3499        use rustmotion_core::css::units::Length;
3500
3501        let scene = vec![child_from_json(json!({
3502            "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3503            "style": {
3504                "width": 100, "height": 100,
3505                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3506                    { "property": "blur_x", "easing": "linear", "keyframes": [
3507                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
3508                    ] }
3509                ] }]
3510            }
3511        }))];
3512        let anim = BuildAnimationCtx {
3513            time: 0.5,
3514            scenario_time: 0.5,
3515            scene_duration: 1.0,
3516            fps: 30,
3517        };
3518        let built = build_scene_at_time(
3519            &scene,
3520            (400.0, 400.0),
3521            default_root_css((400.0, 400.0)),
3522            anim,
3523        );
3524
3525        let filters = built.root.children[0]
3526            .css
3527            .filter
3528            .clone()
3529            .expect("a blur filter was applied");
3530        let blur = filters
3531            .iter()
3532            .find(|f| matches!(f, FilterFn::Blur { .. }))
3533            .expect("a Blur filter");
3534        match blur {
3535            FilterFn::Blur {
3536                radius,
3537                radius_x,
3538                radius_y,
3539            } => {
3540                assert!(radius.is_none(), "isotropic radius must stay unset");
3541                assert!(
3542                    matches!(radius_x, Some(Length::Px(v)) if (*v - 20.0).abs() < 1.0),
3543                    "expected ~20px halfway through a 0->40 linear blur_x ramp, got {:?}",
3544                    radius_x
3545                );
3546                assert!(
3547                    radius_y.is_none(),
3548                    "blur_y was never animated, must stay unset"
3549                );
3550            }
3551            other => panic!("expected Blur, got {:?}", other),
3552        }
3553    }
3554
3555    #[test]
3556    fn motion_blur_smear_mode_produces_no_ghosts_and_a_directional_filter_instead() {
3557        use rustmotion_core::css::style::FilterFn;
3558        use rustmotion_core::css::units::Length;
3559
3560        let scene = vec![child_from_json(json!({
3561            "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3562            "position": "absolute", "x": 0, "y": 0,
3563            "style": {
3564                "width": 100, "height": 60,
3565                "animation": [
3566                    { "name": "keyframes", "duration": 1.0, "keyframes": [
3567                        { "property": "translate_x", "easing": "linear", "keyframes": [
3568                            { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3569                        ] }
3570                    ] },
3571                    { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3572                ]
3573            }
3574        }))];
3575        let anim = BuildAnimationCtx {
3576            time: 0.5,
3577            scenario_time: 0.5,
3578            scene_duration: 1.0,
3579            fps: 30,
3580        };
3581        let built = build_scene_at_time(
3582            &scene,
3583            (1920.0, 1080.0),
3584            default_root_css((1920.0, 1080.0)),
3585            anim,
3586        );
3587
3588        assert_eq!(
3589            built.root.children.len(),
3590            1,
3591            "smear mode must not create ghost nodes"
3592        );
3593        assert!(matches!(built.root.children[0].kind, BoxKind::Component(_)));
3594
3595        let filters = built.root.children[0]
3596            .css
3597            .filter
3598            .clone()
3599            .expect("a smear filter was applied");
3600        let blur = filters
3601            .iter()
3602            .find(|f| matches!(f, FilterFn::DirectionalBlur { .. }))
3603            .expect("a DirectionalBlur filter, not an axis-aligned Blur");
3604        match blur {
3605            FilterFn::DirectionalBlur { angle, radius } => {
3606                assert!(
3607                    angle.abs() < 1.0,
3608                    "a pure horizontal move must orient the streak along 0 degrees, got {angle}"
3609                );
3610                assert!(
3611                    matches!(radius, Length::Px(v) if (*v - 30.0).abs() < 3.0),
3612                    "900px/s over a 1/30s shutter window is a ~30px streak, got {:?}",
3613                    radius
3614                );
3615            }
3616            other => panic!("expected DirectionalBlur, got {:?}", other),
3617        }
3618    }
3619
3620    #[test]
3621    fn a_diagonal_smear_orients_the_directional_blur_along_the_actual_velocity() {
3622        use rustmotion_core::css::style::FilterFn;
3623
3624        let scene = vec![child_from_json(json!({
3625            "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3626            "position": "absolute", "x": 0, "y": 0,
3627            "style": {
3628                "width": 100, "height": 60,
3629                "animation": [
3630                    { "name": "keyframes", "duration": 1.0, "keyframes": [
3631                        { "property": "translate_x", "easing": "linear", "keyframes": [
3632                            { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3633                        ] },
3634                        { "property": "translate_y", "easing": "linear", "keyframes": [
3635                            { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3636                        ] }
3637                    ] },
3638                    { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3639                ]
3640            }
3641        }))];
3642        let anim = BuildAnimationCtx {
3643            time: 0.5,
3644            scenario_time: 0.5,
3645            scene_duration: 1.0,
3646            fps: 30,
3647        };
3648        let built = build_scene_at_time(
3649            &scene,
3650            (1920.0, 1080.0),
3651            default_root_css((1920.0, 1080.0)),
3652            anim,
3653        );
3654
3655        let filters = built.root.children[0]
3656            .css
3657            .filter
3658            .clone()
3659            .expect("a smear filter was applied");
3660        let blur = filters
3661            .iter()
3662            .find(|f| matches!(f, FilterFn::DirectionalBlur { .. }))
3663            .expect("a DirectionalBlur filter");
3664        match blur {
3665            FilterFn::DirectionalBlur { angle, .. } => {
3666                assert!(
3667                    (angle - 45.0).abs() < 1.0,
3668                    "equal x and y velocity must orient the streak at 45 degrees, not the \
3669                     0/90 axes a box blur would be limited to, got {angle}"
3670                );
3671            }
3672            other => panic!("expected DirectionalBlur, got {:?}", other),
3673        }
3674    }
3675
3676    fn read_pixel(surface: &mut skia_safe::Surface, x: i32, y: i32) -> [u8; 4] {
3677        let snapshot = surface.image_snapshot();
3678        let info = skia_safe::ImageInfo::new(
3679            (1, 1),
3680            skia_safe::ColorType::RGBA8888,
3681            skia_safe::AlphaType::Premul,
3682            None,
3683        );
3684        let mut buf = [0u8; 4];
3685        assert!(
3686            snapshot.read_pixels(
3687                &info,
3688                &mut buf,
3689                4,
3690                skia_safe::IPoint::new(x, y),
3691                skia_safe::image::CachingHint::Disallow,
3692            ),
3693            "pixel read should succeed"
3694        );
3695        buf
3696    }
3697
3698    fn ink_extent_x(
3699        surface: &mut skia_safe::Surface,
3700        width: i32,
3701        height: i32,
3702        y0: i32,
3703        y1: i32,
3704    ) -> Option<(i32, i32)> {
3705        let snapshot = surface.image_snapshot();
3706        let info = skia_safe::ImageInfo::new(
3707            (width, height),
3708            skia_safe::ColorType::RGBA8888,
3709            skia_safe::AlphaType::Premul,
3710            None,
3711        );
3712        let mut buf = vec![0u8; (width * height * 4) as usize];
3713        assert!(
3714            snapshot.read_pixels(
3715                &info,
3716                &mut buf,
3717                (width * 4) as usize,
3718                skia_safe::IPoint::new(0, 0),
3719                skia_safe::image::CachingHint::Disallow,
3720            ),
3721            "pixel read should succeed"
3722        );
3723        let mut min_x: Option<i32> = None;
3724        let mut max_x: Option<i32> = None;
3725        for y in y0.max(0)..y1.min(height) {
3726            for x in 0..width {
3727                let alpha = buf[((y * width + x) * 4 + 3) as usize];
3728                if alpha > 0 {
3729                    min_x = Some(min_x.map_or(x, |m| m.min(x)));
3730                    max_x = Some(max_x.map_or(x, |m| m.max(x)));
3731                }
3732            }
3733        }
3734        min_x.zip(max_x)
3735    }
3736
3737    fn paint_scene_at(
3738        built: &BuiltScene,
3739        layout: &rustmotion_core::engine::layout_pass::LayoutResult,
3740        width: i32,
3741        height: i32,
3742        time: f64,
3743    ) -> skia_safe::Surface {
3744        let mut surface =
3745            skia_safe::surfaces::raster_n32_premul((width, height)).expect("raster surface");
3746        let canvas = surface.canvas();
3747        let dispatcher = LegacyPaintDispatcher::for_scene(built);
3748        let frame = PaintFrame {
3749            light: Default::default(),
3750            time,
3751            scenario_time: time,
3752            frame_index: (time * 30.0).round() as u32,
3753            fps: 30,
3754            video_width: width as u32,
3755            video_height: height as u32,
3756            scene_duration: 1.0,
3757            camera: None,
3758        };
3759        paint_tree(canvas, &built.root, layout, &frame, &dispatcher);
3760        surface
3761    }
3762
3763    #[test]
3764    fn a_diagonal_smear_paints_a_streak_only_along_the_velocity_not_across_it() {
3765        const VIEWPORT: f32 = 900.0;
3766        let mut principal = child_from_json(json!({
3767            "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3768            "style": {
3769                "width": 80, "height": 80,
3770                "animation": [
3771                    { "name": "keyframes", "duration": 1.0, "keyframes": [
3772                        { "property": "translate_x", "easing": "linear", "keyframes": [
3773                            { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3774                        ] },
3775                        { "property": "translate_y", "easing": "linear", "keyframes": [
3776                            { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3777                        ] }
3778                    ] },
3779                    { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3780                ]
3781            }
3782        }));
3783        principal.position = Some(crate::PositionMode::Absolute { x: 60.0, y: 60.0 });
3784        let scene = vec![principal];
3785        let anim = BuildAnimationCtx {
3786            time: 0.5,
3787            scenario_time: 0.5,
3788            scene_duration: 1.0,
3789            fps: 30,
3790        };
3791        let built = build_scene_at_time(
3792            &scene,
3793            (VIEWPORT, VIEWPORT),
3794            default_root_css((VIEWPORT, VIEWPORT)),
3795            anim,
3796        );
3797        let layout = run_layout(
3798            &built.root,
3799            (VIEWPORT, VIEWPORT),
3800            &ConversionContext::default(),
3801        );
3802        let mut surface = paint_scene_at(&built, &layout, VIEWPORT as i32, VIEWPORT as i32, 0.5);
3803
3804        let center = (550.0_f32, 550.0_f32);
3805        let half_diagonal = 40.0 * std::f32::consts::SQRT_2;
3806        let reach = half_diagonal + 15.0;
3807        let along = (
3808            (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3809            (center.1 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3810        );
3811        let perpendicular = (
3812            (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3813            (center.1 - reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3814        );
3815
3816        let along_alpha = read_pixel(&mut surface, along.0, along.1)[3];
3817        let perpendicular_alpha = read_pixel(&mut surface, perpendicular.0, perpendicular.1)[3];
3818
3819        assert!(
3820            perpendicular_alpha < 10,
3821            "a point the same distance from the box's corner but 90 degrees off the 45-degree \
3822             motion must stay unlit — a directional blur has zero spread across its own axis; \
3823             got alpha {perpendicular_alpha}"
3824        );
3825        assert!(
3826            along_alpha > 25,
3827            "the same distance along the actual 45-degree motion must carry the blur's tail, \
3828             got alpha {along_alpha}"
3829        );
3830    }
3831
3832    #[test]
3833    fn an_animation_touching_only_border_radius_reaches_the_painted_frame() {
3834        const SIZE: i32 = 200;
3835        let scene = vec![child_from_json(json!({
3836            "type": "div",
3837            "style": {
3838                "width": SIZE, "height": SIZE,
3839                "background": "#FF2D55",
3840                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3841                    { "property": "border_radius", "easing": "linear", "keyframes": [
3842                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 100.0 }
3843                    ] }
3844                ] }]
3845            }
3846        }))];
3847        let anim = BuildAnimationCtx {
3848            time: 1.0,
3849            scenario_time: 1.0,
3850            scene_duration: 1.0,
3851            fps: 30,
3852        };
3853        let built = build_scene_at_time(
3854            &scene,
3855            (SIZE as f32, SIZE as f32),
3856            default_root_css((SIZE as f32, SIZE as f32)),
3857            anim,
3858        );
3859        let layout = run_layout(
3860            &built.root,
3861            (SIZE as f32, SIZE as f32),
3862            &ConversionContext::default(),
3863        );
3864        let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
3865
3866        let corner = read_pixel(&mut surface, 2, 2);
3867        assert_eq!(
3868            corner[3], 0,
3869            "a 200x200 box animated to a 100px border-radius (t=1) must clip its very corner to \
3870             transparent; an animation touching only border_radius must still reach paint \
3871             through box_builder's css, got rgba {:?}",
3872            corner
3873        );
3874    }
3875
3876    #[test]
3877    fn an_animation_touching_only_clip_path_progress_reaches_the_painted_frame() {
3878        const SIZE: i32 = 200;
3879        let scene = vec![child_from_json(json!({
3880            "type": "div",
3881            "style": {
3882                "width": SIZE, "height": SIZE,
3883                "background": "#00AAFF",
3884                "clip-path": {
3885                    "kind": "morph",
3886                    "from": { "kind": "circle", "radius": 10 },
3887                    "to": { "kind": "circle", "radius": 150 },
3888                    "progress": 0
3889                },
3890                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3891                    { "property": "clip_path_progress", "easing": "linear", "keyframes": [
3892                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }
3893                    ] }
3894                ] }]
3895            }
3896        }))];
3897        let anim = BuildAnimationCtx {
3898            time: 1.0,
3899            scenario_time: 1.0,
3900            scene_duration: 1.0,
3901            fps: 30,
3902        };
3903        let built = build_scene_at_time(
3904            &scene,
3905            (SIZE as f32, SIZE as f32),
3906            default_root_css((SIZE as f32, SIZE as f32)),
3907            anim,
3908        );
3909        let layout = run_layout(
3910            &built.root,
3911            (SIZE as f32, SIZE as f32),
3912            &ConversionContext::default(),
3913        );
3914        let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
3915
3916        let corner = read_pixel(&mut surface, 5, 5);
3917        assert!(
3918            corner[3] > 200,
3919            "at t=1 the clip-path morph must have swept to its 150px-radius `to` circle, which \
3920             covers this corner; an animation touching only clip_path_progress must still reach \
3921             paint through box_builder's css, got rgba {:?}",
3922            corner
3923        );
3924    }
3925
3926    #[test]
3927    fn an_animation_touching_only_gap_reaches_the_painted_frame() {
3928        const W: i32 = 400;
3929        const H: i32 = 80;
3930        let scene = vec![child_from_json(json!({
3931            "type": "div",
3932            "style": {
3933                "display": "flex", "flex-direction": "row",
3934                "width": W, "height": H,
3935                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3936                    { "property": "gap", "easing": "linear", "keyframes": [
3937                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 100.0 }
3938                    ] }
3939                ] }]
3940            },
3941            "children": [
3942                { "type": "shape", "shape": "rect", "fill": "#00AA00",
3943                  "style": { "width": 80, "height": 80 } },
3944                { "type": "shape", "shape": "rect", "fill": "#0000AA",
3945                  "style": { "width": 80, "height": 80 } }
3946            ]
3947        }))];
3948        let anim = BuildAnimationCtx {
3949            time: 1.0,
3950            scenario_time: 1.0,
3951            scene_duration: 1.0,
3952            fps: 30,
3953        };
3954        let built = build_scene_at_time(
3955            &scene,
3956            (W as f32, H as f32),
3957            default_root_css((W as f32, H as f32)),
3958            anim,
3959        );
3960        let layout = run_layout(
3961            &built.root,
3962            (W as f32, H as f32),
3963            &ConversionContext::default(),
3964        );
3965        let mut surface = paint_scene_at(&built, &layout, W, H, 1.0);
3966
3967        let between = read_pixel(&mut surface, 130, 40);
3968        assert_eq!(
3969            between[3], 0,
3970            "at t=1 a 100px gap must leave a transparent strip between the two 80px shapes \
3971             (x=80..180); an animation touching only gap must still reach layout through \
3972             box_builder's css, got rgba {:?}",
3973            between
3974        );
3975    }
3976
3977    #[test]
3978    fn an_animation_touching_only_padding_reaches_the_painted_frame() {
3979        const SIZE: i32 = 200;
3980        let scene = vec![child_from_json(json!({
3981            "type": "div",
3982            "style": {
3983                "width": SIZE, "height": SIZE,
3984                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3985                    { "property": "padding", "easing": "linear", "keyframes": [
3986                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 60.0 }
3987                    ] }
3988                ] }]
3989            },
3990            "children": [
3991                { "type": "shape", "shape": "rect", "fill": "#FFAA00",
3992                  "style": { "width": 40, "height": 40 } }
3993            ]
3994        }))];
3995        let anim = BuildAnimationCtx {
3996            time: 1.0,
3997            scenario_time: 1.0,
3998            scene_duration: 1.0,
3999            fps: 30,
4000        };
4001        let built = build_scene_at_time(
4002            &scene,
4003            (SIZE as f32, SIZE as f32),
4004            default_root_css((SIZE as f32, SIZE as f32)),
4005            anim,
4006        );
4007        let layout = run_layout(
4008            &built.root,
4009            (SIZE as f32, SIZE as f32),
4010            &ConversionContext::default(),
4011        );
4012        let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
4013
4014        let near_origin = read_pixel(&mut surface, 10, 10);
4015        assert_eq!(
4016            near_origin[3], 0,
4017            "at t=1 a 60px padding must push the 40px child away from the container's top-left \
4018             corner; an animation touching only padding must still reach layout through \
4019             box_builder's css, got rgba {:?}",
4020            near_origin
4021        );
4022    }
4023
4024    #[test]
4025    fn an_animated_font_size_reaches_layout_not_only_paint() {
4026        let make_text = |style_extra: serde_json::Value| {
4027            child_from_json(json!({
4028                "type": "text",
4029                "content": "The quick brown fox jumps over the lazy dog",
4030                "style": style_extra
4031            }))
4032        };
4033
4034        let baseline_scene = vec![make_text(json!({
4035            "font-size": 20, "white-space": "nowrap", "align-self": "flex-start"
4036        }))];
4037        let baseline_built = build_scene(&baseline_scene, (4000.0, 400.0));
4038        let baseline_layout = run_layout(
4039            &baseline_built.root,
4040            (4000.0, 400.0),
4041            &ConversionContext::default(),
4042        );
4043        let baseline_width = baseline_layout
4044            .get(baseline_built.root.children[0].id)
4045            .expect("baseline text laid out")
4046            .width;
4047
4048        let animated_scene = vec![make_text(json!({
4049            "font-size": 20,
4050            "white-space": "nowrap",
4051            "align-self": "flex-start",
4052            "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4053                { "property": "font_size", "easing": "linear", "keyframes": [
4054                    { "time": 0.0, "value": 20.0 }, { "time": 1.0, "value": 120.0 }
4055                ] }
4056            ] }]
4057        }))];
4058        let anim = BuildAnimationCtx {
4059            time: 1.0,
4060            scenario_time: 1.0,
4061            scene_duration: 1.0,
4062            fps: 30,
4063        };
4064        let animated_built = build_scene_at_time(
4065            &animated_scene,
4066            (4000.0, 400.0),
4067            default_root_css((4000.0, 400.0)),
4068            anim,
4069        );
4070        let animated_layout = run_layout(
4071            &animated_built.root,
4072            (4000.0, 400.0),
4073            &ConversionContext::default(),
4074        );
4075        let animated_width = animated_layout
4076            .get(animated_built.root.children[0].id)
4077            .expect("animated text laid out")
4078            .width;
4079
4080        assert!(
4081            animated_width > baseline_width * 2.0,
4082            "at t=1 the font_size animation has reached 120px (6x the declared static 20px); \
4083             the LAYOUT width must reflect that, not just the painted glyphs — issue #426/#430's \
4084             'paint right, layout stale' symptom. baseline={baseline_width}, \
4085             animated={animated_width}"
4086        );
4087    }
4088
4089    #[test]
4090    fn an_animated_letter_spacing_on_text_reaches_layout_not_only_paint() {
4091        let make_text = |style_extra: serde_json::Value| {
4092            child_from_json(json!({
4093                "type": "text",
4094                "content": "WWWWWWWWWW",
4095                "style": style_extra
4096            }))
4097        };
4098
4099        let baseline_scene = vec![make_text(json!({
4100            "letter-spacing": 0, "font-size": 40, "white-space": "nowrap",
4101            "align-self": "flex-start"
4102        }))];
4103        let baseline_built = build_scene(&baseline_scene, (4000.0, 400.0));
4104        let baseline_layout = run_layout(
4105            &baseline_built.root,
4106            (4000.0, 400.0),
4107            &ConversionContext::default(),
4108        );
4109        let baseline_width = baseline_layout
4110            .get(baseline_built.root.children[0].id)
4111            .expect("baseline text laid out")
4112            .width;
4113
4114        let animated_scene = vec![make_text(json!({
4115            "letter-spacing": 0, "font-size": 40, "white-space": "nowrap",
4116            "align-self": "flex-start",
4117            "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4118                { "property": "letter_spacing", "easing": "linear", "keyframes": [
4119                    { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
4120                ] }
4121            ] }]
4122        }))];
4123        let anim = BuildAnimationCtx {
4124            time: 1.0,
4125            scenario_time: 1.0,
4126            scene_duration: 1.0,
4127            fps: 30,
4128        };
4129        let animated_built = build_scene_at_time(
4130            &animated_scene,
4131            (4000.0, 400.0),
4132            default_root_css((4000.0, 400.0)),
4133            anim,
4134        );
4135        let animated_layout = run_layout(
4136            &animated_built.root,
4137            (4000.0, 400.0),
4138            &ConversionContext::default(),
4139        );
4140        let animated_width = animated_layout
4141            .get(animated_built.root.children[0].id)
4142            .expect("animated text laid out")
4143            .width;
4144
4145        assert!(
4146            animated_width > baseline_width + 200.0,
4147            "at t=1 a 40px letter-spacing animation must widen the measured LAYOUT box, or the \
4148             box the text was given overflows — issue #430's overflow symptom exactly. \
4149             baseline={baseline_width}, animated={animated_width}"
4150        );
4151    }
4152
4153    #[test]
4154    fn an_animated_letter_spacing_reaches_rich_text_through_the_full_pipeline() {
4155        const W: i32 = 800;
4156        const H: i32 = 120;
4157        let scene = vec![child_from_json(json!({
4158            "type": "rich_text",
4159            "spans": [{ "text": "WWWWWWWWWW", "color": "#FFFFFF" }],
4160            "style": {
4161                "letter-spacing": 0, "font-size": 60,
4162                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4163                    { "property": "letter_spacing", "easing": "linear", "keyframes": [
4164                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
4165                    ] }
4166                ] }]
4167            }
4168        }))];
4169
4170        let render_extent_at = |time: f64| -> (i32, i32) {
4171            let anim = BuildAnimationCtx {
4172                time,
4173                scenario_time: time,
4174                scene_duration: 1.0,
4175                fps: 30,
4176            };
4177            let built = build_scene_at_time(
4178                &scene,
4179                (W as f32, H as f32),
4180                default_root_css((W as f32, H as f32)),
4181                anim,
4182            );
4183            let layout = run_layout(
4184                &built.root,
4185                (W as f32, H as f32),
4186                &ConversionContext::default(),
4187            );
4188            let mut surface = paint_scene_at(&built, &layout, W, H, time);
4189            ink_extent_x(&mut surface, W, H, 0, H).expect("rich_text must paint some ink")
4190        };
4191
4192        let (start0, end0) = render_extent_at(0.0);
4193        let (start1, end1) = render_extent_at(1.0);
4194        let extent0 = end0 - start0;
4195        let extent1 = end1 - start1;
4196
4197        assert!(
4198            extent1 > extent0 + 100,
4199            "an animated style.letter-spacing (no per-span override) must widen rich_text's \
4200             painted ink extent — both the box_builder wiring and rich_text's own static/\
4201             animated fallback must be in place (issue #430); extent at t=0 was {extent0}px \
4202             [{start0},{end0}], at t=1 {extent1}px [{start1},{end1}]"
4203        );
4204    }
4205
4206    #[test]
4207    fn a_static_opacity_zero_is_overridden_by_an_active_fade_in_animation_end_to_end() {
4208        const SIZE: i32 = 100;
4209        let scene = vec![child_from_json(json!({
4210            "type": "div",
4211            "style": {
4212                "width": SIZE, "height": SIZE,
4213                "background": "#00FF00",
4214                "opacity": 0,
4215                "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4216                    { "property": "opacity", "easing": "linear", "keyframes": [
4217                        { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 0.8 }
4218                    ] }
4219                ] }]
4220            }
4221        }))];
4222        let anim = BuildAnimationCtx {
4223            time: 1.0,
4224            scenario_time: 1.0,
4225            scene_duration: 1.0,
4226            fps: 30,
4227        };
4228        let built = build_scene_at_time(
4229            &scene,
4230            (SIZE as f32, SIZE as f32),
4231            default_root_css((SIZE as f32, SIZE as f32)),
4232            anim,
4233        );
4234        let layout = run_layout(
4235            &built.root,
4236            (SIZE as f32, SIZE as f32),
4237            &ConversionContext::default(),
4238        );
4239        let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
4240
4241        let center = read_pixel(&mut surface, SIZE / 2, SIZE / 2);
4242        assert!(
4243            center[3] > 100,
4244            "the fade-in animation resolves opacity to 0.8 at t=1; a declared `opacity: 0` \
4245             authored as the animation's own starting point must not multiply it back to zero \
4246             for the rest of the run (issue #430), got rgba {:?}",
4247            center
4248        );
4249    }
4250}