bevy-react 0.3.0

Drive bevy_ui from a React app over an embedded V8 runtime.
Documentation
//! Wire types for the animation bridge — the Reanimated-style surface a React app
//! declares once and the Bevy side drives every frame.
//!
//! These are **bevy-free** and `Deserialize`-only: they travel JS → Bevy through
//! the `op_animate` op (the main crate registers it), exactly like `protocol::Op`
//! travels through `op_flush`. The JS side (`js/src/animated.ts`) hand-writes
//! matching JSON shapes — keep the two in sync, just like `bridge.ts` ↔ `Op`.

use std::collections::BTreeMap;

use serde::Deserialize;

/// Identity of a shared value (Reanimated's `useSharedValue`). Allocated on the
/// JS side; lives in the [`crate::animations::SharedValues`] table on the Bevy side. Its own
/// namespace, unrelated to reconciler node ids.
pub type SharedId = u32;

/// How a shared value should evolve over time — the thing assigned to
/// `sharedValue.value` (`withTiming`, `withSpring`, `withRepeat`, `withSequence`).
/// Drivers compose: `Repeat`/`Sequence` wrap other drivers.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Driver {
    /// Ease from the value's current reading to `to` over `duration` seconds.
    Timing {
        to: f32,
        #[serde(default = "default_duration")]
        duration: f32,
        #[serde(default)]
        easing: Easing,
    },
    /// A damped spring settling on `to`, integrated each frame.
    Spring {
        to: f32,
        #[serde(default = "default_stiffness")]
        stiffness: f32,
        #[serde(default = "default_damping")]
        damping: f32,
        #[serde(default = "default_mass")]
        mass: f32,
    },
    /// Repeat `animation` `count` times (`-1` = forever); `reverse` ping-pongs the
    /// endpoints (Timing/Spring templates) instead of restarting from the top.
    Repeat {
        animation: Box<Driver>,
        #[serde(default = "default_count")]
        count: i32,
        #[serde(default)]
        reverse: bool,
    },
    /// Run each step in order, each starting from the previous step's end value.
    Sequence { steps: Vec<Driver> },
    /// Hold the value's current reading for `delay` seconds, then run `animation`.
    Delay { delay: f32, animation: Box<Driver> },
}

/// Easing curve for [`Driver::Timing`]. Cubic in/out variants.
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Easing {
    #[default]
    Linear,
    EaseIn,
    EaseOut,
    EaseInOut,
}

/// An imperative animation command, carried by `op_animate`. Drains into the
/// [`crate::animations::SharedValues`] table each frame.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum AnimationCommand {
    /// Register a shared value with its initial reading. Idempotent: a second
    /// `Declare` for an existing id keeps the current value (survives re-renders).
    Declare { id: SharedId, initial: f32 },
    /// Set a value immediately, cancelling any active driver.
    Set { id: SharedId, value: f32 },
    /// Start a driver; it animates from the value's live reading. `token`
    /// correlates a JS completion callback: when present, the engine reports the
    /// driver's settlement (finished or interrupted) back with this token; when
    /// absent nothing is reported (callback-free animations stay zero-overhead).
    Animate {
        id: SharedId,
        driver: Driver,
        #[serde(default)]
        token: Option<u64>,
    },
    /// Stop a value's active driver, freezing it where it is.
    Cancel { id: SharedId },
    /// Drop every shared value (sent on reconciler reset / hot reload).
    Clear,
}

/// Binds one animated style property to a shared value. Lives in the reconciler
/// `Props.animated` (see [`AnimatedBindings`]); evaluated each frame by the
/// orchestration system.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Binding {
    /// Use the shared value's current reading directly (numeric props).
    Shared { id: SharedId },
    /// Map the reading through a piecewise-linear curve (clamped to the ends).
    Interpolate {
        id: SharedId,
        input: Vec<f32>,
        output: Vec<f32>,
    },
    /// Map the reading to an rgba color (each component in `0.0..=1.0`). JS
    /// pre-parses hex, so this crate never parses colors.
    InterpolateColor {
        id: SharedId,
        input: Vec<f32>,
        output: Vec<[f32; 4]>,
    },
}

/// Identity of one continuous, animation-driveable style property. This is the
/// open set the generic apply layer dispatches on — adding a new animatable
/// property is a new variant here plus a row in the apply table (`crate::animations`),
/// not a new named field on a fixed struct. Derived from the merged style's
/// inline `{ animated }` wrappers (`crate::style_bindings`), which is also
/// where each variant's style position is defined.
///
/// Not `Copy` ([`Self::FilterParam`] carries the param name); the fieldless
/// variants are still constructed freely at call sites.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AnimatableProperty {
    /// Post-layout x translation, in px (drives `UiTransform`).
    TranslateX,
    /// Post-layout y translation, in px (drives `UiTransform`).
    TranslateY,
    /// Uniform scale (both axes unless `ScaleX`/`ScaleY` override).
    Scale,
    ScaleX,
    ScaleY,
    /// Clockwise rotation, **degrees** on the wire (matching the declarative
    /// `transform.rotate` field it lives in and the `transform3d` rotations);
    /// applied as radians.
    Rotate,
    /// Multiplies color alpha across background/text/image.
    Opacity,
    /// Drives `BackgroundColor`.
    BackgroundColor,
    /// Drives `BorderColor` (all four sides uniformly).
    BorderColor,
    /// Drives `TextColor` (a `<text>` node's color).
    Color,

    // Layout lengths (px) — write `Node`, which re-triggers Bevy layout. The
    // applier writes the field only when it actually changes (no idle relayout).
    Width,
    Height,
    MinWidth,
    MinHeight,
    MaxWidth,
    MaxHeight,
    Left,
    Right,
    Top,
    Bottom,
    FlexBasis,
    /// Sets both row and column gap.
    Gap,
    RowGap,
    ColumnGap,

    // Layout scalars — also write `Node`. (`flexGrow`/`flexShrink` are deliberately
    // not here: they're relative weights, not magnitudes — animating them has no
    // intuitive visual meaning, unlike a size or `aspectRatio`.)
    AspectRatio,

    /// One named parameter of the node's resolved `filter` chain — the wire
    /// key is `filter[<index>].<param>` (e.g. `filter[0].radius`). `index`
    /// addresses the **wire** chain entry, so a binding writes the named slot
    /// in *every* resolved pass carrying that
    /// [`wire_index`](crate::filters::ResolvedFilterPass::wire_index) (blur's
    /// H+V passes both carry `radius`). `name` is a
    /// [`ParamSlot`](crate::filters::ParamSlot) name in the pass layout.
    ///
    /// The bound value is applied in the **same unit as the param's wire
    /// form**: logical px for `Length` slots (scale-rewritten to physical px
    /// like the resolver), **degrees** for `Angle` slots (converted to the
    /// packed radians), raw for single-component `Scalar` slots; `Color`
    /// slots take an `interpolateColor` binding. Index/name/kind are
    /// validated against the resolved chain at bind time (`filterBinding`
    /// devtools warnings); an unmatched binding stays inert.
    FilterParam {
        index: u8,
        name: String,
    },

    /// One named parameter of the node's resolved `backdropFilter` chain —
    /// the wire key is `backdropFilter[<index>].<param>`. Identical
    /// addressing, units, and bind-time validation as
    /// [`Self::FilterParam`] (warn kind `backdropFilterBinding`), against
    /// the backdrop chain instead of the content one.
    BackdropParam {
        index: u8,
        name: String,
    },

    /// One field of the node's `transform3d` style — the wire key is
    /// `transform3d.<field>` (e.g. `transform3d.rotateY`). Drives the
    /// composite-time 3D transform of a promoted layer
    /// ([`crate::layer::transform3d`]); unbound fields keep the static style
    /// value. Values arrive in the **declarative field's wire units**: logical
    /// px for translations/perspective/origin, **degrees** for rotations
    /// (like the 2D [`Rotate`](Self::Rotate) and every other rotation in the
    /// system), raw scalars for scales.
    Transform3d(Transform3dField),
}

/// The addressable fields of [`AnimatableProperty::Transform3d`]. Origin
/// animates as two px offsets (`originX`/`originY`) — a percent origin that
/// must track the node's size belongs in the static style instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Transform3dField {
    Perspective,
    TranslateX,
    TranslateY,
    TranslateZ,
    RotateX,
    RotateY,
    RotateZ,
    Scale,
    ScaleX,
    ScaleY,
    OriginX,
    OriginY,
}

impl AnimatableProperty {
    /// The kind of value this property animates — picks scalar-vs-color resolution
    /// in the apply layer. `Rotate` is an `Angle`: the bound value is degrees
    /// on the wire, resolved as a scalar and converted by the applier.
    pub fn value_kind(&self) -> ValueKind {
        match self {
            Self::TranslateX
            | Self::TranslateY
            | Self::Width
            | Self::Height
            | Self::MinWidth
            | Self::MinHeight
            | Self::MaxWidth
            | Self::MaxHeight
            | Self::Left
            | Self::Right
            | Self::Top
            | Self::Bottom
            | Self::FlexBasis
            | Self::Gap
            | Self::RowGap
            | Self::ColumnGap => ValueKind::Length,
            Self::Scale | Self::ScaleX | Self::ScaleY | Self::Opacity | Self::AspectRatio => {
                ValueKind::Scalar
            }
            Self::Rotate => ValueKind::Angle,
            Self::Transform3d(field) => match field {
                Transform3dField::RotateX
                | Transform3dField::RotateY
                | Transform3dField::RotateZ => ValueKind::Angle,
                Transform3dField::Scale | Transform3dField::ScaleX | Transform3dField::ScaleY => {
                    ValueKind::Scalar
                }
                _ => ValueKind::Length,
            },
            Self::BackgroundColor | Self::BorderColor | Self::Color => ValueKind::Color,
            // Never consulted for the chain params — the applier reads the
            // authoritative kind from the resolved chain's `ParamSlot`
            // layout (`crate::filters`). A documented fallback, not a
            // semantic: the slot decides scalar-vs-color, not this arm.
            Self::FilterParam { .. } | Self::BackdropParam { .. } => ValueKind::Scalar,
        }
    }

    /// Whether this property feeds the `UiTransform` (built from all transform
    /// channels together), so the apply layer can rebuild the transform once.
    pub fn is_transform(&self) -> bool {
        matches!(
            self,
            Self::TranslateX
                | Self::TranslateY
                | Self::Scale
                | Self::ScaleX
                | Self::ScaleY
                | Self::Rotate
        )
    }
}

/// How an animated value resolves and where it lands. Pure metadata shared by the
/// imperative apply layer and (for identity/precedence) the CSS-`transition`
/// engine in `core`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueKind {
    /// A bare `f32` (scale, opacity, …).
    Scalar,
    /// A length in px (translate).
    Length,
    /// An rgba color.
    Color,
    /// An angle in radians.
    Angle,
}

/// A node's animation-driven style properties and what drives each: an open
/// property→[`Binding`] map. Not a wire type — it is **derived** from the
/// merged style's inline `{ animated }` wrappers by
/// `crate::style_bindings::derive_bindings` after every style change, and
/// stamped on the entity as `AnimatedNode`. A `BTreeMap` keeps iteration
/// deterministic (stable transform-group rebuild and test assertions).
#[derive(Debug, Clone, Default)]
pub struct AnimatedBindings(pub BTreeMap<AnimatableProperty, Binding>);

impl AnimatedBindings {
    /// The binding for a property, if bound.
    pub fn get(&self, property: AnimatableProperty) -> Option<&Binding> {
        self.0.get(&property)
    }

    /// Whether a property is bound.
    pub fn contains(&self, property: AnimatableProperty) -> bool {
        self.0.contains_key(&property)
    }

    /// Whether any transform channel is bound (so the orchestrator only writes
    /// `UiTransform` when something actually drives it).
    pub fn has_transform(&self) -> bool {
        self.0.keys().any(|p| p.is_transform())
    }

    /// Whether any per-param filter binding ([`AnimatableProperty::FilterParam`])
    /// is bound — gates the applier's filter stage and, in the transition
    /// engine, `skip_filter` (any filter binding parks the *whole* whole-value
    /// filter channel).
    pub fn has_filter_params(&self) -> bool {
        self.0
            .keys()
            .any(|p| matches!(p, AnimatableProperty::FilterParam { .. }))
    }

    /// The backdrop analog of [`Self::has_filter_params`] — gates the
    /// applier's backdrop stage and the transition engine's `skip_backdrop`.
    pub fn has_backdrop_params(&self) -> bool {
        self.0
            .keys()
            .any(|p| matches!(p, AnimatableProperty::BackdropParam { .. }))
    }

    /// Whether any `transform3d.<field>` binding is bound — gates the
    /// applier's transform3d stage and, in the transition engine,
    /// `skip_transform3d` (any binding parks the whole channel group: the
    /// stage rebuilds the full params struct).
    pub fn has_transform3d(&self) -> bool {
        self.0
            .keys()
            .any(|p| matches!(p, AnimatableProperty::Transform3d(_)))
    }

    /// Iterate the bound (property, binding) pairs in property order.
    pub fn iter(&self) -> impl Iterator<Item = (&AnimatableProperty, &Binding)> {
        self.0.iter()
    }

    /// Whether nothing is bound.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

fn default_duration() -> f32 {
    0.3
}
fn default_stiffness() -> f32 {
    100.0
}
fn default_damping() -> f32 {
    10.0
}
fn default_mass() -> f32 {
    1.0
}
fn default_count() -> i32 {
    1
}