bevy_react_core 0.7.0

The core bridge of bevy-react (drive bevy_ui from React over an embedded V8 runtime). Apps depend on the `bevy-react` crate.
//! `ReactUiAnimationsPlugin` — a Reanimated-style animation engine for
//! `bevy-react`.
//!
//! The model mirrors React Native's Reanimated: a React app declares **shared
//! values** (one animatable `f32` with a stable id) and assigns **drivers**
//! (`withTiming`, `withSpring`, `withRepeat`, `withSequence`) to them; an
//! inline `{ animated }` binding ties a style property or an element attribute
//! to those values. All per-frame work —
//! advancing drivers, interpolation, writing components — happens **here, on the
//! Bevy side**, never crossing back to JS. The one exception is completion:
//! a driver started with a correlation token reports its settlement (one
//! [`AnimationSettled`] message, forwarded to JS by the bridge) so a JS
//! callback can fire — once per animation, not per frame.
//!
//! The module owns the animation wire types ([`mod@protocol`]) and the
//! orchestration systems, and receives commands through an [`AnimationInbox`]
//! channel the bridge hands it.

use std::collections::HashMap;

/// `fn any(&mut self) -> bool` over a `SystemParam`'s listed
/// `RemovedComponents` fields: whether any tracked component was removed
/// since the last run. Reads (and thereby consumes) every stream, so a
/// removal is seen once.
macro_rules! removed_any {
    ($($f:ident),* $(,)?) => {
        fn any(&mut self) -> bool {
            let mut any = false;
            $(any |= self.$f.read().next().is_some(); self.$f.clear();)*
            any
        }
    };
}
pub(crate) use removed_any;

use bevy::prelude::*;
use bevy::ui::UiTransform;
use crossbeam_channel::Receiver;

mod apply;
mod eval;
pub(crate) mod props;
pub mod protocol;
mod runner;

pub(crate) use apply::apply_animated_nodes;
pub(crate) use apply::{push_transform_dirt, write_final_alpha};
pub use eval::{Lerp, build_ui_transform};
use eval::{eval_color, eval_scalar};

pub use protocol::{
    AnimatableProperty, AnimatedBindings, AnimationCommand, Binding, Driver, Easing, SharedId,
    ValueKind,
};
pub use runner::{Runner, build_runner};

/// Adds the animation orchestration: the [`SharedValues`] table, the per-frame
/// driver/apply systems, and the [`AnimationInbox`] that feeds commands in.
///
/// Added by `bevy_react_core::ReactUiPlugin`, which orders
/// [`AnimationSet::Apply`] after the reconciler's op-apply so per-frame
/// animation writes win over this frame's static style.
pub struct ReactUiAnimationsPlugin {
    inbox: Receiver<AnimationCommand>,
}

impl ReactUiAnimationsPlugin {
    /// Build the plugin around the receiving end of the `op_animate` channel.
    pub fn new(inbox: Receiver<AnimationCommand>) -> Self {
        Self { inbox }
    }
}

impl Plugin for ReactUiAnimationsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SharedValues>()
            // The apply system reports content writes to the layer cache; the
            // integrator inits this too, but standalone use shouldn't panic.
            .init_resource::<crate::layer::LayerContentDirt>()
            .add_message::<AnimationSettled>()
            .insert_resource(AnimationInbox(self.inbox.clone()))
            .configure_sets(
                Update,
                (AnimationSet::Drain, AnimationSet::Tick, AnimationSet::Apply).chain(),
            )
            .add_systems(
                Update,
                (
                    drain_animation_commands.in_set(AnimationSet::Drain),
                    tick_animations.in_set(AnimationSet::Tick),
                    apply_animated_nodes.in_set(AnimationSet::Apply),
                ),
            );
    }
}

/// Ordering handles for the three animation systems. The integrator orders
/// [`AnimationSet::Apply`] relative to its own reconciler systems.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum AnimationSet {
    /// Drain inbound commands into the [`SharedValues`] table.
    Drain,
    /// Advance every active driver by the frame delta.
    Tick,
    /// Write resolved values onto `UiTransform` / colors.
    Apply,
}

/// Component the reconciler places on any node with an inline `{ animated }`
/// binding. Carries the property→[`Binding`] map. Requires `UiTransform` so the apply system can always
/// drive it.
#[derive(Component, Debug, Clone)]
#[require(UiTransform)]
pub struct AnimatedNode(pub AnimatedBindings);

/// A token-tagged driver settled: `finished` is `true` when it ran to its natural
/// end, `false` when a `set`/`cancel`/new `animate` interrupted it. Written by
/// the drain/tick systems for every [`AnimationCommand::Animate`] that carried a
/// `token`; the bridge forwards these to the JS completion callbacks — the
/// one thing the engine sends back toward JS.
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationSettled {
    /// The shared value the driver was animating.
    pub id: SharedId,
    /// The JS-side correlation token from the `animate` command.
    pub token: u64,
    /// Natural completion (`true`) vs interruption (`false`).
    pub finished: bool,
}

/// The receiving end of the `op_animate` channel, drained each frame.
#[derive(Resource)]
pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);

/// The live table of shared values, keyed by [`SharedId`]. Each entry holds the
/// current reading plus an optional active driver. Settlements of token-tagged
/// drivers accumulate in `settled` until the owning system flushes them to the
/// [`AnimationSettled`] message stream.
#[derive(Resource, Default)]
pub struct SharedValues {
    values: HashMap<SharedId, SharedValueState>,
    settled: Vec<AnimationSettled>,
    /// How many entries carry a live driver — the tick's idle gate (an
    /// idle table is neither iterated nor marked changed, so the applier's
    /// `Res` change detection reads "nothing moved").
    active: usize,
}

struct SharedValueState {
    current: f32,
    active: Option<Runner>,
    /// Correlation token of the active driver's JS completion callback, if any.
    token: Option<u64>,
}

impl SharedValueState {
    /// The settlement for interrupting a still-active token-tagged driver
    /// (`set`/`cancel`/a superseding `animate`), consuming the token.
    fn interrupted(&mut self, id: SharedId) -> Option<AnimationSettled> {
        self.active.as_ref()?;
        let token = self.token.take()?;
        Some(AnimationSettled {
            id,
            token,
            finished: false,
        })
    }
}

impl SharedValues {
    /// The current reading of a shared value, if it exists.
    pub fn get(&self, id: SharedId) -> Option<f32> {
        self.values.get(&id).map(|s| s.current)
    }

    /// Whether the table is empty.
    pub fn is_empty(&self) -> bool {
        self.values.is_empty()
    }

    /// Whether any shared value has a live driver (something will move on
    /// the next tick).
    pub fn has_active(&self) -> bool {
        self.active > 0
    }

    pub(crate) fn declare(&mut self, id: SharedId, initial: f32) {
        // Idempotent: only the first declaration sets the initial reading, so a
        // value survives React re-renders (matching `useSharedValue`).
        self.values.entry(id).or_insert(SharedValueState {
            current: initial,
            active: None,
            token: None,
        });
    }

    pub(crate) fn set(&mut self, id: SharedId, value: f32) {
        let s = self.values.entry(id).or_insert(SharedValueState {
            current: value,
            active: None,
            token: None,
        });
        self.settled.extend(s.interrupted(id));
        s.current = value;
        if s.active.take().is_some() {
            self.active -= 1;
        }
    }

    pub(crate) fn animate(&mut self, id: SharedId, driver: &Driver, token: Option<u64>) {
        let s = self.values.entry(id).or_insert(SharedValueState {
            current: 0.0,
            active: None,
            token: None,
        });
        self.settled.extend(s.interrupted(id));
        let from = s.current;
        if s.active.is_none() {
            self.active += 1;
        }
        s.active = Some(build_runner(driver, from));
        s.token = token;
    }

    fn cancel(&mut self, id: SharedId) {
        if let Some(s) = self.values.get_mut(&id) {
            self.settled.extend(s.interrupted(id));
            if s.active.take().is_some() {
                self.active -= 1;
            }
        }
    }

    fn clear(&mut self) {
        self.values.clear();
        self.active = 0;
        // Reset also wipes the JS callback registry, so pending settlements would
        // land on nobody — drop them.
        self.settled.clear();
    }

    pub(crate) fn tick(&mut self, dt: f32) {
        if self.active == 0 {
            return;
        }
        for (&id, s) in self.values.iter_mut() {
            if let Some(runner) = s.active.as_mut() {
                let (value, finished) = runner.step(dt);
                s.current = value;
                if finished {
                    s.active = None;
                    self.active -= 1;
                    if let Some(token) = s.token.take() {
                        self.settled.push(AnimationSettled {
                            id,
                            token,
                            finished: true,
                        });
                    }
                }
            }
        }
    }

    /// Flush the settlements accumulated since the last flush.
    fn take_settled(&mut self) -> Vec<AnimationSettled> {
        std::mem::take(&mut self.settled)
    }
}

// --- Systems -------------------------------------------------------------------

fn drain_animation_commands(
    inbox: Res<AnimationInbox>,
    mut values: ResMut<SharedValues>,
    mut settled: MessageWriter<AnimationSettled>,
) {
    while let Ok(cmd) = inbox.0.try_recv() {
        match cmd {
            AnimationCommand::Declare { id, initial } => values.declare(id, initial),
            AnimationCommand::Set { id, value } => values.set(id, value),
            AnimationCommand::Animate { id, driver, token } => values.animate(id, &driver, token),
            AnimationCommand::Cancel { id } => values.cancel(id),
            AnimationCommand::Clear => values.clear(),
        }
    }
    // Read before write: an idle frame must not mark the table changed (the
    // applier's idle gate reads that tick as "a value moved").
    if !values.settled.is_empty() {
        settled.write_batch(values.take_settled());
    }
}

fn tick_animations(
    time: Res<Time>,
    mut values: ResMut<SharedValues>,
    mut settled: MessageWriter<AnimationSettled>,
) {
    // Idle gate: no live driver → nothing to step, and no `DerefMut` so the
    // table's change tick stays put (see `apply_animated_nodes`).
    if !values.has_active() {
        return;
    }
    values.tick(time.delta_secs());
    settled.write_batch(values.take_settled());
}

// (Driver runtime — `Runner`, `build_runner`, easing — lives in `runner.rs`.)

#[cfg(test)]
mod tests;