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