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}