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 numeric attribute of an SVG shape entity (`<circle>`/`<rect>`/…),
206 /// addressed by its **wire** name — the camelCase key in the folded
207 /// `shape` object (`"cx"`, `"r"`, `"strokeWidth"`, …; the full set is
208 /// `crate::svg::NUMERIC_ATTRS`). Derived from `{ animated }` wrappers on
209 /// the shape's numeric attrs
210 /// (`crate::style_bindings::derive_shape_bindings`); the apply stage
211 /// drives the entity's `SvgShape.attrs` field of that name per frame.
212 /// Bound values are in **wire units**: SVG user-space units for geometry
213 /// and `strokeWidth` (the viewBox maps them onto the layout box at
214 /// raster), `0..1` for `opacity`.
215 ShapeAttr {
216 name: String,
217 },
218
219 /// One field of the node's `transform3d` style — the wire key is
220 /// `transform3d.<field>` (e.g. `transform3d.rotateY`). Drives the
221 /// composite-time 3D transform of a promoted layer
222 /// ([`crate::layer::transform3d`]); unbound fields keep the static style
223 /// value. Values arrive in the **declarative field's wire units**: logical
224 /// px for translations/perspective/origin, **degrees** for rotations
225 /// (like the 2D [`Rotate`](Self::Rotate) and every other rotation in the
226 /// system), raw scalars for scales.
227 Transform3d(Transform3dField),
228}
229
230/// The addressable fields of [`AnimatableProperty::Transform3d`]. Origin
231/// animates as two px offsets (`originX`/`originY`) — a percent origin that
232/// must track the node's size belongs in the static style instead.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
234pub enum Transform3dField {
235 Perspective,
236 TranslateX,
237 TranslateY,
238 TranslateZ,
239 RotateX,
240 RotateY,
241 RotateZ,
242 Scale,
243 ScaleX,
244 ScaleY,
245 OriginX,
246 OriginY,
247}
248
249impl AnimatableProperty {
250 /// The kind of value this property animates — picks scalar-vs-color resolution
251 /// in the apply layer. `Rotate` is an `Angle`: the bound value is degrees
252 /// on the wire, resolved as a scalar and converted by the applier.
253 ///
254 /// Static rows generate from the property table
255 /// (`crate::animations::props`); the dynamic domains keep explicit arms.
256 #[allow(unused_parens)]
257 pub fn value_kind(&self) -> ValueKind {
258 use AnimatableProperty as P;
259 use Transform3dField as F;
260 macro_rules! kind_arms {
261 ($(($prop:tt, $kind:ident, $acc:tt, $write:tt, $stage:ident, $park:ident),)*) => {
262 match self {
263 $($prop => ValueKind::$kind,)*
264 // Never consulted for the chain params — the applier reads the
265 // authoritative kind from the resolved chain's `ParamSlot`
266 // layout (`crate::filters`). A documented fallback, not a
267 // semantic: the slot decides scalar-vs-color, not this arm.
268 Self::FilterParam { .. } | Self::BackdropParam { .. } => ValueKind::Scalar,
269 // Genuinely scalar (unlike the chain params' documented fallback
270 // above): shape attrs are raw user-space numbers — no logical→
271 // physical px rewrite applies (the viewBox scales them at
272 // raster), so `Length` semantics would be wrong here.
273 Self::ShapeAttr { .. } => ValueKind::Scalar,
274 }
275 };
276 }
277 crate::animations::props::with_animatable_props!(kind_arms)
278 }
279
280 /// Whether this property feeds the `UiTransform` (built from all transform
281 /// channels together), so the apply layer can rebuild the transform once.
282 /// The channel set is the table's `Transform` stage (`crate::animations::props`).
283 pub fn is_transform(&self) -> bool {
284 self.stage() == crate::animations::props::PropStage::Transform
285 }
286}
287
288/// How an animated value resolves and where it lands. Pure metadata shared by the
289/// imperative apply layer and (for identity/precedence) the CSS-`transition`
290/// engine in `core`.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum ValueKind {
293 /// A bare `f32` (scale, opacity, …).
294 Scalar,
295 /// A length in px (translate).
296 Length,
297 /// An rgba color.
298 Color,
299 /// An angle in radians.
300 Angle,
301}
302
303/// A node's animation-driven style properties and what drives each: an open
304/// property→[`Binding`] map. Not a wire type — it is **derived** from the
305/// merged style's inline `{ animated }` wrappers by
306/// `crate::style_bindings::derive_bindings` after every style change, and
307/// stamped on the entity as `AnimatedNode`. A `BTreeMap` keeps iteration
308/// deterministic (stable transform-group rebuild and test assertions).
309#[derive(Debug, Clone, Default, PartialEq)]
310pub struct AnimatedBindings(pub BTreeMap<AnimatableProperty, Binding>);
311
312impl AnimatedBindings {
313 /// The binding for a property, if bound.
314 pub fn get(&self, property: AnimatableProperty) -> Option<&Binding> {
315 self.0.get(&property)
316 }
317
318 /// Whether a property is bound.
319 pub fn contains(&self, property: AnimatableProperty) -> bool {
320 self.0.contains_key(&property)
321 }
322
323 /// Whether any binding belongs to the given apply stage — the one
324 /// predicate behind every `has_*` gate (stages come from the property
325 /// table, `crate::animations::props`).
326 fn has_stage(&self, stage: crate::animations::props::PropStage) -> bool {
327 self.0.keys().any(|p| p.stage() == stage)
328 }
329
330 /// Whether any transform channel is bound (so the orchestrator only writes
331 /// `UiTransform` when something actually drives it).
332 pub fn has_transform(&self) -> bool {
333 self.has_stage(crate::animations::props::PropStage::Transform)
334 }
335
336 /// Whether any per-param filter binding ([`AnimatableProperty::FilterParam`])
337 /// is bound — gates the applier's filter stage and, in the transition
338 /// engine, `skip_filter` (any filter binding parks the *whole* whole-value
339 /// filter channel).
340 pub fn has_filter_params(&self) -> bool {
341 self.has_stage(crate::animations::props::PropStage::Filter)
342 }
343
344 /// The backdrop analog of [`Self::has_filter_params`] — gates the
345 /// applier's backdrop stage and the transition engine's `skip_backdrop`.
346 pub fn has_backdrop_params(&self) -> bool {
347 self.has_stage(crate::animations::props::PropStage::Backdrop)
348 }
349
350 /// Whether any SVG shape-attr binding ([`AnimatableProperty::ShapeAttr`])
351 /// is bound — gates the applier's shape stage and, in the transition
352 /// engine, the shape channel's coarse skip (any attr binding parks the
353 /// whole shape group).
354 pub fn has_shape_attrs(&self) -> bool {
355 self.has_stage(crate::animations::props::PropStage::Shape)
356 }
357
358 /// Whether any `transform3d.<field>` binding is bound — gates the
359 /// applier's transform3d stage and, in the transition engine,
360 /// `skip_transform3d` (any binding parks the whole channel group: the
361 /// stage rebuilds the full params struct).
362 pub fn has_transform3d(&self) -> bool {
363 self.has_stage(crate::animations::props::PropStage::Transform3d)
364 }
365
366 /// Iterate the bound (property, binding) pairs in property order.
367 pub fn iter(&self) -> impl Iterator<Item = (&AnimatableProperty, &Binding)> {
368 self.0.iter()
369 }
370
371 /// Whether nothing is bound.
372 pub fn is_empty(&self) -> bool {
373 self.0.is_empty()
374 }
375}
376
377fn default_duration() -> f32 {
378 0.3
379}
380fn default_stiffness() -> f32 {
381 100.0
382}
383fn default_damping() -> f32 {
384 10.0
385}
386fn default_mass() -> f32 {
387 1.0
388}
389fn default_count() -> i32 {
390 1
391}