1use 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
35pub struct ReactUiAnimationsPlugin {
43 inbox: Receiver<AnimationCommand>,
44}
45
46impl ReactUiAnimationsPlugin {
47 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#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
76pub enum AnimationSet {
77 Drain,
79 Tick,
81 Apply,
83}
84
85#[derive(Component, Debug, Clone)]
89#[require(UiTransform)]
90pub struct AnimatedNode(pub AnimatedBindings);
91
92#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
98pub struct AnimationSettled {
99 pub id: SharedId,
101 pub token: u64,
103 pub finished: bool,
105}
106
107#[derive(Resource)]
109pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
110
111#[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 token: Option<u64>,
126}
127
128impl SharedValueState {
129 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 pub fn get(&self, id: SharedId) -> Option<f32> {
145 self.values.get(&id).map(|s| s.current)
146 }
147
148 pub fn len(&self) -> usize {
150 self.values.len()
151 }
152
153 pub fn is_empty(&self) -> bool {
155 self.values.is_empty()
156 }
157
158 fn declare(&mut self, id: SharedId, initial: f32) {
159 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 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 fn take_settled(&mut self) -> Vec<AnimationSettled> {
226 std::mem::take(&mut self.settled)
227 }
228}
229
230fn 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#[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 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 let opacity_alpha = b.get(P::Opacity).and_then(|x| eval_scalar(x, &values));
311
312 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 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 _ => {
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 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
406fn 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 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
450pub 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
489fn 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
510pub trait Lerp: Copy {
515 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 [
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
537fn 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
545fn 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
553fn 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#[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); assert_eq!(piecewise(5.0, &input, &output), 20.0); assert!((piecewise(0.5, &input, &output) - 15.0).abs() < 1e-6);
599 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 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); 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 #[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 values.animate(1, &timing(0.0, 0.1), None);
666 values.tick(1.0);
667 assert!(values.take_settled().is_empty());
668 }
669
670 #[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 values.clear();
712 assert!(values.take_settled().is_empty());
713 }
714
715 #[test]
716 fn driver_deserializes_from_js_wire_shape() {
717 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 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 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 #[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); values.set(2, 0.5); values.set(3, 0.0); 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 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 #[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); values.set(11, 0.0); 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 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 #[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); values.set(2, 0.5); values.set(3, 0.0); 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 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}