Skip to main content

bevy_react/animations/
protocol.rs

1//! Wire types for the animation bridge — the Reanimated-style surface a React app
2//! declares once and the Bevy side drives every frame.
3//!
4//! These are **bevy-free** and `Deserialize`-only: they travel JS → Bevy through
5//! the `op_animate` op (the main crate registers it), exactly like `protocol::Op`
6//! travels through `op_flush`. The JS side (`js/src/animated.ts`) hand-writes
7//! matching JSON shapes — keep the two in sync, just like `bridge.ts` ↔ `Op`.
8
9use std::collections::BTreeMap;
10
11use serde::Deserialize;
12use serde::de::{self, Deserializer, MapAccess, Visitor};
13
14/// Identity of a shared value (Reanimated's `useSharedValue`). Allocated on the
15/// JS side; lives in the [`crate::animations::SharedValues`] table on the Bevy side. Its own
16/// namespace, unrelated to reconciler node ids.
17pub type SharedId = u32;
18
19/// How a shared value should evolve over time — the thing assigned to
20/// `sharedValue.value` (`withTiming`, `withSpring`, `withRepeat`, `withSequence`).
21/// Drivers compose: `Repeat`/`Sequence` wrap other drivers.
22#[derive(Debug, Clone, Deserialize)]
23#[serde(tag = "type", rename_all = "camelCase")]
24pub enum Driver {
25    /// Ease from the value's current reading to `to` over `duration` seconds.
26    Timing {
27        to: f32,
28        #[serde(default = "default_duration")]
29        duration: f32,
30        #[serde(default)]
31        easing: Easing,
32    },
33    /// A damped spring settling on `to`, integrated each frame.
34    Spring {
35        to: f32,
36        #[serde(default = "default_stiffness")]
37        stiffness: f32,
38        #[serde(default = "default_damping")]
39        damping: f32,
40        #[serde(default = "default_mass")]
41        mass: f32,
42    },
43    /// Repeat `animation` `count` times (`-1` = forever); `reverse` ping-pongs the
44    /// endpoints (Timing/Spring templates) instead of restarting from the top.
45    Repeat {
46        animation: Box<Driver>,
47        #[serde(default = "default_count")]
48        count: i32,
49        #[serde(default)]
50        reverse: bool,
51    },
52    /// Run each step in order, each starting from the previous step's end value.
53    Sequence { steps: Vec<Driver> },
54    /// Hold the value's current reading for `delay` seconds, then run `animation`.
55    Delay { delay: f32, animation: Box<Driver> },
56}
57
58/// Easing curve for [`Driver::Timing`]. Cubic in/out variants.
59#[derive(Debug, Clone, Copy, Default, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub enum Easing {
62    #[default]
63    Linear,
64    EaseIn,
65    EaseOut,
66    EaseInOut,
67}
68
69/// An imperative animation command, carried by `op_animate`. Drains into the
70/// [`crate::animations::SharedValues`] table each frame.
71#[derive(Debug, Clone, Deserialize)]
72#[serde(tag = "kind", rename_all = "camelCase")]
73pub enum AnimationCommand {
74    /// Register a shared value with its initial reading. Idempotent: a second
75    /// `Declare` for an existing id keeps the current value (survives re-renders).
76    Declare { id: SharedId, initial: f32 },
77    /// Set a value immediately, cancelling any active driver.
78    Set { id: SharedId, value: f32 },
79    /// Start a driver; it animates from the value's live reading. `token`
80    /// correlates a JS completion callback: when present, the engine reports the
81    /// driver's settlement (finished or interrupted) back with this token; when
82    /// absent nothing is reported (callback-free animations stay zero-overhead).
83    Animate {
84        id: SharedId,
85        driver: Driver,
86        #[serde(default)]
87        token: Option<u64>,
88    },
89    /// Stop a value's active driver, freezing it where it is.
90    Cancel { id: SharedId },
91    /// Drop every shared value (sent on reconciler reset / hot reload).
92    Clear,
93}
94
95/// Binds one animated style property to a shared value. Lives in the reconciler
96/// `Props.animated` (see [`AnimatedBindings`]); evaluated each frame by the
97/// orchestration system.
98#[derive(Debug, Clone, Deserialize)]
99#[serde(tag = "type", rename_all = "camelCase")]
100pub enum Binding {
101    /// Use the shared value's current reading directly (numeric props).
102    Shared { id: SharedId },
103    /// Map the reading through a piecewise-linear curve (clamped to the ends).
104    Interpolate {
105        id: SharedId,
106        input: Vec<f32>,
107        output: Vec<f32>,
108    },
109    /// Map the reading to an rgba color (each component in `0.0..=1.0`). JS
110    /// pre-parses hex, so this crate never parses colors.
111    InterpolateColor {
112        id: SharedId,
113        input: Vec<f32>,
114        output: Vec<[f32; 4]>,
115    },
116}
117
118/// Identity of one continuous, animation-driveable style property. This is the
119/// open set the generic apply layer dispatches on — adding a new animatable
120/// property is a new variant here plus a row in the apply table (`crate::animations`),
121/// not a new named field on a fixed struct. The wire key is camelCase (see
122/// [`AnimatableProperty::from_wire`]); the JS side mirrors this set in
123/// `js/src/animated.ts`'s `AnimatableProperty` union.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub enum AnimatableProperty {
126    /// Post-layout x translation, in px (drives `UiTransform`).
127    TranslateX,
128    /// Post-layout y translation, in px (drives `UiTransform`).
129    TranslateY,
130    /// Uniform scale (both axes unless `ScaleX`/`ScaleY` override).
131    Scale,
132    ScaleX,
133    ScaleY,
134    /// Clockwise rotation in radians.
135    Rotate,
136    /// Multiplies color alpha across background/text/image.
137    Opacity,
138    /// Drives `BackgroundColor`.
139    BackgroundColor,
140    /// Drives `BorderColor` (all four sides uniformly).
141    BorderColor,
142    /// Drives `TextColor` (a `<text>` node's color).
143    Color,
144
145    // Layout lengths (px) — write `Node`, which re-triggers Bevy layout. The
146    // applier writes the field only when it actually changes (no idle relayout).
147    Width,
148    Height,
149    MinWidth,
150    MinHeight,
151    MaxWidth,
152    MaxHeight,
153    Left,
154    Right,
155    Top,
156    Bottom,
157    FlexBasis,
158    /// Sets both row and column gap.
159    Gap,
160    RowGap,
161    ColumnGap,
162
163    // Layout scalars — also write `Node`. (`flexGrow`/`flexShrink` are deliberately
164    // not here: they're relative weights, not magnitudes — animating them has no
165    // intuitive visual meaning, unlike a size or `aspectRatio`.)
166    AspectRatio,
167}
168
169impl AnimatableProperty {
170    /// Wire (camelCase) key → property, or `None` for an unrecognised key. The
171    /// deserializer skips unknown keys rather than failing, so a JS bundle newer
172    /// than this binary degrades gracefully instead of dropping the whole node's
173    /// `animatedStyle`.
174    pub fn from_wire(key: &str) -> Option<Self> {
175        Some(match key {
176            "translateX" => Self::TranslateX,
177            "translateY" => Self::TranslateY,
178            "scale" => Self::Scale,
179            "scaleX" => Self::ScaleX,
180            "scaleY" => Self::ScaleY,
181            "rotate" => Self::Rotate,
182            "opacity" => Self::Opacity,
183            "backgroundColor" => Self::BackgroundColor,
184            "borderColor" => Self::BorderColor,
185            "color" => Self::Color,
186            "width" => Self::Width,
187            "height" => Self::Height,
188            "minWidth" => Self::MinWidth,
189            "minHeight" => Self::MinHeight,
190            "maxWidth" => Self::MaxWidth,
191            "maxHeight" => Self::MaxHeight,
192            "left" => Self::Left,
193            "right" => Self::Right,
194            "top" => Self::Top,
195            "bottom" => Self::Bottom,
196            "flexBasis" => Self::FlexBasis,
197            "gap" => Self::Gap,
198            "rowGap" => Self::RowGap,
199            "columnGap" => Self::ColumnGap,
200            "aspectRatio" => Self::AspectRatio,
201            _ => return None,
202        })
203    }
204
205    /// The kind of value this property animates — picks scalar-vs-color resolution
206    /// in the apply layer. `Rotate` is an `Angle` but, imperatively, JS already
207    /// sends radians, so the applier resolves it as a scalar.
208    pub fn value_kind(self) -> ValueKind {
209        match self {
210            Self::TranslateX
211            | Self::TranslateY
212            | Self::Width
213            | Self::Height
214            | Self::MinWidth
215            | Self::MinHeight
216            | Self::MaxWidth
217            | Self::MaxHeight
218            | Self::Left
219            | Self::Right
220            | Self::Top
221            | Self::Bottom
222            | Self::FlexBasis
223            | Self::Gap
224            | Self::RowGap
225            | Self::ColumnGap => ValueKind::Length,
226            Self::Scale | Self::ScaleX | Self::ScaleY | Self::Opacity | Self::AspectRatio => {
227                ValueKind::Scalar
228            }
229            Self::Rotate => ValueKind::Angle,
230            Self::BackgroundColor | Self::BorderColor | Self::Color => ValueKind::Color,
231        }
232    }
233
234    /// Whether this property feeds the `UiTransform` (built from all transform
235    /// channels together), so the apply layer can rebuild the transform once.
236    pub fn is_transform(self) -> bool {
237        matches!(
238            self,
239            Self::TranslateX
240                | Self::TranslateY
241                | Self::Scale
242                | Self::ScaleX
243                | Self::ScaleY
244                | Self::Rotate
245        )
246    }
247}
248
249/// How an animated value resolves and where it lands. Pure metadata shared by the
250/// imperative apply layer and (for identity/precedence) the CSS-`transition`
251/// engine in `core`.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum ValueKind {
254    /// A bare `f32` (scale, opacity, …).
255    Scalar,
256    /// A length in px (translate).
257    Length,
258    /// An rgba color.
259    Color,
260    /// An angle in radians.
261    Angle,
262}
263
264/// The per-node `animatedStyle`: which style properties are animation-driven and
265/// by what. An open property→[`Binding`] map (mirrors the JS object shape: every
266/// camelCase style key maps to a binding). Decodes the same opaque-object way
267/// `Style` does — unknown keys are skipped (warn-and-continue) so a newer JS
268/// bundle never breaks an older binary's whole node. A `BTreeMap` keeps iteration
269/// deterministic (stable transform-group rebuild and test assertions).
270#[derive(Debug, Clone, Default)]
271pub struct AnimatedBindings(pub BTreeMap<AnimatableProperty, Binding>);
272
273impl AnimatedBindings {
274    /// The binding for a property, if bound.
275    pub fn get(&self, property: AnimatableProperty) -> Option<&Binding> {
276        self.0.get(&property)
277    }
278
279    /// Whether a property is bound.
280    pub fn contains(&self, property: AnimatableProperty) -> bool {
281        self.0.contains_key(&property)
282    }
283
284    /// Whether any transform channel is bound (so the orchestrator only writes
285    /// `UiTransform` when something actually drives it).
286    pub fn has_transform(&self) -> bool {
287        self.0.keys().any(|p| p.is_transform())
288    }
289
290    /// Iterate the bound (property, binding) pairs in property order.
291    pub fn iter(&self) -> impl Iterator<Item = (&AnimatableProperty, &Binding)> {
292        self.0.iter()
293    }
294
295    /// Whether nothing is bound.
296    pub fn is_empty(&self) -> bool {
297        self.0.is_empty()
298    }
299}
300
301impl<'de> Deserialize<'de> for AnimatedBindings {
302    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303    where
304        D: Deserializer<'de>,
305    {
306        struct BindingsVisitor;
307
308        impl<'de> Visitor<'de> for BindingsVisitor {
309            type Value = AnimatedBindings;
310
311            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
312                f.write_str("a map of animatable style properties to bindings")
313            }
314
315            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
316            where
317                M: MapAccess<'de>,
318            {
319                let mut out = BTreeMap::new();
320                while let Some(key) = map.next_key::<String>()? {
321                    match AnimatableProperty::from_wire(&key) {
322                        Some(property) => {
323                            out.insert(property, map.next_value::<Binding>()?);
324                        }
325                        None => {
326                            // Consume the value so deserialization stays in sync,
327                            // then skip: forward-compat with a newer JS surface.
328                            map.next_value::<de::IgnoredAny>()?;
329                            tracing::warn!(
330                                target: "bevy_react",
331                                "animatedStyle: ignoring unknown property {key:?}"
332                            );
333                        }
334                    }
335                }
336                Ok(AnimatedBindings(out))
337            }
338        }
339
340        deserializer.deserialize_map(BindingsVisitor)
341    }
342}
343
344fn default_duration() -> f32 {
345    0.3
346}
347fn default_stiffness() -> f32 {
348    100.0
349}
350fn default_damping() -> f32 {
351    10.0
352}
353fn default_mass() -> f32 {
354    1.0
355}
356fn default_count() -> i32 {
357    1
358}