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>()
.init_resource::<crate::layer::LayerContentDirt>()
.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>,
promoted: Option<&'static crate::layer::PromotedLayer>,
layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
rnode: Option<&'static crate::bridge::RNode>,
transform3d: Option<&'static mut crate::layer::transform3d::LayerTransform3d>,
}
#[allow(clippy::type_complexity)]
fn apply_animated_nodes(
mut commands: Commands,
values: Res<SharedValues>,
mut dirt: ResMut<crate::layer::LayerContentDirt>,
mut validated: Local<HashMap<Entity, (Option<u32>, Option<u32>)>>,
mut query: Query<(Entity, Ref<AnimatedNode>, AnimTargets)>,
) {
use AnimatableProperty as P;
let mut filter_bound: Vec<Entity> = Vec::new();
for (entity, anim, mut t) in &mut query {
let b = &anim.0;
let promoted = t.promoted.is_some();
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))
.map(f32::to_radians),
);
if *t.transform != new {
let translate_only =
t.transform.scale == new.scale && t.transform.rotation == new.rotation;
if promoted && translate_only {
dirt.composite_only.push(entity);
} else {
dirt.nodes.push(entity);
}
*t.transform = new;
}
}
if b.has_transform3d()
&& let Some(t3d) = &mut t.transform3d
{
use crate::animations::protocol::Transform3dField as F;
use crate::protocol::Animatable::Static;
use crate::protocol::{Angle, Length, Transform3dOrigin};
let mut new = t3d.0.clone();
for (property, binding) in b.iter() {
let P::Transform3d(field) = property else {
continue;
};
let Some(v) = eval_scalar(binding, &values) else {
continue;
};
let deg = || Some(Static(Angle::from_radians(v.to_radians())));
let origin =
|o: &crate::protocol::Transform3d| o.origin.clone().unwrap_or_default();
match field {
F::Perspective => new.perspective = Some(Static(v)),
F::TranslateX => new.translate_x = Some(Static(v)),
F::TranslateY => new.translate_y = Some(Static(v)),
F::TranslateZ => new.translate_z = Some(Static(v)),
F::RotateX => new.rotate_x = deg(),
F::RotateY => new.rotate_y = deg(),
F::RotateZ => new.rotate_z = deg(),
F::Scale => new.scale = Some(Static(v)),
F::ScaleX => new.scale_x = Some(Static(v)),
F::ScaleY => new.scale_y = Some(Static(v)),
F::OriginX => {
new.origin = Some(Transform3dOrigin {
x: Static(Length::Px(v)),
y: origin(&new).y,
});
}
F::OriginY => {
new.origin = Some(Transform3dOrigin {
x: origin(&new).x,
y: Static(Length::Px(v)),
});
}
}
}
if t3d.0 != new {
t3d.0 = 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()
|| matches!(
property,
P::Opacity | P::FilterParam { .. } | P::Transform3d(_)
)
{
continue;
}
match property.value_kind() {
ValueKind::Color => {
let Some(mut rgba) = eval_color(binding, &values) else {
continue;
};
if !promoted
&& 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;
dirt.nodes.push(entity);
}
Some(_) => {}
None => {
commands.entity(entity).insert(BackgroundColor(color));
dirt.nodes.push(entity);
}
},
P::BorderColor => {
let bc = BorderColor {
top: color,
right: color,
bottom: color,
left: color,
};
match &mut t.border {
Some(c) if **c != bc => {
**c = bc;
dirt.nodes.push(entity);
}
Some(_) => {}
None => {
commands.entity(entity).insert(bc);
dirt.nodes.push(entity);
}
}
}
P::Color => {
if let Some(tc) = &mut t.text
&& tc.0 != color
{
tc.0 = color;
dirt.nodes.push(entity);
}
}
_ => {}
}
}
_ => {
let Some(v) = eval_scalar(binding, &values) else {
continue;
};
if let Some(node) = t.node.as_mut()
&& write_node_value(node, property, v)
{
dirt.nodes.push(entity);
}
}
}
}
if let Some(alpha) = opacity_alpha
&& promoted
{
if let Some(la) = &mut t.layer_alpha
&& la.0 != alpha
{
la.0 = alpha;
dirt.composite_only.push(entity);
}
} else 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)
})
};
let mut wrote = false;
if let Some(c) = &mut t.bg
&& let Some(new) = with_alpha(c.0)
{
c.0 = new;
wrote = true;
}
if let Some(tc) = &mut t.text
&& let Some(new) = with_alpha(tc.0)
{
tc.0 = new;
wrote = true;
}
if let Some(img) = &mut t.image
&& let Some(new) = with_alpha(img.color)
{
img.color = new;
wrote = true;
}
if wrote {
dirt.nodes.push(entity);
}
}
let has_filter = b.has_filter_params();
let has_backdrop = b.has_backdrop_params();
if has_filter || has_backdrop {
filter_bound.push(entity);
let pre = (
t.resolved_filter.as_ref().map(|c| c.version),
t.resolved_backdrop.as_ref().map(|c| c.0.version),
);
let validate = anim.is_changed() || validated.get(&entity) != Some(&pre);
if has_filter {
apply_filter_params(
entity,
b,
&values,
t.resolved_filter.as_mut(),
t.rnode,
validate,
&mut dirt,
false,
);
}
if has_backdrop {
let mut backdrop = t
.resolved_backdrop
.as_mut()
.map(|m| m.reborrow().map_unchanged(|b| &mut b.0));
apply_filter_params(
entity,
b,
&values,
backdrop.as_mut(),
t.rnode,
validate,
&mut dirt,
true,
);
}
let post = (
t.resolved_filter.as_ref().map(|c| c.version),
t.resolved_backdrop.as_ref().map(|c| c.0.version),
);
if validate || post != pre {
validated.insert(entity, post);
}
}
}
if validated.len() > filter_bound.len() {
validated.retain(|e, _| filter_bound.contains(e));
}
}
#[allow(clippy::too_many_arguments)]
fn apply_filter_params(
entity: Entity,
bindings: &AnimatedBindings,
values: &SharedValues,
chain: Option<&mut Mut<crate::filters::ResolvedFilterChain>>,
rnode: Option<&crate::bridge::RNode>,
validate: bool,
dirt: &mut crate::layer::LayerContentDirt,
backdrop: bool,
) {
let (prefix, kind, style_field) = if backdrop {
("backdropFilter", "backdropFilterBinding", "backdropFilter")
} else {
("filter", "filterBinding", "filter")
};
fn channel_param(property: &AnimatableProperty, backdrop: bool) -> Option<(u8, &String)> {
match (property, backdrop) {
(AnimatableProperty::FilterParam { index, name }, false)
| (AnimatableProperty::BackdropParam { index, name }, true) => Some((*index, name)),
_ => None,
}
}
let _diag = rnode.map(|r| crate::diag::node_scope(r.0));
let warn = |validate: bool, make: &dyn Fn() -> (String, String)| {
if validate {
let (key, msg) = make();
crate::diag::report(kind, &key, &msg);
}
};
let Some(chain) = chain else {
for (property, _) in bindings.iter() {
if let Some((index, name)) = channel_param(property, backdrop) {
warn(validate, &|| {
(
format!("{prefix}[{index}].{name}"),
format!(
"binding {prefix}[{index}].{name}: the node has no resolved \
{prefix} chain to drive (no valid `{style_field}` style) — \
binding ignored"
),
)
});
}
}
return;
};
let mut writes: Vec<(usize, usize, usize, f32)> = Vec::new();
{
let chain: &crate::filters::ResolvedFilterChain = chain;
for (property, binding) in bindings.iter() {
let Some((index, name)) = channel_param(property, backdrop) else {
continue;
};
let slot = chain
.passes
.iter()
.filter(|p| p.wire_index == index)
.find_map(|p| p.layout.iter().find(|s| s.name == name.as_str()).copied());
let Some(slot) = slot else {
if chain.passes.iter().any(|p| p.wire_index == index) {
warn(validate, &|| {
let key = format!("{prefix}[{index}].{name}");
let msg = format!(
"{key}: chain entry {index} has no param {name:?} — binding ignored"
);
(key, msg)
});
} else {
warn(validate, &|| {
let key = format!("{prefix}[{index}].{name}");
let msg = format!(
"{key}: the resolved {prefix} chain has no entry at index {index} — \
binding ignored"
);
(key, msg)
});
}
continue;
};
enum Resolved {
Scalar(f32),
Color([f32; 4]),
}
let resolved = match slot.kind {
ValueKind::Color => match eval_color(binding, values) {
Some(rgba) => Resolved::Color(rgba),
None => {
if !matches!(binding, Binding::InterpolateColor { .. }) {
warn(validate, &|| {
let key = format!("{prefix}[{index}].{name}");
let msg = format!(
"{key}: param {name:?} is a color — bind an \
interpolateColor, not a scalar value"
);
(key, msg)
});
}
continue;
}
},
_ if slot.len != 1 => {
warn(validate, &|| {
let key = format!("{prefix}[{index}].{name}");
let msg = format!(
"{key}: param {name:?} spans {} components — multi-component \
params are not animatable per-param",
slot.len
);
(key, msg)
});
continue;
}
kind => match eval_scalar(binding, values) {
Some(v) => Resolved::Scalar(match kind {
ValueKind::Angle => v.to_radians(),
ValueKind::Length => v * chain.scale,
_ => v,
}),
None => {
if matches!(binding, Binding::InterpolateColor { .. }) {
warn(validate, &|| {
let key = format!("{prefix}[{index}].{name}");
let msg = format!(
"{key}: param {name:?} is a scalar — an \
interpolateColor binding cannot drive it"
);
(key, msg)
});
}
continue;
}
},
};
for (pi, pass) in chain.passes.iter().enumerate() {
if pass.wire_index != index {
continue;
}
let Some(slot) = pass.layout.iter().find(|s| s.name == name.as_str()) else {
continue;
};
let Some(vec) = pass.params.get(slot.vec) else {
continue;
};
match &resolved {
Resolved::Scalar(v) => {
if slot.comp < 4 && vec[slot.comp] != *v {
writes.push((pi, slot.vec, slot.comp, *v));
}
}
Resolved::Color(rgba) => {
for comp in slot.comp..(slot.comp + slot.len).min(4) {
let v = rgba[comp - slot.comp];
if vec[comp] != v {
writes.push((pi, slot.vec, comp, v));
}
}
}
}
}
}
}
if !writes.is_empty() {
let chain = &mut **chain;
for (pass, vec, comp, v) in writes {
chain.passes[pass].params[vec][comp] = v;
}
chain.version = chain.version.wrapping_add(1);
dirt.composite_only.push(entity);
}
}
fn write_node_value<N: std::ops::DerefMut<Target = Node>>(
node: &mut N,
property: &AnimatableProperty,
v: f32,
) -> bool {
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 => {
let mut wrote = false;
if node.row_gap != val {
node.row_gap = val;
wrote = true;
}
if node.column_gap != val {
node.column_gap = val;
wrote = true;
}
return wrote;
}
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),
_ => return false,
}
true
}
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::*;
use crate::protocol::AnimatableField;
fn style_bindings(style: serde_json::Value) -> AnimatedBindings {
let style: crate::protocol::Style = serde_json::from_value(style).expect("style decodes");
crate::style_bindings::derive_bindings(Some(&style)).expect("style carries bindings")
}
fn filter_bindings(entries: &[(u8, &str, Binding)]) -> AnimatedBindings {
AnimatedBindings(
entries
.iter()
.map(|(index, name, b)| {
(
AnimatableProperty::FilterParam {
index: *index,
name: (*name).into(),
},
b.clone(),
)
})
.collect(),
)
}
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 = style_bindings(serde_json::json!({
"transform": { "translateX": { "animated": { "id": 1 } } },
"backgroundColor": { "animated": { "type": "interpolateColor", "id": 1,
"input": [0, 1], "output": [[0,0,0,1],[1,1,1,1]] } },
}));
assert!(bindings.contains(AnimatableProperty::TranslateX));
assert!(bindings.contains(AnimatableProperty::BackgroundColor));
assert!(bindings.has_transform());
}
#[test]
fn apply_writes_transform_color_then_opacity() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
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 = style_bindings(serde_json::json!({
"transform": { "translateX": { "animated": { "id": 1 } } },
"opacity": { "animated": { "id": 2 } },
"backgroundColor": { "animated": { "type": "interpolateColor", "id": 3,
"input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] } },
}));
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 rotate_binding_converts_degrees_to_radians() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
let mut values = SharedValues::default();
values.set(1, 90.0); world.insert_resource(values);
let bindings = style_bindings(serde_json::json!({
"transform": { "rotate": { "animated": { "id": 1 } } },
}));
let e = world
.spawn((AnimatedNode(bindings), UiTransform::default()))
.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!(
(t.rotation.as_radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-5,
"90° on the wire → π/2 stored, got {}",
t.rotation.as_radians()
);
}
#[test]
fn apply_drives_node_length_and_border_color() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
let mut values = SharedValues::default();
values.set(10, 200.0); values.set(11, 0.0); world.insert_resource(values);
let bindings = style_bindings(serde_json::json!({
"width": { "animated": { "id": 10 } },
"borderColor": { "animated": { "type": "interpolateColor", "id": 11,
"input": [0, 1], "output": [[0, 1, 0, 1], [1, 0, 0, 1]] } },
}));
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();
world.init_resource::<crate::layer::LayerContentDirt>();
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 = style_bindings(serde_json::json!({
"transform": { "translateX": { "animated": { "id": 1 } } },
"opacity": { "animated": { "id": 2 } },
"backgroundColor": { "animated": { "type": "interpolateColor", "id": 3,
"input": [0, 1], "output": [[1, 0, 0, 1], [0, 0, 1, 1]] } },
"width": { "animated": { "id": 1 } },
}));
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"
);
}
#[test]
fn transform3d_bindings_drive_layer_params() {
use crate::layer::transform3d::LayerTransform3d;
use crate::protocol::Transform3d;
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
let mut values = SharedValues::default();
values.set(1, 90.0); world.insert_resource(values);
let bindings = style_bindings(serde_json::json!({
"transform3d": { "rotateY": { "animated": { "id": 1 } } },
}));
assert!(bindings.has_transform3d());
assert!(!bindings.has_transform(), "distinct from the 2D group");
let static_params = Transform3d {
perspective: Some(crate::protocol::Animatable::Static(500.0)),
..Default::default()
};
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
LayerTransform3d(static_params),
))
.id();
let mut apply = Schedule::default();
apply.add_systems(apply_animated_nodes);
apply.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
assert_eq!(
t.rotate_y.static_val().unwrap().radians(),
std::f32::consts::FRAC_PI_2,
"degrees on the wire, radians stored"
);
assert_eq!(
t.perspective.static_val(),
Some(500.0),
"unbound fields keep the base"
);
let tick_before = world.entity(e).get_ref::<LayerTransform3d>().unwrap();
let last = tick_before.last_changed();
apply.run(&mut world);
let tick_after = world.entity(e).get_ref::<LayerTransform3d>().unwrap();
assert_eq!(
tick_after.last_changed(),
last,
"a settled binding must not re-mark the params changed"
);
}
#[test]
fn bindings_with_filter_params_iterate_deterministically() {
use AnimatableProperty as P;
let bindings = style_bindings(serde_json::json!({
"filter": [
{ "name": "blur", "params": { "radius": { "animated": { "id": 2 } } } },
{ "name": "grayscale" },
{ "name": "custom", "params": { "b": { "animated": { "id": 1 } } } },
],
"opacity": { "animated": { "id": 3 } },
"transform": { "scale": { "animated": { "id": 4 } } },
}));
assert!(bindings.has_filter_params());
assert!(bindings.has_transform());
let keys: Vec<_> = bindings.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(
keys,
vec![
P::Scale,
P::Opacity,
P::FilterParam {
index: 0,
name: "radius".into()
},
P::FilterParam {
index: 2,
name: "b".into()
},
]
);
}
fn slot(
name: &'static str,
kind: ValueKind,
vec: usize,
comp: usize,
len: usize,
) -> crate::filters::ParamSlot {
crate::filters::ParamSlot {
name,
kind,
vec,
comp,
len,
}
}
fn pass(
wire_index: u8,
params: Vec<Vec4>,
layout: Vec<crate::filters::ParamSlot>,
) -> crate::filters::ResolvedFilterPass {
crate::filters::ResolvedFilterPass {
shader: Handle::default(),
params,
layout: std::sync::Arc::from(layout),
wire_index,
}
}
fn chain(
passes: Vec<crate::filters::ResolvedFilterPass>,
scale: f32,
) -> crate::filters::ResolvedFilterChain {
crate::filters::ResolvedFilterChain {
passes,
outset_px: 0,
always_dirty: false,
version: 1,
scale,
}
}
fn filter_world(value: f32) -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
let mut values = SharedValues::default();
values.set(1, value);
world.insert_resource(values);
let mut schedule = Schedule::default();
schedule.add_systems(apply_animated_nodes);
(world, schedule)
}
fn drain_dirt(world: &mut World) {
let mut dirt = world.resource_mut::<crate::layer::LayerContentDirt>();
dirt.nodes.clear();
dirt.composite_only.clear();
}
#[test]
fn filter_param_binding_drives_scalar_slot_composite_only() {
let (mut world, mut schedule) = filter_world(0.25);
let bindings = filter_bindings(&[(0, "amount", Binding::Shared { id: 1 })]);
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
chain(
vec![pass(
0,
vec![Vec4::new(1.0, 0.0, 0.0, 0.0)],
vec![slot("amount", ValueKind::Scalar, 0, 0, 1)],
)],
1.0,
),
))
.id();
schedule.run(&mut world);
{
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(c.passes[0].params[0].x, 0.25, "param follows the value");
assert_eq!(c.version, 2, "one bump per changed frame");
}
let dirt = world.resource::<crate::layer::LayerContentDirt>();
assert_eq!(dirt.composite_only, vec![e], "composite-only dirt");
assert!(dirt.nodes.is_empty(), "the capture is never dirtied");
drain_dirt(&mut world);
schedule.run(&mut world);
{
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(c.version, 2, "settled value is version-quiet");
}
let dirt = world.resource::<crate::layer::LayerContentDirt>();
assert!(dirt.composite_only.is_empty() && dirt.nodes.is_empty());
{
let mut em = world.entity_mut(e);
let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
c.passes[0].params[0].x = 1.0;
c.version = c.version.wrapping_add(1); }
schedule.run(&mut world);
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(c.passes[0].params[0].x, 0.25, "binding re-asserts");
assert_eq!(c.version, 4);
}
#[test]
fn filter_param_binding_routes_wire_index_and_scales_lengths() {
let (mut world, mut schedule) = filter_world(5.0);
let bindings = filter_bindings(&[(0, "radius", Binding::Shared { id: 1 })]);
let radius_layout = || vec![slot("radius", ValueKind::Length, 0, 0, 1)];
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
chain(
vec![
pass(0, vec![Vec4::new(20.0, 1.0, 0.0, 0.0)], radius_layout()),
pass(0, vec![Vec4::new(20.0, 0.0, 1.0, 0.0)], radius_layout()),
pass(1, vec![Vec4::new(20.0, 0.0, 0.0, 0.0)], radius_layout()),
],
2.0,
),
))
.id();
schedule.run(&mut world);
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(c.passes[0].params[0].x, 10.0, "H pass: 5 logical × 2");
assert_eq!(c.passes[1].params[0].x, 10.0, "V pass too");
assert_eq!(c.passes[0].params[0].y, 1.0, "direction untouched");
assert_eq!(c.passes[2].params[0].x, 20.0, "other wire entry untouched");
}
#[test]
fn filter_param_binding_converts_angle_and_writes_color() {
let (mut world, mut schedule) = filter_world(90.0);
world.resource_mut::<SharedValues>().set(2, 0.0);
let bindings = filter_bindings(&[
(0, "angle", Binding::Shared { id: 1 }),
(
0,
"tint",
Binding::InterpolateColor {
id: 2,
input: vec![0.0, 1.0],
output: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
},
),
]);
let e = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
chain(
vec![pass(
0,
vec![Vec4::ZERO, Vec4::ZERO],
vec![
slot("angle", ValueKind::Angle, 0, 0, 1),
slot("tint", ValueKind::Color, 1, 0, 4),
],
)],
1.0,
),
))
.id();
schedule.run(&mut world);
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert!(
(c.passes[0].params[0].x - std::f32::consts::FRAC_PI_2).abs() < 1e-6,
"90° packs as π/2 radians, got {}",
c.passes[0].params[0].x
);
assert_eq!(
c.passes[0].params[1],
Vec4::new(1.0, 0.0, 0.0, 1.0),
"color slot takes all four components"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn filter_param_validation_warns_once_and_stays_inert() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut world, mut schedule) = filter_world(1.0);
let bindings = filter_bindings(&[
(0, "nope", Binding::Shared { id: 1 }),
(3, "amount", Binding::Shared { id: 1 }),
(0, "dir", Binding::Shared { id: 1 }),
]);
let e = world
.spawn((
AnimatedNode(bindings.clone()),
UiTransform::default(),
crate::bridge::RNode(9),
chain(
vec![pass(
0,
vec![Vec4::new(0.5, 0.0, 0.0, 0.0)],
vec![
slot("amount", ValueKind::Scalar, 0, 0, 1),
slot("dir", ValueKind::Scalar, 0, 1, 2),
],
)],
1.0,
),
))
.id();
schedule.run(&mut world);
{
let c = world
.entity(e)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(
c.passes[0].params[0],
Vec4::new(0.5, 0.0, 0.0, 0.0),
"inert"
);
assert_eq!(c.version, 1, "no version churn from inert bindings");
}
let warns = crate::diag::take_runtime_warnings();
let mine: Vec<_> = warns.iter().filter(|w| w.node == Some(9)).collect();
assert_eq!(mine.len(), 3, "{warns:?}");
assert!(mine.iter().all(|w| w.kind == "filterBinding"));
let values: Vec<_> = mine.iter().map(|w| w.value.as_str()).collect();
assert!(values.contains(&"filter[0].nope"), "{values:?}");
assert!(values.contains(&"filter[3].amount"), "{values:?}");
assert!(values.contains(&"filter[0].dir"), "{values:?}");
schedule.run(&mut world);
assert!(
crate::diag::take_runtime_warnings()
.iter()
.all(|w| w.node != Some(9)),
"validation warnings must not repeat per frame"
);
world
.entity_mut(e)
.get_mut::<crate::filters::ResolvedFilterChain>()
.unwrap()
.version = 7;
schedule.run(&mut world);
let refires = crate::diag::take_runtime_warnings()
.iter()
.filter(|w| w.node == Some(9))
.count();
assert_eq!(refires, 3, "a re-resolved chain re-validates");
let e2 = world
.spawn((
AnimatedNode(bindings),
UiTransform::default(),
crate::bridge::RNode(10),
))
.id();
schedule.run(&mut world);
let chainless = crate::diag::take_runtime_warnings()
.iter()
.filter(|w| w.node == Some(10))
.count();
assert_eq!(chainless, 3, "chainless node warns per binding");
assert!(
world
.entity(e2)
.get::<crate::filters::ResolvedFilterChain>()
.is_none()
);
let mixed = filter_bindings(&[
(0, "amount", Binding::Shared { id: 1 }),
(0, "nope", Binding::Shared { id: 1 }),
]);
let e3 = world
.spawn((
AnimatedNode(mixed),
UiTransform::default(),
crate::bridge::RNode(11),
chain(
vec![pass(
0,
vec![Vec4::ZERO],
vec![slot("amount", ValueKind::Scalar, 0, 0, 1)],
)],
1.0,
),
))
.id();
for (frame, v) in [0.1f32, 0.2, 0.3, 0.4].into_iter().enumerate() {
world.resource_mut::<SharedValues>().set(1, v);
schedule.run(&mut world);
let version = world
.entity(e3)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap()
.version;
assert_eq!(
version as usize,
2 + frame,
"the valid binding writes (bumps version) every animated frame"
);
}
let warns = crate::diag::take_runtime_warnings();
let mine: Vec<_> = warns.iter().filter(|w| w.node == Some(11)).collect();
assert_eq!(
mine.len(),
1,
"an animating valid binding must not re-warn the invalid one per frame: {warns:?}"
);
assert_eq!(mine[0].value, "filter[0].nope");
}
}