use std::collections::HashMap;
use bevy::ecs::query::QueryData;
use bevy::prelude::*;
use bevy::ui::UiTransform;
use crossbeam_channel::Receiver;
pub mod protocol;
mod runner;
pub use protocol::{
AnimatableProperty, AnimatedBindings, AnimationCommand, Binding, Driver, Easing, SharedId,
ValueKind,
};
pub use runner::{Runner, build_runner};
pub struct ReactUiAnimationsPlugin {
inbox: Receiver<AnimationCommand>,
}
impl ReactUiAnimationsPlugin {
pub fn new(inbox: Receiver<AnimationCommand>) -> Self {
Self { inbox }
}
}
impl Plugin for ReactUiAnimationsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SharedValues>()
.add_message::<AnimationSettled>()
.insert_resource(AnimationInbox(self.inbox.clone()))
.configure_sets(
Update,
(AnimationSet::Drain, AnimationSet::Tick, AnimationSet::Apply).chain(),
)
.add_systems(
Update,
(
drain_animation_commands.in_set(AnimationSet::Drain),
tick_animations.in_set(AnimationSet::Tick),
apply_animated_nodes.in_set(AnimationSet::Apply),
),
);
}
}
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum AnimationSet {
Drain,
Tick,
Apply,
}
#[derive(Component, Debug, Clone)]
#[require(UiTransform)]
pub struct AnimatedNode(pub AnimatedBindings);
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationSettled {
pub id: SharedId,
pub token: u64,
pub finished: bool,
}
#[derive(Resource)]
pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
#[derive(Resource, Default)]
pub struct SharedValues {
values: HashMap<SharedId, SharedValueState>,
settled: Vec<AnimationSettled>,
}
struct SharedValueState {
current: f32,
active: Option<Runner>,
token: Option<u64>,
}
impl SharedValueState {
fn interrupted(&mut self, id: SharedId) -> Option<AnimationSettled> {
self.active.as_ref()?;
let token = self.token.take()?;
Some(AnimationSettled {
id,
token,
finished: false,
})
}
}
impl SharedValues {
pub fn get(&self, id: SharedId) -> Option<f32> {
self.values.get(&id).map(|s| s.current)
}
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
fn declare(&mut self, id: SharedId, initial: f32) {
self.values.entry(id).or_insert(SharedValueState {
current: initial,
active: None,
token: None,
});
}
fn set(&mut self, id: SharedId, value: f32) {
let s = self.values.entry(id).or_insert(SharedValueState {
current: value,
active: None,
token: None,
});
self.settled.extend(s.interrupted(id));
s.current = value;
s.active = None;
}
fn animate(&mut self, id: SharedId, driver: &Driver, token: Option<u64>) {
let s = self.values.entry(id).or_insert(SharedValueState {
current: 0.0,
active: None,
token: None,
});
self.settled.extend(s.interrupted(id));
let from = s.current;
s.active = Some(build_runner(driver, from));
s.token = token;
}
fn cancel(&mut self, id: SharedId) {
if let Some(s) = self.values.get_mut(&id) {
self.settled.extend(s.interrupted(id));
s.active = None;
}
}
fn clear(&mut self) {
self.values.clear();
self.settled.clear();
}
fn tick(&mut self, dt: f32) {
for (&id, s) in self.values.iter_mut() {
if let Some(runner) = s.active.as_mut() {
let (value, finished) = runner.step(dt);
s.current = value;
if finished {
s.active = None;
if let Some(token) = s.token.take() {
self.settled.push(AnimationSettled {
id,
token,
finished: true,
});
}
}
}
}
}
fn take_settled(&mut self) -> Vec<AnimationSettled> {
std::mem::take(&mut self.settled)
}
}
fn drain_animation_commands(
inbox: Res<AnimationInbox>,
mut values: ResMut<SharedValues>,
mut settled: MessageWriter<AnimationSettled>,
) {
while let Ok(cmd) = inbox.0.try_recv() {
match cmd {
AnimationCommand::Declare { id, initial } => values.declare(id, initial),
AnimationCommand::Set { id, value } => values.set(id, value),
AnimationCommand::Animate { id, driver, token } => values.animate(id, &driver, token),
AnimationCommand::Cancel { id } => values.cancel(id),
AnimationCommand::Clear => values.clear(),
}
}
settled.write_batch(values.take_settled());
}
fn tick_animations(
time: Res<Time>,
mut values: ResMut<SharedValues>,
mut settled: MessageWriter<AnimationSettled>,
) {
values.tick(time.delta_secs());
settled.write_batch(values.take_settled());
}
#[derive(QueryData)]
#[query_data(mutable)]
struct AnimTargets {
transform: &'static mut UiTransform,
bg: Option<&'static mut BackgroundColor>,
border: Option<&'static mut BorderColor>,
text: Option<&'static mut TextColor>,
image: Option<&'static mut ImageNode>,
node: Option<&'static mut Node>,
}
fn apply_animated_nodes(
mut commands: Commands,
values: Res<SharedValues>,
mut query: Query<(Entity, &AnimatedNode, AnimTargets)>,
) {
use AnimatableProperty as P;
for (entity, anim, mut t) in &mut query {
let b = &anim.0;
if b.has_transform() {
let new = build_ui_transform(
b.get(P::TranslateX)
.and_then(|x| eval_scalar(x, &values))
.map(Val::Px),
b.get(P::TranslateY)
.and_then(|x| eval_scalar(x, &values))
.map(Val::Px),
b.get(P::Scale).and_then(|x| eval_scalar(x, &values)),
b.get(P::ScaleX).and_then(|x| eval_scalar(x, &values)),
b.get(P::ScaleY).and_then(|x| eval_scalar(x, &values)),
b.get(P::Rotate).and_then(|x| eval_scalar(x, &values)),
);
if *t.transform != new {
*t.transform = new;
}
}
let opacity_alpha = b.get(P::Opacity).and_then(|x| eval_scalar(x, &values));
for (&property, binding) in b.iter() {
if property.is_transform() || property == P::Opacity {
continue;
}
match property.value_kind() {
ValueKind::Color => {
let Some(mut rgba) = eval_color(binding, &values) else {
continue;
};
if matches!(property, P::BackgroundColor | P::Color)
&& let Some(alpha) = opacity_alpha
{
rgba[3] = alpha;
}
let color = Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3]);
match property {
P::BackgroundColor => match &mut t.bg {
Some(c) if c.0 != color => c.0 = color,
Some(_) => {}
None => {
commands.entity(entity).insert(BackgroundColor(color));
}
},
P::BorderColor => {
let bc = BorderColor {
top: color,
right: color,
bottom: color,
left: color,
};
match &mut t.border {
Some(c) if **c != bc => **c = bc,
Some(_) => {}
None => {
commands.entity(entity).insert(bc);
}
}
}
P::Color => {
if let Some(tc) = &mut t.text
&& tc.0 != color
{
tc.0 = color;
}
}
_ => {}
}
}
_ => {
let Some(v) = eval_scalar(binding, &values) else {
continue;
};
if let Some(node) = t.node.as_mut() {
write_node_value(node, property, v);
}
}
}
}
if let Some(alpha) = opacity_alpha {
let with_alpha = |color: Color| -> Option<Color> {
let mut s = color.to_srgba();
(s.alpha != alpha).then(|| {
s.alpha = alpha;
Color::Srgba(s)
})
};
if let Some(c) = &mut t.bg
&& let Some(new) = with_alpha(c.0)
{
c.0 = new;
}
if let Some(tc) = &mut t.text
&& let Some(new) = with_alpha(tc.0)
{
tc.0 = new;
}
if let Some(img) = &mut t.image
&& let Some(new) = with_alpha(img.color)
{
img.color = new;
}
}
}
}
fn write_node_value<N: std::ops::DerefMut<Target = Node>>(
node: &mut N,
property: AnimatableProperty,
v: f32,
) {
use AnimatableProperty as P;
let val = Val::Px(v);
match property {
P::Width if node.width != val => node.width = val,
P::Height if node.height != val => node.height = val,
P::MinWidth if node.min_width != val => node.min_width = val,
P::MinHeight if node.min_height != val => node.min_height = val,
P::MaxWidth if node.max_width != val => node.max_width = val,
P::MaxHeight if node.max_height != val => node.max_height = val,
P::Left if node.left != val => node.left = val,
P::Right if node.right != val => node.right = val,
P::Top if node.top != val => node.top = val,
P::Bottom if node.bottom != val => node.bottom = val,
P::FlexBasis if node.flex_basis != val => node.flex_basis = val,
P::Gap => {
if node.row_gap != val {
node.row_gap = val;
}
if node.column_gap != val {
node.column_gap = val;
}
}
P::RowGap if node.row_gap != val => node.row_gap = val,
P::ColumnGap if node.column_gap != val => node.column_gap = val,
P::AspectRatio if node.aspect_ratio != Some(v) => node.aspect_ratio = Some(v),
_ => {}
}
}
pub fn build_ui_transform(
translate_x: Option<Val>,
translate_y: Option<Val>,
scale: Option<f32>,
scale_x: Option<f32>,
scale_y: Option<f32>,
rotate: Option<f32>,
) -> UiTransform {
let mut t = UiTransform::IDENTITY;
if let Some(v) = translate_x {
t.translation.x = v;
}
if let Some(v) = translate_y {
t.translation.y = v;
}
let mut sx = 1.0;
let mut sy = 1.0;
if let Some(v) = scale {
sx = v;
sy = v;
}
if let Some(v) = scale_x {
sx = v;
}
if let Some(v) = scale_y {
sy = v;
}
t.scale = Vec2::new(sx, sy);
if let Some(v) = rotate {
t.rotation = Rot2::radians(v);
}
t
}
fn eval_scalar(binding: &Binding, values: &SharedValues) -> Option<f32> {
match binding {
Binding::Shared { id } => values.get(*id),
Binding::Interpolate { id, input, output } => {
Some(piecewise(values.get(*id)?, input, output))
}
Binding::InterpolateColor { .. } => None,
}
}
fn eval_color(binding: &Binding, values: &SharedValues) -> Option<[f32; 4]> {
match binding {
Binding::InterpolateColor { id, input, output } => {
Some(piecewise_color(values.get(*id)?, input, output))
}
_ => None,
}
}
pub trait Lerp: Copy {
fn lerp(self, other: Self, t: f32) -> Self;
}
impl Lerp for f32 {
fn lerp(self, other: Self, t: f32) -> Self {
self + (other - self) * t
}
}
impl Lerp for [f32; 4] {
fn lerp(self, other: Self, t: f32) -> Self {
[
Lerp::lerp(self[0], other[0], t),
Lerp::lerp(self[1], other[1], t),
Lerp::lerp(self[2], other[2], t),
Lerp::lerp(self[3], other[3], t),
]
}
}
fn piecewise(x: f32, input: &[f32], output: &[f32]) -> f32 {
if input.is_empty() || output.is_empty() {
return x;
}
piecewise_impl(x, input, output)
}
fn piecewise_color(x: f32, input: &[f32], output: &[[f32; 4]]) -> [f32; 4] {
if input.is_empty() || output.is_empty() {
return [0.0, 0.0, 0.0, 1.0];
}
piecewise_impl(x, input, output)
}
fn piecewise_impl<T: Lerp>(x: f32, input: &[f32], output: &[T]) -> T {
let n = input.len().min(output.len());
if n == 1 || x <= input[0] {
return output[0];
}
if x >= input[n - 1] {
return output[n - 1];
}
for i in 0..n - 1 {
let (a, b) = (input[i], input[i + 1]);
if x >= a && x <= b {
let t = if (b - a).abs() < f32::EPSILON {
0.0
} else {
(x - a) / (b - a)
};
return output[i].lerp(output[i + 1], t);
}
}
output[n - 1]
}
#[cfg(test)]
mod tests {
use super::*;
fn timing(to: f32, duration: f32) -> Driver {
Driver::Timing {
to,
duration,
easing: Easing::Linear,
}
}
#[test]
fn piecewise_clamps_and_interpolates() {
let input = [0.0, 1.0];
let output = [10.0, 20.0];
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);
let input = [0.0, 0.5, 1.0];
let output = [0.0, 100.0, 0.0];
assert!((piecewise(0.25, &input, &output) - 50.0).abs() < 1e-6);
assert!((piecewise(0.75, &input, &output) - 50.0).abs() < 1e-6);
}
#[test]
fn piecewise_color_interpolates_each_channel() {
let input = [0.0, 1.0];
let output = [[0.0, 0.0, 0.0, 1.0], [1.0, 0.5, 0.0, 1.0]];
let mid = piecewise_color(0.5, &input, &output);
assert!((mid[0] - 0.5).abs() < 1e-6);
assert!((mid[1] - 0.25).abs() < 1e-6);
assert!((mid[2] - 0.0).abs() < 1e-6);
assert!((mid[3] - 1.0).abs() < 1e-6);
}
#[test]
fn shared_values_animate_and_tick_to_target() {
let mut values = SharedValues::default();
values.declare(1, 0.0);
values.animate(1, &timing(100.0, 1.0), None);
values.tick(0.5);
assert!((values.get(1).unwrap() - 50.0).abs() < 1e-3);
values.tick(0.5);
assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
values.tick(1.0);
assert!((values.get(1).unwrap() - 100.0).abs() < 1e-3);
}
#[test]
fn declare_is_idempotent_but_set_overrides() {
let mut values = SharedValues::default();
values.declare(1, 5.0);
values.declare(1, 999.0); assert_eq!(values.get(1), Some(5.0));
values.set(1, 7.0);
assert_eq!(values.get(1), Some(7.0));
values.clear();
assert!(values.is_empty());
}
#[test]
fn tokened_driver_settles_finished_once() {
let mut values = SharedValues::default();
values.declare(1, 0.0);
values.animate(1, &timing(100.0, 1.0), Some(7));
values.tick(0.5);
assert!(values.take_settled().is_empty(), "not settled yet");
values.tick(0.5);
assert_eq!(
values.take_settled(),
vec![AnimationSettled {
id: 1,
token: 7,
finished: true
}]
);
values.tick(1.0);
assert!(values.take_settled().is_empty(), "reported exactly once");
values.animate(1, &timing(0.0, 0.1), None);
values.tick(1.0);
assert!(values.take_settled().is_empty());
}
#[test]
fn interrupting_a_tokened_driver_settles_unfinished() {
let mut values = SharedValues::default();
values.declare(1, 0.0);
values.animate(1, &timing(100.0, 1.0), Some(1));
values.set(1, 50.0);
assert_eq!(
values.take_settled(),
vec![AnimationSettled {
id: 1,
token: 1,
finished: false
}]
);
values.animate(1, &timing(100.0, 1.0), Some(2));
values.cancel(1);
assert_eq!(
values.take_settled(),
vec![AnimationSettled {
id: 1,
token: 2,
finished: false
}]
);
values.animate(1, &timing(100.0, 1.0), Some(3));
values.animate(1, &timing(0.0, 1.0), Some(4));
assert_eq!(
values.take_settled(),
vec![AnimationSettled {
id: 1,
token: 3,
finished: false
}]
);
values.clear();
assert!(values.take_settled().is_empty());
}
#[test]
fn driver_deserializes_from_js_wire_shape() {
let json = r#"{
"type": "repeat",
"animation": {
"type": "sequence",
"steps": [
{ "type": "timing", "to": 50, "duration": 0.4, "easing": "easeInOut" },
{ "type": "spring", "to": 120, "stiffness": 120, "damping": 14, "mass": 1 }
]
},
"count": -1,
"reverse": true
}"#;
let driver: Driver = serde_json::from_str(json).expect("driver decodes");
assert!(matches!(
driver,
Driver::Repeat {
count: -1,
reverse: true,
..
}
));
}
#[test]
fn command_and_binding_deserialize() {
let cmd: AnimationCommand =
serde_json::from_str(r#"{ "kind": "declare", "id": 3, "initial": 0 }"#).unwrap();
assert!(matches!(cmd, AnimationCommand::Declare { id: 3, .. }));
let cmd: AnimationCommand = serde_json::from_str(r#"{ "kind": "clear" }"#).unwrap();
assert!(matches!(cmd, AnimationCommand::Clear));
let cmd: AnimationCommand = serde_json::from_str(
r#"{ "kind": "animate", "id": 1,
"driver": { "type": "timing", "to": 1 }, "token": 9 }"#,
)
.unwrap();
assert!(matches!(
cmd,
AnimationCommand::Animate { token: Some(9), .. }
));
let cmd: AnimationCommand = serde_json::from_str(
r#"{ "kind": "animate", "id": 1, "driver": { "type": "timing", "to": 1 } }"#,
)
.unwrap();
assert!(matches!(cmd, AnimationCommand::Animate { token: None, .. }));
let bindings: AnimatedBindings = serde_json::from_str(
r#"{ "translateX": { "type": "shared", "id": 1 },
"backgroundColor": { "type": "interpolateColor", "id": 1,
"input": [0, 1], "output": [[0,0,0,1],[1,1,1,1]] } }"#,
)
.unwrap();
assert!(bindings.contains(AnimatableProperty::TranslateX));
assert!(bindings.contains(AnimatableProperty::BackgroundColor));
assert!(bindings.has_transform());
}
#[test]
fn animated_bindings_skips_unknown_properties() {
let bindings: AnimatedBindings = serde_json::from_str(
r#"{ "scale": { "type": "shared", "id": 7 },
"someFutureProp": { "type": "shared", "id": 8 } }"#,
)
.unwrap();
assert!(bindings.contains(AnimatableProperty::Scale));
assert!(bindings.has_transform());
assert_eq!(bindings.iter().count(), 1, "unknown property dropped");
}
#[test]
fn apply_writes_transform_color_then_opacity() {
let mut world = World::new();
let mut values = SharedValues::default();
values.set(1, 25.0); values.set(2, 0.5); values.set(3, 0.0); world.insert_resource(values);
let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
"translateX": { "type": "shared", "id": 1 },
"opacity": { "type": "shared", "id": 2 },
"backgroundColor": { "type": "interpolateColor", "id": 3,
"input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] },
}))
.unwrap();
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
BackgroundColor(Color::WHITE),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(apply_animated_nodes);
schedule.run(&mut world);
let t = world.entity(e).get::<UiTransform>().unwrap();
assert_eq!(t.translation.x, Val::Px(25.0));
let s = world
.entity(e)
.get::<BackgroundColor>()
.unwrap()
.0
.to_srgba();
assert!((s.red - 1.0).abs() < 1e-4);
assert!(s.green.abs() < 1e-4);
assert!(s.blue.abs() < 1e-4);
assert!((s.alpha - 0.5).abs() < 1e-4, "opacity owns final alpha");
}
#[test]
fn apply_drives_node_length_and_border_color() {
let mut world = World::new();
let mut values = SharedValues::default();
values.set(10, 200.0); values.set(11, 0.0); world.insert_resource(values);
let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
"width": { "type": "shared", "id": 10 },
"borderColor": { "type": "interpolateColor", "id": 11,
"input": [0, 1], "output": [[0, 1, 0, 1], [1, 0, 0, 1]] },
}))
.unwrap();
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
Node::default(),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(apply_animated_nodes);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<Node>().unwrap().width, Val::Px(200.0));
let bc = world.entity(e).get::<BorderColor>().unwrap();
let s = bc.top.to_srgba();
assert!(
s.green > 0.9 && s.red < 0.1,
"border resolved to green, got {s:?}"
);
assert_eq!(bc.left, bc.top, "all four sides set uniformly");
world.entity_mut(e).get_mut::<Node>().unwrap().width = Val::Px(100.0);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<Node>().unwrap().width,
Val::Px(200.0),
"binding re-applies after a re-render reset"
);
}
#[test]
fn settled_apply_does_not_dirty_components() {
#[derive(Resource, Default)]
struct Dirty(usize);
let mut world = World::new();
let mut values = SharedValues::default();
values.set(1, 25.0); values.set(2, 0.5); values.set(3, 0.0); world.insert_resource(values);
world.init_resource::<Dirty>();
let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
"translateX": { "type": "shared", "id": 1 },
"opacity": { "type": "shared", "id": 2 },
"backgroundColor": { "type": "interpolateColor", "id": 3,
"input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] },
"width": { "type": "shared", "id": 1 },
}))
.unwrap();
world.spawn((
AnimatedNode(bindings),
UiTransform::default(),
BackgroundColor(Color::WHITE),
Node::default(),
));
type AnyTargetChanged = Or<(
Changed<UiTransform>,
Changed<BackgroundColor>,
Changed<Node>,
)>;
let mut apply = Schedule::default();
apply.add_systems(apply_animated_nodes);
let mut detect = Schedule::default();
detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
dirty.0 = q.iter().count();
});
apply.run(&mut world);
detect.run(&mut world);
assert!(
world.resource::<Dirty>().0 > 0,
"first apply must write the bound components"
);
apply.run(&mut world);
detect.run(&mut world);
assert_eq!(
world.resource::<Dirty>().0,
0,
"an apply with settled values must not dirty anything"
);
}
}