use bevy::prelude::*;
use super::channels::Channel;
use crate::protocol::animatable::Animatable;
use crate::svg::{NUMERIC_ATTR_COUNT, NUMERIC_ATTRS, SvgShape};
struct SlotState {
written: f32,
ch: Channel,
}
impl SlotState {
fn snapped(value: f32) -> Self {
let mut ch = Channel::default();
ch.init(value);
SlotState { written: value, ch }
}
}
#[derive(Default)]
pub(super) struct ShapeChannel {
slots: [Option<SlotState>; NUMERIC_ATTR_COUNT],
}
impl ShapeChannel {
pub(super) fn reset(&mut self) {
self.slots = Default::default();
}
pub(super) fn drive(&mut self, shape: &mut Mut<SvgShape>, dt: f32) {
let mut writes: Vec<(usize, f32)> = Vec::new();
{
let attrs = &shape.attrs; let Some(spec) = attrs.transition.as_ref() else {
self.reset();
return;
};
for (i, (_, get, _)) in NUMERIC_ATTRS.iter().enumerate() {
let (component, timing) = match (get(attrs), spec.at(i)) {
(Some(Animatable::Static(v)), Some(timing)) => (*v, timing),
_ => {
self.slots[i] = None;
continue;
}
};
let st = self.slots[i].get_or_insert_with(|| SlotState::snapped(component));
let target = if component != st.written {
component
} else {
st.ch.target
};
let eased = st.ch.drive(target, Some(timing), dt);
st.written = eased;
if eased != component {
writes.push((i, eased));
}
}
}
if !writes.is_empty() {
let shape: &mut SvgShape = shape; for (i, v) in writes {
if let Some(Animatable::Static(cur)) = NUMERIC_ATTRS[i].2(&mut shape.attrs) {
*cur = v;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::{TransitionInput, TransitionState, drive_transitions};
use crate::animations::AnimatedNode;
use crate::animations::protocol::{AnimatableProperty, AnimatedBindings, Binding};
use crate::svg::{ShapeKind, SvgShape};
use bevy::prelude::*;
use bevy::ui::UiTransform;
use std::time::Duration;
fn world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_transitions);
(world, schedule)
}
fn advance(world: &mut World, secs: f32) {
world
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(secs));
}
fn attrs(json: serde_json::Value) -> crate::svg::ShapeAttrs {
serde_json::from_value(json).expect("attrs decode")
}
fn spawn_shape(world: &mut World, kind: ShapeKind, a: crate::svg::ShapeAttrs) -> Entity {
world
.spawn((
SvgShape { kind, attrs: a },
TransitionInput::default(),
TransitionState::default(),
UiTransform::default(),
))
.id()
}
fn cx(world: &World, e: Entity) -> f32 {
use crate::protocol::animatable::AnimatableField;
world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.cx
.static_val()
.expect("cx static")
}
fn set_cx(world: &mut World, e: Entity, v: f32) {
world.entity_mut(e).get_mut::<SvgShape>().unwrap().attrs.cx =
Some(crate::protocol::animatable::Animatable::Static(v));
}
const SPECCED: &str = r#"{
"cx": 30.0, "cy": 50.0, "r": 20.0,
"transition": { "cx": { "duration": 1000, "easing": "linear" } }
}"#;
fn specced() -> crate::svg::ShapeAttrs {
attrs(serde_json::from_str::<serde_json::Value>(SPECCED).unwrap())
}
#[test]
fn target_change_eases_then_settles_silent() {
let (mut world, mut schedule) = world();
let e = spawn_shape(&mut world, ShapeKind::Circle, specced());
schedule.run(&mut world);
assert_eq!(cx(&world, e), 30.0, "first sight snaps");
set_cx(&mut world, e, 70.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let mid = cx(&world, e);
assert!(
mid > 30.0 && mid < 70.0,
"mid-duration is strictly between: {mid}"
);
assert!(
(mid - 50.0).abs() < 1.0,
"linear 1s at 0.5s ≈ 50, got {mid}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert!((cx(&world, e) - 70.0).abs() < 1e-3, "settles on the target");
let tick = world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed();
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(
world
.entity(e)
.get_ref::<SvgShape>()
.unwrap()
.last_changed(),
tick,
"a settled transition must not tick Changed<SvgShape>"
);
}
#[test]
fn retarget_mid_flight_redirects_from_current() {
let (mut world, mut schedule) = world();
let e = spawn_shape(&mut world, ShapeKind::Circle, specced());
schedule.run(&mut world);
set_cx(&mut world, e, 70.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let mid = cx(&world, e);
assert!((mid - 50.0).abs() < 1.0, "mid-flight ≈ 50, got {mid}");
set_cx(&mut world, e, 30.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let redirected = cx(&world, e);
assert!(
redirected > 30.0 && redirected < mid,
"redirect eases from the in-flight value toward 30: {redirected}"
);
assert!(
(redirected - 40.0).abs() < 1.0,
"linear 1s from ~50 at 0.5s ≈ 40, got {redirected}"
);
}
#[test]
fn non_numeric_change_mid_ease_leaves_the_ease_alone() {
let (mut world, mut schedule) = world();
let e = spawn_shape(
&mut world,
ShapeKind::Polyline,
attrs(serde_json::json!({
"cx": 30.0, "points": [0.0, 0.0, 10.0, 10.0],
"transition": { "cx": { "duration": 1000, "easing": "linear" } }
})),
);
schedule.run(&mut world);
{
let mut shape = world.entity_mut(e);
let mut shape = shape.get_mut::<SvgShape>().unwrap();
shape.attrs.cx = Some(crate::protocol::animatable::Animatable::Static(70.0));
shape.attrs.points = Some(vec![Vec2::ZERO, Vec2::splat(99.0)]);
}
advance(&mut world, 0.5);
schedule.run(&mut world);
let mid = cx(&world, e);
assert!((mid - 50.0).abs() < 1.0, "cx still eases: {mid}");
assert_eq!(
world.entity(e).get::<SvgShape>().unwrap().attrs.points,
Some(vec![Vec2::ZERO, Vec2::splat(99.0)]),
"points snapped (never eased, never reverted)"
);
world
.entity_mut(e)
.get_mut::<SvgShape>()
.unwrap()
.attrs
.points = Some(vec![Vec2::splat(1.0)]);
advance(&mut world, 0.25);
schedule.run(&mut world);
let later = cx(&world, e);
assert!(
later > mid && later < 70.0,
"cx ease undisturbed by the points change: {later}"
);
}
#[test]
fn animated_slot_is_never_written() {
let (mut world, mut schedule) = world();
let e = spawn_shape(
&mut world,
ShapeKind::Circle,
attrs(serde_json::json!({
"cx": { "animated": { "id": 1 }, "seed": 10.0 },
"r": 20.0,
"transition": {
"cx": { "duration": 1000, "easing": "linear" },
"r": { "duration": 1000, "easing": "linear" }
}
})),
);
schedule.run(&mut world);
advance(&mut world, 0.5);
schedule.run(&mut world);
let shape = world.entity(e).get::<SvgShape>().unwrap();
assert!(
matches!(
shape.attrs.cx,
Some(crate::protocol::animatable::Animatable::Animated { seed: Some(s), .. }) if s == 10.0
),
"the animated slot (wrapper + seed) survives untouched"
);
}
#[test]
fn shape_binding_parks_the_whole_channel() {
let (mut world, mut schedule) = world();
let e = spawn_shape(
&mut world,
ShapeKind::Circle,
attrs(serde_json::json!({
"cx": { "animated": { "id": 1 }, "seed": 10.0 },
"r": 20.0,
"transition": { "r": { "duration": 1000, "easing": "linear" } }
})),
);
world.entity_mut(e).insert(AnimatedNode(AnimatedBindings(
[(
AnimatableProperty::ShapeAttr { name: "cx".into() },
Binding::Shared { id: 1 },
)]
.into(),
)));
schedule.run(&mut world);
{
let mut em = world.entity_mut(e);
em.get_mut::<SvgShape>().unwrap().attrs.r =
Some(crate::protocol::animatable::Animatable::Static(50.0));
}
advance(&mut world, 0.5);
schedule.run(&mut world);
use crate::protocol::animatable::AnimatableField;
assert_eq!(
world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.r
.static_val(),
Some(50.0),
"coarse park: r snaps while ANY shape binding exists"
);
world.entity_mut(e).remove::<AnimatedNode>();
schedule.run(&mut world); {
let mut em = world.entity_mut(e);
em.get_mut::<SvgShape>().unwrap().attrs.r =
Some(crate::protocol::animatable::Animatable::Static(10.0));
}
advance(&mut world, 0.5);
schedule.run(&mut world);
let r = world
.entity(e)
.get::<SvgShape>()
.unwrap()
.attrs
.r
.static_val()
.unwrap();
assert!(
(r - 30.0).abs() < 1.0,
"unparked: eases 50→10 from the live value, got {r}"
);
}
#[test]
fn spec_removal_snaps() {
let (mut world, mut schedule) = world();
let e = spawn_shape(&mut world, ShapeKind::Circle, specced());
schedule.run(&mut world);
set_cx(&mut world, e, 70.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert!((cx(&world, e) - 50.0).abs() < 1.0, "mid-flight");
world.entity_mut(e).get_mut::<SvgShape>().unwrap().attrs =
attrs(serde_json::json!({ "cx": 70.0, "cy": 50.0, "r": 20.0 }));
advance(&mut world, 0.1);
schedule.run(&mut world);
assert_eq!(cx(&world, e), 70.0, "spec removal snaps to the target");
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(cx(&world, e), 70.0, "and stays put");
}
}