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 `animatedStyle` (imperative) 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::prelude::*;
34use bevy::ui::{ScrollPosition, UiTransform};
35use serde::Deserialize;
36
37use crate::protocol::{Length, Style, Time as WireTime};
38use crate::ui_map::{length_to_val, parse_color};
39
40/// CSS-like per-channel transition timing, set on [`Style::transition`]. Each
41/// field, if present, makes that channel ease on change; `all` is the fallback for
42/// channels without an explicit entry. `transform` covers all six transform
43/// channels together.
44#[derive(Debug, Clone, Default, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct Transition {
47    /// Fallback applied to any channel without its own entry.
48    pub all: Option<ChannelTransition>,
49    /// Applies to every transform channel (translate/scale/rotate).
50    pub transform: Option<ChannelTransition>,
51    pub opacity: Option<ChannelTransition>,
52    pub background_color: Option<ChannelTransition>,
53    /// Applies to every size channel (width/height/maxWidth/maxHeight). These are
54    /// *layout* properties — easing one re-flows the surrounding content (a real
55    /// accordion), unlike the post-layout `transform`.
56    pub size: Option<ChannelTransition>,
57    /// Eases the scroll offset (`ScrollPosition`) of an `overflow: scroll` node
58    /// toward its target on change — the target being a controlled `scrollTop`/
59    /// `scrollLeft`, a `scrollTo`-style jump, or accumulated wheel input. Covers
60    /// both axes. Unlike the others, scroll's target lives in `Props` (it's a
61    /// controlled value), so it's fed by the scroll write path, not `from_style`.
62    pub scroll: Option<ChannelTransition>,
63}
64
65impl Transition {
66    /// The transition for the transform channels (explicit, else `all`).
67    pub fn for_transform(&self) -> Option<&ChannelTransition> {
68        self.transform.as_ref().or(self.all.as_ref())
69    }
70    /// The transition for opacity (explicit, else `all`).
71    pub fn for_opacity(&self) -> Option<&ChannelTransition> {
72        self.opacity.as_ref().or(self.all.as_ref())
73    }
74    /// The transition for background color (explicit, else `all`).
75    pub fn for_background(&self) -> Option<&ChannelTransition> {
76        self.background_color.as_ref().or(self.all.as_ref())
77    }
78    /// The transition for the size channels (explicit, else `all`).
79    pub fn for_size(&self) -> Option<&ChannelTransition> {
80        self.size.as_ref().or(self.all.as_ref())
81    }
82    /// The transition for the scroll offset (explicit, else `all`).
83    pub fn for_scroll(&self) -> Option<&ChannelTransition> {
84        self.scroll.as_ref().or(self.all.as_ref())
85    }
86}
87
88/// Timing for one channel. A spring (any of `stiffness`/`damping` set) or, by
89/// default, a timing curve. `duration`/`delay` are [`WireTime`]s: a bare number is
90/// milliseconds (the JS-facing unit), a string carries an explicit unit
91/// (`"200ms"`/`"0.2s"`), and both decode to the seconds the [`Driver`] consumes.
92#[derive(Debug, Clone, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ChannelTransition {
95    /// Timing duration (default `0.3s`). Ignored for a spring.
96    pub duration: Option<WireTime>,
97    #[serde(default)]
98    pub easing: Easing,
99    /// Hold this long before easing (default `0`).
100    #[serde(default)]
101    pub delay: WireTime,
102    /// Spring stiffness; presence (with/without `damping`) selects a spring.
103    pub stiffness: Option<f32>,
104    pub damping: Option<f32>,
105    #[serde(default = "default_mass")]
106    pub mass: f32,
107}
108
109fn default_mass() -> f32 {
110    1.0
111}
112
113impl ChannelTransition {
114    /// Build the [`Driver`] that eases the value to `to` from its live reading.
115    /// A spring if `stiffness`/`damping` are present, else a (optionally delayed)
116    /// timing curve.
117    fn to_driver(&self, to: f32) -> Driver {
118        if self.stiffness.is_some() || self.damping.is_some() {
119            Driver::Spring {
120                to,
121                stiffness: self.stiffness.unwrap_or(100.0),
122                damping: self.damping.unwrap_or(10.0),
123                mass: self.mass,
124            }
125        } else {
126            let timing = Driver::Timing {
127                to,
128                duration: self.duration.map(WireTime::seconds).unwrap_or(0.3),
129                easing: self.easing,
130            };
131            let delay = self.delay.seconds();
132            if delay > 0.0 {
133                Driver::Delay {
134                    delay,
135                    animation: Box::new(timing),
136                }
137            } else {
138                timing
139            }
140        }
141    }
142}
143
144/// The resolved per-channel target for a transitioning entity, plus the spec.
145/// Written by [`crate::ui_map::apply_style`] from the *merged* style and read each
146/// frame by [`drive_transitions`]. Never written by the engine — keeping it free
147/// of the live components it animates avoids a target-chases-animation feedback
148/// loop. `None` on a channel means "unspecified" (its identity default is used).
149#[derive(Component, Debug, Clone, Default)]
150pub struct TransitionInput {
151    pub spec: Transition,
152    pub translate_x: Option<Length>,
153    pub translate_y: Option<Length>,
154    pub scale: Option<f32>,
155    pub scale_x: Option<f32>,
156    pub scale_y: Option<f32>,
157    pub rotate: Option<f32>,
158    pub opacity: Option<f32>,
159    /// Target background color as straight rgba (no opacity folded in — the
160    /// opacity channel owns alpha, applied after the color, like the animated path).
161    pub background_color: Option<[f32; 4]>,
162    // Size targets, written onto `Node` (layout). `None` → unset (`Val::Auto`).
163    pub width: Option<Length>,
164    pub height: Option<Length>,
165    pub max_width: Option<Length>,
166    pub max_height: Option<Length>,
167}
168
169impl TransitionInput {
170    /// Build the input from a resolved style, or `None` if it has no `transition`.
171    fn from_style(style: &Style) -> Option<Self> {
172        let spec = style.transition.clone()?;
173        let t = style.transform.unwrap_or_default();
174        Some(Self {
175            spec,
176            translate_x: t.translate_x,
177            translate_y: t.translate_y,
178            scale: t.scale,
179            scale_x: t.scale_x,
180            scale_y: t.scale_y,
181            rotate: t.rotate.map(crate::protocol::Angle::radians),
182            opacity: style.opacity,
183            background_color: style
184                .background_color
185                .as_deref()
186                .map(|hex| color_to_rgba(parse_color(hex))),
187            width: style.width,
188            height: style.height,
189            max_width: style.max_width,
190            max_height: style.max_height,
191        })
192    }
193}
194
195/// Per-entity transition runtime: one [`Runner`]-backed channel per animatable
196/// property. Persists across re-renders (the engine owns it); created lazily by
197/// [`apply_transition`]. `#[require(UiTransform)]` so the drive query always
198/// matches even for an opacity/color-only transition.
199#[derive(Component, Default)]
200#[require(UiTransform)]
201pub struct TransitionState {
202    translate_x: ProgressChannel<Length>,
203    translate_y: ProgressChannel<Length>,
204    scale: Channel,
205    scale_x: Channel,
206    scale_y: Channel,
207    rotate: Channel,
208    opacity: Channel,
209    color: ProgressChannel<[f32; 4]>,
210    width: ProgressChannel<Length>,
211    height: ProgressChannel<Length>,
212    max_width: ProgressChannel<Length>,
213    max_height: ProgressChannel<Length>,
214    initialized: bool,
215}
216
217/// One scalar channel: its current reading, last target, and active driver.
218#[derive(Default)]
219struct Channel {
220    current: f32,
221    target: f32,
222    runner: Option<Runner>,
223}
224
225impl Channel {
226    /// Snap to `value` without animating (used to seed the resting state so an
227    /// element doesn't animate from zero when it first appears).
228    fn init(&mut self, value: f32) {
229        self.current = value;
230        self.target = value;
231        self.runner = None;
232    }
233
234    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
235    /// current value.
236    fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
237        if target != self.target {
238            self.target = target;
239            match spec {
240                Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
241                None => {
242                    self.current = target;
243                    self.runner = None;
244                }
245            }
246        }
247        if let Some(r) = self.runner.as_mut() {
248            let (v, done) = r.step(dt);
249            self.current = v;
250            if done {
251                self.runner = None;
252            }
253        }
254        self.current
255    }
256}
257
258/// A progress-lerped channel (colors, [`Length`]s): a single [`Runner`] eases a
259/// progress value 0→1 and the reading lerps from `start` to `target`. Used for
260/// quantities that can't be time-stepped directly in value space (a color's four
261/// channels move together; a `Length` carries a unit). [`ProgressChannel::drive`]
262/// returns the current reading every frame — a caller writing a relayout-
263/// triggering target (`Node`) compares before writing, like every other apply
264/// path.
265#[derive(Default)]
266struct ProgressChannel<T> {
267    current: T,
268    target: T,
269    start: T,
270    runner: Option<Runner>,
271}
272
273impl<T: Lerp + PartialEq> ProgressChannel<T> {
274    /// Snap to `value` without animating (used to seed the resting state so an
275    /// element doesn't animate from zero when it first appears).
276    fn init(&mut self, value: T) {
277        self.current = value;
278        self.target = value;
279        self.runner = None;
280    }
281
282    /// Advance toward `target`. `spec` `Some` eases; `None` snaps. Returns the
283    /// current reading.
284    fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
285        if target != self.target {
286            self.target = target;
287            match spec {
288                Some(s) => {
289                    self.start = self.current;
290                    self.runner = Some(build_runner(&s.to_driver(1.0), 0.0));
291                }
292                None => {
293                    self.current = target;
294                    self.runner = None;
295                }
296            }
297        }
298        if let Some(r) = self.runner.as_mut() {
299            let (p, done) = r.step(dt);
300            self.current = self.start.lerp(self.target, p);
301            if done {
302                self.current = self.target;
303                self.runner = None;
304            }
305        }
306        self.current
307    }
308}
309
310/// Interpolate two lengths of the same unit; mixed units or `auto` can't be
311/// interpolated, so it snaps to the target.
312impl Lerp for Length {
313    fn lerp(self, other: Self, t: f32) -> Self {
314        use Length::*;
315        let lerp = |x: f32, y: f32| x + (y - x) * t;
316        match (self, other) {
317            (Px(x), Px(y)) => Px(lerp(x, y)),
318            (Percent(x), Percent(y)) => Percent(lerp(x, y)),
319            (Vw(x), Vw(y)) => Vw(lerp(x, y)),
320            (Vh(x), Vh(y)) => Vh(lerp(x, y)),
321            (VMin(x), VMin(y)) => VMin(lerp(x, y)),
322            (VMax(x), VMax(y)) => VMax(lerp(x, y)),
323            _ => other,
324        }
325    }
326}
327
328/// The scroll-easing **spec** input: the `transition.scroll` timing, reinserted
329/// fresh on every render (like [`TransitionInput`]) so a changed spec takes effect.
330/// Present only while `transition.scroll` (or `all`) is set. The *target* it eases
331/// toward is NOT here — scroll's target is a controlled `Props` value, fed into
332/// [`ScrollTransitionState`] by the scroll write path / wheel handler.
333#[derive(Component, Debug, Clone)]
334pub struct ScrollTransitionInput(pub ChannelTransition);
335
336/// The scroll-easing **runtime state**: the target offset plus a per-axis eased
337/// [`Channel`]. Persists across re-renders ([`insert_if_new`]). `target` is written
338/// by the feeders ([`crate::reconcile::update_controlled_scroll`] and
339/// `crate::scroll::apply_scroll`); [`drive_scroll_transition`] eases `ScrollPosition`
340/// toward it. Mirrors the [`TransitionState`] half of the split.
341#[derive(Component, Default)]
342pub struct ScrollTransitionState {
343    /// The offset to ease toward (already clamped to the scroll range by the feeder).
344    pub(crate) target: Vec2,
345    x: Channel,
346    y: Channel,
347    initialized: bool,
348}
349
350/// Stamp (or clear) the scroll-ease components from `transition.scroll`. Called
351/// from the reconciler's generic node paths (scroll containers are plain `<node>`s),
352/// alongside `apply_scroll_listener`/`apply_scroll_step`. The spec input is always
353/// reinserted (so a spec change lands); the state is created once and persists.
354pub fn apply_scroll_transition(ec: &mut EntityCommands, style: &Option<Style>) {
355    match style
356        .as_ref()
357        .and_then(|s| s.transition.as_ref())
358        .and_then(|t| t.for_scroll())
359    {
360        Some(spec) => {
361            ec.insert(ScrollTransitionInput(spec.clone()));
362            ec.insert_if_new(ScrollTransitionState::default());
363        }
364        None => {
365            ec.remove::<ScrollTransitionInput>();
366            ec.remove::<ScrollTransitionState>();
367        }
368    }
369}
370
371/// Ease each `ScrollTransitionState` node's `ScrollPosition` toward its `target`
372/// using the same per-channel [`Runner`] as [`drive_transitions`]. Writes only on a
373/// frame the eased value actually moved, so a settled offset doesn't spam
374/// `Changed<ScrollPosition>` (and thus `onScroll`). The target is pre-clamped by the
375/// feeders; Bevy clamps the *rendered* offset regardless.
376pub fn drive_scroll_transition(
377    time: Res<Time>,
378    mut query: Query<(
379        &ScrollTransitionInput,
380        &mut ScrollTransitionState,
381        &mut ScrollPosition,
382    )>,
383) {
384    let dt = time.delta_secs();
385    for (input, mut state, mut pos) in &mut query {
386        // Seed resting state to the live offset so the first target change eases from
387        // where the node actually is, not from zero.
388        if !state.initialized {
389            state.x.init(pos.0.x);
390            state.y.init(pos.0.y);
391            state.target = pos.0;
392            state.initialized = true;
393        }
394        let spec = &input.0;
395        let target = state.target;
396        let nx = state.x.drive(target.x, Some(spec), dt);
397        let ny = state.y.drive(target.y, Some(spec), dt);
398        // Conditional write: equal assignment would still trip change detection.
399        if pos.0.x != nx || pos.0.y != ny {
400            pos.0 = Vec2::new(nx, ny);
401        }
402    }
403}
404
405/// Stamp (or clear) the transition components on a host element. Called from
406/// [`crate::ui_map::apply_style`] with the resolved style, so the input always
407/// reflects the current `Interaction` (base / hover / press). Sibling to
408/// `apply_animated` in the reconciler's apply pattern.
409pub fn apply_transition(ec: &mut EntityCommands, style: &Option<Style>) {
410    match style.as_ref().and_then(TransitionInput::from_style) {
411        Some(input) => {
412            ec.insert(input);
413            // The runtime state persists across re-renders, so only create it once.
414            ec.insert_if_new(TransitionState::default());
415        }
416        None => {
417            ec.remove::<TransitionInput>();
418            ec.remove::<TransitionState>();
419        }
420    }
421}
422
423/// Advance every transitioning entity toward its [`TransitionInput`] target and
424/// write the eased value onto `UiTransform` / `BackgroundColor` / alpha. Runs
425/// after `apply_interaction_styles` (and thus after the op drain) so its writes
426/// land last in the frame.
427#[allow(clippy::type_complexity)]
428pub fn drive_transitions(
429    time: Res<Time>,
430    mut commands: Commands,
431    mut query: Query<(
432        Entity,
433        &TransitionInput,
434        &mut TransitionState,
435        &mut UiTransform,
436        Option<&mut BackgroundColor>,
437        Option<&mut TextColor>,
438        Option<&mut ImageNode>,
439        Option<&mut Node>,
440        Option<&AnimatedNode>,
441    )>,
442) {
443    let dt = time.delta_secs();
444    for (entity, input, mut state, mut transform, bg, text_color, image, node, anim) in &mut query {
445        // Seed resting values on first sight so a freshly mounted element snaps to
446        // its initial style instead of animating in from zero.
447        if !state.initialized {
448            state
449                .translate_x
450                .init(input.translate_x.unwrap_or(Length::Px(0.0)));
451            state
452                .translate_y
453                .init(input.translate_y.unwrap_or(Length::Px(0.0)));
454            state.scale.init(input.scale.unwrap_or(1.0));
455            state.scale_x.init(input.scale_x.unwrap_or(1.0));
456            state.scale_y.init(input.scale_y.unwrap_or(1.0));
457            state.rotate.init(input.rotate.unwrap_or(0.0));
458            state.opacity.init(input.opacity.unwrap_or(1.0));
459            if let Some(c) = input.background_color {
460                state.color.init(c);
461            }
462            state.width.init(input.width.unwrap_or(Length::Auto));
463            state.height.init(input.height.unwrap_or(Length::Auto));
464            state
465                .max_width
466                .init(input.max_width.unwrap_or(Length::Auto));
467            state
468                .max_height
469                .init(input.max_height.unwrap_or(Length::Auto));
470            state.initialized = true;
471        }
472
473        // `animatedStyle` (imperative) wins: skip any channel it already drives.
474        let skip_transform = anim.is_some_and(|a| a.0.has_transform());
475        let skip_opacity = anim.is_some_and(|a| a.0.contains(AnimatableProperty::Opacity));
476        let skip_bg = anim.is_some_and(|a| a.0.contains(AnimatableProperty::BackgroundColor));
477
478        // Transform: only when a transform transition is declared; otherwise the
479        // static `UiTransform` from `apply_style` stands untouched. Only specified
480        // channels are written (passing `None` keeps `build_ui_transform`'s scale
481        // precedence intact).
482        if input.spec.for_transform().is_some() && !skip_transform {
483            let s = input.spec.for_transform();
484            let tx = input
485                .translate_x
486                .map(|t| length_to_val(state.translate_x.drive(t, s, dt)));
487            let ty = input
488                .translate_y
489                .map(|t| length_to_val(state.translate_y.drive(t, s, dt)));
490            let sc = input.scale.map(|t| state.scale.drive(t, s, dt));
491            let scx = input.scale_x.map(|t| state.scale_x.drive(t, s, dt));
492            let scy = input.scale_y.map(|t| state.scale_y.drive(t, s, dt));
493            let rot = input.rotate.map(|t| state.rotate.drive(t, s, dt));
494            // Compare-before-write so a settled transition doesn't dirty change
495            // detection every frame (read via `Deref`, write via `DerefMut`).
496            let new = build_ui_transform(tx, ty, sc, scx, scy, rot);
497            if *transform != new {
498                *transform = new;
499            }
500        }
501
502        let mut bg = bg;
503
504        // Opacity owns the final alpha across background/text/image. Resolved
505        // before the background write so it can be baked into that color —
506        // otherwise the two writes would ping-pong the alpha channel every frame
507        // and the compare-before-write guards would never settle.
508        let alpha = if !skip_opacity && let Some(target) = input.opacity {
509            Some(state.opacity.drive(target, input.spec.for_opacity(), dt))
510        } else {
511            None
512        };
513
514        if !skip_bg && let Some(target) = input.background_color {
515            let mut rgba = state.color.drive(target, input.spec.for_background(), dt);
516            if let Some(a) = alpha {
517                rgba[3] = a;
518            }
519            let color = rgba_to_color(rgba);
520            match &mut bg {
521                Some(c) if c.0 != color => c.0 = color,
522                Some(_) => {}
523                None => {
524                    commands.entity(entity).insert(BackgroundColor(color));
525                }
526            }
527        }
528
529        // Opacity always applies when set (even with no opacity transition: it then
530        // snaps), so a transitioning background color doesn't clobber the alpha.
531        if let Some(alpha) = alpha {
532            if let Some(c) = &mut bg
533                && c.0.alpha() != alpha
534            {
535                c.0 = c.0.with_alpha(alpha);
536            }
537            if let Some(mut tc) = text_color
538                && tc.0.alpha() != alpha
539            {
540                tc.0 = tc.0.with_alpha(alpha);
541            }
542            if let Some(mut img) = image
543                && img.color.alpha() != alpha
544            {
545                img.color = img.color.with_alpha(alpha);
546            }
547        }
548
549        // Size (layout): ease the specified `Node` dimensions. Writing `Node`
550        // re-triggers Bevy's layout, so each field is compared before writing —
551        // a settled transition doesn't force a relayout every frame, and a
552        // re-render that reset `Node` to its static style is corrected here.
553        // The animations engine never writes `Node`, so no precedence check is
554        // needed.
555        if input.spec.for_size().is_some()
556            && let Some(mut node) = node
557        {
558            let s = input.spec.for_size();
559            if let Some(t) = input.width {
560                let v = length_to_val(state.width.drive(t, s, dt));
561                if node.width != v {
562                    node.width = v;
563                }
564            }
565            if let Some(t) = input.height {
566                let v = length_to_val(state.height.drive(t, s, dt));
567                if node.height != v {
568                    node.height = v;
569                }
570            }
571            if let Some(t) = input.max_width {
572                let v = length_to_val(state.max_width.drive(t, s, dt));
573                if node.max_width != v {
574                    node.max_width = v;
575                }
576            }
577            if let Some(t) = input.max_height {
578                let v = length_to_val(state.max_height.drive(t, s, dt));
579                if node.max_height != v {
580                    node.max_height = v;
581                }
582            }
583        }
584    }
585}
586
587fn color_to_rgba(color: Color) -> [f32; 4] {
588    let s = color.to_srgba();
589    [s.red, s.green, s.blue, s.alpha]
590}
591
592fn rgba_to_color(rgba: [f32; 4]) -> Color {
593    Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3])
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use crate::animations::AnimatedBindings;
600    use std::time::Duration;
601
602    fn timing(duration: f32, easing: Easing) -> ChannelTransition {
603        ChannelTransition {
604            duration: Some(WireTime::from_secs(duration)),
605            easing,
606            delay: WireTime::from_secs(0.0),
607            stiffness: None,
608            damping: None,
609            mass: 1.0,
610        }
611    }
612
613    fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
614        serde_json::from_value(json).expect("valid json")
615    }
616
617    #[test]
618    fn channel_resolution_falls_back_to_all() {
619        let t: Transition = parse(serde_json::json!({
620            "all": { "duration": 100 },
621            "opacity": { "duration": 200 },
622        }));
623        // `opacity` has its own entry; `transform`/`background` fall back to `all`.
624        // The wire numbers are milliseconds → seconds (200ms → 0.2s, 100ms → 0.1s).
625        let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
626        assert!(t.for_opacity().is_some());
627        assert_eq!(secs(t.for_opacity().unwrap()), Some(0.2));
628        assert_eq!(secs(t.for_transform().unwrap()), Some(0.1));
629        assert_eq!(secs(t.for_background().unwrap()), Some(0.1));
630
631        // No `all`: an unspecified channel has no transition.
632        let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
633        assert!(t.for_transform().is_none());
634        assert!(t.for_opacity().is_some());
635    }
636
637    #[test]
638    fn to_driver_selects_spring_or_timing() {
639        let spring = ChannelTransition {
640            duration: None,
641            easing: Easing::Linear,
642            delay: WireTime::from_secs(0.0),
643            stiffness: Some(120.0),
644            damping: Some(14.0),
645            mass: 1.0,
646        };
647        assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
648        assert!(matches!(
649            timing(0.3, Easing::Linear).to_driver(1.0),
650            Driver::Timing { .. }
651        ));
652        // A delay wraps the timing in a Delay driver.
653        let delayed = ChannelTransition {
654            delay: WireTime::from_secs(0.2),
655            ..timing(0.3, Easing::Linear)
656        };
657        assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
658    }
659
660    #[test]
661    fn channel_snaps_without_spec_and_eases_with_one() {
662        // No spec → snap straight to target.
663        let mut ch = Channel::default();
664        ch.init(1.0);
665        assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
666
667        // With a 1s linear timing → halfway after 0.5s.
668        let mut ch = Channel::default();
669        ch.init(1.0);
670        let spec = timing(1.0, Easing::Linear);
671        ch.drive(0.0, Some(&spec), 0.0); // arm; no time elapsed yet
672        let v = ch.drive(0.0, Some(&spec), 0.5); // same target, advance 0.5s
673        assert!((v - 0.5).abs() < 1e-3, "halfway expected ~0.5, got {v}");
674        let v = ch.drive(0.0, Some(&spec), 0.5);
675        assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
676        assert!(ch.runner.is_none(), "runner dropped once finished");
677    }
678
679    #[test]
680    fn color_channel_lerps_to_target() {
681        let mut c = ProgressChannel::<[f32; 4]>::default();
682        c.init([0.0, 0.0, 0.0, 1.0]);
683        let spec = timing(1.0, Easing::Linear);
684        c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.0); // arm
685        let mid = c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.5);
686        assert!((mid[0] - 0.5).abs() < 1e-3);
687        assert!((mid[1] - 0.25).abs() < 1e-3);
688        assert!((mid[2] - 0.0).abs() < 1e-3);
689    }
690
691    /// Build a one-entity world running `drive_transitions`, advancing `Time`.
692    fn drive_world() -> (World, Schedule) {
693        let mut world = World::new();
694        world.insert_resource(Time::<()>::default());
695        let mut schedule = Schedule::default();
696        schedule.add_systems(drive_transitions);
697        (world, schedule)
698    }
699
700    fn advance(world: &mut World, secs: f32) {
701        world
702            .resource_mut::<Time>()
703            .advance_by(Duration::from_secs_f32(secs));
704    }
705
706    #[test]
707    fn system_eases_scale_on_press_then_release() {
708        let (mut world, mut schedule) = drive_world();
709        let spec = Transition {
710            transform: Some(timing(1.0, Easing::Linear)),
711            ..Default::default()
712        };
713        let e = world
714            .spawn((
715                TransitionInput {
716                    spec: spec.clone(),
717                    scale: Some(1.0),
718                    ..Default::default()
719                },
720                TransitionState::default(),
721                UiTransform::default(),
722            ))
723            .id();
724
725        // First frame seeds the resting state — scale snaps to 1, no animation.
726        schedule.run(&mut world);
727        assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
728
729        // Press: target 0.95. Halfway through a 1s ease → ~0.975.
730        world
731            .entity_mut(e)
732            .get_mut::<TransitionInput>()
733            .unwrap()
734            .scale = Some(0.95);
735        advance(&mut world, 0.5);
736        schedule.run(&mut world);
737        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
738        assert!(
739            (sx - 0.975).abs() < 1e-2,
740            "mid-press expected ~0.975, got {sx}"
741        );
742
743        // Finish the press ease.
744        advance(&mut world, 0.5);
745        schedule.run(&mut world);
746        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
747        assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
748
749        // Release back to 1.0, eases again.
750        world
751            .entity_mut(e)
752            .get_mut::<TransitionInput>()
753            .unwrap()
754            .scale = Some(1.0);
755        advance(&mut world, 0.5);
756        schedule.run(&mut world);
757        let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
758        assert!(
759            (sx - 0.975).abs() < 1e-2,
760            "mid-release expected ~0.975, got {sx}"
761        );
762    }
763
764    #[test]
765    fn system_eases_percent_translate() {
766        let (mut world, mut schedule) = drive_world();
767        let spec = Transition {
768            transform: Some(timing(1.0, Easing::Linear)),
769            ..Default::default()
770        };
771        let e = world
772            .spawn((
773                TransitionInput {
774                    spec,
775                    translate_x: Some(Length::Percent(0.0)),
776                    ..Default::default()
777                },
778                TransitionState::default(),
779                UiTransform::default(),
780            ))
781            .id();
782
783        // First frame seeds the resting state at 0% — snaps, no animation.
784        schedule.run(&mut world);
785        assert_eq!(
786            world.entity(e).get::<UiTransform>().unwrap().translation.x,
787            Val::Percent(0.0)
788        );
789
790        // Retarget to 100%: halfway through a 1s linear ease → ~50%, still in
791        // percent units (not collapsed to px).
792        world
793            .entity_mut(e)
794            .get_mut::<TransitionInput>()
795            .unwrap()
796            .translate_x = Some(Length::Percent(100.0));
797        advance(&mut world, 0.5);
798        schedule.run(&mut world);
799        let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
800        assert!(
801            matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
802            "mid expected ~50%, got {tx:?}"
803        );
804
805        advance(&mut world, 0.5);
806        schedule.run(&mut world);
807        assert_eq!(
808            world.entity(e).get::<UiTransform>().unwrap().translation.x,
809            Val::Percent(100.0)
810        );
811    }
812
813    #[test]
814    fn animated_style_channel_wins_over_transition() {
815        let (mut world, mut schedule) = drive_world();
816        let spec = Transition {
817            transform: Some(timing(1.0, Easing::Linear)),
818            ..Default::default()
819        };
820        // The entity also has an AnimatedNode binding for scale → transition must
821        // not touch the transform (the imperative path owns it).
822        let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
823            "scale": { "type": "shared", "id": 1 }
824        }))
825        .unwrap();
826        let e = world
827            .spawn((
828                TransitionInput {
829                    spec,
830                    scale: Some(1.0),
831                    ..Default::default()
832                },
833                TransitionState::default(),
834                UiTransform::from_scale(Vec2::splat(2.0)), // a value the imperative path "set"
835                AnimatedNode(bindings),
836            ))
837            .id();
838
839        schedule.run(&mut world);
840        world
841            .entity_mut(e)
842            .get_mut::<TransitionInput>()
843            .unwrap()
844            .scale = Some(0.95);
845        advance(&mut world, 0.5);
846        schedule.run(&mut world);
847        // Untouched by the transition: still the imperative 2.0.
848        assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
849    }
850
851    /// Once a transition has settled, `drive_transitions` must stop marking the
852    /// target components changed (compare-before-write) — a settled hover/press
853    /// style shouldn't keep transform propagation / extraction hot forever.
854    #[test]
855    fn settled_transition_does_not_dirty_components() {
856        #[derive(Resource, Default)]
857        struct Dirty(usize);
858
859        let (mut world, mut schedule) = drive_world();
860        world.init_resource::<Dirty>();
861        let spec = Transition {
862            transform: Some(timing(0.2, Easing::Linear)),
863            background_color: Some(timing(0.2, Easing::Linear)),
864            opacity: Some(timing(0.2, Easing::Linear)),
865            ..Default::default()
866        };
867        let e = world
868            .spawn((
869                TransitionInput {
870                    spec,
871                    scale: Some(1.0),
872                    // Deliberately different from the bg target's alpha: opacity
873                    // owns the final alpha, and the two writes must still settle.
874                    opacity: Some(0.5),
875                    background_color: Some([1.0, 0.0, 0.0, 1.0]),
876                    ..Default::default()
877                },
878                TransitionState::default(),
879                UiTransform::default(),
880                BackgroundColor(Color::WHITE),
881            ))
882            .id();
883
884        type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
885
886        let mut detect = Schedule::default();
887        detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
888            dirty.0 = q.iter().count();
889        });
890
891        // Seed, retarget, and run the ease well past completion.
892        schedule.run(&mut world);
893        world
894            .entity_mut(e)
895            .get_mut::<TransitionInput>()
896            .unwrap()
897            .scale = Some(0.9);
898        advance(&mut world, 0.5);
899        schedule.run(&mut world);
900        detect.run(&mut world); // consume all the churn so far
901
902        advance(&mut world, 0.5);
903        schedule.run(&mut world);
904        detect.run(&mut world);
905        assert_eq!(
906            world.resource::<Dirty>().0,
907            0,
908            "a settled transition must not dirty anything"
909        );
910    }
911
912    #[test]
913    fn lerp_length_same_unit_else_snaps() {
914        assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
915        assert_eq!(
916            Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
917            Length::Percent(25.0)
918        );
919        // `auto` or mixed units can't be interpolated → snap to the target.
920        assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
921        assert_eq!(
922            Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
923            Length::Percent(10.0)
924        );
925    }
926
927    fn px(l: Length) -> f32 {
928        match l {
929            Length::Px(v) => v,
930            other => panic!("expected Px, got {other:?}"),
931        }
932    }
933
934    #[test]
935    fn length_channel_eases_then_idles() {
936        let mut ch = ProgressChannel::<Length>::default();
937        ch.init(Length::Px(0.0));
938        let spec = timing(1.0, Easing::Linear);
939        // Arm toward 100; the arm frame reports the (still 0) value.
940        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
941        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
942        assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
943        // Settled and target unchanged → idle: the runner is dropped and the
944        // reading holds steady (the caller's compare skips the `Node` write).
945        assert!(ch.runner.is_none(), "runner dropped once settled");
946        assert_eq!(
947            ch.drive(Length::Px(100.0), Some(&spec), 0.5),
948            Length::Px(100.0)
949        );
950    }
951
952    #[test]
953    fn system_eases_max_height_layout() {
954        let (mut world, mut schedule) = drive_world();
955        let spec = Transition {
956            size: Some(timing(1.0, Easing::Linear)),
957            ..Default::default()
958        };
959        let e = world
960            .spawn((
961                TransitionInput {
962                    spec,
963                    max_height: Some(Length::Px(120.0)),
964                    ..Default::default()
965                },
966                TransitionState::default(),
967                Node::default(),
968                UiTransform::default(),
969            ))
970            .id();
971
972        // First frame seeds the resting state (120) without writing Node.
973        schedule.run(&mut world);
974
975        // Collapse to 0: halfway through a 1s ease → ~60.
976        world
977            .entity_mut(e)
978            .get_mut::<TransitionInput>()
979            .unwrap()
980            .max_height = Some(Length::Px(0.0));
981        advance(&mut world, 0.5);
982        schedule.run(&mut world);
983        let mh = world.entity(e).get::<Node>().unwrap().max_height;
984        assert!(
985            matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
986            "mid expected ~60px, got {mh:?}"
987        );
988
989        advance(&mut world, 0.5);
990        schedule.run(&mut world);
991        let mh = world.entity(e).get::<Node>().unwrap().max_height;
992        assert!(
993            matches!(mh, Val::Px(v) if v.abs() < 1e-3),
994            "settled expected 0px, got {mh:?}"
995        );
996    }
997
998    /// `drive_scroll_transition` eases `ScrollPosition` toward the state's target
999    /// (seeded at the live offset on first sight) and settles exactly on it.
1000    #[test]
1001    fn system_eases_scroll_toward_target() {
1002        let mut world = World::new();
1003        world.insert_resource(Time::<()>::default());
1004        let mut schedule = Schedule::default();
1005        schedule.add_systems(drive_scroll_transition);
1006
1007        let e = world
1008            .spawn((
1009                ScrollTransitionInput(timing(1.0, Easing::Linear)),
1010                ScrollTransitionState::default(),
1011                ScrollPosition::default(),
1012            ))
1013            .id();
1014
1015        // First frame seeds resting state at the live offset (0) — no movement.
1016        schedule.run(&mut world);
1017        assert_eq!(
1018            world.entity(e).get::<ScrollPosition>().unwrap().0,
1019            Vec2::ZERO
1020        );
1021
1022        // Target y=100; halfway through a 1s linear ease → ~50.
1023        world
1024            .entity_mut(e)
1025            .get_mut::<ScrollTransitionState>()
1026            .unwrap()
1027            .target = Vec2::new(0.0, 100.0);
1028        advance(&mut world, 0.5);
1029        schedule.run(&mut world);
1030        let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1031        assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
1032
1033        // Finish the ease → exactly 100.
1034        advance(&mut world, 0.5);
1035        schedule.run(&mut world);
1036        assert_eq!(
1037            world.entity(e).get::<ScrollPosition>().unwrap().0,
1038            Vec2::new(0.0, 100.0)
1039        );
1040    }
1041}