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) transform3d: transform3d::Transform3dChannels,
63 /// SVG shape-attr easing (spec + targets both ride `SvgShape.attrs` —
64 /// shapes have no style). Self-seeding per attr, so it doesn't
65 /// participate in the `initialized` block.
66 pub(super) shape: shape_channel::ShapeChannel,
67 pub(super) initialized: bool,
68}
69
70/// The whole-value `filter` channel: eases a promoted root's
71/// [`crate::filters::ResolvedFilterChain`] packed params between wire targets
72/// (see [`crate::filters::plan_filter_ease`] for the strategy). Unlike the
73/// scalar channels, its current reading cannot be re-read from the component —
74/// [`crate::filters::resolve_chains`] snaps the component to the new
75/// target on the retarget frame, before this system runs — so the state owns
76/// the last-written pass list (the `ProgressChannel` state-owned-current
77/// pattern, list-shaped).
78#[derive(Default)]
79pub(super) struct FilterChannel {
80 /// The last wire chain seen (retarget detection). Empty = no filter.
81 pub(super) wire: crate::filters::FilterChain,
82 /// The shared ease machinery: `channel.current` is the pass list this
83 /// channel last wrote (or adopted from the resolver) — the next ease's
84 /// start; the interp holds the armed plan. Both are (re)set together at
85 /// retarget, so the runner and its plan can't go out of sync.
86 pub(super) channel: EasedChannel<Vec<crate::filters::ResolvedFilterPass>, FilterEaseInterp>,
87}
88
89/// The filter-ease [`Interp`]: samples a planned pass-list ease
90/// ([`crate::filters::FilterEase`] — strategy + endpoints planned at retarget
91/// by [`crate::filters::plan_filter_ease`], delegated to verbatim, never
92/// reimplemented). The wrapper injects the plan at arm time; `arm` itself is
93/// a no-op because planning needs registry/assets context the trait seam
94/// doesn't carry.
95#[derive(Default)]
96pub(super) struct FilterEaseInterp {
97 plan: Option<crate::filters::FilterEase>,
98}
99
100impl Interp<Vec<crate::filters::ResolvedFilterPass>> for FilterEaseInterp {
101 fn arm(
102 &mut self,
103 _from: &Vec<crate::filters::ResolvedFilterPass>,
104 _to: &Vec<crate::filters::ResolvedFilterPass>,
105 ) {
106 }
107 fn sample(
108 &self,
109 p: f32,
110 _target: &Vec<crate::filters::ResolvedFilterPass>,
111 ) -> Vec<crate::filters::ResolvedFilterPass> {
112 self.plan.as_ref().map(|e| e.sample(p)).unwrap_or_default()
113 }
114 /// Completion writes the plan's own settle list — the resolver's snapped
115 /// output, bit-exact, so the two writers agree and stop churning (the
116 /// stage-interplay rule: bake the final value, don't approximate it).
117 fn settle(
118 &self,
119 _target: &Vec<crate::filters::ResolvedFilterPass>,
120 ) -> Vec<crate::filters::ResolvedFilterPass> {
121 self.plan
122 .as_ref()
123 .map(|e| e.settle().to_vec())
124 .unwrap_or_default()
125 }
126}
127
128impl FilterChannel {
129 /// Advance the filter chain toward the wire target in `input`, writing the
130 /// eased packed params into `resolved`. Returns `true` when it wrote —
131 /// the caller pushes composite-only dirt (filter output never dirties the
132 /// capture, which holds unfiltered content).
133 ///
134 /// Three writers touch [`crate::filters::ResolvedFilterChain`]; precedence
135 /// runs resolver → transition → bindings. On the retarget frame
136 /// [`crate::filters::resolve_chains`] (ordered before
137 /// [`drive_transitions`]) *snaps* the component to the new target; this
138 /// method *eases* over that snap — starting from the state-owned
139 /// `current`, the last value this channel wrote, never the
140 /// already-snapped component; and per-param animation bindings
141 /// (`filter[<i>].<param>`) *re-assert* individual params on top, winning
142 /// by gating this channel out via `skip_filter` (the imperative-wins
143 /// pattern of the scalar channels, coarse: any filter binding parks the
144 /// whole channel).
145 ///
146 /// The target rides the wire-chain component (`FilterInput` /
147 /// `BackdropInput` — the caller projects to the inner [`FilterChain`]),
148 /// NOT [`TransitionInput`] — a chain-only delta dirties the
149 /// FILTER/BACKDROP|LAYER groups, never TRANSITION, so a target stamped
150 /// into the input would go stale; the chain component is re-stamped by
151 /// that same delta. Both channel instances (filter, backdropFilter) run
152 /// this same code over their own component pair.
153 pub(super) fn drive(
154 &mut self,
155 input: Option<&crate::filters::FilterChain>,
156 mut resolved: Option<Mut<crate::filters::ResolvedFilterChain>>,
157 spec: Option<&ChannelTransition>,
158 registry: Option<&crate::filters::FilterRegistry>,
159 assets: Option<&AssetServer>,
160 dt: f32,
161 ) -> bool {
162 let retargeted = match input {
163 Some(fi) => *fi != self.wire,
164 None => !self.wire.0.is_empty(),
165 };
166 if retargeted {
167 let to_wire = input.cloned().unwrap_or_default();
168 let from_wire = std::mem::replace(&mut self.wire, to_wire);
169 match (spec, resolved.as_deref()) {
170 // Ease only toward a live resolved chain. An emptied or
171 // unresolvable target has no component to write into
172 // (unset `filter` demotes the layer; an all-invalid chain
173 // attaches none), so it snaps below.
174 (Some(spec), Some(chain)) if !self.wire.0.is_empty() => {
175 self.channel.interp.plan = Some(crate::filters::plan_filter_ease(
176 &from_wire,
177 &self.wire,
178 self.channel.current.clone(),
179 chain.passes.clone(),
180 registry,
181 assets,
182 chain.scale,
183 ));
184 self.channel.arm(chain.passes.clone(), spec);
185 }
186 _ => {
187 // Snap: adopt whatever the resolver produced.
188 self.channel.interp.plan = None;
189 self.channel.init(
190 resolved
191 .as_deref()
192 .map(|c| c.passes.clone())
193 .unwrap_or_default(),
194 );
195 }
196 }
197 }
198 let mut wrote = false;
199 if self.channel.runner.is_some() {
200 match resolved.as_mut() {
201 Some(resolved) => {
202 // Advance the ease: `tick` samples the plan, and on
203 // completion writes the plan's settle list (bit-exact —
204 // see `FilterEaseInterp::settle`) and drops the runner.
205 self.channel.tick(dt);
206 // Compare via `Deref` first so a no-op frame doesn't
207 // trip change detection.
208 if resolved.passes != self.channel.current {
209 let chain = &mut **resolved;
210 chain.passes = self.channel.current.clone();
211 chain.version = chain.version.wrapping_add(1);
212 wrote = true;
213 }
214 }
215 None => {
216 // The chain vanished mid-ease (demotion tore the
217 // layer down): drop the ease and forget the passes.
218 self.channel.interp.plan = None;
219 self.channel.init(Vec::new());
220 }
221 }
222 }
223 wrote
224 }
225}
226
227/// One scalar channel: its current reading, last target, and active driver.
228#[derive(Default)]
229pub(super) struct Channel {
230 pub(super) current: f32,
231 pub(super) target: f32,
232 pub(super) runner: Option<Runner>,
233}
234
235impl Channel {
236 /// Snap to `value` without animating (used to seed the resting state so an
237 /// element doesn't animate from zero when it first appears).
238 pub(super) fn init(&mut self, value: f32) {
239 self.current = value;
240 self.target = value;
241 self.runner = None;
242 }
243
244 /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
245 /// current value.
246 pub(super) fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
247 if target != self.target {
248 self.target = target;
249 match spec {
250 Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
251 None => {
252 self.current = target;
253 self.runner = None;
254 }
255 }
256 }
257 if let Some(r) = self.runner.as_mut() {
258 let (v, done) = r.step(dt);
259 self.current = v;
260 if done {
261 self.runner = None;
262 }
263 }
264 self.current
265 }
266}
267
268/// How an [`EasedChannel`] turns eased progress into readings of `T` — the
269/// seam between the shared ease machinery (retarget detection, runner
270/// lifecycle, snap-vs-ease, exact settle) and the value space it moves
271/// through. [`LerpInterp`] is the plain start→target lerp; the filter
272/// channel's interp samples a planned pass-list ease instead.
273pub(super) trait Interp<T> {
274 /// Called at retarget (with a spec): capture whatever sampling needs —
275 /// for a lerp, the start value. Domain wrappers that plan their ease
276 /// externally may make this a no-op and inject state directly.
277 fn arm(&mut self, from: &T, to: &T);
278 /// The reading at progress `p` (0→1).
279 fn sample(&self, p: f32, target: &T) -> T;
280 /// The exact final reading — completion writes THIS, never the last
281 /// sampled approximation (bit-exact settle).
282 fn settle(&self, target: &T) -> T;
283}
284
285/// A progress-eased channel: a single [`Runner`] eases progress 0→1 and an
286/// [`Interp`] turns it into readings. Used for quantities that can't be
287/// time-stepped directly in value space (a color's four channels move
288/// together; a `Length` carries a unit; a filter pass list moves as a whole).
289/// [`EasedChannel::drive`] returns the current reading every frame — a caller
290/// writing a relayout-triggering target (`Node`) compares before writing,
291/// like every other apply path.
292#[derive(Default)]
293pub(super) struct EasedChannel<T, I> {
294 pub(super) current: T,
295 pub(super) target: T,
296 pub(super) interp: I,
297 pub(super) runner: Option<Runner>,
298}
299
300impl<T: Clone + PartialEq, I: Interp<T>> EasedChannel<T, I> {
301 /// Snap to `value` without animating (used to seed the resting state so an
302 /// element doesn't animate from zero when it first appears).
303 pub(super) fn init(&mut self, value: T) {
304 self.current = value.clone();
305 self.target = value;
306 self.runner = None;
307 }
308
309 /// Arm an ease toward `target`: the interp captures its start state and a
310 /// fresh progress runner starts at 0. (Domain wrappers with external
311 /// retarget detection call this directly; [`Self::drive`] calls it on a
312 /// target change.)
313 pub(super) fn arm(&mut self, target: T, spec: &ChannelTransition) {
314 self.interp.arm(&self.current, &target);
315 self.target = target;
316 self.runner = Some(build_runner(&spec.to_driver(1.0), 0.0));
317 }
318
319 /// Advance an armed ease by `dt`, updating the current reading. Returns
320 /// whether this frame completed the ease; `None` when idle.
321 pub(super) fn tick(&mut self, dt: f32) -> Option<bool> {
322 let r = self.runner.as_mut()?;
323 let (p, done) = r.step(dt);
324 self.current = if done {
325 self.runner = None;
326 self.interp.settle(&self.target)
327 } else {
328 self.interp.sample(p, &self.target)
329 };
330 Some(done)
331 }
332
333 /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
334 /// current reading.
335 pub(super) fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
336 if target != self.target {
337 match spec {
338 Some(s) => self.arm(target, s),
339 None => self.init(target),
340 }
341 }
342 self.tick(dt);
343 self.current.clone()
344 }
345}
346
347/// The plain value-lerp [`Interp`]: sample = `start.lerp(target, p)`.
348#[derive(Default)]
349pub(super) struct LerpInterp<T> {
350 start: T,
351}
352
353impl<T: Lerp> Interp<T> for LerpInterp<T> {
354 fn arm(&mut self, from: &T, _to: &T) {
355 self.start = *from;
356 }
357 fn sample(&self, p: f32, target: &T) -> T {
358 self.start.lerp(*target, p)
359 }
360 fn settle(&self, target: &T) -> T {
361 *target
362 }
363}
364
365/// A progress-lerped channel (colors, [`Length`]s) — the [`EasedChannel`]
366/// instantiated with the plain lerp.
367pub(super) type ProgressChannel<T> = EasedChannel<T, LerpInterp<T>>;
368
369/// Interpolate two lengths of the same unit; mixed units or `auto` can't be
370/// interpolated, so it snaps to the target.
371impl Lerp for Length {
372 fn lerp(self, other: Self, t: f32) -> Self {
373 use Length::*;
374 let lerp = |x: f32, y: f32| x + (y - x) * t;
375 match (self, other) {
376 (Px(x), Px(y)) => Px(lerp(x, y)),
377 (Percent(x), Percent(y)) => Percent(lerp(x, y)),
378 (Vw(x), Vw(y)) => Vw(lerp(x, y)),
379 (Vh(x), Vh(y)) => Vh(lerp(x, y)),
380 (VMin(x), VMin(y)) => VMin(lerp(x, y)),
381 (VMax(x), VMax(y)) => VMax(lerp(x, y)),
382 _ => other,
383 }
384 }
385}