From b2c14f698e9fd2f5443ebf4554f75d6b678feed7 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 22:16:49 +0200 Subject: [PATCH] feat(engine): wire container stagger into animation resolution The stagger field on flex/grid/card/container was documented and parsed but connected to nothing. Now: - effective_effects() (shared helper) folds style.animation, timeline steps (at-shifted) and the accumulated container-stagger delay into one effect list, with a borrow fast path when nothing merges - build_child threads index*stagger (cumulative across nesting) into the CSS override resolution, shifts start_at/end_at windows by the same amount, and records per-node delays in BuiltScene.stagger_delays - LegacyPaintDispatcher::for_scene() carries those delays so internal animations (draw_progress, char_*) shift identically, and finally feeds PaintCtx.stagger_offset (was hardcoded 0.0); this also closes the gap where timeline steps never reached internal animations --- .../rustmotion-components/src/box_builder.rs | 114 +++++++++++------- .../src/legacy_dispatch.rs | 44 +++++-- crates/rustmotion/src/engine/render/scene.rs | 4 +- crates/rustmotion/src/tests.rs | 56 ++++++++- 4 files changed, 162 insertions(+), 56 deletions(-) diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index e458b8d..58f3e72 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -43,6 +43,11 @@ pub struct BuiltScene<'a> { /// Lookup table — `components[id as usize]` is the component for `id`. /// `None` for synthetic boxes (the root scene wrapper). pub components: Vec>, + /// Per-node animation delay accumulated from ancestor containers' + /// `stagger` (indexed like `components`). Consumed by the paint + /// dispatcher so internal animations shift by the same amount as the + /// CSS overrides resolved at build time. + pub stagger_delays: Vec, } /// Build a box tree for a flat list of scene-level children at a given @@ -105,6 +110,7 @@ where I: IntoIterator, { let mut components: Vec> = vec![None]; + let mut stagger_delays: Vec = vec![0.0]; let mut next_id: NodeId = 1; let mut child_boxes = Vec::new(); @@ -112,9 +118,11 @@ where child_boxes.push(build_child( c, &mut components, + &mut stagger_delays, &mut next_id, anim, format!("/children/{i}"), + 0.0, )); } @@ -132,7 +140,11 @@ where window: None, }; - BuiltScene { root, components } + BuiltScene { + root, + components, + stagger_delays, + } } fn default_root_css(viewport: (f32, f32)) -> CssStyle { @@ -146,16 +158,22 @@ fn default_root_css(viewport: (f32, f32)) -> CssStyle { } /// Convert a single `ChildComponent` to a `BoxNode`. Recurses into containers. +/// `stagger_delay` is the animation delay accumulated from ancestor +/// containers' `stagger` fields. +#[allow(clippy::too_many_arguments)] fn build_child<'a>( child: &'a ChildComponent, components: &mut Vec>, + stagger_delays: &mut Vec, next_id: &mut NodeId, anim: Option, path: String, + stagger_delay: f64, ) -> BoxNode { let id = *next_id; *next_id += 1; components.push(Some(child)); + stagger_delays.push(stagger_delay); let mut css = component_css(&child.component); @@ -175,29 +193,12 @@ fn build_child<'a>( // char_animation remain on the `AnimatedProperties` legacy path. if let Some(actx) = anim { if let Some(animatable) = child.component.as_animatable() { - let effects = animatable.animation_effects(); - let steps = animatable.timeline_steps(); - let props = if steps.is_empty() { - if effects.is_empty() { - None - } else { - Some(resolve_props_for_effects( - effects, - actx.time, - actx.scene_duration, - )) - } - } else { - // Timeline steps resolve as their animations shifted by the - // step's `at` (merged with the plain `style.animation` list). - let merged = merge_timeline_effects(effects, steps); - Some(resolve_props_for_effects( - &merged, - actx.time, - actx.scene_duration, - )) - }; - if let Some(props) = props { + if let Some(effects) = effective_effects( + animatable.animation_effects(), + animatable.timeline_steps(), + stagger_delay, + ) { + let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); if props_has_paint_overrides(&props) { apply_animated_props(&mut css, &props); } @@ -206,13 +207,27 @@ fn build_child<'a>( } // Visibility window (start_at/end_at) — enforced by the paint pass. + // The stagger delay shifts the window too, so a hard-cut child appears + // in step with its staggered siblings. let window = child.component.as_timed().and_then(|t| { let (start, end) = t.timing(); - (start.is_some() || end.is_some()) - .then_some(rustmotion_core::engine::box_tree::PaintWindow { start, end }) + (start.is_some() || end.is_some()).then_some( + rustmotion_core::engine::box_tree::PaintWindow { + start: start.map(|s| s + stagger_delay), + end: end.map(|e| e + stagger_delay), + }, + ) }); - let children_boxes = container_children(&child.component, components, next_id, anim, &path); + let children_boxes = container_children( + &child.component, + components, + stagger_delays, + next_id, + anim, + &path, + stagger_delay, + ); let intrinsic = component_intrinsic(&child.component); BoxNode { @@ -226,13 +241,18 @@ fn build_child<'a>( } } -/// Flatten `timeline` steps into plain animation effects: each step's -/// animations run with `delay += step.at`, appended after the component's -/// own `style.animation` list. -fn merge_timeline_effects( - effects: &[rustmotion_core::schema::AnimationEffect], +/// The full effect list for a component at paint time: `style.animation`, +/// plus `timeline` steps shifted by their `at`, plus the container-stagger +/// delay applied to everything. Returns `None` when there is nothing to +/// resolve, `Some(Cow::Borrowed)` on the no-merge fast path. +pub fn effective_effects<'a>( + effects: &'a [rustmotion_core::schema::AnimationEffect], steps: &[rustmotion_core::schema::TimelineStep], -) -> Vec { + extra_delay: f64, +) -> Option> { + if steps.is_empty() && extra_delay == 0.0 { + return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects)); + } let mut merged = effects.to_vec(); for step in steps { for effect in &step.animation { @@ -241,7 +261,12 @@ fn merge_timeline_effects( merged.push(e); } } - merged + if extra_delay != 0.0 { + for e in &mut merged { + e.shift_delay(extra_delay); + } + } + (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged)) } /// Quick gate: does this resolved `AnimatedProperties` carry any property @@ -286,22 +311,27 @@ fn component_intrinsic( } /// If the component is a container, recurse into its children. Otherwise -/// return an empty Vec. +/// return an empty Vec. A container's `stagger` adds `index * stagger` +/// to each child's inherited animation delay (cumulative across nesting). +#[allow(clippy::too_many_arguments)] fn container_children<'a>( component: &'a Component, components: &mut Vec>, + stagger_delays: &mut Vec, next_id: &mut NodeId, anim: Option, parent_path: &str, + inherited_delay: f64, ) -> Vec { - let children: &[ChildComponent] = match component { - Component::Card(c) => &c.children, - Component::Flex(c) => &c.children, - Component::Grid(c) => &c.children, - Component::Container(c) => &c.children, - Component::Positioned(c) => &c.children, + let (children, stagger): (&[ChildComponent], Option) = match component { + Component::Card(c) => (&c.children, c.stagger), + Component::Flex(c) => (&c.children, c.stagger), + Component::Grid(c) => (&c.children, c.stagger), + Component::Container(c) => (&c.children, c.stagger), + Component::Positioned(c) => (&c.children, None), _ => return Vec::new(), }; + let step = stagger.unwrap_or(0.0) as f64; children .iter() .enumerate() @@ -309,9 +339,11 @@ fn container_children<'a>( build_child( c, components, + stagger_delays, next_id, anim, format!("{parent_path}/children/{j}"), + inherited_delay + j as f64 * step, ) }) .collect() diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 84133f1..f3bb5a3 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -24,11 +24,26 @@ pub struct LegacyPaintDispatcher<'a> { /// `components[id as usize]` is the component for `id`. Slot 0 is the /// synthetic root and is always `None`. components: &'a [Option<&'a ChildComponent>], + /// Per-node container-stagger delay (indexed like `components`); empty + /// when the caller doesn't carry stagger information. + stagger_delays: &'a [f64], } impl<'a> LegacyPaintDispatcher<'a> { pub fn new(components: &'a [Option<&'a ChildComponent>]) -> Self { - Self { components } + Self { + components, + stagger_delays: &[], + } + } + + /// Build from a [`BuiltScene`], carrying its stagger delays so internal + /// animations shift by the same amount as the CSS overrides. + pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self { + Self { + components: &built.components, + stagger_delays: &built.stagger_delays, + } } fn lookup(&self, id: NodeId) -> Option<&'a ChildComponent> { @@ -64,16 +79,25 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { // the CSS overrides injected at box-tree build time, so we don't // wrap the canvas here. `props` is still needed for internal-only // fields like `draw_progress`, `stroke_width`, `visible_chars*`, - // and `char_animation`. + // and `char_animation`. Timeline steps and container-stagger delays + // are folded in so those internal animations shift exactly like the + // CSS overrides do. + let stagger_delay = self + .stagger_delays + .get(*node_id as usize) + .copied() + .unwrap_or(0.0); let props = match child.component.as_animatable() { - Some(a) => { - let effects = a.animation_effects(); - if effects.is_empty() { - AnimatedProperties::default() - } else { - resolve_props_for_effects(effects, frame.time, frame.scene_duration) + Some(a) => match crate::box_builder::effective_effects( + a.animation_effects(), + a.timeline_steps(), + stagger_delay, + ) { + Some(effects) => { + resolve_props_for_effects(&effects, frame.time, frame.scene_duration) } - } + None => AnimatedProperties::default(), + }, None => AnimatedProperties::default(), }; if props.opacity <= 0.0 { @@ -94,7 +118,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { fps: frame.fps, video_width: frame.video_width, video_height: frame.video_height, - stagger_offset: 0.0, + stagger_offset: stagger_delay, }; let local = BoxLayout { x: 0.0, diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 8a7c81f..122d19c 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -368,7 +368,7 @@ fn render_with_new_pipeline_iter<'a, I>( (viewport_w, viewport_h), &ConversionContext::default(), ); - let dispatcher = LegacyPaintDispatcher::new(&built.components); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time: ctx.time, frame_index: ctx.frame_index, @@ -582,7 +582,7 @@ pub fn render_scene_hits( }); let built = build_scene_from_refs(children.iter(), (vw, vh), root_css, anim); let layout = run_layout(&built.root, (vw, vh), &ConversionContext::default()); - let dispatcher = LegacyPaintDispatcher::new(&built.components); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, frame_index: frame_in_scene, diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 01dbe9f..536bfa2 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -302,7 +302,7 @@ mod component_smoke { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let built = build_scene(&scene, (400.0, 300.0)); let layout = run_layout(&built.root, (400.0, 300.0), &ConversionContext::default()); - let dispatcher = LegacyPaintDispatcher::new(&built.components); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); })); if result.is_err() { @@ -383,7 +383,7 @@ mod component_smoke { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let built = build_scene(&scene, (400.0, 300.0)); let layout = run_layout(&built.root, (400.0, 300.0), &ConversionContext::default()); - let dispatcher = LegacyPaintDispatcher::new(&built.components); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); })); if result.is_err() { @@ -440,7 +440,7 @@ mod component_smoke { (w as f32, h as f32), &ConversionContext::default(), ); - let dispatcher = LegacyPaintDispatcher::new(&built.components); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, frame_index: (time * 30.0) as u32, @@ -675,6 +675,56 @@ mod component_smoke { ); } + #[test] + fn container_stagger_offsets_child_animations() { + // flex `stagger: 0.2` with three children fading in over 0.2s each. + // At t=0.25: child 0 finished, child 1 early in its (ease-out) fade, + // child 2 not started (delay 0.4). The stagger field existed in the + // schema but was wired to nothing. + let json = serde_json::json!({ + "type": "flex", + "stagger": 0.2, + "style": { "flex-direction": "column", "gap": "10px", "width": "300px" }, + "children": [ + { "type": "shape", "shape": "rect", "fill": "#ff3366", + "style": { "width": "100px", "height": "40px", + "animation": [{ "name": "fade_in", "duration": 0.2 }] } }, + { "type": "shape", "shape": "rect", "fill": "#ff3366", + "style": { "width": "100px", "height": "40px", + "animation": [{ "name": "fade_in", "duration": 0.2 }] } }, + { "type": "shape", "shape": "rect", "fill": "#ff3366", + "style": { "width": "100px", "height": "40px", + "animation": [{ "name": "fade_in", "duration": 0.2 }] } } + ] + }); + let component: Component = serde_json::from_value(json).expect("deserialize"); + let child = crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }; + let buf = render_new_at(&[child], 300, 200, 0.25, 2.0); + // Children flow in a column with a 10px gap: bands 0..40, 50..90, 100..140. + let band_red = |y0: usize, y1: usize| -> u64 { + (y0..y1) + .flat_map(|y| (0..300).map(move |x| (y * 300 + x) * 4)) + .map(|i| buf[i] as u64) + .sum() + }; + let (b0, b1, b2) = (band_red(0, 40), band_red(50, 90), band_red(100, 140)); + assert!(b0 > 0, "first child must be visible at t=0.25"); + assert!( + b1 > 0 && b1 * 4 < b0 * 3, + "second child must be partially faded (b0={b0}, b1={b1})" + ); + assert_eq!( + b2, 0, + "third child must not have started (stagger delay 0.4 > t=0.25)" + ); + } + #[test] fn timeline_steps_trigger_delayed_animations() { // A timeline step resolves as its animations with `delay += at`: