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}