Skip to main content

ftui_core/animation/
mod.rs

1#![forbid(unsafe_code)]
2
3//! Composable animation primitives.
4//!
5//! Includes [`Timeline`] for multi-event animation scheduling.
6//!
7//! Time-based animations that produce normalized `f32` values (0.0–1.0).
8//! Designed for zero allocation during tick, composable via generics.
9//!
10//! # Budget Integration
11//!
12//! Animations themselves are budget-unaware. The caller decides whether to
13//! call [`Animation::tick`] based on the current [`DegradationLevel`]:
14//!
15//! ```ignore
16//! if budget.degradation().render_decorative() {
17//!     my_animation.tick(dt);
18//! }
19//! ```
20//!
21//! [`DegradationLevel`]: ftui_render::budget::DegradationLevel
22
23use std::time::Duration;
24
25// ---------------------------------------------------------------------------
26// Easing functions
27// ---------------------------------------------------------------------------
28
29/// Easing function signature: maps `t` in [0, 1] to output in [0, 1].
30pub type EasingFn = fn(f32) -> f32;
31
32/// Identity easing (constant velocity).
33#[inline]
34pub fn linear(t: f32) -> f32 {
35    t.clamp(0.0, 1.0)
36}
37
38/// Quadratic ease-in (slow start).
39#[inline]
40pub fn ease_in(t: f32) -> f32 {
41    let t = t.clamp(0.0, 1.0);
42    t * t
43}
44
45/// Quadratic ease-out (slow end).
46#[inline]
47pub fn ease_out(t: f32) -> f32 {
48    let t = t.clamp(0.0, 1.0);
49    1.0 - (1.0 - t) * (1.0 - t)
50}
51
52/// Quadratic ease-in-out (slow start and end).
53#[inline]
54pub fn ease_in_out(t: f32) -> f32 {
55    let t = t.clamp(0.0, 1.0);
56    if t < 0.5 {
57        2.0 * t * t
58    } else {
59        1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
60    }
61}
62
63/// Cubic ease-in (slower start than quadratic).
64#[inline]
65pub fn ease_in_cubic(t: f32) -> f32 {
66    let t = t.clamp(0.0, 1.0);
67    t * t * t
68}
69
70/// Cubic ease-out (slower end than quadratic).
71#[inline]
72pub fn ease_out_cubic(t: f32) -> f32 {
73    let t = t.clamp(0.0, 1.0);
74    1.0 - (1.0 - t).powi(3)
75}
76
77// ---------------------------------------------------------------------------
78// Animation trait
79// ---------------------------------------------------------------------------
80
81/// A time-based animation producing values in [0.0, 1.0].
82pub trait Animation {
83    /// Advance the animation by `dt`.
84    fn tick(&mut self, dt: Duration);
85
86    /// Whether the animation has reached its end.
87    fn is_complete(&self) -> bool;
88
89    /// Current output value, clamped to [0.0, 1.0].
90    fn value(&self) -> f32;
91
92    /// Reset the animation to its initial state.
93    fn reset(&mut self);
94
95    /// Time elapsed past completion. Used by composition types to forward
96    /// remaining time (e.g., [`Sequence`] forwards overshoot from first to second).
97    /// Returns [`Duration::ZERO`] for animations that never complete.
98    fn overshoot(&self) -> Duration {
99        Duration::ZERO
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Fade
105// ---------------------------------------------------------------------------
106
107/// Linear progression from 0.0 to 1.0 over a duration, with configurable easing.
108///
109/// Tracks elapsed time as [`Duration`] internally for precise accumulation
110/// (no floating-point drift) and accurate overshoot calculation.
111#[derive(Debug, Clone, Copy)]
112pub struct Fade {
113    elapsed: Duration,
114    duration: Duration,
115    easing: EasingFn,
116}
117
118impl Fade {
119    /// Create a fade with the given duration and default linear easing.
120    pub fn new(duration: Duration) -> Self {
121        Self {
122            elapsed: Duration::ZERO,
123            duration: if duration.is_zero() {
124                Duration::from_nanos(1)
125            } else {
126                duration
127            },
128            easing: linear,
129        }
130    }
131
132    /// Set the easing function.
133    pub fn easing(mut self, easing: EasingFn) -> Self {
134        self.easing = easing;
135        self
136    }
137
138    /// Raw linear progress (before easing), in [0.0, 1.0].
139    #[inline]
140    pub fn raw_progress(&self) -> f32 {
141        let t = self.elapsed.as_secs_f64() / self.duration.as_secs_f64();
142        (t as f32).clamp(0.0, 1.0)
143    }
144}
145
146impl Animation for Fade {
147    fn tick(&mut self, dt: Duration) {
148        self.elapsed = self.elapsed.saturating_add(dt);
149    }
150
151    fn is_complete(&self) -> bool {
152        self.elapsed >= self.duration
153    }
154
155    fn value(&self) -> f32 {
156        (self.easing)(self.raw_progress())
157    }
158
159    fn reset(&mut self) {
160        self.elapsed = Duration::ZERO;
161    }
162
163    fn overshoot(&self) -> Duration {
164        self.elapsed.saturating_sub(self.duration)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Slide
170// ---------------------------------------------------------------------------
171
172/// Interpolates an `i16` value between `from` and `to` over a duration.
173///
174/// [`Animation::value`] returns the normalized progress; use [`Slide::position`]
175/// for the interpolated integer position.
176#[derive(Debug, Clone, Copy)]
177pub struct Slide {
178    from: i16,
179    to: i16,
180    elapsed: Duration,
181    duration: Duration,
182    easing: EasingFn,
183}
184
185impl Slide {
186    /// Create a new slide animation from `from` to `to` over `duration`.
187    pub fn new(from: i16, to: i16, duration: Duration) -> Self {
188        Self {
189            from,
190            to,
191            elapsed: Duration::ZERO,
192            duration: if duration.is_zero() {
193                Duration::from_nanos(1)
194            } else {
195                duration
196            },
197            easing: ease_out,
198        }
199    }
200
201    /// Set the easing function (builder).
202    pub fn easing(mut self, easing: EasingFn) -> Self {
203        self.easing = easing;
204        self
205    }
206
207    fn progress(&self) -> f32 {
208        let t = self.elapsed.as_secs_f64() / self.duration.as_secs_f64();
209        (t as f32).clamp(0.0, 1.0)
210    }
211
212    /// Current interpolated position as an integer.
213    pub fn position(&self) -> i16 {
214        let t = (self.easing)(self.progress());
215        let range = f32::from(self.to) - f32::from(self.from);
216        let pos = f32::from(self.from) + range * t;
217        pos.round().clamp(f32::from(i16::MIN), f32::from(i16::MAX)) as i16
218    }
219}
220
221impl Animation for Slide {
222    fn tick(&mut self, dt: Duration) {
223        self.elapsed = self.elapsed.saturating_add(dt);
224    }
225
226    fn is_complete(&self) -> bool {
227        self.elapsed >= self.duration
228    }
229
230    fn value(&self) -> f32 {
231        (self.easing)(self.progress())
232    }
233
234    fn reset(&mut self) {
235        self.elapsed = Duration::ZERO;
236    }
237
238    fn overshoot(&self) -> Duration {
239        self.elapsed.saturating_sub(self.duration)
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Pulse
245// ---------------------------------------------------------------------------
246
247/// Continuous sine-wave oscillation. Never completes.
248///
249/// `value()` oscillates between 0.0 and 1.0 at the given frequency (Hz).
250#[derive(Debug, Clone, Copy)]
251pub struct Pulse {
252    frequency: f32,
253    phase: f32,
254}
255
256impl Pulse {
257    /// Create a pulse at the given frequency in Hz.
258    ///
259    /// A frequency of 1.0 means one full cycle per second.
260    pub fn new(frequency: f32) -> Self {
261        Self {
262            frequency: frequency.abs().max(f32::MIN_POSITIVE),
263            phase: 0.0,
264        }
265    }
266
267    /// Current phase in radians.
268    #[inline]
269    pub fn phase(&self) -> f32 {
270        self.phase
271    }
272}
273
274impl Animation for Pulse {
275    fn tick(&mut self, dt: Duration) {
276        self.phase += std::f32::consts::TAU * self.frequency * dt.as_secs_f32();
277        // Keep phase bounded to avoid precision loss over long runs.
278        self.phase %= std::f32::consts::TAU;
279    }
280
281    fn is_complete(&self) -> bool {
282        false // Pulses never complete.
283    }
284
285    fn value(&self) -> f32 {
286        // Map sin output from [-1, 1] to [0, 1].
287        (self.phase.sin() + 1.0) / 2.0
288    }
289
290    fn reset(&mut self) {
291        self.phase = 0.0;
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Sequence
297// ---------------------------------------------------------------------------
298
299/// Play animation `A`, then animation `B`.
300///
301/// `value()` returns A's value while A is running, then B's value.
302#[derive(Debug, Clone, Copy)]
303pub struct Sequence<A, B> {
304    first: A,
305    second: B,
306    first_done: bool,
307}
308
309impl<A: Animation, B: Animation> Sequence<A, B> {
310    /// Create a new sequence that plays `first` then `second`.
311    pub fn new(first: A, second: B) -> Self {
312        Self {
313            first,
314            second,
315            first_done: false,
316        }
317    }
318}
319
320impl<A: Animation, B: Animation> Animation for Sequence<A, B> {
321    fn tick(&mut self, dt: Duration) {
322        if !self.first_done {
323            self.first.tick(dt);
324            if self.first.is_complete() {
325                self.first_done = true;
326                // Forward any overshoot into the second animation.
327                let os = self.first.overshoot();
328                if !os.is_zero() {
329                    self.second.tick(os);
330                }
331            }
332        } else {
333            self.second.tick(dt);
334        }
335    }
336
337    fn is_complete(&self) -> bool {
338        self.first_done && self.second.is_complete()
339    }
340
341    fn value(&self) -> f32 {
342        if self.first_done {
343            self.second.value()
344        } else {
345            self.first.value()
346        }
347    }
348
349    fn reset(&mut self) {
350        self.first.reset();
351        self.second.reset();
352        self.first_done = false;
353    }
354
355    fn overshoot(&self) -> Duration {
356        if self.first_done {
357            self.second.overshoot()
358        } else {
359            Duration::ZERO
360        }
361    }
362}
363
364// ---------------------------------------------------------------------------
365// Parallel
366// ---------------------------------------------------------------------------
367
368/// Play animations `A` and `B` simultaneously.
369///
370/// `value()` returns the average of both values. Completes when both complete.
371#[derive(Debug, Clone, Copy)]
372pub struct Parallel<A, B> {
373    a: A,
374    b: B,
375}
376
377impl<A: Animation, B: Animation> Parallel<A, B> {
378    /// Create a new parallel animation that plays `a` and `b` simultaneously.
379    pub fn new(a: A, b: B) -> Self {
380        Self { a, b }
381    }
382
383    /// Access the first animation.
384    #[inline]
385    pub fn first(&self) -> &A {
386        &self.a
387    }
388
389    /// Access the second animation.
390    #[inline]
391    pub fn second(&self) -> &B {
392        &self.b
393    }
394}
395
396impl<A: Animation, B: Animation> Animation for Parallel<A, B> {
397    fn tick(&mut self, dt: Duration) {
398        if !self.a.is_complete() {
399            self.a.tick(dt);
400        }
401        if !self.b.is_complete() {
402            self.b.tick(dt);
403        }
404    }
405
406    fn is_complete(&self) -> bool {
407        self.a.is_complete() && self.b.is_complete()
408    }
409
410    fn value(&self) -> f32 {
411        (self.a.value() + self.b.value()) / 2.0
412    }
413
414    fn reset(&mut self) {
415        self.a.reset();
416        self.b.reset();
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Delayed
422// ---------------------------------------------------------------------------
423
424/// Wait for a delay, then play the inner animation.
425#[derive(Debug, Clone, Copy)]
426pub struct Delayed<A> {
427    delay: Duration,
428    elapsed: Duration,
429    inner: A,
430    started: bool,
431}
432
433impl<A: Animation> Delayed<A> {
434    /// Create a delayed animation that waits `delay` before starting `inner`.
435    pub fn new(delay: Duration, inner: A) -> Self {
436        Self {
437            delay,
438            elapsed: Duration::ZERO,
439            inner,
440            started: false,
441        }
442    }
443
444    /// Whether the delay period has elapsed and the inner animation has started.
445    #[inline]
446    pub fn has_started(&self) -> bool {
447        self.started
448    }
449
450    /// Access the inner animation.
451    #[inline]
452    pub fn inner(&self) -> &A {
453        &self.inner
454    }
455}
456
457impl<A: Animation> Animation for Delayed<A> {
458    fn tick(&mut self, dt: Duration) {
459        if !self.started {
460            self.elapsed = self.elapsed.saturating_add(dt);
461            if self.elapsed >= self.delay {
462                self.started = true;
463                // Forward overshoot into the inner animation.
464                let os = self.elapsed.saturating_sub(self.delay);
465                if !os.is_zero() {
466                    self.inner.tick(os);
467                }
468            }
469        } else {
470            self.inner.tick(dt);
471        }
472    }
473
474    fn is_complete(&self) -> bool {
475        self.started && self.inner.is_complete()
476    }
477
478    fn value(&self) -> f32 {
479        if self.started {
480            self.inner.value()
481        } else {
482            0.0
483        }
484    }
485
486    fn reset(&mut self) {
487        self.elapsed = Duration::ZERO;
488        self.started = false;
489        self.inner.reset();
490    }
491
492    fn overshoot(&self) -> Duration {
493        if self.started {
494            self.inner.overshoot()
495        } else {
496            Duration::ZERO
497        }
498    }
499}
500
501pub mod callbacks;
502pub mod group;
503pub mod presets;
504pub mod spring;
505pub mod stagger;
506pub mod timeline;
507pub use callbacks::{AnimationEvent, Callbacks};
508pub use group::AnimationGroup;
509pub use presets::InvertedFade;
510pub use spring::Spring;
511pub use stagger::{StaggerMode, stagger_offsets, stagger_offsets_with_jitter};
512pub use timeline::Timeline;
513
514// ---------------------------------------------------------------------------
515// Convenience constructors
516// ---------------------------------------------------------------------------
517
518/// Create a [`Sequence`] from two animations.
519pub fn sequence<A: Animation, B: Animation>(a: A, b: B) -> Sequence<A, B> {
520    Sequence::new(a, b)
521}
522
523/// Create a [`Parallel`] pair from two animations.
524pub fn parallel<A: Animation, B: Animation>(a: A, b: B) -> Parallel<A, B> {
525    Parallel::new(a, b)
526}
527
528/// Create a [`Delayed`] animation.
529pub fn delay<A: Animation>(d: Duration, a: A) -> Delayed<A> {
530    Delayed::new(d, a)
531}
532
533// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    const MS_16: Duration = Duration::from_millis(16);
542    const MS_100: Duration = Duration::from_millis(100);
543    const MS_500: Duration = Duration::from_millis(500);
544    const SEC_1: Duration = Duration::from_secs(1);
545
546    // ---- Easing tests ----
547
548    #[test]
549    fn easing_linear_endpoints() {
550        assert!((linear(0.0) - 0.0).abs() < f32::EPSILON);
551        assert!((linear(1.0) - 1.0).abs() < f32::EPSILON);
552    }
553
554    #[test]
555    fn easing_linear_midpoint() {
556        assert!((linear(0.5) - 0.5).abs() < f32::EPSILON);
557    }
558
559    #[test]
560    fn easing_clamps_input() {
561        assert!((linear(-1.0) - 0.0).abs() < f32::EPSILON);
562        assert!((linear(2.0) - 1.0).abs() < f32::EPSILON);
563        assert!((ease_in(-0.5) - 0.0).abs() < f32::EPSILON);
564        assert!((ease_out(1.5) - 1.0).abs() < f32::EPSILON);
565    }
566
567    #[test]
568    fn ease_in_slower_start() {
569        // At t=0.5, ease_in should be less than linear
570        assert!(ease_in(0.5) < linear(0.5));
571    }
572
573    #[test]
574    fn ease_out_faster_start() {
575        // At t=0.5, ease_out should be more than linear
576        assert!(ease_out(0.5) > linear(0.5));
577    }
578
579    #[test]
580    fn ease_in_out_endpoints() {
581        assert!((ease_in_out(0.0) - 0.0).abs() < f32::EPSILON);
582        assert!((ease_in_out(1.0) - 1.0).abs() < f32::EPSILON);
583    }
584
585    #[test]
586    fn ease_in_out_midpoint() {
587        assert!((ease_in_out(0.5) - 0.5).abs() < 0.01);
588    }
589
590    #[test]
591    fn ease_in_cubic_endpoints() {
592        assert!((ease_in_cubic(0.0) - 0.0).abs() < f32::EPSILON);
593        assert!((ease_in_cubic(1.0) - 1.0).abs() < f32::EPSILON);
594    }
595
596    #[test]
597    fn ease_out_cubic_endpoints() {
598        assert!((ease_out_cubic(0.0) - 0.0).abs() < f32::EPSILON);
599        assert!((ease_out_cubic(1.0) - 1.0).abs() < f32::EPSILON);
600    }
601
602    #[test]
603    fn ease_in_cubic_slower_than_quadratic() {
604        assert!(ease_in_cubic(0.5) < ease_in(0.5));
605    }
606
607    // ---- Fade tests ----
608
609    #[test]
610    fn fade_starts_at_zero() {
611        let fade = Fade::new(SEC_1);
612        assert!((fade.value() - 0.0).abs() < f32::EPSILON);
613        assert!(!fade.is_complete());
614    }
615
616    #[test]
617    fn fade_completes_after_duration() {
618        let mut fade = Fade::new(SEC_1);
619        fade.tick(SEC_1);
620        assert!(fade.is_complete());
621        assert!((fade.value() - 1.0).abs() < f32::EPSILON);
622    }
623
624    #[test]
625    fn fade_midpoint() {
626        let mut fade = Fade::new(SEC_1);
627        fade.tick(MS_500);
628        assert!((fade.value() - 0.5).abs() < 0.01);
629    }
630
631    #[test]
632    fn fade_incremental_ticks() {
633        let mut fade = Fade::new(Duration::from_millis(160));
634        for _ in 0..10 {
635            fade.tick(MS_16);
636        }
637        assert!(fade.is_complete());
638        assert!((fade.value() - 1.0).abs() < f32::EPSILON);
639    }
640
641    #[test]
642    fn fade_with_ease_in() {
643        let mut fade = Fade::new(SEC_1).easing(ease_in);
644        fade.tick(MS_500);
645        // ease_in at 0.5 = 0.25
646        assert!((fade.value() - 0.25).abs() < 0.01);
647    }
648
649    #[test]
650    fn fade_clamps_overshoot() {
651        let mut fade = Fade::new(MS_100);
652        fade.tick(SEC_1); // 10x the duration
653        assert!(fade.is_complete());
654        assert!((fade.value() - 1.0).abs() < f32::EPSILON);
655    }
656
657    #[test]
658    fn fade_reset() {
659        let mut fade = Fade::new(SEC_1);
660        fade.tick(SEC_1);
661        assert!(fade.is_complete());
662        fade.reset();
663        assert!(!fade.is_complete());
664        assert!((fade.value() - 0.0).abs() < f32::EPSILON);
665    }
666
667    #[test]
668    fn fade_zero_duration() {
669        let mut fade = Fade::new(Duration::ZERO);
670        // Should not panic; duration is clamped to MIN_POSITIVE
671        fade.tick(MS_16);
672        assert!(fade.is_complete());
673    }
674
675    #[test]
676    fn fade_raw_progress() {
677        let mut fade = Fade::new(SEC_1).easing(ease_in);
678        fade.tick(MS_500);
679        // Raw progress is 0.5, but value() is ease_in(0.5) = 0.25
680        assert!((fade.raw_progress() - 0.5).abs() < 0.01);
681        assert!((fade.value() - 0.25).abs() < 0.01);
682    }
683
684    // ---- Slide tests ----
685
686    #[test]
687    fn slide_starts_at_from() {
688        let slide = Slide::new(0, 100, SEC_1);
689        assert_eq!(slide.position(), 0);
690    }
691
692    #[test]
693    fn slide_ends_at_to() {
694        let mut slide = Slide::new(0, 100, SEC_1);
695        slide.tick(SEC_1);
696        assert_eq!(slide.position(), 100);
697    }
698
699    #[test]
700    fn slide_negative_range() {
701        let mut slide = Slide::new(100, -50, SEC_1).easing(linear);
702        slide.tick(SEC_1);
703        assert_eq!(slide.position(), -50);
704    }
705
706    #[test]
707    fn slide_midpoint_with_linear() {
708        let mut slide = Slide::new(0, 100, SEC_1).easing(linear);
709        slide.tick(MS_500);
710        assert_eq!(slide.position(), 50);
711    }
712
713    #[test]
714    fn slide_reset() {
715        let mut slide = Slide::new(10, 90, SEC_1);
716        slide.tick(SEC_1);
717        assert_eq!(slide.position(), 90);
718        slide.reset();
719        assert_eq!(slide.position(), 10);
720    }
721
722    // ---- Pulse tests ----
723
724    #[test]
725    fn pulse_starts_at_midpoint() {
726        let pulse = Pulse::new(1.0);
727        // sin(0) = 0, mapped to 0.5
728        assert!((pulse.value() - 0.5).abs() < f32::EPSILON);
729    }
730
731    #[test]
732    fn pulse_never_completes() {
733        let mut pulse = Pulse::new(1.0);
734        for _ in 0..100 {
735            pulse.tick(MS_100);
736        }
737        assert!(!pulse.is_complete());
738    }
739
740    #[test]
741    fn pulse_value_bounded() {
742        let mut pulse = Pulse::new(2.0);
743        for _ in 0..200 {
744            pulse.tick(MS_16);
745            let v = pulse.value();
746            assert!((0.0..=1.0).contains(&v), "pulse value out of range: {v}");
747        }
748    }
749
750    #[test]
751    fn pulse_quarter_cycle_reaches_peak() {
752        let mut pulse = Pulse::new(1.0);
753        // Quarter cycle at 1Hz = 0.25s → sin(π/2) = 1 → value = 1.0
754        pulse.tick(Duration::from_millis(250));
755        assert!((pulse.value() - 1.0).abs() < 0.02);
756    }
757
758    #[test]
759    fn pulse_phase_wraps() {
760        let mut pulse = Pulse::new(1.0);
761        pulse.tick(Duration::from_secs(10)); // Many full cycles
762        // Phase should be bounded
763        assert!(pulse.phase() < std::f32::consts::TAU);
764    }
765
766    #[test]
767    fn pulse_reset() {
768        let mut pulse = Pulse::new(1.0);
769        pulse.tick(SEC_1);
770        pulse.reset();
771        assert!((pulse.phase() - 0.0).abs() < f32::EPSILON);
772        assert!((pulse.value() - 0.5).abs() < f32::EPSILON);
773    }
774
775    #[test]
776    fn pulse_zero_frequency_clamped() {
777        let mut pulse = Pulse::new(0.0);
778        // Should not panic; frequency clamped to MIN_POSITIVE
779        pulse.tick(SEC_1);
780        // Value should barely change
781    }
782
783    // ---- Sequence tests ----
784
785    #[test]
786    fn sequence_plays_first_then_second() {
787        let a = Fade::new(SEC_1);
788        let b = Fade::new(SEC_1);
789        let mut seq = sequence(a, b);
790
791        // First animation runs
792        seq.tick(MS_500);
793        assert!(!seq.is_complete());
794        assert!((seq.value() - 0.5).abs() < 0.01);
795
796        // First completes
797        seq.tick(MS_500);
798        // Now second should start
799        assert!(!seq.is_complete());
800
801        // Second runs
802        seq.tick(MS_500);
803        assert!((seq.value() - 0.5).abs() < 0.01);
804
805        // Both complete
806        seq.tick(MS_500);
807        assert!(seq.is_complete());
808        assert!((seq.value() - 1.0).abs() < f32::EPSILON);
809    }
810
811    #[test]
812    fn sequence_reset() {
813        let mut seq = sequence(Fade::new(MS_100), Fade::new(MS_100));
814        seq.tick(Duration::from_millis(200));
815        assert!(seq.is_complete());
816
817        seq.reset();
818        assert!(!seq.is_complete());
819        assert!((seq.value() - 0.0).abs() < f32::EPSILON);
820    }
821
822    // ---- Parallel tests ----
823
824    #[test]
825    fn parallel_ticks_both() {
826        let a = Fade::new(SEC_1);
827        let b = Fade::new(Duration::from_millis(500));
828        let mut par = parallel(a, b);
829
830        par.tick(MS_500);
831        // a at 0.5, b at 1.0 → average = 0.75
832        assert!((par.value() - 0.75).abs() < 0.01);
833        assert!(!par.is_complete()); // a not done yet
834
835        par.tick(MS_500);
836        assert!(par.is_complete());
837    }
838
839    #[test]
840    fn parallel_access_components() {
841        let par = parallel(Fade::new(SEC_1), Fade::new(SEC_1));
842        assert!((par.first().value() - 0.0).abs() < f32::EPSILON);
843        assert!((par.second().value() - 0.0).abs() < f32::EPSILON);
844    }
845
846    #[test]
847    fn parallel_reset() {
848        let mut par = parallel(Fade::new(MS_100), Fade::new(MS_100));
849        par.tick(MS_100);
850        assert!(par.is_complete());
851
852        par.reset();
853        assert!(!par.is_complete());
854    }
855
856    // ---- Delayed tests ----
857
858    #[test]
859    fn delayed_waits_then_plays() {
860        let mut d = delay(MS_500, Fade::new(MS_500));
861
862        // During delay: value is 0
863        d.tick(Duration::from_millis(250));
864        assert!(!d.has_started());
865        assert!((d.value() - 0.0).abs() < f32::EPSILON);
866
867        // Delay expires
868        d.tick(Duration::from_millis(250));
869        assert!(d.has_started());
870
871        // Inner animation runs
872        d.tick(MS_500);
873        assert!(d.is_complete());
874        assert!((d.value() - 1.0).abs() < f32::EPSILON);
875    }
876
877    #[test]
878    fn delayed_forwards_overshoot() {
879        let mut d = delay(MS_100, Fade::new(SEC_1));
880
881        // Tick 200ms past a 100ms delay → inner should get ~100ms
882        d.tick(Duration::from_millis(200));
883        assert!(d.has_started());
884        // Inner should be at ~0.1 (100ms of 1s)
885        assert!((d.value() - 0.1).abs() < 0.02);
886    }
887
888    #[test]
889    fn delayed_reset() {
890        let mut d = delay(MS_100, Fade::new(MS_100));
891        d.tick(Duration::from_millis(200));
892        assert!(d.is_complete());
893
894        d.reset();
895        assert!(!d.has_started());
896        assert!(!d.is_complete());
897    }
898
899    // ---- Composition tests ----
900
901    #[test]
902    fn nested_sequence() {
903        let inner = sequence(Fade::new(MS_100), Fade::new(MS_100));
904        let mut outer = sequence(inner, Fade::new(MS_100));
905
906        outer.tick(Duration::from_millis(300));
907        assert!(outer.is_complete());
908    }
909
910    #[test]
911    fn delayed_parallel() {
912        let a = delay(MS_100, Fade::new(MS_100));
913        let b = Fade::new(Duration::from_millis(200));
914        let mut par = parallel(a, b);
915
916        par.tick(Duration::from_millis(200));
917        assert!(par.is_complete());
918    }
919
920    #[test]
921    fn parallel_of_sequences() {
922        let s1 = sequence(Fade::new(MS_100), Fade::new(MS_100));
923        let s2 = sequence(Fade::new(MS_100), Fade::new(MS_100));
924        let mut par = parallel(s1, s2);
925
926        par.tick(Duration::from_millis(200));
927        assert!(par.is_complete());
928    }
929
930    // ---- Edge case tests ----
931
932    #[test]
933    fn zero_dt_is_noop() {
934        let mut fade = Fade::new(SEC_1);
935        fade.tick(Duration::ZERO);
936        assert!((fade.value() - 0.0).abs() < f32::EPSILON);
937    }
938
939    #[test]
940    fn very_small_dt() {
941        let mut fade = Fade::new(SEC_1);
942        fade.tick(Duration::from_nanos(1));
943        // Should barely move, not panic
944        assert!(fade.value() < 0.001);
945    }
946
947    #[test]
948    fn very_large_dt() {
949        let mut fade = Fade::new(MS_100);
950        fade.tick(Duration::from_secs(3600));
951        assert!(fade.is_complete());
952        assert!((fade.value() - 1.0).abs() < f32::EPSILON);
953    }
954
955    #[test]
956    fn rapid_small_ticks() {
957        let mut fade = Fade::new(SEC_1);
958        for _ in 0..1000 {
959            fade.tick(Duration::from_millis(1));
960        }
961        assert!(fade.is_complete());
962    }
963
964    #[test]
965    fn tick_after_complete_is_safe() {
966        let mut fade = Fade::new(MS_100);
967        fade.tick(SEC_1);
968        assert!(fade.is_complete());
969        // Extra ticks should not panic or produce values > 1.0
970        fade.tick(SEC_1);
971        assert!((fade.value() - 1.0).abs() < f32::EPSILON);
972    }
973}