Skip to main content

bevy_react/transition/
channels.rs

1//! The transition engine's channel runtime: the shared ease machinery
2//! ([`Interp`] / [`EasedChannel`]), the value-space [`Channel`] (springs
3//! integrate in value space), the whole-value [`FilterChannel`], and the
4//! per-entity [`TransitionState`] they roll up into.
5
6use bevy::prelude::*;
7
8use crate::animations::{Lerp, Runner, build_runner};
9use crate::protocol::units::Length;
10
11use super::spec::ChannelTransition;
12use super::{shape_channel, transform3d};
13
14/// The transition engine's plain scalar/length channels — one row per
15/// [`TransitionState`] channel whose target rides a same-named
16/// [`TransitionInput`] field: `(channel, (identity default), group)`.
17/// Consumed by the mount-seed block (every row seeds `state.<channel>` from
18/// `input.<channel>` at its identity default) and by the size drive block
19/// (`size` rows, which also name the written `Node` field). The
20/// color/filter/backdrop/transform3d/scroll/shape channels have their own
21/// target shapes and stay explicit.
22macro_rules! with_input_channels {
23    ($cb:ident) => {
24        $cb! {
25            (translate_x, (Length::Px(0.0)), transform),
26            (translate_y, (Length::Px(0.0)), transform),
27            (scale, (1.0), transform),
28            (scale_x, (1.0), transform),
29            (scale_y, (1.0), transform),
30            (rotate, (0.0), transform),
31            (opacity, (1.0), value),
32            (width, (Length::Auto), size),
33            (height, (Length::Auto), size),
34            (max_width, (Length::Auto), size),
35            (max_height, (Length::Auto), size),
36        }
37    };
38}
39pub(super) use with_input_channels;
40
41/// Per-entity transition runtime: one [`Runner`]-backed channel per animatable
42/// property. Persists across re-renders (the engine owns it); created lazily by
43/// [`apply_transition`]. `#[require(UiTransform)]` so the drive query always
44/// matches even for an opacity/color-only transition.
45#[derive(Component, Default)]
46#[require(UiTransform)]
47pub struct TransitionState {
48    pub(super) translate_x: ProgressChannel<Length>,
49    pub(super) translate_y: ProgressChannel<Length>,
50    pub(super) scale: Channel,
51    pub(super) scale_x: Channel,
52    pub(super) scale_y: Channel,
53    pub(super) rotate: Channel,
54    pub(super) opacity: Channel,
55    pub(super) color: ProgressChannel<[f32; 4]>,
56    pub(super) width: ProgressChannel<Length>,
57    pub(super) height: ProgressChannel<Length>,
58    pub(super) max_width: ProgressChannel<Length>,
59    pub(super) max_height: ProgressChannel<Length>,
60    pub(super) filter: FilterChannel,
61    pub(super) backdrop_filter: FilterChannel,
62    pub(super) morph: MorphChannel,
63    pub(super) transform3d: transform3d::Transform3dChannels,
64    /// SVG shape-attr easing (spec + targets both ride `SvgShape.attrs` —
65    /// shapes have no style). Self-seeding per attr, so it doesn't
66    /// participate in the `initialized` block.
67    pub(super) shape: shape_channel::ShapeChannel,
68    pub(super) initialized: bool,
69}
70
71/// The whole-value `filter` channel: eases a promoted root's
72/// [`crate::filters::ResolvedFilterChain`] packed params between wire targets
73/// (see [`crate::filters::plan_filter_ease`] for the strategy). Unlike the
74/// scalar channels, its current reading cannot be re-read from the component —
75/// [`crate::filters::resolve_chains`] snaps the component to the new
76/// target on the retarget frame, before this system runs — so the state owns
77/// the last-written pass list (the `ProgressChannel` state-owned-current
78/// pattern, list-shaped).
79#[derive(Default)]
80pub(super) struct FilterChannel {
81    /// The last wire chain seen (retarget detection). Empty = no filter.
82    pub(super) wire: crate::filters::FilterChain,
83    /// The shared ease machinery: `channel.current` is the pass list this
84    /// channel last wrote (or adopted from the resolver) — the next ease's
85    /// start; the interp holds the armed plan. Both are (re)set together at
86    /// retarget, so the runner and its plan can't go out of sync.
87    pub(super) channel: EasedChannel<Vec<crate::filters::ResolvedFilterPass>, FilterEaseInterp>,
88}
89
90/// The filter-ease [`Interp`]: samples a planned pass-list ease
91/// ([`crate::filters::FilterEase`] — strategy + endpoints planned at retarget
92/// by [`crate::filters::plan_filter_ease`], delegated to verbatim, never
93/// reimplemented). The wrapper injects the plan at arm time; `arm` itself is
94/// a no-op because planning needs registry/assets context the trait seam
95/// doesn't carry.
96#[derive(Default)]
97pub(super) struct FilterEaseInterp {
98    plan: Option<crate::filters::FilterEase>,
99}
100
101impl Interp<Vec<crate::filters::ResolvedFilterPass>> for FilterEaseInterp {
102    fn arm(
103        &mut self,
104        _from: &Vec<crate::filters::ResolvedFilterPass>,
105        _to: &Vec<crate::filters::ResolvedFilterPass>,
106    ) {
107    }
108    fn sample(
109        &self,
110        p: f32,
111        _target: &Vec<crate::filters::ResolvedFilterPass>,
112    ) -> Vec<crate::filters::ResolvedFilterPass> {
113        self.plan.as_ref().map(|e| e.sample(p)).unwrap_or_default()
114    }
115    /// Completion writes the plan's own settle list — the resolver's snapped
116    /// output, bit-exact, so the two writers agree and stop churning (the
117    /// stage-interplay rule: bake the final value, don't approximate it).
118    fn settle(
119        &self,
120        _target: &Vec<crate::filters::ResolvedFilterPass>,
121    ) -> Vec<crate::filters::ResolvedFilterPass> {
122        self.plan
123            .as_ref()
124            .map(|e| e.settle().to_vec())
125            .unwrap_or_default()
126    }
127}
128
129impl FilterChannel {
130    /// Advance the filter chain toward the wire target in `input`, writing the
131    /// eased packed params into `resolved`. Returns `true` when it wrote —
132    /// the caller pushes composite-only dirt (filter output never dirties the
133    /// capture, which holds unfiltered content).
134    ///
135    /// Three writers touch [`crate::filters::ResolvedFilterChain`]; precedence
136    /// runs resolver → transition → bindings. On the retarget frame
137    /// [`crate::filters::resolve_chains`] (ordered before
138    /// [`drive_transitions`]) *snaps* the component to the new target; this
139    /// method *eases* over that snap — starting from the state-owned
140    /// `current`, the last value this channel wrote, never the
141    /// already-snapped component; and per-param animation bindings
142    /// (`filter[<i>].<param>`) *re-assert* individual params on top, winning
143    /// by gating this channel out via `skip_filter` (the imperative-wins
144    /// pattern of the scalar channels, coarse: any filter binding parks the
145    /// whole channel).
146    ///
147    /// The target rides the wire-chain component (`FilterInput` /
148    /// `BackdropInput` — the caller projects to the inner [`FilterChain`]),
149    /// NOT [`TransitionInput`] — a chain-only delta dirties the
150    /// FILTER/BACKDROP|LAYER groups, never TRANSITION, so a target stamped
151    /// into the input would go stale; the chain component is re-stamped by
152    /// that same delta. Both channel instances (filter, backdropFilter) run
153    /// this same code over their own component pair.
154    pub(super) fn drive(
155        &mut self,
156        input: Option<&crate::filters::FilterChain>,
157        mut resolved: Option<Mut<crate::filters::ResolvedFilterChain>>,
158        spec: Option<&ChannelTransition>,
159        registry: Option<&crate::filters::FilterRegistry>,
160        assets: Option<&AssetServer>,
161        dt: f32,
162    ) -> bool {
163        let retargeted = match input {
164            Some(fi) => *fi != self.wire,
165            None => !self.wire.0.is_empty(),
166        };
167        if retargeted {
168            let to_wire = input.cloned().unwrap_or_default();
169            let from_wire = std::mem::replace(&mut self.wire, to_wire);
170            match (spec, resolved.as_deref()) {
171                // Ease only toward a live resolved chain. An emptied or
172                // unresolvable target has no component to write into
173                // (unset `filter` demotes the layer; an all-invalid chain
174                // attaches none), so it snaps below.
175                (Some(spec), Some(chain)) if !self.wire.0.is_empty() => {
176                    self.channel.interp.plan = Some(crate::filters::plan_filter_ease(
177                        &from_wire,
178                        &self.wire,
179                        self.channel.current.clone(),
180                        chain.passes.clone(),
181                        registry,
182                        assets,
183                        chain.scale,
184                    ));
185                    self.channel.arm(chain.passes.clone(), spec);
186                }
187                _ => {
188                    // Snap: adopt whatever the resolver produced.
189                    self.channel.interp.plan = None;
190                    self.channel.init(
191                        resolved
192                            .as_deref()
193                            .map(|c| c.passes.clone())
194                            .unwrap_or_default(),
195                    );
196                }
197            }
198        }
199        let mut wrote = false;
200        if self.channel.runner.is_some() {
201            match resolved.as_mut() {
202                Some(resolved) => {
203                    // Advance the ease: `tick` samples the plan, and on
204                    // completion writes the plan's settle list (bit-exact —
205                    // see `FilterEaseInterp::settle`) and drops the runner.
206                    self.channel.tick(dt);
207                    // Compare via `Deref` first so a no-op frame doesn't
208                    // trip change detection.
209                    if resolved.passes != self.channel.current {
210                        let chain = &mut **resolved;
211                        chain.passes = self.channel.current.clone();
212                        chain.version = chain.version.wrapping_add(1);
213                        wrote = true;
214                    }
215                }
216                None => {
217                    // The chain vanished mid-ease (demotion tore the
218                    // layer down): drop the ease and forget the passes.
219                    self.channel.interp.plan = None;
220                    self.channel.init(Vec::new());
221                }
222            }
223        }
224        wrote
225    }
226}
227
228/// The `morphFilter` progress channel: retargets on a `key` change and eases
229/// an engine-owned progress 0→1. The freeze itself (stealing the layer's
230/// on-screen capture as the "from" texture) happens render-side; this channel
231/// only sequences it — bumping `MorphState::freeze_seq`, recording the
232/// on-screen rect, and driving `MorphState::progress`, all applied by the
233/// caller from the returned [`MorphAction`] (the channel stays ECS-free for
234/// testability, like the other channels).
235///
236/// Unlike every other channel it never snaps for lack of a spec — the caller
237/// passes `spec::morph_default()` when the style names none. It *does* snap
238/// (adopt the key without animating) when there is nothing to blend: no
239/// on-screen rect yet (first layout — the mount rule) or no resolved chain
240/// (unknown/invalid morph filter — the degrade rule).
241#[derive(Default)]
242pub(super) struct MorphChannel {
243    /// The last key seen (retarget detection), like [`FilterChannel::wire`].
244    /// `None` until seeded / while the style has no morph.
245    pub(super) key: Option<serde_json::Value>,
246    /// The active progress runner (0→1); `None` when idle.
247    pub(super) runner: Option<Runner>,
248    /// Mirrors [`MorphState::freeze_seq`]; owned here so a retarget can bump
249    /// it even when the state component doesn't exist yet.
250    pub(super) seq: u64,
251    /// The settle frame rendered at exactly `1.0`; deactivation happens on
252    /// the NEXT drive — by the morph-shader identity contract that frame is
253    /// pixel-equal to no pass, so dropping the pass can never flash.
254    pub(super) settling: bool,
255}
256
257/// What [`MorphChannel::drive`] asks the caller to do this frame.
258pub(super) enum MorphAction {
259    /// Idle — nothing to write.
260    None,
261    /// A retarget: write this state (insert the component if absent) and
262    /// push capture dirt — the swapped content must re-capture this frame.
263    Freeze(crate::filters::MorphState),
264    /// Mid-flight: write the new progress onto the existing state.
265    Progress(f32),
266    /// The morph ended (settled, unset, or degraded): clear `active` on the
267    /// existing state, if any.
268    Deactivate,
269}
270
271impl MorphChannel {
272    pub(super) fn drive(
273        &mut self,
274        input: Option<&crate::filters::MorphInput>,
275        has_chain: bool,
276        rect: Option<&crate::layer::LayerCaptureRect>,
277        spec: &ChannelTransition,
278        dt: f32,
279    ) -> MorphAction {
280        let key_now = input.map(|m| &m.key);
281        let retargeted = match key_now {
282            Some(key) => self.key.as_ref() != Some(key),
283            None => self.key.is_some(),
284        };
285        if retargeted {
286            self.key = key_now.cloned();
287            self.settling = false;
288            return match (key_now, rect, has_chain) {
289                // A real morph: freeze what's on screen and arm the runner.
290                // The rect (last frame's — `sync_layer_geometry` runs later,
291                // in PostUpdate) is only a "something is on screen" gate:
292                // the frozen snapshot is layout-anchored, stretched onto the
293                // capture rect wherever it is each frame.
294                (Some(_), Some(_), true) => {
295                    self.seq = self.seq.wrapping_add(1);
296                    self.runner = Some(build_runner(&spec.to_driver(1.0), 0.0));
297                    MorphAction::Freeze(crate::filters::MorphState {
298                        active: true,
299                        progress: 0.0,
300                        freeze_seq: self.seq,
301                    })
302                }
303                // Mount (nothing on screen yet), unset, or an unresolved
304                // morph filter: adopt the key without animating.
305                _ => {
306                    self.runner = None;
307                    MorphAction::Deactivate
308                }
309            };
310        }
311        if let Some(runner) = self.runner.as_mut() {
312            let (p, done) = runner.step(dt);
313            // Clamp: a spring spec may overshoot, and progress is a texture
314            // blend factor — out-of-range values sample garbage.
315            let p = if done { 1.0 } else { p.clamp(0.0, 1.0) };
316            if done {
317                self.runner = None;
318                self.settling = true;
319            }
320            return MorphAction::Progress(p);
321        }
322        if self.settling {
323            self.settling = false;
324            return MorphAction::Deactivate;
325        }
326        MorphAction::None
327    }
328}
329
330/// One scalar channel: its current reading, last target, and active driver.
331#[derive(Default)]
332pub(super) struct Channel {
333    pub(super) current: f32,
334    pub(super) target: f32,
335    pub(super) runner: Option<Runner>,
336}
337
338impl Channel {
339    /// Snap to `value` without animating (used to seed the resting state so an
340    /// element doesn't animate from zero when it first appears).
341    pub(super) fn init(&mut self, value: f32) {
342        self.current = value;
343        self.target = value;
344        self.runner = None;
345    }
346
347    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
348    /// current value.
349    pub(super) fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
350        if target != self.target {
351            self.target = target;
352            match spec {
353                Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
354                None => {
355                    self.current = target;
356                    self.runner = None;
357                }
358            }
359        }
360        if let Some(r) = self.runner.as_mut() {
361            let (v, done) = r.step(dt);
362            self.current = v;
363            if done {
364                self.runner = None;
365            }
366        }
367        self.current
368    }
369}
370
371/// How an [`EasedChannel`] turns eased progress into readings of `T` — the
372/// seam between the shared ease machinery (retarget detection, runner
373/// lifecycle, snap-vs-ease, exact settle) and the value space it moves
374/// through. [`LerpInterp`] is the plain start→target lerp; the filter
375/// channel's interp samples a planned pass-list ease instead.
376pub(super) trait Interp<T> {
377    /// Called at retarget (with a spec): capture whatever sampling needs —
378    /// for a lerp, the start value. Domain wrappers that plan their ease
379    /// externally may make this a no-op and inject state directly.
380    fn arm(&mut self, from: &T, to: &T);
381    /// The reading at progress `p` (0→1).
382    fn sample(&self, p: f32, target: &T) -> T;
383    /// The exact final reading — completion writes THIS, never the last
384    /// sampled approximation (bit-exact settle).
385    fn settle(&self, target: &T) -> T;
386}
387
388/// A progress-eased channel: a single [`Runner`] eases progress 0→1 and an
389/// [`Interp`] turns it into readings. Used for quantities that can't be
390/// time-stepped directly in value space (a color's four channels move
391/// together; a `Length` carries a unit; a filter pass list moves as a whole).
392/// [`EasedChannel::drive`] returns the current reading every frame — a caller
393/// writing a relayout-triggering target (`Node`) compares before writing,
394/// like every other apply path.
395#[derive(Default)]
396pub(super) struct EasedChannel<T, I> {
397    pub(super) current: T,
398    pub(super) target: T,
399    pub(super) interp: I,
400    pub(super) runner: Option<Runner>,
401}
402
403impl<T: Clone + PartialEq, I: Interp<T>> EasedChannel<T, I> {
404    /// Snap to `value` without animating (used to seed the resting state so an
405    /// element doesn't animate from zero when it first appears).
406    pub(super) fn init(&mut self, value: T) {
407        self.current = value.clone();
408        self.target = value;
409        self.runner = None;
410    }
411
412    /// Arm an ease toward `target`: the interp captures its start state and a
413    /// fresh progress runner starts at 0. (Domain wrappers with external
414    /// retarget detection call this directly; [`Self::drive`] calls it on a
415    /// target change.)
416    pub(super) fn arm(&mut self, target: T, spec: &ChannelTransition) {
417        self.interp.arm(&self.current, &target);
418        self.target = target;
419        self.runner = Some(build_runner(&spec.to_driver(1.0), 0.0));
420    }
421
422    /// Advance an armed ease by `dt`, updating the current reading. Returns
423    /// whether this frame completed the ease; `None` when idle.
424    pub(super) fn tick(&mut self, dt: f32) -> Option<bool> {
425        let r = self.runner.as_mut()?;
426        let (p, done) = r.step(dt);
427        self.current = if done {
428            self.runner = None;
429            self.interp.settle(&self.target)
430        } else {
431            self.interp.sample(p, &self.target)
432        };
433        Some(done)
434    }
435
436    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
437    /// current reading.
438    pub(super) fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
439        if target != self.target {
440            match spec {
441                Some(s) => self.arm(target, s),
442                None => self.init(target),
443            }
444        }
445        self.tick(dt);
446        self.current.clone()
447    }
448}
449
450/// The plain value-lerp [`Interp`]: sample = `start.lerp(target, p)`.
451#[derive(Default)]
452pub(super) struct LerpInterp<T> {
453    start: T,
454}
455
456impl<T: Lerp> Interp<T> for LerpInterp<T> {
457    fn arm(&mut self, from: &T, _to: &T) {
458        self.start = *from;
459    }
460    fn sample(&self, p: f32, target: &T) -> T {
461        self.start.lerp(*target, p)
462    }
463    fn settle(&self, target: &T) -> T {
464        *target
465    }
466}
467
468/// A progress-lerped channel (colors, [`Length`]s) — the [`EasedChannel`]
469/// instantiated with the plain lerp.
470pub(super) type ProgressChannel<T> = EasedChannel<T, LerpInterp<T>>;
471
472/// Interpolate two lengths of the same unit; mixed units or `auto` can't be
473/// interpolated, so it snaps to the target.
474impl Lerp for Length {
475    fn lerp(self, other: Self, t: f32) -> Self {
476        use Length::*;
477        let lerp = |x: f32, y: f32| x + (y - x) * t;
478        match (self, other) {
479            (Px(x), Px(y)) => Px(lerp(x, y)),
480            (Percent(x), Percent(y)) => Percent(lerp(x, y)),
481            (Vw(x), Vw(y)) => Vw(lerp(x, y)),
482            (Vh(x), Vh(y)) => Vh(lerp(x, y)),
483            (VMin(x), VMin(y)) => VMin(lerp(x, y)),
484            (VMax(x), VMax(y)) => VMax(lerp(x, y)),
485            _ => other,
486        }
487    }
488}