Skip to main content

lumen_primitives/
transition.rs

1//! Generic per-property tween primitive.
2//!
3//! `Transition<T>` carries a start value, end value, start instant,
4//! duration, and easing. Querying [`Transition::sample`] returns the
5//! interpolated value at any wall-clock instant; once
6//! [`Transition::done`] is true the transition can be retired.
7//!
8//! The crate ships the primitive plus a [`Lerp`] impl for [`f32`] and
9//! [`Color`] - concrete property bridges (CSS `transition: opacity` ->
10//! `Transition<f32>` driving [`Opacity`], CSS `transition: bg` ->
11//! `Transition<Color>` driving [`Visuals.fill`]) live in the CSS
12//! application layer, not here.
13//!
14//! Designed for the markup-driven CSS `transition:` shorthand
15//! (Transitions Level 1 spec). Hover/press tints in [`crate::hover`]
16//! keep their bespoke state machine - their bidirectional snap-on-state-
17//! flip semantics don't fit a one-shot transition.
18
19use bevy_ecs::prelude::*;
20use lumen_core::components::{Color, Fill, Opacity, Visuals};
21use lumen_core::prelude::{Tick, TickStage};
22use lumen_core::time::{Duration, Instant};
23
24/// Tween-able value. Implementations should return a linear interpolation
25/// between `self` and `other` at fraction `t` in `[0, 1]`. Implementations
26/// are free to assume `t` is already easing-adjusted by [`Easing::apply`].
27pub trait Lerp: Copy + PartialEq {
28    /// Linear interpolation between `self` (at `t = 0`) and `other` (at
29    /// `t = 1`).
30    fn lerp(self, other: Self, t: f32) -> Self;
31}
32
33impl Lerp for f32 {
34    fn lerp(self, other: Self, t: f32) -> Self {
35        self + (other - self) * t
36    }
37}
38
39impl Lerp for Color {
40    fn lerp(self, other: Self, t: f32) -> Self {
41        Color {
42            r: self.r.lerp(other.r, t),
43            g: self.g.lerp(other.g, t),
44            b: self.b.lerp(other.b, t),
45            a: self.a.lerp(other.a, t),
46        }
47    }
48}
49
50/// Easing curve sampled by [`Easing::apply`]. The curves match common
51/// CSS / native-control timings:
52///
53/// - `Linear` - uniform speed; not common in UI but useful for tests.
54/// - `EaseIn` - cubic ease-in (slow start).
55/// - `EaseOut` - cubic ease-out (fast start, slow settle); matches the
56///   Cocoa AppKit / Material 3 short-transition curve.
57/// - `EaseInOut` - cubic ease-in-out.
58/// - `CubicBezier(p1x, p1y, p2x, p2y)` - CSS `cubic-bezier(...)` form.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum Easing {
61    /// `f(t) = t`.
62    Linear,
63    /// `f(t) = t^3`.
64    EaseIn,
65    /// `f(t) = 1 - (1 - t)^3`.
66    EaseOut,
67    /// `f(t) = 0.5*(2t)^3` for `t < 0.5`, mirrored above.
68    EaseInOut,
69    /// CSS `cubic-bezier(p1x, p1y, p2x, p2y)`. Anchors are implicit
70    /// `(0, 0)` and `(1, 1)`.
71    CubicBezier(f32, f32, f32, f32),
72}
73
74impl Easing {
75    /// Map a linear progress `t` in `[0, 1]` through the curve.
76    pub fn apply(self, t: f32) -> f32 {
77        let t = t.clamp(0.0, 1.0);
78        match self {
79            Easing::Linear => t,
80            Easing::EaseIn => t * t * t,
81            Easing::EaseOut => {
82                let inv = 1.0 - t;
83                1.0 - inv * inv * inv
84            }
85            Easing::EaseInOut => {
86                if t < 0.5 {
87                    4.0 * t * t * t
88                } else {
89                    let inv = -2.0 * t + 2.0;
90                    1.0 - inv * inv * inv * 0.5
91                }
92            }
93            Easing::CubicBezier(p1x, p1y, p2x, p2y) => cubic_bezier(p1x, p1y, p2x, p2y, t),
94        }
95    }
96}
97
98/// One-shot transition between two values of a tween-able type.
99///
100/// Use [`Transition::new`] to construct or `From<(T, T, Duration)>` for
101/// the common linear case. [`Transition::sample`] returns the eased value
102/// at any instant; [`Transition::done`] returns `true` once elapsed time
103/// has met or exceeded `duration` so the caller can despawn the component
104/// and stop tweening.
105#[derive(Component, Clone, Copy, Debug)]
106pub struct Transition<T: Lerp + Send + Sync + 'static> {
107    /// Value at `start`.
108    pub from: T,
109    /// Value at `start + duration`.
110    pub to: T,
111    /// Wall-clock instant the transition began.
112    pub start: Instant,
113    /// Total length of the transition.
114    pub duration: Duration,
115    /// Curve applied to the linear progress fraction.
116    pub easing: Easing,
117}
118
119impl<T: Lerp + Send + Sync + 'static> Transition<T> {
120    /// Construct a transition that starts at `now()` and runs over
121    /// `duration` with `easing`.
122    pub fn new(from: T, to: T, duration: Duration, easing: Easing) -> Self {
123        Self {
124            from,
125            to,
126            start: Instant::now(),
127            duration,
128            easing,
129        }
130    }
131
132    /// Linear fraction of total `duration` elapsed at `now`, clamped to
133    /// `[0, 1]`. Zero-duration transitions report `1` immediately so they
134    /// surface their `to` value without dividing by zero.
135    pub fn progress(&self, now: Instant) -> f32 {
136        if self.duration.is_zero() {
137            return 1.0;
138        }
139        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
140        (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
141    }
142
143    /// Eased value at `now`.
144    pub fn sample(&self, now: Instant) -> T {
145        let t = self.easing.apply(self.progress(now));
146        self.from.lerp(self.to, t)
147    }
148
149    /// `true` once the transition has met or exceeded `duration`.
150    pub fn done(&self, now: Instant) -> bool {
151        self.progress(now) >= 1.0
152    }
153}
154
155impl<T: Lerp + Send + Sync + 'static> From<(T, T, Duration)> for Transition<T> {
156    /// Convenience: `(from, to, duration).into()` builds a
157    /// [`Easing::Linear`] transition starting at `now()`.
158    fn from((from, to, duration): (T, T, Duration)) -> Self {
159        Self::new(from, to, duration, Easing::Linear)
160    }
161}
162
163/// Property identifier for a [`TransitionSpec`].
164///
165/// The v1 animatable set is colors + opacity - geometry-free visual
166/// properties only. Layout properties (`width`, `height`, padding, ...)
167/// are deliberately not transitionable: animating them would re-run
168/// layout every frame; the CSS parser warns and drops them.
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
170pub enum TransitionProperty {
171    /// CSS `opacity`; driven by [`step_opacity_transitions`] against the [`Opacity`] component.
172    Opacity,
173    /// CSS `background-color`; driven by [`step_background_transitions`]
174    /// against the solid [`Visuals::fill`]. Also parameterises the
175    /// hover / press tint tweens in [`crate::hover`] (duration + easing).
176    BackgroundColor,
177    /// CSS `color` / `text-color`; driven by
178    /// [`step_text_color_transitions`] against `TextStyle::color`.
179    TextColor,
180    /// CSS `border-color`; driven by [`step_border_color_transitions`]
181    /// against `Visuals::border.color`.
182    BorderColor,
183}
184
185impl TransitionProperty {
186    /// Parse a CSS property name into a recognised [`TransitionProperty`].
187    /// Unknown names return `None`; callers should warn and ignore.
188    pub fn from_css(name: &str) -> Option<Self> {
189        match name {
190            "opacity" => Some(Self::Opacity),
191            "background-color" | "background" | "bg" => Some(Self::BackgroundColor),
192            "color" | "text-color" => Some(Self::TextColor),
193            "border-color" => Some(Self::BorderColor),
194            _ => None,
195        }
196    }
197}
198
199/// Author-declared transition. CSS shorthand
200/// `transition: opacity 200ms ease-out` lands as one of these per
201/// comma-separated entry. The runtime stores all specs for an entity in
202/// a [`TransitionSpecs`] component and consults them when a relevant
203/// property changes (e.g. on a class flip).
204#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct TransitionSpec {
206    /// Which property this spec applies to.
207    pub property: TransitionProperty,
208    /// Length of the tween.
209    pub duration: Duration,
210    /// Easing curve.
211    pub easing: Easing,
212}
213
214/// Every [`TransitionSpec`] declared for an entity, in source order.
215#[derive(Component, Clone, Debug, Default, PartialEq)]
216pub struct TransitionSpecs(pub Vec<TransitionSpec>);
217
218impl TransitionSpecs {
219    /// Look up the last spec matching `property`. Earlier duplicates are
220    /// overridden - CSS Transitions Level 1 / Cascade 5 mandate
221    /// last-declaration-wins per property when authors list the same
222    /// property twice in a `transition:` shorthand or across multiple
223    /// rule blocks of equal specificity.
224    pub fn for_property(&self, property: TransitionProperty) -> Option<&TransitionSpec> {
225        self.0.iter().rev().find(|s| s.property == property)
226    }
227}
228
229/// Active [`Transition<f32>`] driving an entity's [`Opacity`]. Spawned
230/// when a class flip changes the resolved opacity and the entity carries
231/// a matching [`TransitionSpec`]. The driver removes this component
232/// when the transition completes.
233#[derive(Component, Clone, Copy, Debug)]
234pub struct OpacityTransition(pub Transition<f32>);
235
236/// Active [`Transition<Color>`] driving an entity's solid
237/// [`Visuals::fill`] (CSS `transition: background-color`). Started by
238/// the restyle path (theme / class flips) when the computed background
239/// changes; hover / press pseudo-class tints keep their own FSM in
240/// [`crate::hover`] (which reads the same [`TransitionSpecs`] for
241/// duration + easing).
242#[derive(Component, Clone, Copy, Debug)]
243pub struct BackgroundTransition(pub Transition<Color>);
244
245/// Active [`Transition<Color>`] driving `TextStyle::color`.
246#[derive(Component, Clone, Copy, Debug)]
247pub struct TextColorTransition(pub Transition<Color>);
248
249/// Active [`Transition<Color>`] driving `Visuals::border.color`.
250#[derive(Component, Clone, Copy, Debug)]
251pub struct BorderColorTransition(pub Transition<Color>);
252
253/// CSS-retarget rule: when a transition re-triggers mid-flight, the new
254/// tween starts from the CURRENT interpolated value (the live component
255/// value the previous driver tick wrote), never the old endpoint. This
256/// helper builds that tween; equal endpoints return `None` (equal-value
257/// writes are no-ops, no zombie transitions).
258pub fn retarget<T: Lerp + Send + Sync + 'static>(
259    current: T,
260    to: T,
261    spec: &TransitionSpec,
262) -> Option<Transition<T>> {
263    if current == to {
264        return None;
265    }
266    Some(Transition::new(current, to, spec.duration, spec.easing))
267}
268
269/// Sample every active [`OpacityTransition`] and write the result into
270/// the entity's [`Opacity`]. When the transition is done, snap to the
271/// final value and despawn the transition component.
272///
273/// Hidden entities (`Visible(false)`) tick no animations (CSS: a
274/// `display: none` subtree has no transitions): the tween completes
275/// instantly - snap to the target value, drop the component, no
276/// self-scheduled frame.
277pub fn step_opacity_transitions(
278    mut commands: Commands,
279    tick: Res<Tick>,
280    anim: Res<lumen_core::render_world::AnimationsActive>,
281    mut q: Query<(
282        Entity,
283        &OpacityTransition,
284        &mut Opacity,
285        Option<&lumen_core::components::Visible>,
286    )>,
287) {
288    let now = tick.now;
289    for (entity, tween, mut opacity, visible) in &mut q {
290        let hidden = visible.is_some_and(|v| !v.0);
291        let next = if hidden {
292            tween.0.to
293        } else {
294            tween.0.sample(now)
295        };
296        if opacity.0 != next {
297            opacity.0 = next;
298        }
299        if hidden || tween.0.done(now) {
300            commands.entity(entity).remove::<OpacityTransition>();
301        } else {
302            // Transition still running - keep the loop awake so it advances
303            // without waiting for an unrelated OS event.
304            anim.request();
305        }
306    }
307}
308
309/// Sample every active [`BackgroundTransition`] into the entity's solid
310/// [`Visuals::fill`]. Gradient fills are never animated (the component
311/// is dropped on sight). A fill some runtime system holds in
312/// [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides) (a hover
313/// or press tint, a toggle's state) is that system's to paint, so the
314/// restyle tween is retired rather than left to fight over the colour.
315#[allow(clippy::type_complexity)]
316pub fn step_background_transitions(
317    mut commands: Commands,
318    tick: Res<Tick>,
319    anim: Res<lumen_core::render_world::AnimationsActive>,
320    mut q: Query<(
321        Entity,
322        &BackgroundTransition,
323        &mut Visuals,
324        Option<&lumen_core::components::Visible>,
325        Option<&lumen_core::style_overrides::StyleOverrides>,
326    )>,
327) {
328    use lumen_core::style_overrides::StyleFields;
329    let now = tick.now;
330    for (entity, tween, mut vis, visible, overrides) in &mut q {
331        if overrides.is_some_and(|o| o.held().contains(StyleFields::FILL)) {
332            commands.entity(entity).remove::<BackgroundTransition>();
333            continue;
334        }
335        let hidden = visible.is_some_and(|v| !v.0);
336        let next = if hidden {
337            tween.0.to
338        } else {
339            tween.0.sample(now)
340        };
341        match vis.fill.as_mut() {
342            Some(Fill::Solid(slot)) => {
343                if *slot != next {
344                    *slot = next;
345                }
346            }
347            _ => {
348                commands.entity(entity).remove::<BackgroundTransition>();
349                continue;
350            }
351        }
352        if hidden || tween.0.done(now) {
353            commands.entity(entity).remove::<BackgroundTransition>();
354        } else {
355            anim.request();
356        }
357    }
358}
359
360/// Sample every active [`TextColorTransition`] into `TextStyle::color`.
361pub fn step_text_color_transitions(
362    mut commands: Commands,
363    tick: Res<Tick>,
364    anim: Res<lumen_core::render_world::AnimationsActive>,
365    mut q: Query<(
366        Entity,
367        &TextColorTransition,
368        &mut lumen_core::components::TextStyle,
369        Option<&lumen_core::components::Visible>,
370    )>,
371) {
372    let now = tick.now;
373    for (entity, tween, mut style, visible) in &mut q {
374        let hidden = visible.is_some_and(|v| !v.0);
375        let next = if hidden {
376            tween.0.to
377        } else {
378            tween.0.sample(now)
379        };
380        if style.color != next {
381            style.color = next;
382        }
383        if hidden || tween.0.done(now) {
384            commands.entity(entity).remove::<TextColorTransition>();
385        } else {
386            anim.request();
387        }
388    }
389}
390
391/// Sample every active [`BorderColorTransition`] into
392/// `Visuals::border.color`. A border removed mid-flight retires the
393/// tween (nothing left to paint).
394pub fn step_border_color_transitions(
395    mut commands: Commands,
396    tick: Res<Tick>,
397    anim: Res<lumen_core::render_world::AnimationsActive>,
398    mut q: Query<(
399        Entity,
400        &BorderColorTransition,
401        &mut Visuals,
402        Option<&lumen_core::components::Visible>,
403    )>,
404) {
405    let now = tick.now;
406    for (entity, tween, mut vis, visible) in &mut q {
407        let hidden = visible.is_some_and(|v| !v.0);
408        let next = if hidden {
409            tween.0.to
410        } else {
411            tween.0.sample(now)
412        };
413        match vis.border.as_mut() {
414            Some(border) => {
415                if border.color != next {
416                    border.color = next;
417                }
418            }
419            None => {
420                commands.entity(entity).remove::<BorderColorTransition>();
421                continue;
422            }
423        }
424        if hidden || tween.0.done(now) {
425            commands.entity(entity).remove::<BorderColorTransition>();
426        } else {
427            anim.request();
428        }
429    }
430}
431
432/// Plugin: registers every per-property transition driver in
433/// `TickStage::Systems`. Add via `App::add_plugin(TransitionPlugin)` once;
434/// subsequent additions are no-ops as long as the same instance is reused.
435pub struct TransitionPlugin;
436
437impl lumen_core::prelude::Plugin for TransitionPlugin {
438    fn build(self, app: &mut lumen_core::prelude::App) {
439        app.add_systems(TickStage::Systems, step_opacity_transitions);
440        // Background tween runs after the hover/press tint systems so
441        // its owns-the-fill check observes this tick's snapshots.
442        app.add_systems(
443            TickStage::Systems,
444            step_background_transitions.after(crate::hover::apply_press_tint),
445        );
446        app.add_systems(TickStage::Systems, step_text_color_transitions);
447        app.add_systems(TickStage::Systems, step_border_color_transitions);
448    }
449}
450
451/// Sample a CSS-style cubic Bezier `(0, 0) - (p1x, p1y) - (p2x, p2y) -
452/// (1, 1)` at fraction `t`. The Bezier parametric form is sampled by
453/// Newton-Raphson - three iterations + a bisection fallback - to invert
454/// `x(s) -> s` then evaluate `y(s)`. Matches CSS Transitions Level 1.
455fn cubic_bezier(p1x: f32, p1y: f32, p2x: f32, p2y: f32, t: f32) -> f32 {
456    let ax = 3.0 * p1x - 3.0 * p2x + 1.0;
457    let bx = -6.0 * p1x + 3.0 * p2x;
458    let cx = 3.0 * p1x;
459    let ay = 3.0 * p1y - 3.0 * p2y + 1.0;
460    let by = -6.0 * p1y + 3.0 * p2y;
461    let cy = 3.0 * p1y;
462    let bx_t = |s: f32| ((ax * s + bx) * s + cx) * s;
463    let dbx_t = |s: f32| (3.0 * ax * s + 2.0 * bx) * s + cx;
464    let by_t = |s: f32| ((ay * s + by) * s + cy) * s;
465    let mut s = t;
466    for _ in 0..3 {
467        let x = bx_t(s) - t;
468        let dx = dbx_t(s);
469        if dx.abs() < 1e-6 {
470            break;
471        }
472        s -= x / dx;
473        s = s.clamp(0.0, 1.0);
474    }
475    // Bisection if Newton drifted; rare in practice for the standard CSS
476    // ease curves which are well-conditioned.
477    let mut lo = 0.0;
478    let mut hi = 1.0;
479    for _ in 0..16 {
480        let x = bx_t(s) - t;
481        if x.abs() < 1e-4 {
482            break;
483        }
484        if x < 0.0 {
485            lo = s;
486        } else {
487            hi = s;
488        }
489        s = 0.5 * (lo + hi);
490    }
491    by_t(s)
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn approx(a: f32, b: f32) -> bool {
499        (a - b).abs() < 1e-3
500    }
501
502    #[test]
503    fn linear_easing_is_identity() {
504        assert!(approx(Easing::Linear.apply(0.0), 0.0));
505        assert!(approx(Easing::Linear.apply(0.5), 0.5));
506        assert!(approx(Easing::Linear.apply(1.0), 1.0));
507    }
508
509    #[test]
510    fn ease_in_starts_slow_ends_fast() {
511        let mid = Easing::EaseIn.apply(0.5);
512        // t^3 at 0.5 = 0.125 - well below the linear midpoint.
513        assert!(mid < 0.2);
514        assert!(approx(Easing::EaseIn.apply(0.0), 0.0));
515        assert!(approx(Easing::EaseIn.apply(1.0), 1.0));
516    }
517
518    #[test]
519    fn ease_out_starts_fast_ends_slow() {
520        let mid = Easing::EaseOut.apply(0.5);
521        // 1 - (1 - 0.5)^3 = 0.875 - well above the linear midpoint.
522        assert!(mid > 0.8);
523        assert!(approx(Easing::EaseOut.apply(0.0), 0.0));
524        assert!(approx(Easing::EaseOut.apply(1.0), 1.0));
525    }
526
527    #[test]
528    fn ease_in_out_passes_through_midpoint() {
529        assert!(approx(Easing::EaseInOut.apply(0.5), 0.5));
530        // First half resembles ease-in.
531        assert!(Easing::EaseInOut.apply(0.25) < 0.25);
532        // Second half resembles ease-out.
533        assert!(Easing::EaseInOut.apply(0.75) > 0.75);
534    }
535
536    #[test]
537    fn cubic_bezier_matches_linear_anchors() {
538        // (0.25, 0.1, 0.25, 1.0) is the classic CSS ease curve. Endpoints
539        // must still pin at 0 and 1.
540        assert!(approx(
541            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(0.0),
542            0.0
543        ));
544        assert!(approx(
545            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(1.0),
546            1.0
547        ));
548        // Midpoint of CSS ease is above 0.5.
549        let mid = Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(0.5);
550        assert!(mid > 0.5 && mid < 1.0);
551    }
552
553    #[test]
554    fn transition_samples_linearly() {
555        let t = Transition::<f32>::new(0.0, 10.0, Duration::from_millis(100), Easing::Linear);
556        let halfway = t.start + Duration::from_millis(50);
557        assert!(approx(t.sample(halfway), 5.0));
558        let done = t.start + Duration::from_millis(100);
559        assert!(approx(t.sample(done), 10.0));
560        assert!(t.done(done));
561    }
562
563    #[test]
564    fn transition_clamps_after_duration() {
565        let t = Transition::<f32>::new(0.0, 1.0, Duration::from_millis(100), Easing::Linear);
566        let after = t.start + Duration::from_millis(200);
567        assert!(approx(t.sample(after), 1.0));
568        assert!(t.done(after));
569    }
570
571    #[test]
572    fn transition_zero_duration_is_instant() {
573        // Zero-duration transitions must not divide-by-zero; they jump
574        // straight to `to`.
575        let t = Transition::<f32>::new(0.0, 1.0, Duration::ZERO, Easing::Linear);
576        assert!(approx(t.sample(t.start), 1.0));
577        assert!(t.done(t.start));
578    }
579
580    #[test]
581    fn from_tuple_builds_linear_transition() {
582        let t: Transition<f32> = (0.0, 10.0, Duration::from_millis(100)).into();
583        assert_eq!(t.easing, Easing::Linear);
584        assert!(approx(t.sample(t.start + Duration::from_millis(50)), 5.0));
585    }
586
587    #[test]
588    fn for_property_returns_last_declaration() {
589        // CSS cascade-5: last declaration wins for a duplicated property.
590        let specs = TransitionSpecs(vec![
591            TransitionSpec {
592                property: TransitionProperty::Opacity,
593                duration: Duration::from_millis(100),
594                easing: Easing::Linear,
595            },
596            TransitionSpec {
597                property: TransitionProperty::Opacity,
598                duration: Duration::from_millis(500),
599                easing: Easing::EaseOut,
600            },
601        ]);
602        let chosen = specs.for_property(TransitionProperty::Opacity).unwrap();
603        assert_eq!(chosen.duration, Duration::from_millis(500));
604        assert_eq!(chosen.easing, Easing::EaseOut);
605    }
606
607    #[test]
608    fn retarget_starts_from_current_value_and_skips_noops() {
609        let spec = TransitionSpec {
610            property: TransitionProperty::BackgroundColor,
611            duration: Duration::from_millis(200),
612            easing: Easing::Linear,
613        };
614        let red = Color::rgb(1.0, 0.0, 0.0);
615        let blue = Color::rgb(0.0, 0.0, 1.0);
616        // Mid-flight current value (e.g. half-way between old endpoints).
617        let mid = red.lerp(blue, 0.5);
618        let t = retarget(mid, blue, &spec).expect("differing values start a tween");
619        // CSS retarget rule: the new tween starts from the CURRENT
620        // interpolated value, not either old endpoint.
621        assert_eq!(t.sample(t.start), mid);
622        assert_eq!(t.sample(t.start + Duration::from_millis(200)), blue);
623        // Equal-value writes are no-ops - no zombie transitions.
624        assert!(retarget(blue, blue, &spec).is_none());
625    }
626
627    #[test]
628    fn background_driver_writes_solid_fill_and_retires() {
629        use bevy_ecs::system::RunSystemOnce;
630        let mut world = bevy_ecs::world::World::new();
631        world.insert_resource(Tick::default());
632        world.insert_resource(lumen_core::render_world::AnimationsActive::default());
633        let from = Color::rgb(0.0, 0.0, 0.0);
634        let to = Color::rgb(1.0, 1.0, 1.0);
635        let e = world
636            .spawn((
637                Visuals {
638                    fill: Some(Fill::Solid(from)),
639                    radius: 0.0,
640                    corner_radii: None,
641                    shadows: Vec::new(),
642                    border: None,
643                },
644                BackgroundTransition(Transition::new(
645                    from,
646                    to,
647                    Duration::from_millis(100),
648                    Easing::Linear,
649                )),
650            ))
651            .id();
652        // Half-way: Tick.now is seeded at system run; shift the tween's
653        // start back 50 ms so sampling lands mid-flight.
654        world.get_mut::<BackgroundTransition>(e).unwrap().0.start -= Duration::from_millis(50);
655        {
656            let now =
657                world.get::<BackgroundTransition>(e).unwrap().0.start + Duration::from_millis(50);
658            world.resource_mut::<Tick>().now = now;
659        }
660        world.run_system_once(step_background_transitions).unwrap();
661        let mid = world
662            .get::<Visuals>(e)
663            .unwrap()
664            .fill
665            .as_ref()
666            .and_then(Fill::as_solid)
667            .unwrap();
668        assert!((mid.r - 0.5).abs() < 0.05, "mid-flight fill (r={})", mid.r);
669        assert!(
670            world.get::<BackgroundTransition>(e).is_some(),
671            "tween still active mid-flight"
672        );
673        // Past the end: snaps to target and retires the component.
674        {
675            let now =
676                world.get::<BackgroundTransition>(e).unwrap().0.start + Duration::from_millis(500);
677            world.resource_mut::<Tick>().now = now;
678        }
679        world.run_system_once(step_background_transitions).unwrap();
680        let done = world
681            .get::<Visuals>(e)
682            .unwrap()
683            .fill
684            .as_ref()
685            .and_then(Fill::as_solid)
686            .unwrap();
687        assert_eq!(done, to);
688        assert!(world.get::<BackgroundTransition>(e).is_none());
689    }
690
691    #[test]
692    fn hidden_entity_ticks_no_animation() {
693        use bevy_ecs::system::RunSystemOnce;
694        let mut world = bevy_ecs::world::World::new();
695        world.insert_resource(Tick::default());
696        world.insert_resource(lumen_core::render_world::AnimationsActive::default());
697        let e = world
698            .spawn((
699                Opacity(0.0),
700                OpacityTransition(Transition::new(
701                    0.0,
702                    1.0,
703                    Duration::from_millis(500),
704                    Easing::Linear,
705                )),
706                lumen_core::components::Visible(false),
707            ))
708            .id();
709        world.run_system_once(step_opacity_transitions).unwrap();
710        // Hidden: snap to target, retire, no self-scheduled frame.
711        assert_eq!(world.get::<Opacity>(e).unwrap().0, 1.0);
712        assert!(world.get::<OpacityTransition>(e).is_none());
713        assert!(
714            !world
715                .resource::<lumen_core::render_world::AnimationsActive>()
716                .get(),
717            "hidden transitions must not keep the loop awake"
718        );
719    }
720
721    #[test]
722    fn lerp_color() {
723        let a = Color {
724            r: 0.0,
725            g: 0.0,
726            b: 0.0,
727            a: 0.0,
728        };
729        let b = Color {
730            r: 1.0,
731            g: 1.0,
732            b: 1.0,
733            a: 1.0,
734        };
735        let mid = a.lerp(b, 0.5);
736        assert!(approx(mid.r, 0.5));
737        assert!(approx(mid.g, 0.5));
738        assert!(approx(mid.b, 0.5));
739        assert!(approx(mid.a, 0.5));
740    }
741}