Skip to content

Commit b2c14f6

Browse files
committed
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
1 parent bb14797 commit b2c14f6

4 files changed

Lines changed: 162 additions & 56 deletions

File tree

crates/rustmotion-components/src/box_builder.rs

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ pub struct BuiltScene<'a> {
4343
/// Lookup table — `components[id as usize]` is the component for `id`.
4444
/// `None` for synthetic boxes (the root scene wrapper).
4545
pub components: Vec<Option<&'a ChildComponent>>,
46+
/// Per-node animation delay accumulated from ancestor containers'
47+
/// `stagger` (indexed like `components`). Consumed by the paint
48+
/// dispatcher so internal animations shift by the same amount as the
49+
/// CSS overrides resolved at build time.
50+
pub stagger_delays: Vec<f64>,
4651
}
4752

4853
/// Build a box tree for a flat list of scene-level children at a given
@@ -105,16 +110,19 @@ where
105110
I: IntoIterator<Item = &'a ChildComponent>,
106111
{
107112
let mut components: Vec<Option<&'a ChildComponent>> = vec![None];
113+
let mut stagger_delays: Vec<f64> = vec![0.0];
108114
let mut next_id: NodeId = 1;
109115

110116
let mut child_boxes = Vec::new();
111117
for (i, c) in children.into_iter().enumerate() {
112118
child_boxes.push(build_child(
113119
c,
114120
&mut components,
121+
&mut stagger_delays,
115122
&mut next_id,
116123
anim,
117124
format!("/children/{i}"),
125+
0.0,
118126
));
119127
}
120128

@@ -132,7 +140,11 @@ where
132140
window: None,
133141
};
134142

135-
BuiltScene { root, components }
143+
BuiltScene {
144+
root,
145+
components,
146+
stagger_delays,
147+
}
136148
}
137149

138150
fn default_root_css(viewport: (f32, f32)) -> CssStyle {
@@ -146,16 +158,22 @@ fn default_root_css(viewport: (f32, f32)) -> CssStyle {
146158
}
147159

148160
/// Convert a single `ChildComponent` to a `BoxNode`. Recurses into containers.
161+
/// `stagger_delay` is the animation delay accumulated from ancestor
162+
/// containers' `stagger` fields.
163+
#[allow(clippy::too_many_arguments)]
149164
fn build_child<'a>(
150165
child: &'a ChildComponent,
151166
components: &mut Vec<Option<&'a ChildComponent>>,
167+
stagger_delays: &mut Vec<f64>,
152168
next_id: &mut NodeId,
153169
anim: Option<BuildAnimationCtx>,
154170
path: String,
171+
stagger_delay: f64,
155172
) -> BoxNode {
156173
let id = *next_id;
157174
*next_id += 1;
158175
components.push(Some(child));
176+
stagger_delays.push(stagger_delay);
159177

160178
let mut css = component_css(&child.component);
161179

@@ -175,29 +193,12 @@ fn build_child<'a>(
175193
// char_animation remain on the `AnimatedProperties` legacy path.
176194
if let Some(actx) = anim {
177195
if let Some(animatable) = child.component.as_animatable() {
178-
let effects = animatable.animation_effects();
179-
let steps = animatable.timeline_steps();
180-
let props = if steps.is_empty() {
181-
if effects.is_empty() {
182-
None
183-
} else {
184-
Some(resolve_props_for_effects(
185-
effects,
186-
actx.time,
187-
actx.scene_duration,
188-
))
189-
}
190-
} else {
191-
// Timeline steps resolve as their animations shifted by the
192-
// step's `at` (merged with the plain `style.animation` list).
193-
let merged = merge_timeline_effects(effects, steps);
194-
Some(resolve_props_for_effects(
195-
&merged,
196-
actx.time,
197-
actx.scene_duration,
198-
))
199-
};
200-
if let Some(props) = props {
196+
if let Some(effects) = effective_effects(
197+
animatable.animation_effects(),
198+
animatable.timeline_steps(),
199+
stagger_delay,
200+
) {
201+
let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration);
201202
if props_has_paint_overrides(&props) {
202203
apply_animated_props(&mut css, &props);
203204
}
@@ -206,13 +207,27 @@ fn build_child<'a>(
206207
}
207208

208209
// Visibility window (start_at/end_at) — enforced by the paint pass.
210+
// The stagger delay shifts the window too, so a hard-cut child appears
211+
// in step with its staggered siblings.
209212
let window = child.component.as_timed().and_then(|t| {
210213
let (start, end) = t.timing();
211-
(start.is_some() || end.is_some())
212-
.then_some(rustmotion_core::engine::box_tree::PaintWindow { start, end })
214+
(start.is_some() || end.is_some()).then_some(
215+
rustmotion_core::engine::box_tree::PaintWindow {
216+
start: start.map(|s| s + stagger_delay),
217+
end: end.map(|e| e + stagger_delay),
218+
},
219+
)
213220
});
214221

215-
let children_boxes = container_children(&child.component, components, next_id, anim, &path);
222+
let children_boxes = container_children(
223+
&child.component,
224+
components,
225+
stagger_delays,
226+
next_id,
227+
anim,
228+
&path,
229+
stagger_delay,
230+
);
216231
let intrinsic = component_intrinsic(&child.component);
217232

218233
BoxNode {
@@ -226,13 +241,18 @@ fn build_child<'a>(
226241
}
227242
}
228243

229-
/// Flatten `timeline` steps into plain animation effects: each step's
230-
/// animations run with `delay += step.at`, appended after the component's
231-
/// own `style.animation` list.
232-
fn merge_timeline_effects(
233-
effects: &[rustmotion_core::schema::AnimationEffect],
244+
/// The full effect list for a component at paint time: `style.animation`,
245+
/// plus `timeline` steps shifted by their `at`, plus the container-stagger
246+
/// delay applied to everything. Returns `None` when there is nothing to
247+
/// resolve, `Some(Cow::Borrowed)` on the no-merge fast path.
248+
pub fn effective_effects<'a>(
249+
effects: &'a [rustmotion_core::schema::AnimationEffect],
234250
steps: &[rustmotion_core::schema::TimelineStep],
235-
) -> Vec<rustmotion_core::schema::AnimationEffect> {
251+
extra_delay: f64,
252+
) -> Option<std::borrow::Cow<'a, [rustmotion_core::schema::AnimationEffect]>> {
253+
if steps.is_empty() && extra_delay == 0.0 {
254+
return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects));
255+
}
236256
let mut merged = effects.to_vec();
237257
for step in steps {
238258
for effect in &step.animation {
@@ -241,7 +261,12 @@ fn merge_timeline_effects(
241261
merged.push(e);
242262
}
243263
}
244-
merged
264+
if extra_delay != 0.0 {
265+
for e in &mut merged {
266+
e.shift_delay(extra_delay);
267+
}
268+
}
269+
(!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged))
245270
}
246271

247272
/// Quick gate: does this resolved `AnimatedProperties` carry any property
@@ -286,32 +311,39 @@ fn component_intrinsic(
286311
}
287312

288313
/// If the component is a container, recurse into its children. Otherwise
289-
/// return an empty Vec.
314+
/// return an empty Vec. A container's `stagger` adds `index * stagger`
315+
/// to each child's inherited animation delay (cumulative across nesting).
316+
#[allow(clippy::too_many_arguments)]
290317
fn container_children<'a>(
291318
component: &'a Component,
292319
components: &mut Vec<Option<&'a ChildComponent>>,
320+
stagger_delays: &mut Vec<f64>,
293321
next_id: &mut NodeId,
294322
anim: Option<BuildAnimationCtx>,
295323
parent_path: &str,
324+
inherited_delay: f64,
296325
) -> Vec<BoxNode> {
297-
let children: &[ChildComponent] = match component {
298-
Component::Card(c) => &c.children,
299-
Component::Flex(c) => &c.children,
300-
Component::Grid(c) => &c.children,
301-
Component::Container(c) => &c.children,
302-
Component::Positioned(c) => &c.children,
326+
let (children, stagger): (&[ChildComponent], Option<f32>) = match component {
327+
Component::Card(c) => (&c.children, c.stagger),
328+
Component::Flex(c) => (&c.children, c.stagger),
329+
Component::Grid(c) => (&c.children, c.stagger),
330+
Component::Container(c) => (&c.children, c.stagger),
331+
Component::Positioned(c) => (&c.children, None),
303332
_ => return Vec::new(),
304333
};
334+
let step = stagger.unwrap_or(0.0) as f64;
305335
children
306336
.iter()
307337
.enumerate()
308338
.map(|(j, c)| {
309339
build_child(
310340
c,
311341
components,
342+
stagger_delays,
312343
next_id,
313344
anim,
314345
format!("{parent_path}/children/{j}"),
346+
inherited_delay + j as f64 * step,
315347
)
316348
})
317349
.collect()

crates/rustmotion-components/src/legacy_dispatch.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,26 @@ pub struct LegacyPaintDispatcher<'a> {
2424
/// `components[id as usize]` is the component for `id`. Slot 0 is the
2525
/// synthetic root and is always `None`.
2626
components: &'a [Option<&'a ChildComponent>],
27+
/// Per-node container-stagger delay (indexed like `components`); empty
28+
/// when the caller doesn't carry stagger information.
29+
stagger_delays: &'a [f64],
2730
}
2831

2932
impl<'a> LegacyPaintDispatcher<'a> {
3033
pub fn new(components: &'a [Option<&'a ChildComponent>]) -> Self {
31-
Self { components }
34+
Self {
35+
components,
36+
stagger_delays: &[],
37+
}
38+
}
39+
40+
/// Build from a [`BuiltScene`], carrying its stagger delays so internal
41+
/// animations shift by the same amount as the CSS overrides.
42+
pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self {
43+
Self {
44+
components: &built.components,
45+
stagger_delays: &built.stagger_delays,
46+
}
3247
}
3348

3449
fn lookup(&self, id: NodeId) -> Option<&'a ChildComponent> {
@@ -64,16 +79,25 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
6479
// the CSS overrides injected at box-tree build time, so we don't
6580
// wrap the canvas here. `props` is still needed for internal-only
6681
// fields like `draw_progress`, `stroke_width`, `visible_chars*`,
67-
// and `char_animation`.
82+
// and `char_animation`. Timeline steps and container-stagger delays
83+
// are folded in so those internal animations shift exactly like the
84+
// CSS overrides do.
85+
let stagger_delay = self
86+
.stagger_delays
87+
.get(*node_id as usize)
88+
.copied()
89+
.unwrap_or(0.0);
6890
let props = match child.component.as_animatable() {
69-
Some(a) => {
70-
let effects = a.animation_effects();
71-
if effects.is_empty() {
72-
AnimatedProperties::default()
73-
} else {
74-
resolve_props_for_effects(effects, frame.time, frame.scene_duration)
91+
Some(a) => match crate::box_builder::effective_effects(
92+
a.animation_effects(),
93+
a.timeline_steps(),
94+
stagger_delay,
95+
) {
96+
Some(effects) => {
97+
resolve_props_for_effects(&effects, frame.time, frame.scene_duration)
7598
}
76-
}
99+
None => AnimatedProperties::default(),
100+
},
77101
None => AnimatedProperties::default(),
78102
};
79103
if props.opacity <= 0.0 {
@@ -94,7 +118,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
94118
fps: frame.fps,
95119
video_width: frame.video_width,
96120
video_height: frame.video_height,
97-
stagger_offset: 0.0,
121+
stagger_offset: stagger_delay,
98122
};
99123
let local = BoxLayout {
100124
x: 0.0,

crates/rustmotion/src/engine/render/scene.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ fn render_with_new_pipeline_iter<'a, I>(
368368
(viewport_w, viewport_h),
369369
&ConversionContext::default(),
370370
);
371-
let dispatcher = LegacyPaintDispatcher::new(&built.components);
371+
let dispatcher = LegacyPaintDispatcher::for_scene(&built);
372372
let frame = PaintFrame {
373373
time: ctx.time,
374374
frame_index: ctx.frame_index,
@@ -582,7 +582,7 @@ pub fn render_scene_hits(
582582
});
583583
let built = build_scene_from_refs(children.iter(), (vw, vh), root_css, anim);
584584
let layout = run_layout(&built.root, (vw, vh), &ConversionContext::default());
585-
let dispatcher = LegacyPaintDispatcher::new(&built.components);
585+
let dispatcher = LegacyPaintDispatcher::for_scene(&built);
586586
let frame = PaintFrame {
587587
time,
588588
frame_index: frame_in_scene,

crates/rustmotion/src/tests.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ mod component_smoke {
302302
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
303303
let built = build_scene(&scene, (400.0, 300.0));
304304
let layout = run_layout(&built.root, (400.0, 300.0), &ConversionContext::default());
305-
let dispatcher = LegacyPaintDispatcher::new(&built.components);
305+
let dispatcher = LegacyPaintDispatcher::for_scene(&built);
306306
paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
307307
}));
308308
if result.is_err() {
@@ -383,7 +383,7 @@ mod component_smoke {
383383
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
384384
let built = build_scene(&scene, (400.0, 300.0));
385385
let layout = run_layout(&built.root, (400.0, 300.0), &ConversionContext::default());
386-
let dispatcher = LegacyPaintDispatcher::new(&built.components);
386+
let dispatcher = LegacyPaintDispatcher::for_scene(&built);
387387
paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
388388
}));
389389
if result.is_err() {
@@ -440,7 +440,7 @@ mod component_smoke {
440440
(w as f32, h as f32),
441441
&ConversionContext::default(),
442442
);
443-
let dispatcher = LegacyPaintDispatcher::new(&built.components);
443+
let dispatcher = LegacyPaintDispatcher::for_scene(&built);
444444
let frame = PaintFrame {
445445
time,
446446
frame_index: (time * 30.0) as u32,
@@ -675,6 +675,56 @@ mod component_smoke {
675675
);
676676
}
677677

678+
#[test]
679+
fn container_stagger_offsets_child_animations() {
680+
// flex `stagger: 0.2` with three children fading in over 0.2s each.
681+
// At t=0.25: child 0 finished, child 1 early in its (ease-out) fade,
682+
// child 2 not started (delay 0.4). The stagger field existed in the
683+
// schema but was wired to nothing.
684+
let json = serde_json::json!({
685+
"type": "flex",
686+
"stagger": 0.2,
687+
"style": { "flex-direction": "column", "gap": "10px", "width": "300px" },
688+
"children": [
689+
{ "type": "shape", "shape": "rect", "fill": "#ff3366",
690+
"style": { "width": "100px", "height": "40px",
691+
"animation": [{ "name": "fade_in", "duration": 0.2 }] } },
692+
{ "type": "shape", "shape": "rect", "fill": "#ff3366",
693+
"style": { "width": "100px", "height": "40px",
694+
"animation": [{ "name": "fade_in", "duration": 0.2 }] } },
695+
{ "type": "shape", "shape": "rect", "fill": "#ff3366",
696+
"style": { "width": "100px", "height": "40px",
697+
"animation": [{ "name": "fade_in", "duration": 0.2 }] } }
698+
]
699+
});
700+
let component: Component = serde_json::from_value(json).expect("deserialize");
701+
let child = crate::components::ChildComponent {
702+
component,
703+
position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }),
704+
x: None,
705+
y: None,
706+
z_index: None,
707+
};
708+
let buf = render_new_at(&[child], 300, 200, 0.25, 2.0);
709+
// Children flow in a column with a 10px gap: bands 0..40, 50..90, 100..140.
710+
let band_red = |y0: usize, y1: usize| -> u64 {
711+
(y0..y1)
712+
.flat_map(|y| (0..300).map(move |x| (y * 300 + x) * 4))
713+
.map(|i| buf[i] as u64)
714+
.sum()
715+
};
716+
let (b0, b1, b2) = (band_red(0, 40), band_red(50, 90), band_red(100, 140));
717+
assert!(b0 > 0, "first child must be visible at t=0.25");
718+
assert!(
719+
b1 > 0 && b1 * 4 < b0 * 3,
720+
"second child must be partially faded (b0={b0}, b1={b1})"
721+
);
722+
assert_eq!(
723+
b2, 0,
724+
"third child must not have started (stagger delay 0.4 > t=0.25)"
725+
);
726+
}
727+
678728
#[test]
679729
fn timeline_steps_trigger_delayed_animations() {
680730
// A timeline step resolves as its animations with `delay += at`:

0 commit comments

Comments
 (0)