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, PartialEq, Eq, 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    /// Drives the `ImageNode.color` of a `backgroundImage`-styled node (the
149    /// spec's `tint`). Inert when no `ImageNode` is present.
150    BackgroundImageTint,
151
152    // Layout lengths (px) — write `Node`, which re-triggers Bevy layout. The
153    // applier writes the field only when it actually changes (no idle relayout).
154    Width,
155    Height,
156    MinWidth,
157    MinHeight,
158    MaxWidth,
159    MaxHeight,
160    Left,
161    Right,
162    Top,
163    Bottom,
164    FlexBasis,
165    /// Sets both row and column gap.
166    Gap,
167    RowGap,
168    ColumnGap,
169
170    // Layout scalars — also write `Node`. (`flexGrow`/`flexShrink` are deliberately
171    // not here: they're relative weights, not magnitudes — animating them has no
172    // intuitive visual meaning, unlike a size or `aspectRatio`.)
173    AspectRatio,
174
175    /// One named parameter of the node's resolved `filter` chain — the wire
176    /// key is `filter[<index>].<param>` (e.g. `filter[0].radius`). `index`
177    /// addresses the **wire** chain entry, so a binding writes the named slot
178    /// in *every* resolved pass carrying that
179    /// [`wire_index`](crate::filters::ResolvedFilterPass::wire_index) (blur's
180    /// H+V passes both carry `radius`). `name` is a
181    /// [`ParamSlot`](crate::filters::ParamSlot) name in the pass layout.
182    ///
183    /// The bound value is applied in the **same unit as the param's wire
184    /// form**: logical px for `Length` slots (scale-rewritten to physical px
185    /// like the resolver), **degrees** for `Angle` slots (converted to the
186    /// packed radians), raw for single-component `Scalar` slots; `Color`
187    /// slots take an `interpolateColor` binding. Index/name/kind are
188    /// validated against the resolved chain at bind time (`filterBinding`
189    /// devtools warnings); an unmatched binding stays inert.
190    FilterParam {
191        index: u8,
192        name: String,
193    },
194
195    /// One named parameter of the node's resolved `backdropFilter` chain —
196    /// the wire key is `backdropFilter[<index>].<param>`. Identical
197    /// addressing, units, and bind-time validation as
198    /// [`Self::FilterParam`] (warn kind `backdropFilterBinding`), against
199    /// the backdrop chain instead of the content one.
200    BackdropParam {
201        index: u8,
202        name: String,
203    },
204
205    /// One named parameter of the node's resolved `morphFilter` — the wire
206    /// key is `morphFilter.<param>` (a morph is a single filter use, no
207    /// index). Identical units and bind-time validation as
208    /// [`Self::FilterParam`] (warn kind `morphFilterBinding`), against the
209    /// resolved morph chain. Unlike the filter/backdrop params it parks NO
210    /// transition channel: the morph channel eases the engine-owned
211    /// *progress*, never the params, so the two writers cannot collide.
212    MorphParam {
213        name: String,
214    },
215
216    /// One numeric attribute of an SVG shape entity (`<circle>`/`<rect>`/…),
217    /// addressed by its **wire** name — the camelCase key in the folded
218    /// `shape` object (`"cx"`, `"r"`, `"strokeWidth"`, …; the full set is
219    /// `crate::svg::NUMERIC_ATTRS`). Derived from `{ animated }` wrappers on
220    /// the shape's numeric attrs
221    /// (`crate::style_bindings::derive_shape_bindings`); the apply stage
222    /// drives the entity's `SvgShape.attrs` field of that name per frame.
223    /// Bound values are in **wire units**: SVG user-space units for geometry
224    /// and `strokeWidth` (the viewBox maps them onto the layout box at
225    /// raster), `0..1` for `opacity`.
226    ShapeAttr {
227        name: String,
228    },
229
230    /// One field of the node's `transform3d` style — the wire key is
231    /// `transform3d.<field>` (e.g. `transform3d.rotateY`). Drives the
232    /// composite-time 3D transform of a promoted layer
233    /// ([`crate::layer::transform3d`]); unbound fields keep the static style
234    /// value. Values arrive in the **declarative field's wire units**: logical
235    /// px for translations/perspective/origin, **degrees** for rotations
236    /// (like the 2D [`Rotate`](Self::Rotate) and every other rotation in the
237    /// system), raw scalars for scales.
238    Transform3d(Transform3dField),
239}
240
241/// The addressable fields of [`AnimatableProperty::Transform3d`]. Origin
242/// animates as two px offsets (`originX`/`originY`) — a percent origin that
243/// must track the node's size belongs in the static style instead.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
245pub enum Transform3dField {
246    Perspective,
247    TranslateX,
248    TranslateY,
249    TranslateZ,
250    RotateX,
251    RotateY,
252    RotateZ,
253    Scale,
254    ScaleX,
255    ScaleY,
256    OriginX,
257    OriginY,
258}
259
260impl AnimatableProperty {
261    /// The kind of value this property animates — picks scalar-vs-color resolution
262    /// in the apply layer. `Rotate` is an `Angle`: the bound value is degrees
263    /// on the wire, resolved as a scalar and converted by the applier.
264    ///
265    /// Static rows generate from the property table
266    /// (`crate::animations::props`); the dynamic domains keep explicit arms.
267    #[allow(unused_parens)]
268    pub fn value_kind(&self) -> ValueKind {
269        use AnimatableProperty as P;
270        use Transform3dField as F;
271        macro_rules! kind_arms {
272            ($(($prop:tt, $kind:ident, $acc:tt, $write:tt, $stage:ident, $park:ident),)*) => {
273                match self {
274                    $($prop => ValueKind::$kind,)*
275                    // Never consulted for the chain params — the applier reads the
276                    // authoritative kind from the resolved chain's `ParamSlot`
277                    // layout (`crate::filters`). A documented fallback, not a
278                    // semantic: the slot decides scalar-vs-color, not this arm.
279                    Self::FilterParam { .. }
280                    | Self::BackdropParam { .. }
281                    | Self::MorphParam { .. } => ValueKind::Scalar,
282                    // Genuinely scalar (unlike the chain params' documented fallback
283                    // above): shape attrs are raw user-space numbers — no logical→
284                    // physical px rewrite applies (the viewBox scales them at
285                    // raster), so `Length` semantics would be wrong here.
286                    Self::ShapeAttr { .. } => ValueKind::Scalar,
287                }
288            };
289        }
290        crate::animations::props::with_animatable_props!(kind_arms)
291    }
292
293    /// Whether this property feeds the `UiTransform` (built from all transform
294    /// channels together), so the apply layer can rebuild the transform once.
295    /// The channel set is the table's `Transform` stage (`crate::animations::props`).
296    pub fn is_transform(&self) -> bool {
297        self.stage() == crate::animations::props::PropStage::Transform
298    }
299}
300
301/// How an animated value resolves and where it lands. Pure metadata shared by the
302/// imperative apply layer and (for identity/precedence) the CSS-`transition`
303/// engine in `core`.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum ValueKind {
306    /// A bare `f32` (scale, opacity, …).
307    Scalar,
308    /// A length in px (translate).
309    Length,
310    /// An rgba color.
311    Color,
312    /// An angle in radians.
313    Angle,
314}
315
316/// A node's animation-driven style properties and what drives each: an open
317/// property→[`Binding`] map. Not a wire type — it is **derived** from the
318/// merged style's inline `{ animated }` wrappers by
319/// `crate::style_bindings::derive_bindings` after every style change, and
320/// stamped on the entity as `AnimatedNode`. A `BTreeMap` keeps iteration
321/// deterministic (stable transform-group rebuild and test assertions).
322#[derive(Debug, Clone, Default, PartialEq)]
323pub struct AnimatedBindings(pub BTreeMap<AnimatableProperty, Binding>);
324
325impl AnimatedBindings {
326    /// The binding for a property, if bound.
327    pub fn get(&self, property: AnimatableProperty) -> Option<&Binding> {
328        self.0.get(&property)
329    }
330
331    /// Whether a property is bound.
332    pub fn contains(&self, property: AnimatableProperty) -> bool {
333        self.0.contains_key(&property)
334    }
335
336    /// Whether any binding belongs to the given apply stage — the one
337    /// predicate behind every `has_*` gate (stages come from the property
338    /// table, `crate::animations::props`).
339    fn has_stage(&self, stage: crate::animations::props::PropStage) -> bool {
340        self.0.keys().any(|p| p.stage() == stage)
341    }
342
343    /// Whether any transform channel is bound (so the orchestrator only writes
344    /// `UiTransform` when something actually drives it).
345    pub fn has_transform(&self) -> bool {
346        self.has_stage(crate::animations::props::PropStage::Transform)
347    }
348
349    /// Whether any per-param filter binding ([`AnimatableProperty::FilterParam`])
350    /// is bound — gates the applier's filter stage and, in the transition
351    /// engine, `skip_filter` (any filter binding parks the *whole* whole-value
352    /// filter channel).
353    pub fn has_filter_params(&self) -> bool {
354        self.has_stage(crate::animations::props::PropStage::Filter)
355    }
356
357    /// The backdrop analog of [`Self::has_filter_params`] — gates the
358    /// applier's backdrop stage and the transition engine's `skip_backdrop`.
359    pub fn has_backdrop_params(&self) -> bool {
360        self.has_stage(crate::animations::props::PropStage::Backdrop)
361    }
362
363    /// The morph analog of [`Self::has_filter_params`] — gates the applier's
364    /// morph stage only (morph param bindings park no transition channel:
365    /// the morph channel owns progress, not params).
366    pub fn has_morph_params(&self) -> bool {
367        self.has_stage(crate::animations::props::PropStage::Morph)
368    }
369
370    /// Whether any SVG shape-attr binding ([`AnimatableProperty::ShapeAttr`])
371    /// is bound — gates the applier's shape stage and, in the transition
372    /// engine, the shape channel's coarse skip (any attr binding parks the
373    /// whole shape group).
374    pub fn has_shape_attrs(&self) -> bool {
375        self.has_stage(crate::animations::props::PropStage::Shape)
376    }
377
378    /// Whether any `transform3d.<field>` binding is bound — gates the
379    /// applier's transform3d stage and, in the transition engine,
380    /// `skip_transform3d` (any binding parks the whole channel group: the
381    /// stage rebuilds the full params struct).
382    pub fn has_transform3d(&self) -> bool {
383        self.has_stage(crate::animations::props::PropStage::Transform3d)
384    }
385
386    /// Iterate the bound (property, binding) pairs in property order.
387    pub fn iter(&self) -> impl Iterator<Item = (&AnimatableProperty, &Binding)> {
388        self.0.iter()
389    }
390
391    /// Whether nothing is bound.
392    pub fn is_empty(&self) -> bool {
393        self.0.is_empty()
394    }
395}
396
397fn default_duration() -> f32 {
398    0.3
399}
400fn default_stiffness() -> f32 {
401    100.0
402}
403fn default_damping() -> f32 {
404    10.0
405}
406fn default_mass() -> f32 {
407    1.0
408}
409fn default_count() -> i32 {
410    1
411}