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;
12
13/// Identity of a shared value (Reanimated's `useSharedValue`). Allocated on the
14/// JS side; lives in the [`crate::animations::SharedValues`] table on the Bevy side. Its own
15/// namespace, unrelated to reconciler node ids.
16pub type SharedId = u32;
17
18/// How a shared value should evolve over time — the thing assigned to
19/// `sharedValue.value` (`withTiming`, `withSpring`, `withRepeat`, `withSequence`).
20/// Drivers compose: `Repeat`/`Sequence` wrap other drivers.
21#[derive(Debug, Clone, Deserialize)]
22#[serde(tag = "type", rename_all = "camelCase")]
23pub enum Driver {
24    /// Ease from the value's current reading to `to` over `duration` seconds.
25    Timing {
26        to: f32,
27        #[serde(default = "default_duration")]
28        duration: f32,
29        #[serde(default)]
30        easing: Easing,
31    },
32    /// A damped spring settling on `to`, integrated each frame.
33    Spring {
34        to: f32,
35        #[serde(default = "default_stiffness")]
36        stiffness: f32,
37        #[serde(default = "default_damping")]
38        damping: f32,
39        #[serde(default = "default_mass")]
40        mass: f32,
41    },
42    /// Repeat `animation` `count` times (`-1` = forever); `reverse` ping-pongs the
43    /// endpoints (Timing/Spring templates) instead of restarting from the top.
44    Repeat {
45        animation: Box<Driver>,
46        #[serde(default = "default_count")]
47        count: i32,
48        #[serde(default)]
49        reverse: bool,
50    },
51    /// Run each step in order, each starting from the previous step's end value.
52    Sequence { steps: Vec<Driver> },
53    /// Hold the value's current reading for `delay` seconds, then run `animation`.
54    Delay { delay: f32, animation: Box<Driver> },
55}
56
57/// Easing curve for [`Driver::Timing`]. Cubic in/out variants.
58#[derive(Debug, Clone, Copy, Default, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub enum Easing {
61    #[default]
62    Linear,
63    EaseIn,
64    EaseOut,
65    EaseInOut,
66}
67
68/// An imperative animation command, carried by `op_animate`. Drains into the
69/// [`crate::animations::SharedValues`] table each frame.
70#[derive(Debug, Clone, Deserialize)]
71#[serde(tag = "kind", rename_all = "camelCase")]
72pub enum AnimationCommand {
73    /// Register a shared value with its initial reading. Idempotent: a second
74    /// `Declare` for an existing id keeps the current value (survives re-renders).
75    Declare { id: SharedId, initial: f32 },
76    /// Set a value immediately, cancelling any active driver.
77    Set { id: SharedId, value: f32 },
78    /// Start a driver; it animates from the value's live reading. `token`
79    /// correlates a JS completion callback: when present, the engine reports the
80    /// driver's settlement (finished or interrupted) back with this token; when
81    /// absent nothing is reported (callback-free animations stay zero-overhead).
82    Animate {
83        id: SharedId,
84        driver: Driver,
85        #[serde(default)]
86        token: Option<u64>,
87    },
88    /// Stop a value's active driver, freezing it where it is.
89    Cancel { id: SharedId },
90    /// Drop every shared value (sent on reconciler reset / hot reload).
91    Clear,
92}
93
94/// Binds one animated style property to a shared value. Lives in the reconciler
95/// `Props.animated` (see [`AnimatedBindings`]); evaluated each frame by the
96/// orchestration system.
97#[derive(Debug, Clone, PartialEq, Deserialize)]
98#[serde(tag = "type", rename_all = "camelCase")]
99pub enum Binding {
100    /// Use the shared value's current reading directly (numeric props).
101    Shared { id: SharedId },
102    /// Map the reading through a piecewise-linear curve (clamped to the ends).
103    Interpolate {
104        id: SharedId,
105        input: Vec<f32>,
106        output: Vec<f32>,
107    },
108    /// Map the reading to an rgba color (each component in `0.0..=1.0`). JS
109    /// pre-parses hex, so this crate never parses colors.
110    InterpolateColor {
111        id: SharedId,
112        input: Vec<f32>,
113        output: Vec<[f32; 4]>,
114    },
115}
116
117/// Identity of one continuous, animation-driveable style property. This is the
118/// open set the generic apply layer dispatches on — adding a new animatable
119/// property is a new variant here plus a row in the apply table (`crate::animations`),
120/// not a new named field on a fixed struct. Derived from the merged style's
121/// inline `{ animated }` wrappers (`crate::style_bindings`), which is also
122/// where each variant's style position is defined.
123///
124/// Not `Copy` ([`Self::FilterParam`] carries the param name); the fieldless
125/// variants are still constructed freely at call sites.
126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
127pub enum AnimatableProperty {
128    /// Post-layout x translation, in px (drives `UiTransform`).
129    TranslateX,
130    /// Post-layout y translation, in px (drives `UiTransform`).
131    TranslateY,
132    /// Uniform scale (both axes unless `ScaleX`/`ScaleY` override).
133    Scale,
134    ScaleX,
135    ScaleY,
136    /// Clockwise rotation, **degrees** on the wire (matching the declarative
137    /// `transform.rotate` field it lives in and the `transform3d` rotations);
138    /// applied as radians.
139    Rotate,
140    /// Multiplies color alpha across background/text/image.
141    Opacity,
142    /// Drives `BackgroundColor`.
143    BackgroundColor,
144    /// Drives `BorderColor` (all four sides uniformly).
145    BorderColor,
146    /// Drives `TextColor` (a `<text>` node's color).
147    Color,
148
149    // Layout lengths (px) — write `Node`, which re-triggers Bevy layout. The
150    // applier writes the field only when it actually changes (no idle relayout).
151    Width,
152    Height,
153    MinWidth,
154    MinHeight,
155    MaxWidth,
156    MaxHeight,
157    Left,
158    Right,
159    Top,
160    Bottom,
161    FlexBasis,
162    /// Sets both row and column gap.
163    Gap,
164    RowGap,
165    ColumnGap,
166
167    // Layout scalars — also write `Node`. (`flexGrow`/`flexShrink` are deliberately
168    // not here: they're relative weights, not magnitudes — animating them has no
169    // intuitive visual meaning, unlike a size or `aspectRatio`.)
170    AspectRatio,
171
172    /// One named parameter of the node's resolved `filter` chain — the wire
173    /// key is `filter[<index>].<param>` (e.g. `filter[0].radius`). `index`
174    /// addresses the **wire** chain entry, so a binding writes the named slot
175    /// in *every* resolved pass carrying that
176    /// [`wire_index`](crate::filters::ResolvedFilterPass::wire_index) (blur's
177    /// H+V passes both carry `radius`). `name` is a
178    /// [`ParamSlot`](crate::filters::ParamSlot) name in the pass layout.
179    ///
180    /// The bound value is applied in the **same unit as the param's wire
181    /// form**: logical px for `Length` slots (scale-rewritten to physical px
182    /// like the resolver), **degrees** for `Angle` slots (converted to the
183    /// packed radians), raw for single-component `Scalar` slots; `Color`
184    /// slots take an `interpolateColor` binding. Index/name/kind are
185    /// validated against the resolved chain at bind time (`filterBinding`
186    /// devtools warnings); an unmatched binding stays inert.
187    FilterParam {
188        index: u8,
189        name: String,
190    },
191
192    /// One named parameter of the node's resolved `backdropFilter` chain —
193    /// the wire key is `backdropFilter[<index>].<param>`. Identical
194    /// addressing, units, and bind-time validation as
195    /// [`Self::FilterParam`] (warn kind `backdropFilterBinding`), against
196    /// the backdrop chain instead of the content one.
197    BackdropParam {
198        index: u8,
199        name: String,
200    },
201
202    /// One field of the node's `transform3d` style — the wire key is
203    /// `transform3d.<field>` (e.g. `transform3d.rotateY`). Drives the
204    /// composite-time 3D transform of a promoted layer
205    /// ([`crate::layer::transform3d`]); unbound fields keep the static style
206    /// value. Values arrive in the **declarative field's wire units**: logical
207    /// px for translations/perspective/origin, **degrees** for rotations
208    /// (like the 2D [`Rotate`](Self::Rotate) and every other rotation in the
209    /// system), raw scalars for scales.
210    Transform3d(Transform3dField),
211}
212
213/// The addressable fields of [`AnimatableProperty::Transform3d`]. Origin
214/// animates as two px offsets (`originX`/`originY`) — a percent origin that
215/// must track the node's size belongs in the static style instead.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
217pub enum Transform3dField {
218    Perspective,
219    TranslateX,
220    TranslateY,
221    TranslateZ,
222    RotateX,
223    RotateY,
224    RotateZ,
225    Scale,
226    ScaleX,
227    ScaleY,
228    OriginX,
229    OriginY,
230}
231
232impl AnimatableProperty {
233    /// The kind of value this property animates — picks scalar-vs-color resolution
234    /// in the apply layer. `Rotate` is an `Angle`: the bound value is degrees
235    /// on the wire, resolved as a scalar and converted by the applier.
236    pub fn value_kind(&self) -> ValueKind {
237        match self {
238            Self::TranslateX
239            | Self::TranslateY
240            | Self::Width
241            | Self::Height
242            | Self::MinWidth
243            | Self::MinHeight
244            | Self::MaxWidth
245            | Self::MaxHeight
246            | Self::Left
247            | Self::Right
248            | Self::Top
249            | Self::Bottom
250            | Self::FlexBasis
251            | Self::Gap
252            | Self::RowGap
253            | Self::ColumnGap => ValueKind::Length,
254            Self::Scale | Self::ScaleX | Self::ScaleY | Self::Opacity | Self::AspectRatio => {
255                ValueKind::Scalar
256            }
257            Self::Rotate => ValueKind::Angle,
258            Self::Transform3d(field) => match field {
259                Transform3dField::RotateX
260                | Transform3dField::RotateY
261                | Transform3dField::RotateZ => ValueKind::Angle,
262                Transform3dField::Scale | Transform3dField::ScaleX | Transform3dField::ScaleY => {
263                    ValueKind::Scalar
264                }
265                _ => ValueKind::Length,
266            },
267            Self::BackgroundColor | Self::BorderColor | Self::Color => ValueKind::Color,
268            // Never consulted for the chain params — the applier reads the
269            // authoritative kind from the resolved chain's `ParamSlot`
270            // layout (`crate::filters`). A documented fallback, not a
271            // semantic: the slot decides scalar-vs-color, not this arm.
272            Self::FilterParam { .. } | Self::BackdropParam { .. } => ValueKind::Scalar,
273        }
274    }
275
276    /// Whether this property feeds the `UiTransform` (built from all transform
277    /// channels together), so the apply layer can rebuild the transform once.
278    pub fn is_transform(&self) -> bool {
279        matches!(
280            self,
281            Self::TranslateX
282                | Self::TranslateY
283                | Self::Scale
284                | Self::ScaleX
285                | Self::ScaleY
286                | Self::Rotate
287        )
288    }
289}
290
291/// How an animated value resolves and where it lands. Pure metadata shared by the
292/// imperative apply layer and (for identity/precedence) the CSS-`transition`
293/// engine in `core`.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum ValueKind {
296    /// A bare `f32` (scale, opacity, …).
297    Scalar,
298    /// A length in px (translate).
299    Length,
300    /// An rgba color.
301    Color,
302    /// An angle in radians.
303    Angle,
304}
305
306/// A node's animation-driven style properties and what drives each: an open
307/// property→[`Binding`] map. Not a wire type — it is **derived** from the
308/// merged style's inline `{ animated }` wrappers by
309/// `crate::style_bindings::derive_bindings` after every style change, and
310/// stamped on the entity as `AnimatedNode`. A `BTreeMap` keeps iteration
311/// deterministic (stable transform-group rebuild and test assertions).
312#[derive(Debug, Clone, Default)]
313pub struct AnimatedBindings(pub BTreeMap<AnimatableProperty, Binding>);
314
315impl AnimatedBindings {
316    /// The binding for a property, if bound.
317    pub fn get(&self, property: AnimatableProperty) -> Option<&Binding> {
318        self.0.get(&property)
319    }
320
321    /// Whether a property is bound.
322    pub fn contains(&self, property: AnimatableProperty) -> bool {
323        self.0.contains_key(&property)
324    }
325
326    /// Whether any transform channel is bound (so the orchestrator only writes
327    /// `UiTransform` when something actually drives it).
328    pub fn has_transform(&self) -> bool {
329        self.0.keys().any(|p| p.is_transform())
330    }
331
332    /// Whether any per-param filter binding ([`AnimatableProperty::FilterParam`])
333    /// is bound — gates the applier's filter stage and, in the transition
334    /// engine, `skip_filter` (any filter binding parks the *whole* whole-value
335    /// filter channel).
336    pub fn has_filter_params(&self) -> bool {
337        self.0
338            .keys()
339            .any(|p| matches!(p, AnimatableProperty::FilterParam { .. }))
340    }
341
342    /// The backdrop analog of [`Self::has_filter_params`] — gates the
343    /// applier's backdrop stage and the transition engine's `skip_backdrop`.
344    pub fn has_backdrop_params(&self) -> bool {
345        self.0
346            .keys()
347            .any(|p| matches!(p, AnimatableProperty::BackdropParam { .. }))
348    }
349
350    /// Whether any `transform3d.<field>` binding is bound — gates the
351    /// applier's transform3d stage and, in the transition engine,
352    /// `skip_transform3d` (any binding parks the whole channel group: the
353    /// stage rebuilds the full params struct).
354    pub fn has_transform3d(&self) -> bool {
355        self.0
356            .keys()
357            .any(|p| matches!(p, AnimatableProperty::Transform3d(_)))
358    }
359
360    /// Iterate the bound (property, binding) pairs in property order.
361    pub fn iter(&self) -> impl Iterator<Item = (&AnimatableProperty, &Binding)> {
362        self.0.iter()
363    }
364
365    /// Whether nothing is bound.
366    pub fn is_empty(&self) -> bool {
367        self.0.is_empty()
368    }
369}
370
371fn default_duration() -> f32 {
372    0.3
373}
374fn default_stiffness() -> f32 {
375    100.0
376}
377fn default_damping() -> f32 {
378    10.0
379}
380fn default_mass() -> f32 {
381    1.0
382}
383fn default_count() -> i32 {
384    1
385}