Skip to main content

bevy_react/animations/
mod.rs

1//! `ReactUiAnimationsPlugin` — a Reanimated-style animation engine for
2//! `bevy-react`.
3//!
4//! The model mirrors React Native's Reanimated: a React app declares **shared
5//! values** (one animatable `f32` with a stable id) and assigns **drivers**
6//! (`withTiming`, `withSpring`, `withRepeat`, `withSequence`) to them; an
7//! `Animated.node` binds style properties to those values. All per-frame work —
8//! advancing drivers, interpolation, writing components — happens **here, on the
9//! Bevy side**, never crossing back to JS. The one exception is completion:
10//! a driver started with a correlation token reports its settlement (one
11//! [`AnimationSettled`] message, forwarded by the integrator) so a JS callback
12//! can fire — once per animation, not per frame.
13//!
14//! This crate is deliberately decoupled from the main `bevy-react` crate (which
15//! depends on it): it owns the animation wire types ([`mod@protocol`]) and the
16//! orchestration systems, and receives commands through an [`AnimationInbox`]
17//! channel the integrator hands it.
18
19use std::collections::HashMap;
20
21use bevy::ecs::query::QueryData;
22use bevy::prelude::*;
23use bevy::ui::UiTransform;
24use crossbeam_channel::Receiver;
25
26pub mod protocol;
27mod runner;
28
29pub use protocol::{
30    AnimatableProperty, AnimatedBindings, AnimationCommand, Binding, Driver, Easing, SharedId,
31    ValueKind,
32};
33pub use runner::{Runner, build_runner};
34
35/// Adds the animation orchestration: the [`SharedValues`] table, the per-frame
36/// driver/apply systems, and the [`AnimationInbox`] that feeds commands in.
37///
38/// Added automatically by `bevy_react::ReactUiPlugin` unless
39/// `.with_animations(false)`. The integrator is responsible for ordering
40/// [`AnimationSet::Apply`] after the reconciler's op-apply so per-frame animation
41/// writes win over this frame's static style.
42pub struct ReactUiAnimationsPlugin {
43    inbox: Receiver<AnimationCommand>,
44}
45
46impl ReactUiAnimationsPlugin {
47    /// Build the plugin around the receiving end of the `op_animate` channel.
48    pub fn new(inbox: Receiver<AnimationCommand>) -> Self {
49        Self { inbox }
50    }
51}
52
53impl Plugin for ReactUiAnimationsPlugin {
54    fn build(&self, app: &mut App) {
55        app.init_resource::<SharedValues>()
56            .add_message::<AnimationSettled>()
57            .insert_resource(AnimationInbox(self.inbox.clone()))
58            .configure_sets(
59                Update,
60                (AnimationSet::Drain, AnimationSet::Tick, AnimationSet::Apply).chain(),
61            )
62            .add_systems(
63                Update,
64                (
65                    drain_animation_commands.in_set(AnimationSet::Drain),
66                    tick_animations.in_set(AnimationSet::Tick),
67                    apply_animated_nodes.in_set(AnimationSet::Apply),
68                ),
69            );
70    }
71}
72
73/// Ordering handles for the three animation systems. The integrator orders
74/// [`AnimationSet::Apply`] relative to its own reconciler systems.
75#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
76pub enum AnimationSet {
77    /// Drain inbound commands into the [`SharedValues`] table.
78    Drain,
79    /// Advance every active driver by the frame delta.
80    Tick,
81    /// Write resolved values onto `UiTransform` / colors.
82    Apply,
83}
84
85/// Component placed (by the main reconciler) on any `Animated.node`. Carries the
86/// property→[`Binding`] map. Requires `UiTransform` so the apply system can always
87/// drive it.
88#[derive(Component, Debug, Clone)]
89#[require(UiTransform)]
90pub struct AnimatedNode(pub AnimatedBindings);
91
92/// A token-tagged driver settled: `finished` is `true` when it ran to its natural
93/// end, `false` when a `set`/`cancel`/new `animate` interrupted it. Written by
94/// the drain/tick systems for every [`AnimationCommand::Animate`] that carried a
95/// `token`; the integrator (`bevy-react`) forwards these to the JS completion
96/// callbacks. The one thing this crate sends back toward JS.
97#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
98pub struct AnimationSettled {
99    /// The shared value the driver was animating.
100    pub id: SharedId,
101    /// The JS-side correlation token from the `animate` command.
102    pub token: u64,
103    /// Natural completion (`true`) vs interruption (`false`).
104    pub finished: bool,
105}
106
107/// The receiving end of the `op_animate` channel, drained each frame.
108#[derive(Resource)]
109pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
110
111/// The live table of shared values, keyed by [`SharedId`]. Each entry holds the
112/// current reading plus an optional active driver. Settlements of token-tagged
113/// drivers accumulate in `settled` until the owning system flushes them to the
114/// [`AnimationSettled`] message stream.
115#[derive(Resource, Default)]
116pub struct SharedValues {
117    values: HashMap<SharedId, SharedValueState>,
118    settled: Vec<AnimationSettled>,
119}
120
121struct SharedValueState {
122    current: f32,
123    active: Option<Runner>,
124    /// Correlation token of the active driver's JS completion callback, if any.
125    token: Option<u64>,
126}
127
128impl SharedValueState {
129    /// The settlement for interrupting a still-active token-tagged driver
130    /// (`set`/`cancel`/a superseding `animate`), consuming the token.
131    fn interrupted(&mut self, id: SharedId) -> Option<AnimationSettled> {
132        self.active.as_ref()?;
133        let token = self.token.take()?;
134        Some(AnimationSettled {
135            id,
136            token,
137            finished: false,
138        })
139    }
140}
141
142impl SharedValues {
143    /// The current reading of a shared value, if it exists.
144    pub fn get(&self, id: SharedId) -> Option<f32> {
145        self.values.get(&id).map(|s| s.current)
146    }
147
148    /// Number of live shared values (handy in tests).
149    pub fn len(&self) -> usize {
150        self.values.len()
151    }
152
153    /// Whether the table is empty.
154    pub fn is_empty(&self) -> bool {
155        self.values.is_empty()
156    }
157
158    fn declare(&mut self, id: SharedId, initial: f32) {
159        // Idempotent: only the first declaration sets the initial reading, so a
160        // value survives React re-renders (matching `useSharedValue`).
161        self.values.entry(id).or_insert(SharedValueState {
162            current: initial,
163            active: None,
164            token: None,
165        });
166    }
167
168    fn set(&mut self, id: SharedId, value: f32) {
169        let s = self.values.entry(id).or_insert(SharedValueState {
170            current: value,
171            active: None,
172            token: None,
173        });
174        self.settled.extend(s.interrupted(id));
175        s.current = value;
176        s.active = None;
177    }
178
179    fn animate(&mut self, id: SharedId, driver: &Driver, token: Option<u64>) {
180        let s = self.values.entry(id).or_insert(SharedValueState {
181            current: 0.0,
182            active: None,
183            token: None,
184        });
185        self.settled.extend(s.interrupted(id));
186        let from = s.current;
187        s.active = Some(build_runner(driver, from));
188        s.token = token;
189    }
190
191    fn cancel(&mut self, id: SharedId) {
192        if let Some(s) = self.values.get_mut(&id) {
193            self.settled.extend(s.interrupted(id));
194            s.active = None;
195        }
196    }
197
198    fn clear(&mut self) {
199        self.values.clear();
200        // Reset also wipes the JS callback registry, so pending settlements would
201        // land on nobody — drop them.
202        self.settled.clear();
203    }
204
205    fn tick(&mut self, dt: f32) {
206        for (&id, s) in self.values.iter_mut() {
207            if let Some(runner) = s.active.as_mut() {
208                let (value, finished) = runner.step(dt);
209                s.current = value;
210                if finished {
211                    s.active = None;
212                    if let Some(token) = s.token.take() {
213                        self.settled.push(AnimationSettled {
214                            id,
215                            token,
216                            finished: true,
217                        });
218                    }
219                }
220            }
221        }
222    }
223
224    /// Flush the settlements accumulated since the last flush.
225    fn take_settled(&mut self) -> Vec<AnimationSettled> {
226        std::mem::take(&mut self.settled)
227    }
228}
229
230// --- Systems -------------------------------------------------------------------
231
232fn drain_animation_commands(
233    inbox: Res<AnimationInbox>,
234    mut values: ResMut<SharedValues>,
235    mut settled: MessageWriter<AnimationSettled>,
236) {
237    while let Ok(cmd) = inbox.0.try_recv() {
238        match cmd {
239            AnimationCommand::Declare { id, initial } => values.declare(id, initial),
240            AnimationCommand::Set { id, value } => values.set(id, value),
241            AnimationCommand::Animate { id, driver, token } => values.animate(id, &driver, token),
242            AnimationCommand::Cancel { id } => values.cancel(id),
243            AnimationCommand::Clear => values.clear(),
244        }
245    }
246    settled.write_batch(values.take_settled());
247}
248
249fn tick_animations(
250    time: Res<Time>,
251    mut values: ResMut<SharedValues>,
252    mut settled: MessageWriter<AnimationSettled>,
253) {
254    values.tick(time.delta_secs());
255    settled.write_batch(values.take_settled());
256}
257
258/// The components an animated node can drive. A `QueryData` struct (rather than a
259/// tuple) so a new animatable target component is one field, not a tuple-arity
260/// problem. Every visual/layout target is optional except `UiTransform` (required
261/// by [`AnimatedNode`]).
262#[derive(QueryData)]
263#[query_data(mutable)]
264struct AnimTargets {
265    transform: &'static mut UiTransform,
266    bg: Option<&'static mut BackgroundColor>,
267    border: Option<&'static mut BorderColor>,
268    text: Option<&'static mut TextColor>,
269    image: Option<&'static mut ImageNode>,
270    node: Option<&'static mut Node>,
271}
272
273fn apply_animated_nodes(
274    mut commands: Commands,
275    values: Res<SharedValues>,
276    mut query: Query<(Entity, &AnimatedNode, AnimTargets)>,
277) {
278    use AnimatableProperty as P;
279    for (entity, anim, mut t) in &mut query {
280        let b = &anim.0;
281
282        // Stage 1 — transform group: rebuild the whole `UiTransform` from the six
283        // channels each frame (unbound channels stay at identity). Grouped because
284        // scale precedence (`scale` vs `scaleX`/`scaleY`) needs all channels at once.
285        // Compare-before-write (here and in every stage below): the read goes
286        // through `Deref` (no change mark), only the assignment through `DerefMut`
287        // — so a settled binding doesn't dirty change detection every frame.
288        if b.has_transform() {
289            let new = build_ui_transform(
290                b.get(P::TranslateX)
291                    .and_then(|x| eval_scalar(x, &values))
292                    .map(Val::Px),
293                b.get(P::TranslateY)
294                    .and_then(|x| eval_scalar(x, &values))
295                    .map(Val::Px),
296                b.get(P::Scale).and_then(|x| eval_scalar(x, &values)),
297                b.get(P::ScaleX).and_then(|x| eval_scalar(x, &values)),
298                b.get(P::ScaleY).and_then(|x| eval_scalar(x, &values)),
299                b.get(P::Rotate).and_then(|x| eval_scalar(x, &values)),
300            );
301            if *t.transform != new {
302                *t.transform = new;
303            }
304        }
305
306        // Opacity owns the final alpha across background/text/image (stage 3).
307        // Resolved once up front so stage 2 can bake it into any color it writes —
308        // otherwise the two stages would ping-pong the alpha every frame and the
309        // compare-before-write guards would never settle.
310        let opacity_alpha = b.get(P::Opacity).and_then(|x| eval_scalar(x, &values));
311
312        // Stage 2 — every non-transform, non-opacity binding. Colors land on their
313        // component; lengths/scalars land on `Node`. Opacity is deferred to stage 3
314        // so it owns the final alpha after any color write (the original ordering).
315        for (&property, binding) in b.iter() {
316            if property.is_transform() || property == P::Opacity {
317                continue;
318            }
319            match property.value_kind() {
320                ValueKind::Color => {
321                    let Some(mut rgba) = eval_color(binding, &values) else {
322                        continue;
323                    };
324                    // Bake the final alpha in for the components stage 3 drives
325                    // (border is not one of them: opacity never touches it).
326                    if matches!(property, P::BackgroundColor | P::Color)
327                        && let Some(alpha) = opacity_alpha
328                    {
329                        rgba[3] = alpha;
330                    }
331                    let color = Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3]);
332                    match property {
333                        P::BackgroundColor => match &mut t.bg {
334                            Some(c) if c.0 != color => c.0 = color,
335                            Some(_) => {}
336                            None => {
337                                commands.entity(entity).insert(BackgroundColor(color));
338                            }
339                        },
340                        P::BorderColor => {
341                            let bc = BorderColor {
342                                top: color,
343                                right: color,
344                                bottom: color,
345                                left: color,
346                            };
347                            match &mut t.border {
348                                Some(c) if **c != bc => **c = bc,
349                                Some(_) => {}
350                                None => {
351                                    commands.entity(entity).insert(bc);
352                                }
353                            }
354                        }
355                        P::Color => {
356                            if let Some(tc) = &mut t.text
357                                && tc.0 != color
358                            {
359                                tc.0 = color;
360                            }
361                        }
362                        _ => {}
363                    }
364                }
365                // Length/Scalar (and the unused Angle) all target `Node` here —
366                // transform's Length/Scalar/Angle members were handled in stage 1.
367                _ => {
368                    let Some(v) = eval_scalar(binding, &values) else {
369                        continue;
370                    };
371                    if let Some(node) = t.node.as_mut() {
372                        write_node_value(node, property, v);
373                    }
374                }
375            }
376        }
377
378        // Stage 3 — opacity owns the final alpha across background/text/image.
379        if let Some(alpha) = opacity_alpha {
380            let with_alpha = |color: Color| -> Option<Color> {
381                let mut s = color.to_srgba();
382                (s.alpha != alpha).then(|| {
383                    s.alpha = alpha;
384                    Color::Srgba(s)
385                })
386            };
387            if let Some(c) = &mut t.bg
388                && let Some(new) = with_alpha(c.0)
389            {
390                c.0 = new;
391            }
392            if let Some(tc) = &mut t.text
393                && let Some(new) = with_alpha(tc.0)
394            {
395                tc.0 = new;
396            }
397            if let Some(img) = &mut t.image
398                && let Some(new) = with_alpha(img.color)
399            {
400                img.color = new;
401            }
402        }
403    }
404}
405
406/// Write a resolved scalar onto the matching `Node` layout field — but only when
407/// it actually differs from the live value. Writing `Node` re-triggers Bevy's
408/// layout, so the compare keeps a settled length binding from forcing a relayout
409/// every frame (the read goes through `Deref`, only the assignment through
410/// `DerefMut`, so an unchanged value never trips change detection). It also means a
411/// re-render that resets `Node` to its static style is corrected next frame.
412/// Lengths resolve to `Val::Px`: the imperative animation surface is scalar `f32`.
413fn write_node_value<N: std::ops::DerefMut<Target = Node>>(
414    node: &mut N,
415    property: AnimatableProperty,
416    v: f32,
417) {
418    use AnimatableProperty as P;
419    let val = Val::Px(v);
420    // Each arm's guard reads the live field through `Deref` (no change mark) and
421    // the body writes through `DerefMut` (marks changed) only when it differs — so
422    // a settled binding never forces a relayout. `Gap` writes both axes.
423    match property {
424        P::Width if node.width != val => node.width = val,
425        P::Height if node.height != val => node.height = val,
426        P::MinWidth if node.min_width != val => node.min_width = val,
427        P::MinHeight if node.min_height != val => node.min_height = val,
428        P::MaxWidth if node.max_width != val => node.max_width = val,
429        P::MaxHeight if node.max_height != val => node.max_height = val,
430        P::Left if node.left != val => node.left = val,
431        P::Right if node.right != val => node.right = val,
432        P::Top if node.top != val => node.top = val,
433        P::Bottom if node.bottom != val => node.bottom = val,
434        P::FlexBasis if node.flex_basis != val => node.flex_basis = val,
435        P::Gap => {
436            if node.row_gap != val {
437                node.row_gap = val;
438            }
439            if node.column_gap != val {
440                node.column_gap = val;
441            }
442        }
443        P::RowGap if node.row_gap != val => node.row_gap = val,
444        P::ColumnGap if node.column_gap != val => node.column_gap = val,
445        P::AspectRatio if node.aspect_ratio != Some(v) => node.aspect_ratio = Some(v),
446        _ => {}
447    }
448}
449
450/// Build a `UiTransform` from the six scalar transform channels (each `None`
451/// stays at identity: no translation, unit scale, no rotation). `scale` is
452/// uniform; `scale_x`/`scale_y` override a single axis. Shared by the animated
453/// node apply and `bevy-react`'s static/transition transform path so the channel
454/// semantics stay identical across both.
455pub fn build_ui_transform(
456    translate_x: Option<Val>,
457    translate_y: Option<Val>,
458    scale: Option<f32>,
459    scale_x: Option<f32>,
460    scale_y: Option<f32>,
461    rotate: Option<f32>,
462) -> UiTransform {
463    let mut t = UiTransform::IDENTITY;
464    if let Some(v) = translate_x {
465        t.translation.x = v;
466    }
467    if let Some(v) = translate_y {
468        t.translation.y = v;
469    }
470    let mut sx = 1.0;
471    let mut sy = 1.0;
472    if let Some(v) = scale {
473        sx = v;
474        sy = v;
475    }
476    if let Some(v) = scale_x {
477        sx = v;
478    }
479    if let Some(v) = scale_y {
480        sy = v;
481    }
482    t.scale = Vec2::new(sx, sy);
483    if let Some(v) = rotate {
484        t.rotation = Rot2::radians(v);
485    }
486    t
487}
488
489// --- Binding evaluation --------------------------------------------------------
490
491fn eval_scalar(binding: &Binding, values: &SharedValues) -> Option<f32> {
492    match binding {
493        Binding::Shared { id } => values.get(*id),
494        Binding::Interpolate { id, input, output } => {
495            Some(piecewise(values.get(*id)?, input, output))
496        }
497        Binding::InterpolateColor { .. } => None,
498    }
499}
500
501fn eval_color(binding: &Binding, values: &SharedValues) -> Option<[f32; 4]> {
502    match binding {
503        Binding::InterpolateColor { id, input, output } => {
504            Some(piecewise_color(values.get(*id)?, input, output))
505        }
506        _ => None,
507    }
508}
509
510/// Linear interpolation between two values of the same kind, `t` in `0.0..=1.0`.
511/// The one primitive every interpolated quantity shares — implemented here for
512/// the scalar and color bindings, and by `bevy-react`'s transition engine for its
513/// own channel types (hence public).
514pub trait Lerp: Copy {
515    /// `self + (other - self) * t`, component-wise where applicable.
516    fn lerp(self, other: Self, t: f32) -> Self;
517}
518
519impl Lerp for f32 {
520    fn lerp(self, other: Self, t: f32) -> Self {
521        self + (other - self) * t
522    }
523}
524
525impl Lerp for [f32; 4] {
526    fn lerp(self, other: Self, t: f32) -> Self {
527        // Qualified: `bevy::math::FloatExt::lerp` is also in scope for `f32`.
528        [
529            Lerp::lerp(self[0], other[0], t),
530            Lerp::lerp(self[1], other[1], t),
531            Lerp::lerp(self[2], other[2], t),
532            Lerp::lerp(self[3], other[3], t),
533        ]
534    }
535}
536
537/// Piecewise-linear interpolation, clamped at the ends. `input` must be ascending.
538fn piecewise(x: f32, input: &[f32], output: &[f32]) -> f32 {
539    if input.is_empty() || output.is_empty() {
540        return x;
541    }
542    piecewise_impl(x, input, output)
543}
544
545/// Per-channel piecewise-linear color interpolation (rgba in `0.0..=1.0`).
546fn piecewise_color(x: f32, input: &[f32], output: &[[f32; 4]]) -> [f32; 4] {
547    if input.is_empty() || output.is_empty() {
548        return [0.0, 0.0, 0.0, 1.0];
549    }
550    piecewise_impl(x, input, output)
551}
552
553/// The shared segment routine behind [`piecewise`]/[`piecewise_color`]: find the
554/// segment containing `x` and lerp within it, clamping at both ends. `input` must
555/// be ascending and both slices non-empty (the wrappers handle empty).
556fn piecewise_impl<T: Lerp>(x: f32, input: &[f32], output: &[T]) -> T {
557    let n = input.len().min(output.len());
558    if n == 1 || x <= input[0] {
559        return output[0];
560    }
561    if x >= input[n - 1] {
562        return output[n - 1];
563    }
564    for i in 0..n - 1 {
565        let (a, b) = (input[i], input[i + 1]);
566        if x >= a && x <= b {
567            let t = if (b - a).abs() < f32::EPSILON {
568                0.0
569            } else {
570                (x - a) / (b - a)
571            };
572            return output[i].lerp(output[i + 1], t);
573        }
574    }
575    output[n - 1]
576}
577
578// (Driver runtime — `Runner`, `build_runner`, easing — lives in `runner.rs`.)
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    fn timing(to: f32, duration: f32) -> Driver {
585        Driver::Timing {
586            to,
587            duration,
588            easing: Easing::Linear,
589        }
590    }
591
592    #[test]
593    fn piecewise_clamps_and_interpolates() {
594        let input = [0.0, 1.0];
595        let output = [10.0, 20.0];
596        assert_eq!(piecewise(-5.0, &input, &output), 10.0); // clamp low
597        assert_eq!(piecewise(5.0, &input, &output), 20.0); // clamp high
598        assert!((piecewise(0.5, &input, &output) - 15.0).abs() < 1e-6);
599        // Multi-segment.
600        let input = [0.0, 0.5, 1.0];
601        let output = [0.0, 100.0, 0.0];
602        assert!((piecewise(0.25, &input, &output) - 50.0).abs() < 1e-6);
603        assert!((piecewise(0.75, &input, &output) - 50.0).abs() < 1e-6);
604    }
605
606    #[test]
607    fn piecewise_color_interpolates_each_channel() {
608        let input = [0.0, 1.0];
609        let output = [[0.0, 0.0, 0.0, 1.0], [1.0, 0.5, 0.0, 1.0]];
610        let mid = piecewise_color(0.5, &input, &output);
611        assert!((mid[0] - 0.5).abs() < 1e-6);
612        assert!((mid[1] - 0.25).abs() < 1e-6);
613        assert!((mid[2] - 0.0).abs() < 1e-6);
614        assert!((mid[3] - 1.0).abs() < 1e-6);
615    }
616
617    #[test]
618    fn shared_values_animate_and_tick_to_target() {
619        let mut values = SharedValues::default();
620        values.declare(1, 0.0);
621        values.animate(1, &timing(100.0, 1.0), None);
622        values.tick(0.5);
623        assert!((values.get(1).unwrap() - 50.0).abs() < 1e-3);
624        values.tick(0.5);
625        assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
626        // Driver dropped once finished; further ticks are inert.
627        values.tick(1.0);
628        assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
629    }
630
631    #[test]
632    fn declare_is_idempotent_but_set_overrides() {
633        let mut values = SharedValues::default();
634        values.declare(1, 5.0);
635        values.declare(1, 999.0); // ignored — keeps 5.0
636        assert_eq!(values.get(1), Some(5.0));
637        values.set(1, 7.0);
638        assert_eq!(values.get(1), Some(7.0));
639        values.clear();
640        assert!(values.is_empty());
641    }
642
643    /// A token-tagged driver reports exactly one `finished: true` settlement when
644    /// it runs to its natural end — and nothing at all without a token.
645    #[test]
646    fn tokened_driver_settles_finished_once() {
647        let mut values = SharedValues::default();
648        values.declare(1, 0.0);
649        values.animate(1, &timing(100.0, 1.0), Some(7));
650        values.tick(0.5);
651        assert!(values.take_settled().is_empty(), "not settled yet");
652        values.tick(0.5);
653        assert_eq!(
654            values.take_settled(),
655            vec![AnimationSettled {
656                id: 1,
657                token: 7,
658                finished: true
659            }]
660        );
661        values.tick(1.0);
662        assert!(values.take_settled().is_empty(), "reported exactly once");
663
664        // Token-free drivers stay silent.
665        values.animate(1, &timing(0.0, 0.1), None);
666        values.tick(1.0);
667        assert!(values.take_settled().is_empty());
668    }
669
670    /// Interrupting an active token-tagged driver — via `set`, `cancel`, or a
671    /// superseding `animate` — reports `finished: false` for the old token.
672    #[test]
673    fn interrupting_a_tokened_driver_settles_unfinished() {
674        let mut values = SharedValues::default();
675        values.declare(1, 0.0);
676
677        values.animate(1, &timing(100.0, 1.0), Some(1));
678        values.set(1, 50.0);
679        assert_eq!(
680            values.take_settled(),
681            vec![AnimationSettled {
682                id: 1,
683                token: 1,
684                finished: false
685            }]
686        );
687
688        values.animate(1, &timing(100.0, 1.0), Some(2));
689        values.cancel(1);
690        assert_eq!(
691            values.take_settled(),
692            vec![AnimationSettled {
693                id: 1,
694                token: 2,
695                finished: false
696            }]
697        );
698
699        values.animate(1, &timing(100.0, 1.0), Some(3));
700        values.animate(1, &timing(0.0, 1.0), Some(4));
701        assert_eq!(
702            values.take_settled(),
703            vec![AnimationSettled {
704                id: 1,
705                token: 3,
706                finished: false
707            }]
708        );
709
710        // `clear` (reset/hot reload) drops pending settlements silently.
711        values.clear();
712        assert!(values.take_settled().is_empty());
713    }
714
715    #[test]
716    fn driver_deserializes_from_js_wire_shape() {
717        // The exact JSON `animated.ts` produces for a nested driver.
718        let json = r#"{
719            "type": "repeat",
720            "animation": {
721                "type": "sequence",
722                "steps": [
723                    { "type": "timing", "to": 50, "duration": 0.4, "easing": "easeInOut" },
724                    { "type": "spring", "to": 120, "stiffness": 120, "damping": 14, "mass": 1 }
725                ]
726            },
727            "count": -1,
728            "reverse": true
729        }"#;
730        let driver: Driver = serde_json::from_str(json).expect("driver decodes");
731        assert!(matches!(
732            driver,
733            Driver::Repeat {
734                count: -1,
735                reverse: true,
736                ..
737            }
738        ));
739    }
740
741    #[test]
742    fn command_and_binding_deserialize() {
743        let cmd: AnimationCommand =
744            serde_json::from_str(r#"{ "kind": "declare", "id": 3, "initial": 0 }"#).unwrap();
745        assert!(matches!(cmd, AnimationCommand::Declare { id: 3, .. }));
746        let cmd: AnimationCommand = serde_json::from_str(r#"{ "kind": "clear" }"#).unwrap();
747        assert!(matches!(cmd, AnimationCommand::Clear));
748
749        // `animate` decodes with and without the completion-callback token (the
750        // JS side omits the key entirely when no callback was passed).
751        let cmd: AnimationCommand = serde_json::from_str(
752            r#"{ "kind": "animate", "id": 1,
753                 "driver": { "type": "timing", "to": 1 }, "token": 9 }"#,
754        )
755        .unwrap();
756        assert!(matches!(
757            cmd,
758            AnimationCommand::Animate { token: Some(9), .. }
759        ));
760        let cmd: AnimationCommand = serde_json::from_str(
761            r#"{ "kind": "animate", "id": 1, "driver": { "type": "timing", "to": 1 } }"#,
762        )
763        .unwrap();
764        assert!(matches!(cmd, AnimationCommand::Animate { token: None, .. }));
765
766        let bindings: AnimatedBindings = serde_json::from_str(
767            r#"{ "translateX": { "type": "shared", "id": 1 },
768                 "backgroundColor": { "type": "interpolateColor", "id": 1,
769                     "input": [0, 1], "output": [[0,0,0,1],[1,1,1,1]] } }"#,
770        )
771        .unwrap();
772        assert!(bindings.contains(AnimatableProperty::TranslateX));
773        assert!(bindings.contains(AnimatableProperty::BackgroundColor));
774        assert!(bindings.has_transform());
775    }
776
777    #[test]
778    fn animated_bindings_skips_unknown_properties() {
779        // A newer JS bundle can send a property this binary doesn't know yet; the
780        // unknown key is skipped and the recognised ones still decode (rather than
781        // the whole `animatedStyle` failing).
782        let bindings: AnimatedBindings = serde_json::from_str(
783            r#"{ "scale": { "type": "shared", "id": 7 },
784                 "someFutureProp": { "type": "shared", "id": 8 } }"#,
785        )
786        .unwrap();
787        assert!(bindings.contains(AnimatableProperty::Scale));
788        assert!(bindings.has_transform());
789        assert_eq!(bindings.iter().count(), 1, "unknown property dropped");
790    }
791
792    /// The table-driven applier writes the transform translation, the interpolated
793    /// background color, and lets opacity own the final alpha — exactly the three
794    /// stages (transform → color → opacity) the per-field applier did.
795    #[test]
796    fn apply_writes_transform_color_then_opacity() {
797        let mut world = World::new();
798        let mut values = SharedValues::default();
799        values.set(1, 25.0); // translateX (px)
800        values.set(2, 0.5); // opacity
801        values.set(3, 0.0); // color progress → output[0] = red
802        world.insert_resource(values);
803
804        let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
805            "translateX": { "type": "shared", "id": 1 },
806            "opacity": { "type": "shared", "id": 2 },
807            "backgroundColor": { "type": "interpolateColor", "id": 3,
808                "input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] },
809        }))
810        .unwrap();
811
812        let e = world
813            .spawn((
814                AnimatedNode(bindings),
815                UiTransform::default(),
816                BackgroundColor(Color::WHITE),
817            ))
818            .id();
819
820        let mut schedule = Schedule::default();
821        schedule.add_systems(apply_animated_nodes);
822        schedule.run(&mut world);
823
824        let t = world.entity(e).get::<UiTransform>().unwrap();
825        assert_eq!(t.translation.x, Val::Px(25.0));
826
827        // Color resolved to red, then opacity overwrote alpha to 0.5.
828        let s = world
829            .entity(e)
830            .get::<BackgroundColor>()
831            .unwrap()
832            .0
833            .to_srgba();
834        assert!((s.red - 1.0).abs() < 1e-4);
835        assert!(s.green.abs() < 1e-4);
836        assert!(s.blue.abs() < 1e-4);
837        assert!((s.alpha - 0.5).abs() < 1e-4, "opacity owns final alpha");
838    }
839
840    /// A layout length lands on `Node` (as px); a `borderColor` binding inserts a
841    /// `BorderColor` on all sides when absent; and a re-render that resets `Node`
842    /// is corrected on the next apply (the compare-before-write re-applies because
843    /// the live value differs from the still-active binding's value).
844    #[test]
845    fn apply_drives_node_length_and_border_color() {
846        let mut world = World::new();
847        let mut values = SharedValues::default();
848        values.set(10, 200.0); // width (px)
849        values.set(11, 0.0); // border-color progress → output[0] = green
850        world.insert_resource(values);
851
852        let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
853            "width": { "type": "shared", "id": 10 },
854            "borderColor": { "type": "interpolateColor", "id": 11,
855                "input": [0, 1], "output": [[0, 1, 0, 1], [1, 0, 0, 1]] },
856        }))
857        .unwrap();
858
859        let e = world
860            .spawn((
861                AnimatedNode(bindings),
862                UiTransform::default(),
863                Node::default(),
864            ))
865            .id();
866
867        let mut schedule = Schedule::default();
868        schedule.add_systems(apply_animated_nodes);
869        schedule.run(&mut world);
870
871        assert_eq!(world.entity(e).get::<Node>().unwrap().width, Val::Px(200.0));
872        let bc = world.entity(e).get::<BorderColor>().unwrap();
873        let s = bc.top.to_srgba();
874        assert!(
875            s.green > 0.9 && s.red < 0.1,
876            "border resolved to green, got {s:?}"
877        );
878        assert_eq!(bc.left, bc.top, "all four sides set uniformly");
879
880        // A re-render resets the static width; the still-active binding re-applies.
881        world.entity_mut(e).get_mut::<Node>().unwrap().width = Val::Px(100.0);
882        schedule.run(&mut world);
883        assert_eq!(
884            world.entity(e).get::<Node>().unwrap().width,
885            Val::Px(200.0),
886            "binding re-applies after a re-render reset"
887        );
888    }
889
890    /// Once every bound shared value has settled, the apply system must stop
891    /// marking the target components changed — otherwise every `Animated.node`
892    /// keeps Bevy's transform propagation / render extraction hot forever.
893    #[test]
894    fn settled_apply_does_not_dirty_components() {
895        #[derive(Resource, Default)]
896        struct Dirty(usize);
897
898        let mut world = World::new();
899        let mut values = SharedValues::default();
900        values.set(1, 25.0); // translateX (px)
901        values.set(2, 0.5); // opacity
902        values.set(3, 0.0); // color progress
903        world.insert_resource(values);
904        world.init_resource::<Dirty>();
905
906        let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
907            "translateX": { "type": "shared", "id": 1 },
908            "opacity": { "type": "shared", "id": 2 },
909            "backgroundColor": { "type": "interpolateColor", "id": 3,
910                "input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] },
911            "width": { "type": "shared", "id": 1 },
912        }))
913        .unwrap();
914
915        world.spawn((
916            AnimatedNode(bindings),
917            UiTransform::default(),
918            BackgroundColor(Color::WHITE),
919            Node::default(),
920        ));
921
922        type AnyTargetChanged = Or<(
923            Changed<UiTransform>,
924            Changed<BackgroundColor>,
925            Changed<Node>,
926        )>;
927
928        let mut apply = Schedule::default();
929        apply.add_systems(apply_animated_nodes);
930        // A separate schedule so the detector's change ticks span exactly one
931        // apply run (Changed<> is relative to the detector's own last run).
932        let mut detect = Schedule::default();
933        detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
934            dirty.0 = q.iter().count();
935        });
936
937        apply.run(&mut world);
938        detect.run(&mut world);
939        assert!(
940            world.resource::<Dirty>().0 > 0,
941            "first apply must write the bound components"
942        );
943
944        apply.run(&mut world);
945        detect.run(&mut world);
946        assert_eq!(
947            world.resource::<Dirty>().0,
948            0,
949            "an apply with settled values must not dirty anything"
950        );
951    }
952}