Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 73 additions & 41 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<&'a ChildComponent>>,
/// 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<f64>,
}

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

let mut child_boxes = Vec::new();
for (i, c) in children.into_iter().enumerate() {
child_boxes.push(build_child(
c,
&mut components,
&mut stagger_delays,
&mut next_id,
anim,
format!("/children/{i}"),
0.0,
));
}

Expand All @@ -132,7 +140,11 @@ where
window: None,
};

BuiltScene { root, components }
BuiltScene {
root,
components,
stagger_delays,
}
}

fn default_root_css(viewport: (f32, f32)) -> CssStyle {
Expand All @@ -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<Option<&'a ChildComponent>>,
stagger_delays: &mut Vec<f64>,
next_id: &mut NodeId,
anim: Option<BuildAnimationCtx>,
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);

Expand All @@ -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);
}
Expand All @@ -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 {
Expand All @@ -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<rustmotion_core::schema::AnimationEffect> {
extra_delay: f64,
) -> Option<std::borrow::Cow<'a, [rustmotion_core::schema::AnimationEffect]>> {
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 {
Expand All @@ -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
Expand Down Expand Up @@ -286,32 +311,39 @@ 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<Option<&'a ChildComponent>>,
stagger_delays: &mut Vec<f64>,
next_id: &mut NodeId,
anim: Option<BuildAnimationCtx>,
parent_path: &str,
inherited_delay: f64,
) -> Vec<BoxNode> {
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<f32>) = 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()
.map(|(j, c)| {
build_child(
c,
components,
stagger_delays,
next_id,
anim,
format!("{parent_path}/children/{j}"),
inherited_delay + j as f64 * step,
)
})
.collect()
Expand Down
44 changes: 34 additions & 10 deletions crates/rustmotion-components/src/legacy_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions crates/rustmotion/src/engine/render/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 53 additions & 3 deletions crates/rustmotion/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`:
Expand Down
Loading