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