use std::collections::BTreeMap;
use crate::animations::protocol::{
AnimatableProperty as P, AnimatedBindings, Binding, GradientLeaf, Transform3dField as F,
};
use crate::filters::FilterChain;
use crate::protocol::visual::{GradientList, GradientSpec, GradientStop, RadialShapeSpec};
use crate::protocol::{
animatable::{AnimatableField, binding_from_wrapper},
props::Props,
};
use crate::style::Style;
use crate::style::props::{
BACKDROP_FILTER, BACKGROUND_GRADIENT, BACKGROUND_IMAGE, BORDER_GRADIENT, FILTER, MORPH_FILTER,
TRANSFORM, TRANSFORM3D, TRANSITION,
};
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));
}
}
}
fn gradient_prop(border: bool, index: u8, leaf: GradientLeaf) -> P {
if border {
P::BorderGradientParam { index, leaf }
} else {
P::BackgroundGradientParam { index, leaf }
}
}
fn stop_bindings(stops: &[GradientStop], border: bool, gi: u8, out: &mut BTreeMap<P, Binding>) {
for (si, stop) in stops.iter().enumerate() {
let Ok(si) = u8::try_from(si) else { break };
if let Some(b) = stop.color.binding() {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopColor(si)),
b.clone(),
);
}
if let Some(b) = AnimatableField::binding(&stop.position) {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopPosition(si)),
b.clone(),
);
}
if let Some(b) = AnimatableField::binding(&stop.hint) {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopHint(si)),
b.clone(),
);
}
}
}
fn gradient_bindings(list: Option<&GradientList>, border: bool, out: &mut BTreeMap<P, Binding>) {
let Some(list) = list else { return };
let specs: &[GradientSpec] = match list {
GradientList::One(one) => std::slice::from_ref(one),
GradientList::Many(many) => many,
};
for (gi, spec) in specs.iter().enumerate() {
let Ok(gi) = u8::try_from(gi) else { break };
match spec {
GradientSpec::Linear(l) => {
if let Some(b) = AnimatableField::binding(&l.angle) {
out.insert(gradient_prop(border, gi, GradientLeaf::Angle), b.clone());
}
stop_bindings(&l.stops, border, gi, out);
}
GradientSpec::Radial(r) => {
match &r.shape {
Some(RadialShapeSpec::Circle { circle }) => {
if let Some(b) = circle.binding() {
out.insert(gradient_prop(border, gi, GradientLeaf::ShapeX), b.clone());
}
}
Some(RadialShapeSpec::Ellipse { ellipse }) => {
for (radius, leaf) in [
(&ellipse[0], GradientLeaf::ShapeX),
(&ellipse[1], GradientLeaf::ShapeY),
] {
if let Some(b) = radius.binding() {
out.insert(gradient_prop(border, gi, leaf), b.clone());
}
}
}
Some(RadialShapeSpec::Keyword(_)) | None => {}
}
stop_bindings(&r.stops, border, gi, out);
}
GradientSpec::Conic(c) => {
if let Some(b) = AnimatableField::binding(&c.start) {
out.insert(gradient_prop(border, gi, GradientLeaf::Angle), b.clone());
}
for (si, stop) in c.stops.iter().enumerate() {
let Ok(si) = u8::try_from(si) else { break };
if let Some(b) = stop.color.binding() {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopColor(si)),
b.clone(),
);
}
if let Some(b) = AnimatableField::binding(&stop.angle) {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopPosition(si)),
b.clone(),
);
}
if let Some(b) = AnimatableField::binding(&stop.hint) {
out.insert(
gradient_prop(border, gi, GradientLeaf::StopHint(si)),
b.clone(),
);
}
}
}
}
}
}
pub(crate) fn derive_bindings(style: Option<&Style>) -> Option<AnimatedBindings> {
let style = style?;
let mut out = BTreeMap::new();
use crate::protocol::animatable::Animatable;
use crate::style::props::CoreId;
let keys = style.keys();
let transform = style.get(&TRANSFORM);
let transform3d = style.get(&TRANSFORM3D);
let background_image = style.get(&BACKGROUND_IMAGE);
macro_rules! row {
($prop:tt, (prop $key:ident)) => {
if keys.contains_core(CoreId::$key)
&& let Some(Animatable::Animated(a)) = style.get(&crate::style::props::$key)
{
out.insert($prop, a.binding.clone());
}
};
($prop:tt, (transform $field:ident)) => {
if let Some(t) = transform
&& let Some(Animatable::Animated(a)) = &t.$field
{
out.insert($prop, a.binding.clone());
}
};
($prop:tt, (t3d $field:ident $($unit:tt)*)) => {
if let Some(t) = transform3d
&& let Some(Animatable::Animated(a)) = &t.$field
{
out.insert($prop, a.binding.clone());
}
};
($prop:tt, (t3d_origin $axis:ident)) => {
if let Some(t) = transform3d
&& let Some(origin) = &t.origin
&& let Animatable::Animated(a) = &origin.$axis
{
out.insert($prop, a.binding.clone());
}
};
($prop:tt, (bg_tint)) => {
if let Some(bg) = background_image
&& let Some(Animatable::Animated(a)) = &bg.tint
{
out.insert($prop, a.binding.clone());
}
};
}
macro_rules! walk {
($(($prop:tt, $acc:tt, $write:tt, $stage:ident, $park:ident),)*) => {
$(row!($prop, $acc);)*
};
}
crate::animations::props::with_animatable_props!(walk);
chain_bindings(style.get(&FILTER), false, &mut out);
chain_bindings(style.get(&BACKDROP_FILTER), true, &mut out);
if let Some(morph) = style.get(&MORPH_FILTER) {
for (name, value) in &morph.filter.params {
if let Some(inner) = value.as_object().and_then(|m| m.get("animated")) {
out.insert(
P::MorphParam { name: name.clone() },
binding_from_wrapper(inner),
);
}
}
}
gradient_bindings(style.get(&BACKGROUND_GRADIENT), false, &mut out);
gradient_bindings(style.get(&BORDER_GRADIENT), true, &mut out);
(!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);
for (attr, value) in props.attrs.iter() {
if let Some((domain, binding)) = attr.binding(value) {
out.insert(
P::Ext {
domain,
name: attr.name().to_owned(),
},
binding,
);
}
}
(!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");
crate::diag::report("styleBinding", name, &msg);
}
}
}
pub(crate) fn warn_gradient_transition_mix(props: &Props, bindings: Option<&AnimatedBindings>) {
let Some(t) = props.style.as_ref().and_then(|s| s.get(&TRANSITION)) else {
return;
};
let Some(b) = bindings else { return };
use crate::animations::props::ChannelId;
for (spec, parked, name) in [
(
t.background_gradient.is_some(),
b.parked(ChannelId::BackgroundGradient),
"backgroundGradient",
),
(
t.border_gradient.is_some(),
b.parked(ChannelId::BorderGradient),
"borderGradient",
),
] {
if spec && parked {
let msg = format!(
"transition.{name} is inert while {name} carries {{ animated }} bindings (bindings win)"
);
crate::diag::report("gradientTransition", 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.get(&TRANSFORM3D).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 } } } },
"morphFilter": { "key": "a", "name": "linearWipe", "params": {
"angle": { "animated": { "id": 4 } },
"softness": 12,
} },
}));
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"
);
assert_eq!(
b.get(P::MorphParam {
name: "angle".into()
}),
Some(&shared(4)),
"morph params derive keyed by name alone"
);
assert_eq!(
b.get(P::MorphParam {
name: "softness".into()
}),
None,
"static morph param not derived"
);
assert!(b.has_stage(crate::animations::props::PropStage::Morph));
assert!(
b.parked(crate::animations::props::ChannelId::Filter),
"the filter binding parks the filter channel"
);
let s = style(serde_json::json!({
"morphFilter": { "key": "a", "name": "linearWipe", "params": {
"angle": { "animated": { "id": 4 } },
} },
}));
let b = derive_bindings(Some(&s)).expect("bindings derived");
use crate::animations::props::ChannelId;
for channel in [
ChannelId::Transform,
ChannelId::Opacity,
ChannelId::Background,
ChannelId::BorderRadius,
ChannelId::Filter,
ChannelId::Backdrop,
ChannelId::Transform3d,
] {
assert!(!b.parked(channel), "morph bindings park no channel");
}
}
#[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 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 } },
"borderRadius": { "animated": { "id": 41 } },
"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");
assert!(
b.parked(crate::animations::props::ChannelId::BorderRadius),
"the borderRadius binding parks its transition channel"
);
assert!(
!derive_bindings(Some(&style(serde_json::json!({
"borderRadius": { "animated": { "id": 41 } },
}))))
.expect("derives")
.has_node_props(),
"a radius binding never owns the rect (layout channel keeps chasing)"
);
macro_rules! rows {
($(($prop:tt, $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 derives_gradient_leaf_bindings() {
let s = style(serde_json::json!({
"backgroundGradient": {
"type": "linear",
"angle": { "animated": { "id": 1 } },
"stops": [
{ "color": { "animated": { "type": "interpolateColor", "id": 2,
"input": [0,1], "output": [[0,0,0,1],[1,1,1,1]] } } },
{ "color": "#fff", "position": { "animated": { "id": 3 }, "seed": "10px" },
"hint": { "animated": { "id": 4 } } },
],
},
"borderGradient": [{ "type": "radial",
"shape": { "circle": { "circle": { "animated": { "id": 5 } } } },
"stops": [{ "color": "#000" }] }],
}));
let b = derive_bindings(Some(&s)).expect("derived");
use crate::animations::protocol::GradientLeaf as L;
assert!(b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::Angle
}));
assert!(b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::StopColor(0)
}));
assert!(b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::StopPosition(1)
}));
assert!(b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::StopHint(1)
}));
assert!(b.contains(P::BorderGradientParam {
index: 0,
leaf: L::ShapeX
}));
use crate::animations::props::ChannelId;
assert!(b.parked(ChannelId::BackgroundGradient));
assert!(b.parked(ChannelId::BorderGradient));
let s = style(serde_json::json!({ "backgroundGradient": {
"type": "linear", "stops": [{ "color": "#fff" }] } }));
assert!(derive_bindings(Some(&s)).is_none());
}
#[test]
fn derives_conic_and_ellipse_gradient_leaves() {
use crate::animations::protocol::GradientLeaf as L;
let s = style(serde_json::json!({
"backgroundGradient": { "type": "conic",
"start": { "animated": { "id": 1 } },
"stops": [
{ "color": "#fff" },
{ "color": "#000", "angle": { "animated": { "id": 2 } } },
],
},
"borderGradient": { "type": "radial",
"shape": { "ellipse": { "ellipse": [
{ "animated": { "id": 3 } },
{ "animated": { "id": 4 } },
] } },
"stops": [{ "color": "#000" }] },
}));
let b = derive_bindings(Some(&s)).expect("derived");
assert!(b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::Angle
}));
assert!(
b.contains(P::BackgroundGradientParam {
index: 0,
leaf: L::StopPosition(1)
}),
"a conic stop angle is its (angular) position"
);
assert!(b.contains(P::BorderGradientParam {
index: 0,
leaf: L::ShapeX
}));
assert!(b.contains(P::BorderGradientParam {
index: 0,
leaf: L::ShapeY
}));
assert!(
b.has_stage(crate::animations::props::PropStage::Gradient),
"the gate sees gradient bindings"
);
let filter_only = derive_bindings(Some(&style(serde_json::json!({
"filter": { "name": "blur", "params": { "radius": { "animated": { "id": 9 } } } },
}))))
.expect("derived");
assert!(
!filter_only.has_stage(crate::animations::props::PropStage::Gradient),
"no cross-talk with the filter domain"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn warns_on_gradient_binding_plus_transition_mix() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let props = |v: serde_json::Value| -> crate::protocol::props::Props {
serde_json::from_value(v).unwrap()
};
let check = |p: &crate::protocol::props::Props| {
warn_gradient_transition_mix(p, derive_props_bindings(p).as_ref());
crate::diag::take_runtime_warnings()
};
let animated_bg = serde_json::json!({
"type": "linear",
"angle": { "animated": { "id": 1 } },
"stops": [{ "color": "#fff" }],
});
let mixed = props(serde_json::json!({ "style": {
"backgroundGradient": animated_bg,
"transition": { "backgroundGradient": { "duration": 300 } },
} }));
let warns = check(&mixed);
assert_eq!(warns.len(), 1, "exactly one warning, got {warns:?}");
assert_eq!(warns[0].kind, "gradientTransition");
assert!(
warns[0].message.contains("backgroundGradient"),
"message names the surface: {:?}",
warns[0].message
);
let cross = props(serde_json::json!({ "style": {
"backgroundGradient": animated_bg,
"transition": { "borderGradient": { "duration": 300 } },
} }));
assert!(
check(&cross).is_empty(),
"spec and binding on different surfaces must not warn"
);
let binding_only = props(serde_json::json!({ "style": {
"backgroundGradient": animated_bg,
} }));
assert!(
check(&binding_only).is_empty(),
"a binding without a transition spec must not warn"
);
}
#[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");
}
}