Skip to main content

bevy_react/
transition.rs

1//! CSS-like `transition`: declarative easing of `transform` / `opacity` /
2//! `backgroundColor` between style states.
3//!
4//! The clunky way to "scale a button down on press" is to allocate a shared
5//! value and hand-wire `onPointerDown`/`onPointerUp` to drivers. A `transition`
6//! instead lets a plain style change — a re-render, or a `hoverStyle`/`pressStyle`
7//! kicking in — *ease* to its new value. It reuses the animations crate's driver
8//! runtime ([`Runner`]) rather than a parallel engine.
9//!
10//! ## How it fits the style pipeline
11//!
12//! Every style change funnels through [`crate::ui_map::apply_style`] — both the
13//! base re-render path (`Op::Update`) and the hover/press path
14//! ([`crate::reconcile::apply_interaction_styles`], which re-applies the *merged*
15//! style for the current `Interaction`). So `apply_style` is the one place that
16//! always knows the resolved target. It stamps a [`TransitionInput`] (the spec +
17//! the resolved per-channel target) — a *stateless input* the engine reads but
18//! never writes, so there's no feedback loop with the live `UiTransform`/color it
19//! animates.
20//!
21//! [`drive_transitions`] then runs after `apply_interaction_styles`: it advances a
22//! per-entity [`TransitionState`] (one [`Runner`] per channel) toward the input's
23//! target and writes the interpolated value onto `UiTransform`/`BackgroundColor`/
24//! alpha — *last* in the frame, so a coincident re-render's snap value never wins.
25//!
26//! A channel also driven by an inline `{ animated }` binding is left to the animations
27//! plugin: the transition skips any channel bound by the entity's `AnimatedNode`.
28
29use crate::animations::{
30    AnimatableProperty, AnimatedNode, Driver, Easing, Lerp, Runner, build_runner,
31    build_ui_transform,
32};
33use bevy::ecs::query::QueryData;
34use bevy::prelude::*;
35use bevy::ui::{ScrollPosition, UiTransform};
36use serde::Deserialize;
37
38use crate::protocol::{AnimatableField, Length, Style, Time as WireTime};
39use crate::ui_map::{length_to_val, parse_color};
40
41mod transform3d;
42
43/// CSS-like per-channel transition timing, set on [`Style::transition`]. Each
44/// field, if present, makes that channel ease on change; `all` is the fallback for
45/// channels without an explicit entry. `transform` covers all six transform
46/// channels together.
47#[derive(Debug, Clone, Default, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct Transition {
50    /// Fallback applied to any channel without its own entry.
51    pub all: Option<ChannelTransition>,
52    /// Applies to every transform channel (translate/scale/rotate).
53    pub transform: Option<ChannelTransition>,
54    pub opacity: Option<ChannelTransition>,
55    pub background_color: Option<ChannelTransition>,
56    /// Applies to every size channel (width/height/maxWidth/maxHeight). These are
57    /// *layout* properties — easing one re-flows the surrounding content (a real
58    /// accordion), unlike the post-layout `transform`.
59    pub size: Option<ChannelTransition>,
60    /// Eases the scroll offset (`ScrollPosition`) of an `overflow: scroll` node
61    /// toward its target on change — the target being a controlled `scrollTop`/
62    /// `scrollLeft`, a `scrollTo`-style jump, or accumulated wheel input. Covers
63    /// both axes. Unlike the others, scroll's target lives in `Props` (it's a
64    /// controlled value), so it's fed by the scroll write path, not `from_style`.
65    pub scroll: Option<ChannelTransition>,
66    /// Eases the layer-based `filter` chain (see [`crate::filters`]) between
67    /// style states, whole-value: matching chains interpolate their packed
68    /// params; a chain that grows/shrinks at the end over built-in filters
69    /// fades through identity values (hover-adds-blur fades in); anything
70    /// else swaps at the midpoint. Unlike the others, the *target* doesn't
71    /// ride [`TransitionInput`] — it is read live from
72    /// [`crate::filters::FilterInput`] (a filter-only delta re-stamps that
73    /// component but not the input).
74    pub filter: Option<ChannelTransition>,
75    /// Eases the `backdropFilter` chain — the second, independent instance of
76    /// the `filter` channel (same whole-value strategy, same target rule: the
77    /// target is read live from [`crate::filters::BackdropInput`], not
78    /// [`TransitionInput`]). The same ease-to-empty snap applies: unsetting
79    /// `backdropFilter` demotes the layer (no resolved chain to write into),
80    /// so keep an identity entry — e.g. `{ name: "blur", params: { radius:
81    /// 0 } }` — in the base chain when removal should ease.
82    pub backdrop_filter: Option<ChannelTransition>,
83    /// Applies to every `transform3d` channel together (field-wise easing of
84    /// the composite-time 3D transform on a promoted layer — see
85    /// [`crate::layer::transform3d`]). `perspective` snaps whenever either
86    /// endpoint is orthographic (no numeric identity for "no perspective");
87    /// unsetting the whole `transform3d` style demotes the layer and snaps,
88    /// like `filter`'s ease-to-empty — keep an identity `{}` in the base
89    /// style when removal should ease.
90    pub transform3d: Option<ChannelTransition>,
91}
92
93impl Transition {
94    /// The transition for the transform channels (explicit, else `all`).
95    pub fn for_transform(&self) -> Option<&ChannelTransition> {
96        self.transform.as_ref().or(self.all.as_ref())
97    }
98    /// The transition for opacity (explicit, else `all`).
99    pub fn for_opacity(&self) -> Option<&ChannelTransition> {
100        self.opacity.as_ref().or(self.all.as_ref())
101    }
102    /// The transition for background color (explicit, else `all`).
103    pub fn for_background(&self) -> Option<&ChannelTransition> {
104        self.background_color.as_ref().or(self.all.as_ref())
105    }
106    /// The transition for the size channels (explicit, else `all`).
107    pub fn for_size(&self) -> Option<&ChannelTransition> {
108        self.size.as_ref().or(self.all.as_ref())
109    }
110    /// The transition for the scroll offset (explicit, else `all`).
111    pub fn for_scroll(&self) -> Option<&ChannelTransition> {
112        self.scroll.as_ref().or(self.all.as_ref())
113    }
114    /// The transition for the filter chain (explicit, else `all`).
115    pub fn for_filter(&self) -> Option<&ChannelTransition> {
116        self.filter.as_ref().or(self.all.as_ref())
117    }
118    /// The transition for the backdrop-filter chain (explicit, else `all`).
119    pub fn for_backdrop_filter(&self) -> Option<&ChannelTransition> {
120        self.backdrop_filter.as_ref().or(self.all.as_ref())
121    }
122    /// The transition for the transform3d channels (explicit, else `all`).
123    pub fn for_transform3d(&self) -> Option<&ChannelTransition> {
124        self.transform3d.as_ref().or(self.all.as_ref())
125    }
126}
127
128/// Timing for one channel. A spring (any of `stiffness`/`damping` set) or, by
129/// default, a timing curve. `duration`/`delay` are [`WireTime`]s: a bare number is
130/// milliseconds (the JS-facing unit), a string carries an explicit unit
131/// (`"200ms"`/`"0.2s"`), and both decode to the seconds the [`Driver`] consumes.
132#[derive(Debug, Clone, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct ChannelTransition {
135    /// Timing duration (default `0.3s`). Ignored for a spring.
136    pub duration: Option<WireTime>,
137    #[serde(default)]
138    pub easing: Easing,
139    /// Hold this long before easing (default `0`).
140    #[serde(default)]
141    pub delay: WireTime,
142    /// Spring stiffness; presence (with/without `damping`) selects a spring.
143    pub stiffness: Option<f32>,
144    pub damping: Option<f32>,
145    #[serde(default = "default_mass")]
146    pub mass: f32,
147}
148
149fn default_mass() -> f32 {
150    1.0
151}
152
153impl ChannelTransition {
154    /// Build the [`Driver`] that eases the value to `to` from its live reading.
155    /// A spring if `stiffness`/`damping` are present, else a (optionally delayed)
156    /// timing curve.
157    fn to_driver(&self, to: f32) -> Driver {
158        if self.stiffness.is_some() || self.damping.is_some() {
159            Driver::Spring {
160                to,
161                stiffness: self.stiffness.unwrap_or(100.0),
162                damping: self.damping.unwrap_or(10.0),
163                mass: self.mass,
164            }
165        } else {
166            let timing = Driver::Timing {
167                to,
168                duration: self.duration.map(WireTime::seconds).unwrap_or(0.3),
169                easing: self.easing,
170            };
171            let delay = self.delay.seconds();
172            if delay > 0.0 {
173                Driver::Delay {
174                    delay,
175                    animation: Box::new(timing),
176                }
177            } else {
178                timing
179            }
180        }
181    }
182}
183
184/// The resolved per-channel target for a transitioning entity, plus the spec.
185/// Written by [`crate::ui_map::apply_style`] from the *merged* style and read each
186/// frame by [`drive_transitions`]. Never written by the engine — keeping it free
187/// of the live components it animates avoids a target-chases-animation feedback
188/// loop. `None` on a channel means "unspecified" (its identity default is used).
189#[derive(Component, Debug, Clone, Default)]
190pub struct TransitionInput {
191    pub spec: Transition,
192    pub translate_x: Option<Length>,
193    pub translate_y: Option<Length>,
194    pub scale: Option<f32>,
195    pub scale_x: Option<f32>,
196    pub scale_y: Option<f32>,
197    pub rotate: Option<f32>,
198    pub opacity: Option<f32>,
199    /// Target background color as straight rgba (no opacity folded in — the
200    /// opacity channel owns alpha, applied after the color, like the animated path).
201    pub background_color: Option<[f32; 4]>,
202    // Size targets, written onto `Node` (layout). `None` → unset (`Val::Auto`).
203    pub width: Option<Length>,
204    pub height: Option<Length>,
205    pub max_width: Option<Length>,
206    pub max_height: Option<Length>,
207    /// Target `transform3d` params, eased field-wise onto the layer's
208    /// [`LayerTransform3d`](crate::layer::transform3d::LayerTransform3d).
209    pub transform3d: Option<crate::protocol::Transform3d>,
210}
211
212impl TransitionInput {
213    /// Build the input from a resolved style, or `None` if it has no `transition`.
214    fn from_style(style: &Style) -> Option<Self> {
215        let spec = style.transition.clone()?;
216        let t = style.transform.clone().unwrap_or_default();
217        // `static_val` throughout: an `{ animated }` channel has no static
218        // target to ease toward — it reads as unset here, and the per-channel
219        // skip rules park it anyway (bindings win over transitions).
220        Some(Self {
221            spec,
222            translate_x: t.translate_x.static_val(),
223            translate_y: t.translate_y.static_val(),
224            scale: t.scale.static_val(),
225            scale_x: t.scale_x.static_val(),
226            scale_y: t.scale_y.static_val(),
227            rotate: t.rotate.static_val().map(crate::protocol::Angle::radians),
228            opacity: style.opacity.static_val(),
229            background_color: style
230                .background_color
231                .static_ref()
232                .map(|hex| color_to_rgba(parse_color(hex))),
233            width: style.width.static_val(),
234            height: style.height.static_val(),
235            max_width: style.max_width.static_val(),
236            max_height: style.max_height.static_val(),
237            transform3d: style.transform3d.clone(),
238        })
239    }
240}
241
242/// Per-entity transition runtime: one [`Runner`]-backed channel per animatable
243/// property. Persists across re-renders (the engine owns it); created lazily by
244/// [`apply_transition`]. `#[require(UiTransform)]` so the drive query always
245/// matches even for an opacity/color-only transition.
246#[derive(Component, Default)]
247#[require(UiTransform)]
248pub struct TransitionState {
249    translate_x: ProgressChannel<Length>,
250    translate_y: ProgressChannel<Length>,
251    scale: Channel,
252    scale_x: Channel,
253    scale_y: Channel,
254    rotate: Channel,
255    opacity: Channel,
256    color: ProgressChannel<[f32; 4]>,
257    width: ProgressChannel<Length>,
258    height: ProgressChannel<Length>,
259    max_width: ProgressChannel<Length>,
260    max_height: ProgressChannel<Length>,
261    filter: FilterChannel,
262    backdrop_filter: FilterChannel,
263    transform3d: transform3d::Transform3dChannels,
264    initialized: bool,
265}
266
267/// The whole-value `filter` channel: eases a promoted root's
268/// [`crate::filters::ResolvedFilterChain`] packed params between wire targets
269/// (see [`crate::filters::plan_filter_ease`] for the strategy). Unlike the
270/// scalar channels, its current reading cannot be re-read from the component —
271/// [`crate::filters::resolve_chains`] snaps the component to the new
272/// target on the retarget frame, before this system runs — so the state owns
273/// the last-written pass list (the `ProgressChannel` state-owned-current
274/// pattern, list-shaped).
275#[derive(Default)]
276struct FilterChannel {
277    /// The last wire chain seen (retarget detection). Empty = no filter.
278    wire: crate::filters::FilterChain,
279    /// The pass list this channel last wrote (or adopted from the resolver) —
280    /// the next ease's start.
281    current: Vec<crate::filters::ResolvedFilterPass>,
282    /// The in-flight ease, present only while animating. A single `Option`
283    /// so the runner and its plan can never go out of sync.
284    ease: Option<ActiveFilterEase>,
285}
286
287/// An armed filter ease: the [`Runner`] eases progress 0→1 and the plan turns
288/// that progress into a pass list. Armed together at retarget, dropped
289/// together on settle/teardown.
290struct ActiveFilterEase {
291    runner: Runner,
292    ease: crate::filters::FilterEase,
293}
294
295impl FilterChannel {
296    /// Advance the filter chain toward the wire target in `input`, writing the
297    /// eased packed params into `resolved`. Returns `true` when it wrote —
298    /// the caller pushes composite-only dirt (filter output never dirties the
299    /// capture, which holds unfiltered content).
300    ///
301    /// Three writers touch [`crate::filters::ResolvedFilterChain`]; precedence
302    /// runs resolver → transition → bindings. On the retarget frame
303    /// [`crate::filters::resolve_chains`] (ordered before
304    /// [`drive_transitions`]) *snaps* the component to the new target; this
305    /// method *eases* over that snap — starting from the state-owned
306    /// `current`, the last value this channel wrote, never the
307    /// already-snapped component; and per-param animation bindings
308    /// (`filter[<i>].<param>`) *re-assert* individual params on top, winning
309    /// by gating this channel out via `skip_filter` (the imperative-wins
310    /// pattern of the scalar channels, coarse: any filter binding parks the
311    /// whole channel).
312    ///
313    /// The target rides the wire-chain component (`FilterInput` /
314    /// `BackdropInput` — the caller projects to the inner [`FilterChain`]),
315    /// NOT [`TransitionInput`] — a chain-only delta dirties the
316    /// FILTER/BACKDROP|LAYER groups, never TRANSITION, so a target stamped
317    /// into the input would go stale; the chain component is re-stamped by
318    /// that same delta. Both channel instances (filter, backdropFilter) run
319    /// this same code over their own component pair.
320    fn drive(
321        &mut self,
322        input: Option<&crate::filters::FilterChain>,
323        mut resolved: Option<Mut<crate::filters::ResolvedFilterChain>>,
324        spec: Option<&ChannelTransition>,
325        registry: Option<&crate::filters::FilterRegistry>,
326        assets: Option<&AssetServer>,
327        dt: f32,
328    ) -> bool {
329        let retargeted = match input {
330            Some(fi) => *fi != self.wire,
331            None => !self.wire.0.is_empty(),
332        };
333        if retargeted {
334            let to_wire = input.cloned().unwrap_or_default();
335            let from_wire = std::mem::replace(&mut self.wire, to_wire);
336            match (spec, resolved.as_deref()) {
337                // Ease only toward a live resolved chain. An emptied or
338                // unresolvable target has no component to write into
339                // (unset `filter` demotes the layer; an all-invalid chain
340                // attaches none), so it snaps below.
341                (Some(spec), Some(chain)) if !self.wire.0.is_empty() => {
342                    self.ease = Some(ActiveFilterEase {
343                        runner: build_runner(&spec.to_driver(1.0), 0.0),
344                        ease: crate::filters::plan_filter_ease(
345                            &from_wire,
346                            &self.wire,
347                            self.current.clone(),
348                            chain.passes.clone(),
349                            registry,
350                            assets,
351                            chain.scale,
352                        ),
353                    });
354                }
355                _ => {
356                    // Snap: adopt whatever the resolver produced.
357                    self.current = resolved
358                        .as_deref()
359                        .map(|c| c.passes.clone())
360                        .unwrap_or_default();
361                    self.ease = None;
362                }
363            }
364        }
365        let mut wrote = false;
366        if let Some(mut active) = self.ease.take() {
367            match resolved.as_mut() {
368                Some(resolved) => {
369                    let (p, done) = active.runner.step(dt);
370                    // Completion writes the resolver's own snapped output,
371                    // bit-exact, so the two writers agree and stop
372                    // churning (the stage-interplay rule: bake the final
373                    // value, don't approximate it).
374                    let new = if done {
375                        active.ease.settle().to_vec()
376                    } else {
377                        active.ease.sample(p)
378                    };
379                    // Compare via `Deref` first so a no-op frame doesn't
380                    // trip change detection.
381                    if resolved.passes != new {
382                        let chain = &mut **resolved;
383                        chain.passes = new.clone();
384                        chain.version = chain.version.wrapping_add(1);
385                        wrote = true;
386                    }
387                    self.current = new;
388                    if !done {
389                        self.ease = Some(active);
390                    }
391                }
392                None => {
393                    // The chain vanished mid-ease (demotion tore the
394                    // layer down): drop the ease and forget the passes.
395                    self.current = Vec::new();
396                }
397            }
398        }
399        wrote
400    }
401}
402
403/// One scalar channel: its current reading, last target, and active driver.
404#[derive(Default)]
405struct Channel {
406    current: f32,
407    target: f32,
408    runner: Option<Runner>,
409}
410
411impl Channel {
412    /// Snap to `value` without animating (used to seed the resting state so an
413    /// element doesn't animate from zero when it first appears).
414    fn init(&mut self, value: f32) {
415        self.current = value;
416        self.target = value;
417        self.runner = None;
418    }
419
420    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
421    /// current value.
422    fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
423        if target != self.target {
424            self.target = target;
425            match spec {
426                Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
427                None => {
428                    self.current = target;
429                    self.runner = None;
430                }
431            }
432        }
433        if let Some(r) = self.runner.as_mut() {
434            let (v, done) = r.step(dt);
435            self.current = v;
436            if done {
437                self.runner = None;
438            }
439        }
440        self.current
441    }
442}
443
444/// A progress-lerped channel (colors, [`Length`]s): a single [`Runner`] eases a
445/// progress value 0→1 and the reading lerps from `start` to `target`. Used for
446/// quantities that can't be time-stepped directly in value space (a color's four
447/// channels move together; a `Length` carries a unit). [`ProgressChannel::drive`]
448/// returns the current reading every frame — a caller writing a relayout-
449/// triggering target (`Node`) compares before writing, like every other apply
450/// path.
451#[derive(Default)]
452struct ProgressChannel<T> {
453    current: T,
454    target: T,
455    start: T,
456    runner: Option<Runner>,
457}
458
459impl<T: Lerp + PartialEq> ProgressChannel<T> {
460    /// Snap to `value` without animating (used to seed the resting state so an
461    /// element doesn't animate from zero when it first appears).
462    fn init(&mut self, value: T) {
463        self.current = value;
464        self.target = value;
465        self.runner = None;
466    }
467
468    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
469    /// current reading.
470    fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
471        if target != self.target {
472            self.target = target;
473            match spec {
474                Some(s) => {
475                    self.start = self.current;
476                    self.runner = Some(build_runner(&s.to_driver(1.0), 0.0));
477                }
478                None => {
479                    self.current = target;
480                    self.runner = None;
481                }
482            }
483        }
484        if let Some(r) = self.runner.as_mut() {
485            let (p, done) = r.step(dt);
486            self.current = self.start.lerp(self.target, p);
487            if done {
488                self.current = self.target;
489                self.runner = None;
490            }
491        }
492        self.current
493    }
494}
495
496/// Interpolate two lengths of the same unit; mixed units or `auto` can't be
497/// interpolated, so it snaps to the target.
498impl Lerp for Length {
499    fn lerp(self, other: Self, t: f32) -> Self {
500        use Length::*;
501        let lerp = |x: f32, y: f32| x + (y - x) * t;
502        match (self, other) {
503            (Px(x), Px(y)) => Px(lerp(x, y)),
504            (Percent(x), Percent(y)) => Percent(lerp(x, y)),
505            (Vw(x), Vw(y)) => Vw(lerp(x, y)),
506            (Vh(x), Vh(y)) => Vh(lerp(x, y)),
507            (VMin(x), VMin(y)) => VMin(lerp(x, y)),
508            (VMax(x), VMax(y)) => VMax(lerp(x, y)),
509            _ => other,
510        }
511    }
512}
513
514/// The scroll-easing **spec** input: the `transition.scroll` timing, reinserted
515/// fresh on every render (like [`TransitionInput`]) so a changed spec takes effect.
516/// Present only while `transition.scroll` (or `all`) is set. The *target* it eases
517/// toward is NOT here — scroll's target is a controlled `Props` value, fed into
518/// [`ScrollTransitionState`] by the scroll write path / wheel handler.
519#[derive(Component, Debug, Clone)]
520pub struct ScrollTransitionInput(pub ChannelTransition);
521
522/// The scroll-easing **runtime state**: the target offset plus a per-axis eased
523/// [`Channel`]. Persists across re-renders ([`insert_if_new`]). `target` is written
524/// by the feeders ([`crate::reconcile::update_controlled_scroll`] and
525/// `crate::scroll::apply_scroll`); [`drive_scroll_transition`] eases `ScrollPosition`
526/// toward it. Mirrors the [`TransitionState`] half of the split.
527#[derive(Component, Default)]
528pub struct ScrollTransitionState {
529    /// The offset to ease toward (already clamped to the scroll range by the feeder).
530    pub(crate) target: Vec2,
531    x: Channel,
532    y: Channel,
533    initialized: bool,
534}
535
536impl ScrollTransitionState {
537    /// Snap the eased state to `value`: target + both channels, runners dropped.
538    /// Used when the offset is manipulated directly (scrollbar thumb drag /
539    /// track click) so easing neither lags nor reverts the direct write.
540    pub(crate) fn snap_to(&mut self, value: Vec2) {
541        self.target = value;
542        self.x.init(value.x);
543        self.y.init(value.y);
544        self.initialized = true;
545    }
546}
547
548/// Stamp (or clear) the scroll-ease components from `transition.scroll`. Called
549/// from the reconciler's generic node paths (scroll containers are plain `<node>`s),
550/// alongside `apply_scroll_listener`/`apply_scroll_step`. The spec input is always
551/// reinserted (so a spec change lands); the state is created once and persists.
552pub fn apply_scroll_transition(ec: &mut EntityCommands, style: &Option<Style>) {
553    match style
554        .as_ref()
555        .and_then(|s| s.transition.as_ref())
556        .and_then(|t| t.for_scroll())
557    {
558        Some(spec) => {
559            ec.insert(ScrollTransitionInput(spec.clone()));
560            ec.insert_if_new(ScrollTransitionState::default());
561        }
562        None => {
563            ec.remove::<ScrollTransitionInput>();
564            ec.remove::<ScrollTransitionState>();
565        }
566    }
567}
568
569/// Ease each `ScrollTransitionState` node's `ScrollPosition` toward its `target`
570/// using the same per-channel [`Runner`] as [`drive_transitions`]. Writes only on a
571/// frame the eased value actually moved, so a settled offset doesn't spam
572/// `Changed<ScrollPosition>` (and thus `onScroll`). The target is pre-clamped by the
573/// feeders; Bevy clamps the *rendered* offset regardless.
574///
575/// A `ScrollPosition` that moved *underneath* the easing (it no longer matches the
576/// channels' last-written value) was written directly by scrollbar manipulation —
577/// Bevy's widget writes the offset itself on thumb drag and track-click paging —
578/// and snaps: direct manipulation bypasses the animation entirely.
579pub fn drive_scroll_transition(
580    time: Res<Time>,
581    mut query: Query<(
582        &ScrollTransitionInput,
583        &mut ScrollTransitionState,
584        &mut ScrollPosition,
585    )>,
586) {
587    let dt = time.delta_secs();
588    for (input, mut state, mut pos) in &mut query {
589        // Seed resting state to the live offset so the first target change eases from
590        // where the node actually is, not from zero.
591        if !state.initialized {
592            state.x.init(pos.0.x);
593            state.y.init(pos.0.y);
594            state.target = pos.0;
595            state.initialized = true;
596        }
597        // After a drive the offset exactly equals (x.current, y.current) — on eased
598        // containers every in-crate feeder writes `state.target`, so a mismatch means
599        // the scrollbar widget wrote `ScrollPosition` directly this frame (thumb
600        // drag, track-click page, or the final release-frame write): snap to it.
601        let current = Vec2::new(state.x.current, state.y.current);
602        if pos.0 != current {
603            state.snap_to(pos.0);
604            continue;
605        }
606        let spec = &input.0;
607        let target = state.target;
608        let nx = state.x.drive(target.x, Some(spec), dt);
609        let ny = state.y.drive(target.y, Some(spec), dt);
610        // Conditional write: equal assignment would still trip change detection.
611        if pos.0.x != nx || pos.0.y != ny {
612            pos.0 = Vec2::new(nx, ny);
613        }
614    }
615}
616
617/// Stamp (or clear) the transition components on a host element. Called from
618/// [`crate::ui_map::apply_style`] with the resolved style, so the input always
619/// reflects the current `Interaction` (base / hover / press). Sibling to
620/// `apply_animated` in the reconciler's apply pattern.
621pub fn apply_transition(ec: &mut EntityCommands, style: &Option<Style>) {
622    match style.as_ref().and_then(TransitionInput::from_style) {
623        Some(input) => {
624            ec.insert(input);
625            // The runtime state persists across re-renders, so only create it once.
626            ec.insert_if_new(TransitionState::default());
627        }
628        None => {
629            ec.remove::<TransitionInput>();
630            ec.remove::<TransitionState>();
631        }
632    }
633}
634
635/// The components a transition can drive, plus the read-only inputs that gate how
636/// it drives them. A `QueryData` struct (rather than a tuple) so a new transition
637/// target component is one field, not a tuple-arity problem — the filter
638/// channel's fields (`filter_input`/`resolved_filter`) live here already.
639/// The per-param filter bindings (`filter[<i>].<param>`) write through the
640/// *animation side* instead: `AnimTargets` (the animations applier's mirror
641/// of this struct) carries its own resolved-chain field. Every target is
642/// optional except `UiTransform` (required by [`TransitionState`]).
643#[derive(QueryData)]
644#[query_data(mutable)]
645pub struct TransitionTargets {
646    transform: &'static mut UiTransform,
647    bg: Option<&'static mut BackgroundColor>,
648    text: Option<&'static mut TextColor>,
649    image: Option<&'static mut ImageNode>,
650    node: Option<&'static mut Node>,
651    /// The node's derived animation bindings; any channel they drive is skipped.
652    anim: Option<&'static AnimatedNode>,
653    // On a promoted layer root (see `crate::layer`) a transitioned `opacity`
654    // drives the composite-time group alpha instead of the color folds.
655    promoted: Option<&'static crate::layer::PromotedLayer>,
656    layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
657    /// The wire `filter` chain — the filter channel's *target*. Read here
658    /// (not from [`TransitionInput`]) because a filter-only delta re-stamps
659    /// this component but never the input: the `filter` style field is in the
660    /// FILTER|LAYER dirty groups, not TRANSITION.
661    filter_input: Option<&'static crate::filters::FilterInput>,
662    /// The resolved chain the filter channel writes eased packed params into
663    /// (promoted roots only; snapped to the target by
664    /// `resolve_chains`, ordered before this system).
665    resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
666    /// The `backdropFilter` channel's target — same live-read rule as
667    /// [`Self::filter_input`].
668    backdrop_input: Option<&'static crate::filters::BackdropInput>,
669    /// The resolved backdrop chain the second filter-channel instance writes
670    /// into (projected to the inner chain via `Mut::map_unchanged`).
671    resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
672    /// The composite-time 3D transform params on a promoted root; the eased
673    /// value lands here and `sync_transform3d_matrices` (PostUpdate) turns
674    /// the change into the matrix + composite-only dirt — no dirt push here.
675    transform3d: Option<&'static mut crate::layer::transform3d::LayerTransform3d>,
676}
677
678/// Advance every transitioning entity toward its [`TransitionInput`] target and
679/// write the eased value onto `UiTransform` / `BackgroundColor` / alpha. Runs
680/// after `apply_interaction_styles` (and thus after the op drain) so its writes
681/// land last in the frame.
682pub fn drive_transitions(
683    time: Res<Time>,
684    mut commands: Commands,
685    mut dirt: ResMut<crate::layer::LayerContentDirt>,
686    // The filter channel resolves identity padding at retarget time. Both are
687    // optional so schedule-only test worlds without asset machinery still
688    // drive the scalar channels; a missing pair degrades a chain extension to
689    // a discrete swap (see `crate::filters::plan_filter_ease`).
690    filter_registry: Option<Res<crate::filters::FilterRegistry>>,
691    assets: Option<Res<AssetServer>>,
692    mut query: Query<(
693        Entity,
694        &TransitionInput,
695        &mut TransitionState,
696        TransitionTargets,
697    )>,
698) {
699    let dt = time.delta_secs();
700    for (entity, input, mut state, mut targets) in &mut query {
701        // Seed resting values on first sight so a freshly mounted element snaps to
702        // its initial style instead of animating in from zero.
703        if !state.initialized {
704            state
705                .translate_x
706                .init(input.translate_x.unwrap_or(Length::Px(0.0)));
707            state
708                .translate_y
709                .init(input.translate_y.unwrap_or(Length::Px(0.0)));
710            state.scale.init(input.scale.unwrap_or(1.0));
711            state.scale_x.init(input.scale_x.unwrap_or(1.0));
712            state.scale_y.init(input.scale_y.unwrap_or(1.0));
713            state.rotate.init(input.rotate.unwrap_or(0.0));
714            state.opacity.init(input.opacity.unwrap_or(1.0));
715            if let Some(c) = input.background_color {
716                state.color.init(c);
717            }
718            state.width.init(input.width.unwrap_or(Length::Auto));
719            state.height.init(input.height.unwrap_or(Length::Auto));
720            state
721                .max_width
722                .init(input.max_width.unwrap_or(Length::Auto));
723            state
724                .max_height
725                .init(input.max_height.unwrap_or(Length::Auto));
726            // Filter: adopt the current wire chain and whatever the resolver
727            // produced, so a freshly mounted filtered element snaps instead
728            // of fading in from identity.
729            state.filter.wire = targets
730                .filter_input
731                .map(|f| f.0.clone())
732                .unwrap_or_default();
733            state.filter.current = targets
734                .resolved_filter
735                .as_deref()
736                .map(|c| c.passes.clone())
737                .unwrap_or_default();
738            state.backdrop_filter.wire = targets
739                .backdrop_input
740                .map(|f| f.0.clone())
741                .unwrap_or_default();
742            state.backdrop_filter.current = targets
743                .resolved_backdrop
744                .as_deref()
745                .map(|c| c.0.passes.clone())
746                .unwrap_or_default();
747            state
748                .transform3d
749                .init(&input.transform3d.clone().unwrap_or_default());
750            state.initialized = true;
751        }
752
753        // Imperative bindings win: skip any channel an `{ animated }` wrapper drives.
754        let skip_transform = targets.anim.is_some_and(|a| a.0.has_transform());
755        let skip_opacity = targets
756            .anim
757            .is_some_and(|a| a.0.contains(AnimatableProperty::Opacity));
758        let skip_bg = targets
759            .anim
760            .is_some_and(|a| a.0.contains(AnimatableProperty::BackgroundColor));
761        // Coarser than its siblings by design: ANY `filter[<i>].<param>`
762        // binding parks the WHOLE whole-value filter channel — the channel
763        // eases a complete pass list, so there is no per-param seam to merge
764        // an imperative writer into. The bindings then re-assert their params
765        // on top of the resolver's snap every frame (`AnimationSet::Apply`).
766        let skip_filter = targets.anim.is_some_and(|a| a.0.has_filter_params());
767        // Same coarse rule for the backdrop channel — independent of the
768        // content one (a `backdropFilter[…]` binding parks only backdrop).
769        let skip_backdrop = targets.anim.is_some_and(|a| a.0.has_backdrop_params());
770        // Any `transform3d.<field>` binding parks the whole channel group,
771        // like `filter` (the bindings rebuild the full params struct).
772        let skip_transform3d = targets.anim.is_some_and(|a| a.0.has_transform3d());
773
774        // Transform: only when a transform transition is declared; otherwise the
775        // static `UiTransform` from `apply_style` stands untouched. Only specified
776        // channels are written (passing `None` keeps `build_ui_transform`'s scale
777        // precedence intact).
778        if input.spec.for_transform().is_some() && !skip_transform {
779            let s = input.spec.for_transform();
780            let tx = input
781                .translate_x
782                .map(|t| length_to_val(state.translate_x.drive(t, s, dt)));
783            let ty = input
784                .translate_y
785                .map(|t| length_to_val(state.translate_y.drive(t, s, dt)));
786            let sc = input.scale.map(|t| state.scale.drive(t, s, dt));
787            let scx = input.scale_x.map(|t| state.scale_x.drive(t, s, dt));
788            let scy = input.scale_y.map(|t| state.scale_y.drive(t, s, dt));
789            let rot = input.rotate.map(|t| state.rotate.drive(t, s, dt));
790            // Compare-before-write so a settled transition doesn't dirty change
791            // detection every frame (read via `Deref`, write via `DerefMut`).
792            let new = build_ui_transform(tx, ty, sc, scx, scy, rot);
793            if *targets.transform != new {
794                // Layer-cache classification (see the animation applier): a
795                // promoted root's own pure translation is composite-only.
796                let translate_only = targets.transform.scale == new.scale
797                    && targets.transform.rotation == new.rotation;
798                if targets.promoted.is_some() && translate_only {
799                    dirt.composite_only.push(entity);
800                } else {
801                    dirt.nodes.push(entity);
802                }
803                *targets.transform = new;
804            }
805        }
806
807        // transform3d: eased field-wise onto the layer's params component;
808        // `sync_transform3d_matrices` (PostUpdate) derives the matrix and the
809        // composite-only dirt from the change, so no dirt push here. A
810        // demoted/never-promoted entity has no component — nothing to drive.
811        // Mid-ease unset removes the component with the promotion (snap
812        // semantics, like filter's ease-to-empty).
813        if input.spec.for_transform3d().is_some()
814            && !skip_transform3d
815            && let Some(target) = &input.transform3d
816            && let Some(t3d) = &mut targets.transform3d
817        {
818            let new = state
819                .transform3d
820                .drive(target, input.spec.for_transform3d(), dt);
821            // Compare-before-write: a settled ease must not re-trigger the
822            // matrix sync's change detection every frame.
823            if t3d.0 != new {
824                t3d.0 = new;
825            }
826        }
827
828        // Opacity owns the final alpha across background/text/image. Resolved
829        // before the background write so it can be baked into that color —
830        // otherwise the two writes would ping-pong the alpha channel every frame
831        // and the compare-before-write guards would never settle.
832        let alpha = if !skip_opacity && let Some(target) = input.opacity {
833            Some(state.opacity.drive(target, input.spec.for_opacity(), dt))
834        } else {
835            None
836        };
837
838        // On a promoted layer root the eased opacity drives the group alpha
839        // (below) — colors keep their own alpha, so nothing to bake here. The
840        // spring itself always eases, keeping a mid-ease promote/demote
841        // continuous.
842        let promoted = targets.promoted.is_some();
843        if !skip_bg && let Some(target) = input.background_color {
844            let mut rgba = state.color.drive(target, input.spec.for_background(), dt);
845            if let Some(a) = alpha
846                && !promoted
847            {
848                rgba[3] = a;
849            }
850            let color = rgba_to_color(rgba);
851            match &mut targets.bg {
852                Some(c) if c.0 != color => {
853                    c.0 = color;
854                    dirt.nodes.push(entity);
855                }
856                Some(_) => {}
857                None => {
858                    commands.entity(entity).insert(BackgroundColor(color));
859                    dirt.nodes.push(entity);
860                }
861            }
862        }
863
864        // Opacity always applies when set (even with no opacity transition: it then
865        // snaps), so a transitioning background color doesn't clobber the alpha.
866        // Promoted → the group alpha is the single target instead.
867        if let Some(alpha) = alpha
868            && promoted
869        {
870            if let Some(la) = &mut targets.layer_alpha
871                && la.0 != alpha
872            {
873                la.0 = alpha;
874                // Composite-only: applied to the cached texture at composite
875                // time (content of the *enclosing* layer, if any).
876                dirt.composite_only.push(entity);
877            }
878        } else if let Some(alpha) = alpha {
879            let mut wrote = false;
880            if let Some(c) = &mut targets.bg
881                && c.0.alpha() != alpha
882            {
883                c.0 = c.0.with_alpha(alpha);
884                wrote = true;
885            }
886            if let Some(tc) = &mut targets.text
887                && tc.0.alpha() != alpha
888            {
889                tc.0 = tc.0.with_alpha(alpha);
890                wrote = true;
891            }
892            if let Some(img) = &mut targets.image
893                && img.color.alpha() != alpha
894            {
895                img.color = img.color.with_alpha(alpha);
896                wrote = true;
897            }
898            if wrote {
899                dirt.nodes.push(entity);
900            }
901        }
902
903        // Size (layout): ease the specified `Node` dimensions. Writing `Node`
904        // re-triggers Bevy's layout, so each field is compared before writing —
905        // a settled transition doesn't force a relayout every frame, and a
906        // re-render that reset `Node` to its static style is corrected here.
907        // The animations engine never writes `Node`, so no precedence check is
908        // needed.
909        if input.spec.for_size().is_some()
910            && let Some(node) = targets.node.as_mut()
911        {
912            let s = input.spec.for_size();
913            if let Some(t) = input.width {
914                let v = length_to_val(state.width.drive(t, s, dt));
915                if node.width != v {
916                    node.width = v;
917                }
918            }
919            if let Some(t) = input.height {
920                let v = length_to_val(state.height.drive(t, s, dt));
921                if node.height != v {
922                    node.height = v;
923                }
924            }
925            if let Some(t) = input.max_width {
926                let v = length_to_val(state.max_width.drive(t, s, dt));
927                if node.max_width != v {
928                    node.max_width = v;
929                }
930            }
931            if let Some(t) = input.max_height {
932                let v = length_to_val(state.max_height.drive(t, s, dt));
933                if node.max_height != v {
934                    node.max_height = v;
935                }
936            }
937        }
938
939        // Filter: ease the promoted root's resolved chain between wire
940        // targets (see [`FilterChannel::drive`] for the retarget/writer
941        // contract). A write is composite-only dirt, like the resolver's.
942        if !skip_filter
943            && state.filter.drive(
944                targets.filter_input.map(|f| &f.0),
945                targets.resolved_filter.as_mut().map(Mut::reborrow),
946                input.spec.for_filter(),
947                filter_registry.as_deref(),
948                assets.as_deref(),
949                dt,
950            )
951        {
952            dirt.composite_only.push(entity);
953        }
954
955        // Backdrop filter: the second instance of the same channel, over the
956        // backdrop component pair (targets projected to the shared inner
957        // types). A write is composite-only dirt like the content one.
958        if !skip_backdrop
959            && state.backdrop_filter.drive(
960                targets.backdrop_input.map(|f| &f.0),
961                targets
962                    .resolved_backdrop
963                    .as_mut()
964                    .map(|m| m.reborrow().map_unchanged(|b| &mut b.0)),
965                input.spec.for_backdrop_filter(),
966                filter_registry.as_deref(),
967                assets.as_deref(),
968                dt,
969            )
970        {
971            dirt.composite_only.push(entity);
972        }
973    }
974}
975
976fn color_to_rgba(color: Color) -> [f32; 4] {
977    let s = color.to_srgba();
978    [s.red, s.green, s.blue, s.alpha]
979}
980
981fn rgba_to_color(rgba: [f32; 4]) -> Color {
982    Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3])
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use crate::animations::AnimatedBindings;
989    use std::time::Duration;
990
991    fn timing(duration: f32, easing: Easing) -> ChannelTransition {
992        ChannelTransition {
993            duration: Some(WireTime::from_secs(duration)),
994            easing,
995            delay: WireTime::from_secs(0.0),
996            stiffness: None,
997            damping: None,
998            mass: 1.0,
999        }
1000    }
1001
1002    fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
1003        serde_json::from_value(json).expect("valid json")
1004    }
1005
1006    #[test]
1007    fn channel_resolution_falls_back_to_all() {
1008        let t: Transition = parse(serde_json::json!({
1009            "all": { "duration": 100 },
1010            "opacity": { "duration": 200 },
1011        }));
1012        // `opacity` has its own entry; `transform`/`background` fall back to `all`.
1013        // The wire numbers are milliseconds → seconds (200ms → 0.2s, 100ms → 0.1s).
1014        let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
1015        assert!(t.for_opacity().is_some());
1016        assert_eq!(secs(t.for_opacity().unwrap()), Some(0.2));
1017        assert_eq!(secs(t.for_transform().unwrap()), Some(0.1));
1018        assert_eq!(secs(t.for_background().unwrap()), Some(0.1));
1019
1020        // No `all`: an unspecified channel has no transition.
1021        let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
1022        assert!(t.for_transform().is_none());
1023        assert!(t.for_opacity().is_some());
1024    }
1025
1026    /// The filter channel resolves like its siblings: explicit entry first,
1027    /// else `all`, else none.
1028    #[test]
1029    fn filter_channel_falls_back_to_all() {
1030        let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
1031        let t: Transition = parse(serde_json::json!({
1032            "all": { "duration": 100 },
1033            "filter": { "duration": 400 },
1034        }));
1035        assert_eq!(secs(t.for_filter().unwrap()), Some(0.4));
1036
1037        let t: Transition = parse(serde_json::json!({ "all": { "duration": 100 } }));
1038        assert_eq!(secs(t.for_filter().unwrap()), Some(0.1));
1039
1040        let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
1041        assert!(t.for_filter().is_none());
1042    }
1043
1044    #[test]
1045    fn to_driver_selects_spring_or_timing() {
1046        let spring = ChannelTransition {
1047            duration: None,
1048            easing: Easing::Linear,
1049            delay: WireTime::from_secs(0.0),
1050            stiffness: Some(120.0),
1051            damping: Some(14.0),
1052            mass: 1.0,
1053        };
1054        assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
1055        assert!(matches!(
1056            timing(0.3, Easing::Linear).to_driver(1.0),
1057            Driver::Timing { .. }
1058        ));
1059        // A delay wraps the timing in a Delay driver.
1060        let delayed = ChannelTransition {
1061            delay: WireTime::from_secs(0.2),
1062            ..timing(0.3, Easing::Linear)
1063        };
1064        assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
1065    }
1066
1067    #[test]
1068    fn channel_snaps_without_spec_and_eases_with_one() {
1069        // No spec → snap straight to target.
1070        let mut ch = Channel::default();
1071        ch.init(1.0);
1072        assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
1073
1074        // With a 1s linear timing → halfway after 0.5s.
1075        let mut ch = Channel::default();
1076        ch.init(1.0);
1077        let spec = timing(1.0, Easing::Linear);
1078        ch.drive(0.0, Some(&spec), 0.0); // arm; no time elapsed yet
1079        let v = ch.drive(0.0, Some(&spec), 0.5); // same target, advance 0.5s
1080        assert!((v - 0.5).abs() < 1e-3, "halfway expected ~0.5, got {v}");
1081        let v = ch.drive(0.0, Some(&spec), 0.5);
1082        assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
1083        assert!(ch.runner.is_none(), "runner dropped once finished");
1084    }
1085
1086    #[test]
1087    fn color_channel_lerps_to_target() {
1088        let mut c = ProgressChannel::<[f32; 4]>::default();
1089        c.init([0.0, 0.0, 0.0, 1.0]);
1090        let spec = timing(1.0, Easing::Linear);
1091        c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.0); // arm
1092        let mid = c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.5);
1093        assert!((mid[0] - 0.5).abs() < 1e-3);
1094        assert!((mid[1] - 0.25).abs() < 1e-3);
1095        assert!((mid[2] - 0.0).abs() < 1e-3);
1096    }
1097
1098    /// Build a one-entity world running `drive_transitions`, advancing `Time`.
1099    fn drive_world() -> (World, Schedule) {
1100        let mut world = World::new();
1101        world.init_resource::<crate::layer::LayerContentDirt>();
1102        world.insert_resource(Time::<()>::default());
1103        let mut schedule = Schedule::default();
1104        schedule.add_systems(drive_transitions);
1105        (world, schedule)
1106    }
1107
1108    fn advance(world: &mut World, secs: f32) {
1109        world
1110            .resource_mut::<Time>()
1111            .advance_by(Duration::from_secs_f32(secs));
1112    }
1113
1114    #[test]
1115    fn system_eases_scale_on_press_then_release() {
1116        let (mut world, mut schedule) = drive_world();
1117        let spec = Transition {
1118            transform: Some(timing(1.0, Easing::Linear)),
1119            ..Default::default()
1120        };
1121        let e = world
1122            .spawn((
1123                TransitionInput {
1124                    spec: spec.clone(),
1125                    scale: Some(1.0),
1126                    ..Default::default()
1127                },
1128                TransitionState::default(),
1129                UiTransform::default(),
1130            ))
1131            .id();
1132
1133        // First frame seeds the resting state — scale snaps to 1, no animation.
1134        schedule.run(&mut world);
1135        assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
1136
1137        // Press: target 0.95. Halfway through a 1s ease → ~0.975.
1138        world
1139            .entity_mut(e)
1140            .get_mut::<TransitionInput>()
1141            .unwrap()
1142            .scale = Some(0.95);
1143        advance(&mut world, 0.5);
1144        schedule.run(&mut world);
1145        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1146        assert!(
1147            (sx - 0.975).abs() < 1e-2,
1148            "mid-press expected ~0.975, got {sx}"
1149        );
1150
1151        // Finish the press ease.
1152        advance(&mut world, 0.5);
1153        schedule.run(&mut world);
1154        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1155        assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
1156
1157        // Release back to 1.0, eases again.
1158        world
1159            .entity_mut(e)
1160            .get_mut::<TransitionInput>()
1161            .unwrap()
1162            .scale = Some(1.0);
1163        advance(&mut world, 0.5);
1164        schedule.run(&mut world);
1165        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1166        assert!(
1167            (sx - 0.975).abs() < 1e-2,
1168            "mid-release expected ~0.975, got {sx}"
1169        );
1170    }
1171
1172    /// `transition.transform3d` eases the layer's params field-wise; without a
1173    /// spec the write snaps; perspective snaps when the previous target was
1174    /// orthographic; a demoted entity (no `LayerTransform3d`) is a no-op.
1175    #[test]
1176    fn system_eases_transform3d() {
1177        use crate::layer::transform3d::LayerTransform3d;
1178        use crate::protocol::Transform3d;
1179
1180        let (mut world, mut schedule) = drive_world();
1181        let spec = Transition {
1182            transform3d: Some(timing(1.0, Easing::Linear)),
1183            ..Default::default()
1184        };
1185        let base = Transform3d::default();
1186        let e = world
1187            .spawn((
1188                TransitionInput {
1189                    spec: spec.clone(),
1190                    transform3d: Some(base.clone()),
1191                    ..Default::default()
1192                },
1193                TransitionState::default(),
1194                UiTransform::default(),
1195                LayerTransform3d(base.clone()),
1196            ))
1197            .id();
1198
1199        // First frame seeds resting state: identity, no ease-in from nowhere.
1200        schedule.run(&mut world);
1201        let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1202        assert!(t.is_identity());
1203
1204        // Retarget rotateY 90° (+ a perspective from an orthographic start —
1205        // that channel snaps while the rotation eases).
1206        let target = Transform3d {
1207            rotate_y: Some(crate::protocol::Animatable::Static(
1208                crate::protocol::Angle::from_radians(std::f32::consts::FRAC_PI_2),
1209            )),
1210            perspective: Some(crate::protocol::Animatable::Static(800.0)),
1211            ..Default::default()
1212        };
1213        world
1214            .entity_mut(e)
1215            .get_mut::<TransitionInput>()
1216            .unwrap()
1217            .transform3d = Some(target.clone());
1218        advance(&mut world, 0.5);
1219        schedule.run(&mut world);
1220        let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1221        let ry = t.rotate_y.static_val().unwrap().radians();
1222        assert!(
1223            (ry - std::f32::consts::FRAC_PI_4).abs() < 0.05,
1224            "mid-ease expected ~45°, got {}°",
1225            ry.to_degrees()
1226        );
1227        assert_eq!(
1228            t.perspective.static_val(),
1229            Some(800.0),
1230            "ortho→perspective snaps"
1231        );
1232
1233        // Finish the ease.
1234        advance(&mut world, 0.6);
1235        schedule.run(&mut world);
1236        let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1237        assert!(
1238            (t.rotate_y.static_val().unwrap().radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-3
1239        );
1240
1241        // No spec → snap. (Fresh entity, `all`-less spec without transform3d.)
1242        let e2 = world
1243            .spawn((
1244                TransitionInput {
1245                    spec: Transition {
1246                        opacity: Some(timing(1.0, Easing::Linear)),
1247                        ..Default::default()
1248                    },
1249                    transform3d: Some(target.clone()),
1250                    ..Default::default()
1251                },
1252                TransitionState::default(),
1253                UiTransform::default(),
1254                LayerTransform3d(base),
1255            ))
1256            .id();
1257        schedule.run(&mut world);
1258        // Without a transform3d (or `all`) spec the drive block never runs —
1259        // the static style applier owns the component (stays at `base` here,
1260        // since this harness has no style apply).
1261        let t2 = world
1262            .entity(e2)
1263            .get::<LayerTransform3d>()
1264            .unwrap()
1265            .0
1266            .clone();
1267        assert!(t2.is_identity());
1268
1269        // Demoted entity (no LayerTransform3d): driving is a no-op, no panic.
1270        let e3 = world
1271            .spawn((
1272                TransitionInput {
1273                    spec,
1274                    transform3d: Some(target.clone()),
1275                    ..Default::default()
1276                },
1277                TransitionState::default(),
1278                UiTransform::default(),
1279            ))
1280            .id();
1281        advance(&mut world, 0.1);
1282        schedule.run(&mut world);
1283        assert!(world.entity(e3).get::<LayerTransform3d>().is_none());
1284    }
1285
1286    #[test]
1287    fn system_eases_percent_translate() {
1288        let (mut world, mut schedule) = drive_world();
1289        let spec = Transition {
1290            transform: Some(timing(1.0, Easing::Linear)),
1291            ..Default::default()
1292        };
1293        let e = world
1294            .spawn((
1295                TransitionInput {
1296                    spec,
1297                    translate_x: Some(Length::Percent(0.0)),
1298                    ..Default::default()
1299                },
1300                TransitionState::default(),
1301                UiTransform::default(),
1302            ))
1303            .id();
1304
1305        // First frame seeds the resting state at 0% — snaps, no animation.
1306        schedule.run(&mut world);
1307        assert_eq!(
1308            world.entity(e).get::<UiTransform>().unwrap().translation.x,
1309            Val::Percent(0.0)
1310        );
1311
1312        // Retarget to 100%: halfway through a 1s linear ease → ~50%, still in
1313        // percent units (not collapsed to px).
1314        world
1315            .entity_mut(e)
1316            .get_mut::<TransitionInput>()
1317            .unwrap()
1318            .translate_x = Some(Length::Percent(100.0));
1319        advance(&mut world, 0.5);
1320        schedule.run(&mut world);
1321        let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
1322        assert!(
1323            matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
1324            "mid expected ~50%, got {tx:?}"
1325        );
1326
1327        advance(&mut world, 0.5);
1328        schedule.run(&mut world);
1329        assert_eq!(
1330            world.entity(e).get::<UiTransform>().unwrap().translation.x,
1331            Val::Percent(100.0)
1332        );
1333    }
1334
1335    #[test]
1336    fn animated_style_channel_wins_over_transition() {
1337        let (mut world, mut schedule) = drive_world();
1338        let spec = Transition {
1339            transform: Some(timing(1.0, Easing::Linear)),
1340            ..Default::default()
1341        };
1342        // The entity also has an AnimatedNode binding for scale → transition must
1343        // not touch the transform (the imperative path owns it).
1344        let bindings = AnimatedBindings(
1345            [(
1346                crate::animations::AnimatableProperty::Scale,
1347                crate::animations::protocol::Binding::Shared { id: 1 },
1348            )]
1349            .into(),
1350        );
1351        let e = world
1352            .spawn((
1353                TransitionInput {
1354                    spec,
1355                    scale: Some(1.0),
1356                    ..Default::default()
1357                },
1358                TransitionState::default(),
1359                UiTransform::from_scale(Vec2::splat(2.0)), // a value the imperative path "set"
1360                AnimatedNode(bindings),
1361            ))
1362            .id();
1363
1364        schedule.run(&mut world);
1365        world
1366            .entity_mut(e)
1367            .get_mut::<TransitionInput>()
1368            .unwrap()
1369            .scale = Some(0.95);
1370        advance(&mut world, 0.5);
1371        schedule.run(&mut world);
1372        // Untouched by the transition: still the imperative 2.0.
1373        assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
1374    }
1375
1376    /// A `filter[<i>].<param>` binding parks the WHOLE whole-value filter
1377    /// channel (`skip_filter`): on a filter retarget the transition must not
1378    /// touch the resolved chain — the per-param binding (the animations
1379    /// applier) owns it. A control entity without the binding shows the
1380    /// channel would otherwise write.
1381    #[test]
1382    fn filter_param_binding_gates_filter_transition() {
1383        use crate::animations::ValueKind;
1384        use std::sync::Arc;
1385
1386        let (mut world, mut schedule) = drive_world();
1387        let spec = Transition {
1388            filter: Some(timing(1.0, Easing::Linear)),
1389            ..Default::default()
1390        };
1391        let pass = |amount: f32| crate::filters::ResolvedFilterPass {
1392            shader: Handle::default(),
1393            params: vec![Vec4::new(amount, 0.0, 0.0, 0.0)],
1394            layout: Arc::from(vec![crate::filters::ParamSlot {
1395                name: "amount",
1396                kind: ValueKind::Scalar,
1397                vec: 0,
1398                comp: 0,
1399                len: 1,
1400            }]),
1401            wire_index: 0,
1402        };
1403        let wire = |amount: f32| -> crate::filters::FilterChain {
1404            serde_json::from_value(serde_json::json!(
1405                { "name": "grayscale", "params": { "amount": amount } }
1406            ))
1407            .unwrap()
1408        };
1409        let chain = |amount: f32| crate::filters::ResolvedFilterChain {
1410            passes: vec![pass(amount)],
1411            outset_px: 0,
1412            always_dirty: false,
1413            version: 1,
1414            scale: 1.0,
1415        };
1416        let bindings = AnimatedBindings(
1417            [(
1418                crate::animations::AnimatableProperty::FilterParam {
1419                    index: 0,
1420                    name: "amount".into(),
1421                },
1422                crate::animations::protocol::Binding::Shared { id: 1 },
1423            )]
1424            .into(),
1425        );
1426
1427        let spawn = |world: &mut World, gated: bool| {
1428            let mut e = world.spawn((
1429                TransitionInput {
1430                    spec: spec.clone(),
1431                    ..Default::default()
1432                },
1433                TransitionState::default(),
1434                UiTransform::default(),
1435                crate::filters::FilterInput(wire(0.0)),
1436                chain(0.0),
1437            ));
1438            if gated {
1439                e.insert(AnimatedNode(bindings.clone()));
1440            }
1441            e.id()
1442        };
1443        let gated = spawn(&mut world, true);
1444        let control = spawn(&mut world, false);
1445
1446        // Seed frame: both channels adopt the current wire chain + passes.
1447        schedule.run(&mut world);
1448
1449        // Retarget: stamp the new wire chain and simulate the resolver's
1450        // same-frame snap of the component to the target.
1451        for e in [gated, control] {
1452            *world
1453                .entity_mut(e)
1454                .get_mut::<crate::filters::FilterInput>()
1455                .unwrap() = crate::filters::FilterInput(wire(1.0));
1456            let mut em = world.entity_mut(e);
1457            let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
1458            c.passes = vec![pass(1.0)];
1459            c.version = 2;
1460        }
1461        advance(&mut world, 0.1);
1462        schedule.run(&mut world);
1463
1464        // Control: the channel armed a matched ease over the snap and wrote
1465        // a mid-ease value — proving the channel was live.
1466        let c = world
1467            .entity(control)
1468            .get::<crate::filters::ResolvedFilterChain>()
1469            .unwrap();
1470        let w = c.passes[0].params[0].x;
1471        assert!(
1472            w > 0.0 && w < 1.0,
1473            "control: transition eased over the snap, got {w}"
1474        );
1475        assert_eq!(c.version, 3, "control: transition bumped the version");
1476
1477        // Gated: `skip_filter` — the snapped chain is untouched.
1478        let c = world
1479            .entity(gated)
1480            .get::<crate::filters::ResolvedFilterChain>()
1481            .unwrap();
1482        assert_eq!(
1483            c.passes[0].params[0].x, 1.0,
1484            "gated: the transition must not touch the chain"
1485        );
1486        assert_eq!(c.version, 2, "gated: version stays the resolver's");
1487    }
1488
1489    /// Once a transition has settled, `drive_transitions` must stop marking the
1490    /// target components changed (compare-before-write) — a settled hover/press
1491    /// style shouldn't keep transform propagation / extraction hot forever.
1492    #[test]
1493    fn settled_transition_does_not_dirty_components() {
1494        #[derive(Resource, Default)]
1495        struct Dirty(usize);
1496
1497        let (mut world, mut schedule) = drive_world();
1498        world.init_resource::<Dirty>();
1499        let spec = Transition {
1500            transform: Some(timing(0.2, Easing::Linear)),
1501            background_color: Some(timing(0.2, Easing::Linear)),
1502            opacity: Some(timing(0.2, Easing::Linear)),
1503            ..Default::default()
1504        };
1505        let e = world
1506            .spawn((
1507                TransitionInput {
1508                    spec,
1509                    scale: Some(1.0),
1510                    // Deliberately different from the bg target's alpha: opacity
1511                    // owns the final alpha, and the two writes must still settle.
1512                    opacity: Some(0.5),
1513                    background_color: Some([1.0, 0.0, 0.0, 1.0]),
1514                    ..Default::default()
1515                },
1516                TransitionState::default(),
1517                UiTransform::default(),
1518                BackgroundColor(Color::WHITE),
1519            ))
1520            .id();
1521
1522        type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
1523
1524        let mut detect = Schedule::default();
1525        detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
1526            dirty.0 = q.iter().count();
1527        });
1528
1529        // Seed, retarget, and run the ease well past completion.
1530        schedule.run(&mut world);
1531        world
1532            .entity_mut(e)
1533            .get_mut::<TransitionInput>()
1534            .unwrap()
1535            .scale = Some(0.9);
1536        advance(&mut world, 0.5);
1537        schedule.run(&mut world);
1538        detect.run(&mut world); // consume all the churn so far
1539
1540        advance(&mut world, 0.5);
1541        schedule.run(&mut world);
1542        detect.run(&mut world);
1543        assert_eq!(
1544            world.resource::<Dirty>().0,
1545            0,
1546            "a settled transition must not dirty anything"
1547        );
1548    }
1549
1550    #[test]
1551    fn lerp_length_same_unit_else_snaps() {
1552        assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
1553        assert_eq!(
1554            Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
1555            Length::Percent(25.0)
1556        );
1557        // `auto` or mixed units can't be interpolated → snap to the target.
1558        assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
1559        assert_eq!(
1560            Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
1561            Length::Percent(10.0)
1562        );
1563    }
1564
1565    fn px(l: Length) -> f32 {
1566        match l {
1567            Length::Px(v) => v,
1568            other => panic!("expected Px, got {other:?}"),
1569        }
1570    }
1571
1572    #[test]
1573    fn length_channel_eases_then_idles() {
1574        let mut ch = ProgressChannel::<Length>::default();
1575        ch.init(Length::Px(0.0));
1576        let spec = timing(1.0, Easing::Linear);
1577        // Arm toward 100; the arm frame reports the (still 0) value.
1578        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
1579        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
1580        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
1581        // Settled and target unchanged → idle: the runner is dropped and the
1582        // reading holds steady (the caller's compare skips the `Node` write).
1583        assert!(ch.runner.is_none(), "runner dropped once settled");
1584        assert_eq!(
1585            ch.drive(Length::Px(100.0), Some(&spec), 0.5),
1586            Length::Px(100.0)
1587        );
1588    }
1589
1590    #[test]
1591    fn system_eases_max_height_layout() {
1592        let (mut world, mut schedule) = drive_world();
1593        let spec = Transition {
1594            size: Some(timing(1.0, Easing::Linear)),
1595            ..Default::default()
1596        };
1597        let e = world
1598            .spawn((
1599                TransitionInput {
1600                    spec,
1601                    max_height: Some(Length::Px(120.0)),
1602                    ..Default::default()
1603                },
1604                TransitionState::default(),
1605                Node::default(),
1606                UiTransform::default(),
1607            ))
1608            .id();
1609
1610        // First frame seeds the resting state (120) without writing Node.
1611        schedule.run(&mut world);
1612
1613        // Collapse to 0: halfway through a 1s ease → ~60.
1614        world
1615            .entity_mut(e)
1616            .get_mut::<TransitionInput>()
1617            .unwrap()
1618            .max_height = Some(Length::Px(0.0));
1619        advance(&mut world, 0.5);
1620        schedule.run(&mut world);
1621        let mh = world.entity(e).get::<Node>().unwrap().max_height;
1622        assert!(
1623            matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
1624            "mid expected ~60px, got {mh:?}"
1625        );
1626
1627        advance(&mut world, 0.5);
1628        schedule.run(&mut world);
1629        let mh = world.entity(e).get::<Node>().unwrap().max_height;
1630        assert!(
1631            matches!(mh, Val::Px(v) if v.abs() < 1e-3),
1632            "settled expected 0px, got {mh:?}"
1633        );
1634    }
1635
1636    /// `drive_scroll_transition` eases `ScrollPosition` toward the state's target
1637    /// (seeded at the live offset on first sight) and settles exactly on it.
1638    #[test]
1639    fn system_eases_scroll_toward_target() {
1640        let mut world = World::new();
1641        world.init_resource::<crate::layer::LayerContentDirt>();
1642        world.insert_resource(Time::<()>::default());
1643        let mut schedule = Schedule::default();
1644        schedule.add_systems(drive_scroll_transition);
1645
1646        let e = world
1647            .spawn((
1648                ScrollTransitionInput(timing(1.0, Easing::Linear)),
1649                ScrollTransitionState::default(),
1650                ScrollPosition::default(),
1651            ))
1652            .id();
1653
1654        // First frame seeds resting state at the live offset (0) — no movement.
1655        schedule.run(&mut world);
1656        assert_eq!(
1657            world.entity(e).get::<ScrollPosition>().unwrap().0,
1658            Vec2::ZERO
1659        );
1660
1661        // Target y=100; halfway through a 1s linear ease → ~50.
1662        world
1663            .entity_mut(e)
1664            .get_mut::<ScrollTransitionState>()
1665            .unwrap()
1666            .target = Vec2::new(0.0, 100.0);
1667        advance(&mut world, 0.5);
1668        schedule.run(&mut world);
1669        let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1670        assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
1671
1672        // Finish the ease → exactly 100.
1673        advance(&mut world, 0.5);
1674        schedule.run(&mut world);
1675        assert_eq!(
1676            world.entity(e).get::<ScrollPosition>().unwrap().0,
1677            Vec2::new(0.0, 100.0)
1678        );
1679    }
1680
1681    /// A direct external write to `ScrollPosition` mid-ease (the scrollbar widget's
1682    /// thumb-drag and track-click paging write the offset directly) snaps: the
1683    /// written value survives exactly and nothing eases back toward the stale target.
1684    #[test]
1685    fn scroll_direct_write_snaps_the_ease() {
1686        let mut world = World::new();
1687        world.init_resource::<crate::layer::LayerContentDirt>();
1688        world.insert_resource(Time::<()>::default());
1689        let mut schedule = Schedule::default();
1690        schedule.add_systems(drive_scroll_transition);
1691
1692        let e = world
1693            .spawn((
1694                ScrollTransitionInput(timing(1.0, Easing::Linear)),
1695                ScrollTransitionState::default(),
1696                ScrollPosition::default(),
1697            ))
1698            .id();
1699        schedule.run(&mut world); // seed resting state at 0
1700
1701        // Leave an ease mid-flight toward y=100.
1702        world
1703            .entity_mut(e)
1704            .get_mut::<ScrollTransitionState>()
1705            .unwrap()
1706            .target = Vec2::new(0.0, 100.0);
1707        advance(&mut world, 0.5);
1708        schedule.run(&mut world);
1709        let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1710        assert!(y > 0.0 && y < 100.0, "mid-ease expected, got {y}");
1711
1712        // The widget writes the offset directly: the value must survive as-is...
1713        world.entity_mut(e).get_mut::<ScrollPosition>().unwrap().0 = Vec2::new(0.0, 42.0);
1714        advance(&mut world, 0.25);
1715        schedule.run(&mut world);
1716        assert_eq!(
1717            world.entity(e).get::<ScrollPosition>().unwrap().0,
1718            Vec2::new(0.0, 42.0)
1719        );
1720        // ...and stay put on later frames (target adopted, runners dropped).
1721        advance(&mut world, 0.25);
1722        schedule.run(&mut world);
1723        assert_eq!(
1724            world.entity(e).get::<ScrollPosition>().unwrap().0,
1725            Vec2::new(0.0, 42.0)
1726        );
1727    }
1728
1729    /// `snap_to` (what `bridge_scrollbar_capture` calls each drag frame) parks a
1730    /// mid-flight ease at the live offset: the stale target stops mattering.
1731    #[test]
1732    fn scroll_snap_to_parks_a_mid_flight_ease() {
1733        let mut world = World::new();
1734        world.init_resource::<crate::layer::LayerContentDirt>();
1735        world.insert_resource(Time::<()>::default());
1736        let mut schedule = Schedule::default();
1737        schedule.add_systems(drive_scroll_transition);
1738
1739        let e = world
1740            .spawn((
1741                ScrollTransitionInput(timing(1.0, Easing::Linear)),
1742                ScrollTransitionState::default(),
1743                ScrollPosition::default(),
1744            ))
1745            .id();
1746        schedule.run(&mut world); // seed resting state at 0
1747
1748        world
1749            .entity_mut(e)
1750            .get_mut::<ScrollTransitionState>()
1751            .unwrap()
1752            .target = Vec2::new(0.0, 100.0);
1753        advance(&mut world, 0.5);
1754        schedule.run(&mut world);
1755        let live = world.entity(e).get::<ScrollPosition>().unwrap().0;
1756        assert!(
1757            live.y > 0.0 && live.y < 100.0,
1758            "mid-ease expected, got {live:?}"
1759        );
1760
1761        world
1762            .entity_mut(e)
1763            .get_mut::<ScrollTransitionState>()
1764            .unwrap()
1765            .snap_to(live);
1766        advance(&mut world, 0.5);
1767        schedule.run(&mut world);
1768        assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
1769        advance(&mut world, 0.5);
1770        schedule.run(&mut world);
1771        assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
1772    }
1773}