use std::collections::BTreeMap;
use crate::animations::protocol::{
AnimatableProperty as P, AnimatedBindings, Binding, Transform3dField as F,
};
use crate::filters::FilterChain;
use crate::protocol::{animatable::binding_from_wrapper, props::Props, style::Style};
use crate::svg::ShapeAttrs;
pub(crate) fn animated_param_seed(value: &serde_json::Value) -> Option<Option<&serde_json::Value>> {
let map = value.as_object()?;
map.contains_key("animated").then(|| map.get("seed"))
}
fn chain_bindings(chain: Option<&FilterChain>, backdrop: bool, out: &mut BTreeMap<P, Binding>) {
let Some(chain) = chain else { return };
for (index, fu) in chain.0.iter().enumerate() {
let Ok(index) = u8::try_from(index) else {
break;
};
for (name, value) in &fu.params {
let Some(inner) = value.as_object().and_then(|m| m.get("animated")) else {
continue;
};
let property = if backdrop {
P::BackdropParam {
index,
name: name.clone(),
}
} else {
P::FilterParam {
index,
name: name.clone(),
}
};
out.insert(property, binding_from_wrapper(inner));
}
}
}
pub(crate) fn derive_bindings(style: Option<&Style>) -> Option<AnimatedBindings> {
let style = style?;
let mut out = BTreeMap::new();
use crate::protocol::animatable::Animatable;
macro_rules! row {
($prop:tt, (base $field:ident)) => {
if let Some(Animatable::Animated { binding: b, .. }) = &style.$field {
out.insert($prop, b.clone());
}
};
($prop:tt, (transform $field:ident)) => {
if let Some(t) = &style.transform
&& let Some(Animatable::Animated { binding: b, .. }) = &t.$field
{
out.insert($prop, b.clone());
}
};
($prop:tt, (t3d $field:ident $($unit:tt)*)) => {
if let Some(t) = &style.transform3d
&& let Some(Animatable::Animated { binding: b, .. }) = &t.$field
{
out.insert($prop, b.clone());
}
};
($prop:tt, (t3d_origin $axis:ident)) => {
if let Some(t) = &style.transform3d
&& let Some(origin) = &t.origin
&& let Animatable::Animated { binding: b, .. } = &origin.$axis
{
out.insert($prop, b.clone());
}
};
($prop:tt, (bg_tint)) => {
if let Some(bg) = &style.background_image
&& let Some(Animatable::Animated { binding: b, .. }) = &bg.tint
{
out.insert($prop, b.clone());
}
};
}
macro_rules! walk {
($(($prop:tt, $kind:ident, $acc:tt, $write:tt, $stage:ident, $park:ident),)*) => {
$(row!($prop, $acc);)*
};
}
crate::animations::props::with_animatable_props!(walk);
chain_bindings(style.filter.as_ref(), false, &mut out);
chain_bindings(style.backdrop_filter.as_ref(), true, &mut out);
(!out.is_empty()).then_some(AnimatedBindings(out))
}
pub(crate) fn derive_shape_bindings(shape: Option<&ShapeAttrs>) -> Option<AnimatedBindings> {
let shape = shape?;
let mut out = BTreeMap::new();
for (name, field, _) in &crate::svg::NUMERIC_ATTRS {
if let Some(crate::protocol::animatable::Animatable::Animated { binding, .. }) =
field(shape)
{
out.insert(
P::ShapeAttr {
name: (*name).to_string(),
},
binding.clone(),
);
}
}
(!out.is_empty()).then_some(AnimatedBindings(out))
}
pub(crate) fn derive_props_bindings(props: &Props) -> Option<AnimatedBindings> {
let mut out = derive_bindings(props.style.as_ref()).map_or_else(BTreeMap::new, |b| b.0);
if let Some(shape) = derive_shape_bindings(props.shape.as_ref()) {
out.extend(shape.0);
}
(!out.is_empty()).then_some(AnimatedBindings(out))
}
pub(crate) fn warn_variant_bindings(props: &Props) {
for (name, style) in [
("hoverStyle", props.hover_style.as_ref()),
("pressStyle", props.press_style.as_ref()),
("focusStyle", props.focus_style.as_ref()),
] {
if derive_bindings(style).is_some() {
let msg =
format!("{name}: animated bindings are only supported in the base style; ignoring");
tracing::warn!(target: "bevy_react", "{msg}");
crate::diag::report("styleBinding", name, &msg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn style(v: serde_json::Value) -> Style {
serde_json::from_value(v).expect("style decodes")
}
fn shared(id: u32) -> Binding {
Binding::Shared { id }
}
#[test]
fn derives_fields_transform_and_transform3d() {
let s = style(serde_json::json!({
"opacity": { "animated": { "id": 1 } },
"width": { "animated": { "id": 2 } },
"height": 40,
"transform": {
"translateX": { "animated": { "id": 3 } },
"rotate": { "animated": { "id": 4 } },
"scale": 2.0,
},
"transform3d": {
"perspective": 600,
"rotateY": { "animated": { "id": 5 } },
"origin": { "x": { "animated": { "id": 6 } }, "y": "50%" },
},
}));
let b = derive_bindings(Some(&s)).expect("bindings derived");
assert_eq!(b.get(P::Opacity), Some(&shared(1)));
assert_eq!(b.get(P::Width), Some(&shared(2)));
assert_eq!(b.get(P::Height), None, "static field not derived");
assert_eq!(b.get(P::TranslateX), Some(&shared(3)));
assert_eq!(b.get(P::Rotate), Some(&shared(4)));
assert_eq!(b.get(P::Scale), None, "static transform channel");
assert_eq!(b.get(P::Transform3d(F::RotateY)), Some(&shared(5)));
assert_eq!(b.get(P::Transform3d(F::OriginX)), Some(&shared(6)));
assert_eq!(b.get(P::Transform3d(F::OriginY)), None);
assert_eq!(b.get(P::Transform3d(F::Perspective)), None);
assert!(s.transform3d.as_ref().unwrap().perspective.is_some());
}
#[test]
fn derives_background_image_tint() {
let s = style(serde_json::json!({
"backgroundImage": {
"src": "bg.png",
"tint": { "animated": { "id": 9 } },
},
}));
let b = derive_bindings(Some(&s)).expect("bindings derived");
assert_eq!(b.get(P::BackgroundImageTint), Some(&shared(9)));
let s = style(serde_json::json!({
"backgroundImage": { "src": "bg.png", "tint": "#ff0000" },
}));
assert!(
derive_bindings(Some(&s)).is_none(),
"a static tint derives no bindings"
);
}
#[test]
fn derives_chain_params_by_position() {
let s = style(serde_json::json!({
"filter": [
{ "name": "blur", "params": { "radius": { "animated": { "id": 1 } } } },
{ "name": "grayscale" },
{ "name": "dissolve", "params": {
"progress": { "animated": { "type": "interpolate", "id": 2,
"input": [0, 1], "output": [0, 100] } },
"seed": 7,
} },
],
"backdropFilter": { "name": "blur", "params": { "radius": { "animated": { "id": 3 } } } },
}));
let b = derive_bindings(Some(&s)).expect("bindings derived");
assert_eq!(
b.get(P::FilterParam {
index: 0,
name: "radius".into()
}),
Some(&shared(1))
);
assert_eq!(
b.get(P::FilterParam {
index: 2,
name: "progress".into()
}),
Some(&Binding::Interpolate {
id: 2,
input: vec![0.0, 1.0],
output: vec![0.0, 100.0],
})
);
assert_eq!(
b.get(P::FilterParam {
index: 2,
name: "seed".into()
}),
None,
"static param not derived"
);
assert_eq!(
b.get(P::BackdropParam {
index: 0,
name: "radius".into()
}),
Some(&shared(3)),
"single-object chain is index 0"
);
}
#[test]
fn static_style_derives_none() {
let s = style(serde_json::json!({
"opacity": 0.5,
"transform": { "rotate": 45 },
"filter": { "name": "blur", "params": { "radius": 4 } },
}));
assert!(derive_bindings(Some(&s)).is_none());
assert!(derive_bindings(None).is_none());
}
#[test]
fn derives_shape_attr_bindings_by_wire_name() {
let shape: crate::svg::ShapeAttrs = serde_json::from_value(serde_json::json!({
"cx": { "animated": { "id": 3 } },
"strokeWidth": { "animated": { "id": 4 }, "seed": 2 },
"r": 10,
}))
.unwrap();
let b = derive_shape_bindings(Some(&shape)).expect("bindings derived");
assert_eq!(b.0.len(), 2, "exactly the animated attrs derive");
assert_eq!(b.get(P::ShapeAttr { name: "cx".into() }), Some(&shared(3)));
assert_eq!(
b.get(P::ShapeAttr {
name: "strokeWidth".into()
}),
Some(&shared(4)),
"wire name is the camelCase key, not the field name"
);
assert_eq!(b.get(P::ShapeAttr { name: "r".into() }), None, "static");
assert!(b.has_shape_attrs(), "the gate sees a bound attr");
assert!(!b.has_filter_params(), "no cross-talk with other gates");
let static_shape: crate::svg::ShapeAttrs =
serde_json::from_value(serde_json::json!({ "cx": 1, "r": 2 })).unwrap();
assert!(
derive_shape_bindings(Some(&static_shape)).is_none(),
"all-static attrs derive nothing"
);
assert!(derive_shape_bindings(None).is_none());
let style_only = derive_bindings(Some(&style(
serde_json::json!({ "opacity": { "animated": { "id": 1 } } }),
)))
.unwrap();
assert!(!style_only.has_shape_attrs(), "gate is false without attrs");
}
#[test]
fn props_bindings_union_style_and_shape() {
let props = |v: serde_json::Value| -> crate::protocol::props::Props {
serde_json::from_value(v).unwrap()
};
let both = props(serde_json::json!({
"style": { "opacity": { "animated": { "id": 1 } } },
"shape": { "cx": { "animated": { "id": 2 } } },
}));
let b = derive_props_bindings(&both).expect("union derived");
assert_eq!(b.get(P::Opacity), Some(&shared(1)));
assert_eq!(b.get(P::ShapeAttr { name: "cx".into() }), Some(&shared(2)));
let shape_only = props(serde_json::json!({
"shape": { "r": { "animated": { "id": 5 } } },
}));
let b = derive_props_bindings(&shape_only).expect("shape alone derives");
assert_eq!(b.get(P::ShapeAttr { name: "r".into() }), Some(&shared(5)));
let neither = props(serde_json::json!({
"style": { "opacity": 0.5 },
"shape": { "cx": 1 },
}));
assert!(
derive_props_bindings(&neither).is_none(),
"both-empty removes the stamp"
);
}
#[test]
fn maximal_style_derives_every_static_row() {
let maximal = serde_json::json!({
"left": { "animated": { "id": 1 } },
"right": { "animated": { "id": 2 } },
"top": { "animated": { "id": 3 } },
"bottom": { "animated": { "id": 4 } },
"width": { "animated": { "id": 5 } },
"height": { "animated": { "id": 6 } },
"minWidth": { "animated": { "id": 7 } },
"minHeight": { "animated": { "id": 8 } },
"maxWidth": { "animated": { "id": 9 } },
"maxHeight": { "animated": { "id": 10 } },
"aspectRatio": { "animated": { "id": 11 } },
"flexBasis": { "animated": { "id": 12 } },
"gap": { "animated": { "id": 13 } },
"rowGap": { "animated": { "id": 14 } },
"columnGap": { "animated": { "id": 15 } },
"opacity": { "animated": { "id": 16 } },
"backgroundColor": { "animated": { "type": "interpolateColor", "id": 17,
"input": [0, 1], "output": [[0, 0, 0, 1], [1, 1, 1, 1]] } },
"borderColor": { "animated": { "type": "interpolateColor", "id": 18,
"input": [0, 1], "output": [[0, 0, 0, 1], [1, 1, 1, 1]] } },
"color": { "animated": { "type": "interpolateColor", "id": 19,
"input": [0, 1], "output": [[0, 0, 0, 1], [1, 1, 1, 1]] } },
"transform": {
"translateX": { "animated": { "id": 20 } },
"translateY": { "animated": { "id": 21 } },
"scale": { "animated": { "id": 22 } },
"scaleX": { "animated": { "id": 23 } },
"scaleY": { "animated": { "id": 24 } },
"rotate": { "animated": { "id": 25 } },
},
"transform3d": {
"perspective": { "animated": { "id": 26 } },
"translateX": { "animated": { "id": 27 } },
"translateY": { "animated": { "id": 28 } },
"translateZ": { "animated": { "id": 29 } },
"rotateX": { "animated": { "id": 30 } },
"rotateY": { "animated": { "id": 31 } },
"rotateZ": { "animated": { "id": 32 } },
"scale": { "animated": { "id": 33 } },
"scaleX": { "animated": { "id": 34 } },
"scaleY": { "animated": { "id": 35 } },
"origin": {
"x": { "animated": { "id": 36 } },
"y": { "animated": { "id": 37 } },
},
},
"backgroundImage": { "src": "bg.png", "tint": { "animated": { "id": 38 } } },
"filter": { "name": "blur", "params": { "radius": { "animated": { "id": 39 } } } },
"backdropFilter": { "name": "blur", "params": { "radius": { "animated": { "id": 40 } } } },
});
let b = derive_bindings(Some(&style(maximal))).expect("maximal style derives");
macro_rules! rows {
($(($prop:tt, $kind:ident, $acc:tt, $write:tt, $stage:ident, $park:ident),)*) => {
vec![$($prop),*]
};
}
let all: Vec<P> = crate::animations::props::with_animatable_props!(rows);
for p in all {
assert!(b.contains(p.clone()), "static row {p:?} did not derive");
}
}
#[test]
fn wrapper_decode_tolerates_junk_and_degrades() {
let s = style(serde_json::json!({
"opacity": { "animated": { "id": 9, "value": 0.3, "whatever": true } },
"width": { "animated": "nonsense" },
}));
let b = derive_bindings(Some(&s)).expect("bindings derived");
assert_eq!(b.get(P::Opacity), Some(&shared(9)));
assert_eq!(b.get(P::Width), Some(&shared(0)), "garbage → inert");
}
}