Skip to main content

ftui_core/animation/
timeline.rs

1#![forbid(unsafe_code)]
2
3//! Timeline: multi-event animation scheduler.
4//!
5//! A [`Timeline`] sequences multiple [`Animation`] events at specified offsets,
6//! with support for looping, pause/resume, and seek. The timeline itself
7//! implements [`Animation`], producing a progress value (0.0–1.0) based on
8//! elapsed time vs total duration.
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use std::time::Duration;
14//! use ftui_core::animation::{Fade, Timeline};
15//!
16//! let timeline = Timeline::new()
17//!     .add(Duration::ZERO, Fade::new(Duration::from_millis(500)))
18//!     .add(Duration::from_millis(300), Fade::new(Duration::from_millis(400)))
19//!     .duration(Duration::from_millis(700));
20//!
21//! // Events at [0..500ms] and [300..700ms] overlap.
22//! ```
23//!
24//! # Invariants
25//!
26//! 1. Events are always sorted by offset (maintained on insertion).
27//! 2. `value()` returns 0.0 when idle, and `current_time / duration` when
28//!    playing (clamped to [0.0, 1.0]).
29//! 3. `tick()` only advances animations in `Playing` state.
30//! 4. Loop counter decrements only when current_time reaches duration.
31//! 5. `seek()` clamps to [0, duration] and re-ticks all animations from
32//!    their last state.
33//!
34//! # Failure Modes
35//!
36//! - Zero duration: clamped to 1ns to avoid division by zero.
37//! - Seek past end: clamps to duration.
38//! - Empty timeline: progress is always 1.0 (immediately complete).
39
40use std::time::Duration;
41
42use super::Animation;
43
44// ---------------------------------------------------------------------------
45// Types
46// ---------------------------------------------------------------------------
47
48/// How many times to loop the timeline.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LoopCount {
51    /// Play once (no looping).
52    Once,
53    /// Repeat a fixed number of times (total plays = times + 1).
54    Times(u32),
55    /// Loop forever.
56    Infinite,
57}
58
59/// Playback state of the timeline.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum PlaybackState {
62    /// Not yet started.
63    Idle,
64    /// Actively playing.
65    Playing,
66    /// Paused; can be resumed.
67    Paused,
68    /// Reached the end (all loops exhausted).
69    Finished,
70}
71
72/// A single event in the timeline.
73struct TimelineEvent {
74    /// When this event starts relative to timeline start.
75    offset: Duration,
76    /// The animation for this event.
77    animation: Box<dyn Animation>,
78    /// Optional label for seeking by name.
79    label: Option<String>,
80}
81
82impl std::fmt::Debug for TimelineEvent {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("TimelineEvent")
85            .field("offset", &self.offset)
86            .field("label", &self.label)
87            .finish_non_exhaustive()
88    }
89}
90
91/// A timeline that schedules multiple animations at specific offsets.
92///
93/// Implements [`Animation`] where `value()` returns overall progress (0.0–1.0).
94pub struct Timeline {
95    events: Vec<TimelineEvent>,
96    /// Total duration of the timeline. If not set explicitly, computed as
97    /// max(event.offset) (animation durations are unknown without ticking).
98    total_duration: Duration,
99    /// Whether total_duration was explicitly set by the user.
100    duration_explicit: bool,
101    loop_count: LoopCount,
102    /// Remaining loops (decremented during playback).
103    loops_remaining: u32,
104    state: PlaybackState,
105    current_time: Duration,
106}
107
108impl std::fmt::Debug for Timeline {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Timeline")
111            .field("event_count", &self.events.len())
112            .field("total_duration", &self.total_duration)
113            .field("loop_count", &self.loop_count)
114            .field("state", &self.state)
115            .field("current_time", &self.current_time)
116            .finish()
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Construction
122// ---------------------------------------------------------------------------
123
124impl Timeline {
125    /// Create an empty timeline.
126    #[must_use]
127    pub fn new() -> Self {
128        Self {
129            events: Vec::new(),
130            total_duration: Duration::from_nanos(1),
131            duration_explicit: false,
132            loop_count: LoopCount::Once,
133            loops_remaining: 0,
134            state: PlaybackState::Idle,
135            current_time: Duration::ZERO,
136        }
137    }
138
139    /// Add an animation event at an absolute offset (builder pattern).
140    #[must_use]
141    pub fn add(mut self, offset: Duration, animation: impl Animation + 'static) -> Self {
142        self.push_event(offset, Box::new(animation), None);
143        self
144    }
145
146    /// Add a labeled animation event at an absolute offset (builder pattern).
147    #[must_use]
148    pub fn add_labeled(
149        mut self,
150        label: &str,
151        offset: Duration,
152        animation: impl Animation + 'static,
153    ) -> Self {
154        self.push_event(offset, Box::new(animation), Some(label.to_string()));
155        self
156    }
157
158    /// Add an event relative to the last event's offset (builder pattern).
159    ///
160    /// If no events exist, the offset is 0.
161    #[must_use]
162    pub fn then(self, animation: impl Animation + 'static) -> Self {
163        let offset = self.events.last().map_or(Duration::ZERO, |e| e.offset);
164        self.add(offset, animation)
165    }
166
167    /// Set the total duration explicitly (builder pattern).
168    ///
169    /// If not called, duration is inferred as `max(event.offset)`.
170    /// A zero duration is clamped to 1ns.
171    #[must_use]
172    pub fn set_duration(mut self, d: Duration) -> Self {
173        self.total_duration = if d.is_zero() {
174            Duration::from_nanos(1)
175        } else {
176            d
177        };
178        self.duration_explicit = true;
179        self
180    }
181
182    /// Set the loop count (builder pattern).
183    #[must_use]
184    pub fn set_loop_count(mut self, count: LoopCount) -> Self {
185        self.loop_count = count;
186        self.loops_remaining = match count {
187            LoopCount::Once => 0,
188            LoopCount::Times(n) => n,
189            LoopCount::Infinite => u32::MAX,
190        };
191        self
192    }
193
194    /// Internal: insert event maintaining sort order by offset.
195    fn push_event(
196        &mut self,
197        offset: Duration,
198        animation: Box<dyn Animation>,
199        label: Option<String>,
200    ) {
201        let event = TimelineEvent {
202            offset,
203            animation,
204            label,
205        };
206        // Insert sorted by offset (stable — preserves insertion order for same offset).
207        let pos = self.events.partition_point(|e| e.offset <= offset);
208        self.events.insert(pos, event);
209
210        // Auto-compute duration if not explicitly set.
211        if !self.duration_explicit {
212            self.total_duration = self.events.last().map_or(Duration::from_nanos(1), |e| {
213                if e.offset.is_zero() {
214                    Duration::from_nanos(1)
215                } else {
216                    e.offset
217                }
218            });
219        }
220    }
221}
222
223impl Default for Timeline {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Playback control
231// ---------------------------------------------------------------------------
232
233impl Timeline {
234    /// Start or restart playback from the beginning.
235    pub fn play(&mut self) {
236        self.current_time = Duration::ZERO;
237        self.loops_remaining = match self.loop_count {
238            LoopCount::Once => 0,
239            LoopCount::Times(n) => n,
240            LoopCount::Infinite => u32::MAX,
241        };
242        for event in &mut self.events {
243            event.animation.reset();
244        }
245        self.state = PlaybackState::Playing;
246    }
247
248    /// Pause playback. No-op if not playing.
249    pub fn pause(&mut self) {
250        if self.state == PlaybackState::Playing {
251            self.state = PlaybackState::Paused;
252        }
253    }
254
255    /// Resume from pause. No-op if not paused.
256    pub fn resume(&mut self) {
257        if self.state == PlaybackState::Paused {
258            self.state = PlaybackState::Playing;
259        }
260    }
261
262    /// Stop playback and reset to idle.
263    pub fn stop(&mut self) {
264        self.state = PlaybackState::Idle;
265        self.current_time = Duration::ZERO;
266        for event in &mut self.events {
267            event.animation.reset();
268        }
269    }
270
271    /// Seek to an absolute time position.
272    ///
273    /// Clamps to [0, total_duration]. Resets all animations and re-ticks
274    /// them up to the seek point so their state is consistent.
275    pub fn seek(&mut self, time: Duration) {
276        let clamped = if time > self.total_duration {
277            self.total_duration
278        } else {
279            time
280        };
281
282        // Reset all animations, then re-tick them up to `clamped`.
283        for event in &mut self.events {
284            event.animation.reset();
285            if clamped > event.offset {
286                let dt = clamped.saturating_sub(event.offset);
287                event.animation.tick(dt);
288            }
289        }
290        self.current_time = clamped;
291
292        // If we were idle/finished, transition to paused at the seek point.
293        if self.state == PlaybackState::Idle || self.state == PlaybackState::Finished {
294            self.state = PlaybackState::Paused;
295        }
296    }
297
298    /// Seek to a labeled event's offset.
299    ///
300    /// Returns `true` if the label was found, `false` otherwise (no-op).
301    pub fn seek_label(&mut self, label: &str) -> bool {
302        let offset = self
303            .events
304            .iter()
305            .find(|e| e.label.as_deref() == Some(label))
306            .map(|e| e.offset);
307        if let Some(offset) = offset {
308            self.seek(offset);
309            true
310        } else {
311            false
312        }
313    }
314
315    /// Current progress as a value in [0.0, 1.0].
316    #[inline]
317    #[must_use]
318    pub fn progress(&self) -> f32 {
319        if self.events.is_empty() {
320            return 1.0;
321        }
322        let t = self.current_time.as_secs_f64() / self.total_duration.as_secs_f64();
323        (t as f32).clamp(0.0, 1.0)
324    }
325
326    /// Current playback state.
327    #[inline]
328    #[must_use]
329    pub fn state(&self) -> PlaybackState {
330        self.state
331    }
332
333    /// Current time position.
334    #[inline]
335    #[must_use]
336    pub fn current_time(&self) -> Duration {
337        self.current_time
338    }
339
340    /// Total duration.
341    #[inline]
342    #[must_use]
343    pub fn duration(&self) -> Duration {
344        self.total_duration
345    }
346
347    /// Number of events in the timeline.
348    #[inline]
349    #[must_use]
350    pub fn event_count(&self) -> usize {
351        self.events.len()
352    }
353
354    /// Get the animation value for a specific labeled event.
355    ///
356    /// Returns `None` if the label doesn't exist.
357    #[must_use]
358    pub fn event_value(&self, label: &str) -> Option<f32> {
359        self.events
360            .iter()
361            .find(|e| e.label.as_deref() == Some(label))
362            .map(|e| e.animation.value())
363    }
364
365    /// Get the animation value for an event by index.
366    ///
367    /// Returns `None` if index is out of bounds.
368    #[must_use]
369    pub fn event_value_at(&self, index: usize) -> Option<f32> {
370        self.events.get(index).map(|e| e.animation.value())
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Animation trait implementation
376// ---------------------------------------------------------------------------
377
378impl Animation for Timeline {
379    fn tick(&mut self, dt: Duration) {
380        if self.state != PlaybackState::Playing {
381            return;
382        }
383
384        let new_time = self.current_time.saturating_add(dt);
385
386        // Tick each event that overlaps with [current_time, new_time].
387        for event in &mut self.events {
388            if new_time > event.offset && !event.animation.is_complete() {
389                // How much time has elapsed for this event.
390                let event_start = event.offset;
391                if self.current_time >= event_start {
392                    // Already past offset — just forward dt.
393                    event.animation.tick(dt);
394                } else {
395                    // Event starts within this tick — forward only the portion after offset.
396                    let partial = new_time.saturating_sub(event_start);
397                    event.animation.tick(partial);
398                }
399            }
400        }
401
402        self.current_time = new_time;
403
404        // Check if we've reached the end of the timeline.
405        if self.current_time >= self.total_duration {
406            match self.loop_count {
407                LoopCount::Once => {
408                    self.current_time = self.total_duration;
409                    self.state = PlaybackState::Finished;
410                }
411                LoopCount::Times(_) | LoopCount::Infinite => {
412                    if self.loops_remaining > 0 {
413                        if self.loop_count != LoopCount::Infinite {
414                            self.loops_remaining -= 1;
415                        }
416                        // Calculate overshoot to carry into next loop.
417                        let overshoot = self.current_time.saturating_sub(self.total_duration);
418                        self.current_time = Duration::ZERO;
419                        for event in &mut self.events {
420                            event.animation.reset();
421                        }
422                        // Apply overshoot to next loop.
423                        if !overshoot.is_zero() {
424                            self.tick(overshoot);
425                        }
426                    } else {
427                        self.current_time = self.total_duration;
428                        self.state = PlaybackState::Finished;
429                    }
430                }
431            }
432        }
433    }
434
435    fn is_complete(&self) -> bool {
436        self.state == PlaybackState::Finished
437    }
438
439    fn value(&self) -> f32 {
440        self.progress()
441    }
442
443    fn reset(&mut self) {
444        self.current_time = Duration::ZERO;
445        self.loops_remaining = match self.loop_count {
446            LoopCount::Once => 0,
447            LoopCount::Times(n) => n,
448            LoopCount::Infinite => u32::MAX,
449        };
450        self.state = PlaybackState::Idle;
451        for event in &mut self.events {
452            event.animation.reset();
453        }
454    }
455
456    fn overshoot(&self) -> Duration {
457        if self.state == PlaybackState::Finished {
458            self.current_time.saturating_sub(self.total_duration)
459        } else {
460            Duration::ZERO
461        }
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Tests
467// ---------------------------------------------------------------------------
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::animation::Fade;
473
474    const MS_100: Duration = Duration::from_millis(100);
475    const MS_200: Duration = Duration::from_millis(200);
476    const MS_250: Duration = Duration::from_millis(250);
477    const MS_300: Duration = Duration::from_millis(300);
478    const MS_500: Duration = Duration::from_millis(500);
479    const SEC_1: Duration = Duration::from_secs(1);
480
481    #[test]
482    fn empty_timeline_is_immediately_complete() {
483        let tl = Timeline::new();
484        assert_eq!(tl.progress(), 1.0);
485        assert_eq!(tl.event_count(), 0);
486    }
487
488    #[test]
489    fn sequential_events() {
490        let mut tl = Timeline::new()
491            .add(Duration::ZERO, Fade::new(MS_200))
492            .add(MS_200, Fade::new(MS_200))
493            .add(Duration::from_millis(400), Fade::new(MS_200))
494            .set_duration(Duration::from_millis(600));
495
496        tl.play();
497
498        // At 100ms: first event at 50%, others not started
499        tl.tick(MS_100);
500        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.01);
501        assert!((tl.event_value_at(1).unwrap() - 0.0).abs() < 0.01);
502
503        // At 300ms: first complete, second at 50%
504        tl.tick(MS_200);
505        assert!(tl.event_value_at(0).unwrap() > 0.99);
506        assert!((tl.event_value_at(1).unwrap() - 0.5).abs() < 0.01);
507
508        // At 600ms: all complete
509        tl.tick(MS_300);
510        assert!(tl.is_complete());
511        assert!((tl.progress() - 1.0).abs() < f32::EPSILON);
512    }
513
514    #[test]
515    fn overlapping_events() {
516        let mut tl = Timeline::new()
517            .add(Duration::ZERO, Fade::new(MS_500))
518            .add(MS_200, Fade::new(MS_500))
519            .set_duration(Duration::from_millis(700));
520
521        tl.play();
522
523        // At 300ms: first at 60%, second at 20%
524        tl.tick(MS_300);
525        assert!((tl.event_value_at(0).unwrap() - 0.6).abs() < 0.02);
526        assert!((tl.event_value_at(1).unwrap() - 0.2).abs() < 0.02);
527    }
528
529    #[test]
530    fn labeled_events_and_seek() {
531        let mut tl = Timeline::new()
532            .add_labeled("intro", Duration::ZERO, Fade::new(MS_500))
533            .add_labeled("main", MS_500, Fade::new(MS_500))
534            .set_duration(SEC_1);
535
536        tl.play();
537
538        // Seek to "main"
539        assert!(tl.seek_label("main"));
540        // First animation should have been ticked for full 500ms
541        assert!(tl.event_value("intro").unwrap() > 0.99);
542        // Main just started
543        assert!((tl.event_value("main").unwrap() - 0.0).abs() < f32::EPSILON);
544
545        // Unknown label returns false
546        assert!(!tl.seek_label("nonexistent"));
547    }
548
549    #[test]
550    fn loop_finite() {
551        let mut tl = Timeline::new()
552            .add(Duration::ZERO, Fade::new(MS_100))
553            .set_duration(MS_100)
554            .set_loop_count(LoopCount::Times(2));
555
556        tl.play();
557
558        // First play-through
559        tl.tick(MS_100);
560        assert!(!tl.is_complete());
561        assert_eq!(tl.state(), PlaybackState::Playing);
562
563        // Second play-through (first loop)
564        tl.tick(MS_100);
565        assert!(!tl.is_complete());
566
567        // Third play-through (second loop) — should finish
568        tl.tick(MS_100);
569        assert!(tl.is_complete());
570    }
571
572    #[test]
573    fn loop_infinite_never_finishes() {
574        let mut tl = Timeline::new()
575            .add(Duration::ZERO, Fade::new(MS_100))
576            .set_duration(MS_100)
577            .set_loop_count(LoopCount::Infinite);
578
579        tl.play();
580
581        // Run through many cycles
582        for _ in 0..100 {
583            tl.tick(MS_100);
584        }
585        assert!(!tl.is_complete());
586        assert_eq!(tl.state(), PlaybackState::Playing);
587    }
588
589    #[test]
590    fn pause_resume() {
591        let mut tl = Timeline::new()
592            .add(Duration::ZERO, Fade::new(SEC_1))
593            .set_duration(SEC_1);
594
595        tl.play();
596        tl.tick(MS_250);
597
598        tl.pause();
599        assert_eq!(tl.state(), PlaybackState::Paused);
600        let time_at_pause = tl.current_time();
601
602        // Tick while paused — should not advance
603        tl.tick(MS_500);
604        assert_eq!(tl.current_time(), time_at_pause);
605
606        tl.resume();
607        assert_eq!(tl.state(), PlaybackState::Playing);
608
609        // Now ticks advance again
610        tl.tick(MS_250);
611        assert!(tl.current_time() > time_at_pause);
612    }
613
614    #[test]
615    fn seek_clamps_to_duration() {
616        let mut tl = Timeline::new()
617            .add(Duration::ZERO, Fade::new(MS_500))
618            .set_duration(MS_500);
619
620        tl.play();
621        tl.seek(SEC_1); // Past end
622        assert_eq!(tl.current_time(), MS_500);
623    }
624
625    #[test]
626    fn seek_resets_and_reticks_animations() {
627        let mut tl = Timeline::new()
628            .add(Duration::ZERO, Fade::new(SEC_1))
629            .set_duration(SEC_1);
630
631        tl.play();
632        tl.tick(MS_500);
633        // Event at ~50%
634        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.02);
635
636        // Seek back to 250ms
637        tl.seek(MS_250);
638        assert!((tl.event_value_at(0).unwrap() - 0.25).abs() < 0.02);
639    }
640
641    #[test]
642    fn stop_resets_everything() {
643        let mut tl = Timeline::new()
644            .add(Duration::ZERO, Fade::new(SEC_1))
645            .set_duration(SEC_1);
646
647        tl.play();
648        tl.tick(MS_500);
649        tl.stop();
650
651        assert_eq!(tl.state(), PlaybackState::Idle);
652        assert_eq!(tl.current_time(), Duration::ZERO);
653        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
654    }
655
656    #[test]
657    fn play_restarts_from_beginning() {
658        let mut tl = Timeline::new()
659            .add(Duration::ZERO, Fade::new(SEC_1))
660            .set_duration(SEC_1);
661
662        tl.play();
663        tl.tick(SEC_1);
664        assert!(tl.is_complete());
665
666        tl.play();
667        assert_eq!(tl.state(), PlaybackState::Playing);
668        assert_eq!(tl.current_time(), Duration::ZERO);
669        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
670    }
671
672    #[test]
673    fn then_chains_at_same_offset() {
674        let tl = Timeline::new()
675            .add(MS_100, Fade::new(MS_100))
676            .then(Fade::new(MS_100)); // Should be at offset 100ms too
677
678        assert_eq!(tl.event_count(), 2);
679    }
680
681    #[test]
682    fn progress_tracks_time() {
683        let mut tl = Timeline::new()
684            .add(Duration::ZERO, Fade::new(SEC_1))
685            .set_duration(SEC_1);
686
687        tl.play();
688        assert!((tl.progress() - 0.0).abs() < f32::EPSILON);
689
690        tl.tick(MS_250);
691        assert!((tl.progress() - 0.25).abs() < 0.02);
692
693        tl.tick(MS_250);
694        assert!((tl.progress() - 0.5).abs() < 0.02);
695    }
696
697    #[test]
698    fn animation_trait_value_matches_progress() {
699        let mut tl = Timeline::new()
700            .add(Duration::ZERO, Fade::new(SEC_1))
701            .set_duration(SEC_1);
702
703        tl.play();
704        tl.tick(MS_500);
705
706        assert!((tl.value() - tl.progress()).abs() < f32::EPSILON);
707    }
708
709    #[test]
710    fn animation_trait_reset() {
711        let mut tl = Timeline::new()
712            .add(Duration::ZERO, Fade::new(SEC_1))
713            .set_duration(SEC_1);
714
715        tl.play();
716        tl.tick(SEC_1);
717        assert!(tl.is_complete());
718
719        tl.reset();
720        assert_eq!(tl.state(), PlaybackState::Idle);
721        assert!(!tl.is_complete());
722    }
723
724    #[test]
725    fn debug_format() {
726        let tl = Timeline::new()
727            .add(Duration::ZERO, Fade::new(MS_100))
728            .set_duration(MS_100);
729
730        let dbg = format!("{:?}", tl);
731        assert!(dbg.contains("Timeline"));
732        assert!(dbg.contains("event_count"));
733    }
734
735    #[test]
736    fn loop_once_plays_exactly_once() {
737        let mut tl = Timeline::new()
738            .add(Duration::ZERO, Fade::new(MS_100))
739            .set_duration(MS_100)
740            .set_loop_count(LoopCount::Once);
741
742        tl.play();
743        tl.tick(MS_100);
744        assert!(tl.is_complete());
745    }
746
747    #[test]
748    fn event_value_by_label_missing_returns_none() {
749        let tl = Timeline::new()
750            .add(Duration::ZERO, Fade::new(MS_100))
751            .set_duration(MS_100);
752
753        assert!(tl.event_value("nope").is_none());
754    }
755
756    #[test]
757    fn event_value_at_out_of_bounds() {
758        let tl = Timeline::new()
759            .add(Duration::ZERO, Fade::new(MS_100))
760            .set_duration(MS_100);
761
762        assert!(tl.event_value_at(5).is_none());
763    }
764
765    #[test]
766    fn idle_timeline_value_is_zero() {
767        let tl = Timeline::new()
768            .add(Duration::ZERO, Fade::new(MS_500))
769            .set_duration(MS_500);
770
771        // Not yet played: value should be 0.0
772        assert!((tl.value() - 0.0).abs() < f32::EPSILON);
773        assert_eq!(tl.state(), PlaybackState::Idle);
774    }
775
776    #[test]
777    fn overshoot_is_zero_while_playing() {
778        let mut tl = Timeline::new()
779            .add(Duration::ZERO, Fade::new(MS_500))
780            .set_duration(MS_500);
781
782        tl.play();
783        tl.tick(MS_250);
784
785        assert_eq!(tl.overshoot(), Duration::ZERO);
786    }
787
788    #[test]
789    fn seek_to_zero_resets_animations() {
790        let mut tl = Timeline::new()
791            .add(Duration::ZERO, Fade::new(MS_500))
792            .set_duration(MS_500);
793
794        tl.play();
795        tl.tick(MS_250);
796        assert!(tl.event_value_at(0).unwrap() > 0.0);
797
798        tl.seek(Duration::ZERO);
799        assert_eq!(tl.current_time(), Duration::ZERO);
800    }
801
802    // ── Edge-case tests (bd-nq7yt) ──────────────────────────────────
803
804    #[test]
805    fn default_trait() {
806        let tl = Timeline::default();
807        assert_eq!(tl.event_count(), 0);
808        assert_eq!(tl.state(), PlaybackState::Idle);
809        assert_eq!(tl.progress(), 1.0); // empty timeline is immediately complete
810    }
811
812    #[test]
813    fn zero_duration_clamped_to_1ns() {
814        let tl = Timeline::new()
815            .add(Duration::ZERO, Fade::new(MS_100))
816            .set_duration(Duration::ZERO);
817        assert_eq!(tl.duration(), Duration::from_nanos(1));
818    }
819
820    #[test]
821    fn then_on_empty_timeline_uses_zero_offset() {
822        let tl = Timeline::new().then(Fade::new(MS_100));
823        assert_eq!(tl.event_count(), 1);
824        // Auto-computed duration from a single event at offset 0 → 1ns.
825        assert_eq!(tl.duration(), Duration::from_nanos(1));
826    }
827
828    #[test]
829    fn pause_when_not_playing_is_noop() {
830        let mut tl = Timeline::new()
831            .add(Duration::ZERO, Fade::new(MS_100))
832            .set_duration(MS_100);
833
834        // Pause from Idle → still Idle.
835        tl.pause();
836        assert_eq!(tl.state(), PlaybackState::Idle);
837
838        // Pause from Finished → still Finished.
839        tl.play();
840        tl.tick(MS_100);
841        assert_eq!(tl.state(), PlaybackState::Finished);
842        tl.pause();
843        assert_eq!(tl.state(), PlaybackState::Finished);
844    }
845
846    #[test]
847    fn resume_when_not_paused_is_noop() {
848        let mut tl = Timeline::new()
849            .add(Duration::ZERO, Fade::new(MS_100))
850            .set_duration(MS_100);
851
852        // Resume from Idle → still Idle.
853        tl.resume();
854        assert_eq!(tl.state(), PlaybackState::Idle);
855
856        // Resume from Playing → still Playing.
857        tl.play();
858        tl.resume();
859        assert_eq!(tl.state(), PlaybackState::Playing);
860    }
861
862    #[test]
863    fn seek_from_idle_transitions_to_paused() {
864        let mut tl = Timeline::new()
865            .add(Duration::ZERO, Fade::new(MS_500))
866            .set_duration(MS_500);
867
868        assert_eq!(tl.state(), PlaybackState::Idle);
869        tl.seek(MS_250);
870        assert_eq!(tl.state(), PlaybackState::Paused);
871        assert_eq!(tl.current_time(), MS_250);
872    }
873
874    #[test]
875    fn seek_from_finished_transitions_to_paused() {
876        let mut tl = Timeline::new()
877            .add(Duration::ZERO, Fade::new(MS_500))
878            .set_duration(MS_500);
879
880        tl.play();
881        tl.tick(MS_500);
882        assert_eq!(tl.state(), PlaybackState::Finished);
883
884        tl.seek(MS_250);
885        assert_eq!(tl.state(), PlaybackState::Paused);
886        assert_eq!(tl.current_time(), MS_250);
887    }
888
889    #[test]
890    fn seek_from_playing_stays_playing() {
891        let mut tl = Timeline::new()
892            .add(Duration::ZERO, Fade::new(MS_500))
893            .set_duration(MS_500);
894
895        tl.play();
896        tl.tick(MS_100);
897        assert_eq!(tl.state(), PlaybackState::Playing);
898
899        tl.seek(MS_300);
900        // Seek doesn't change Playing state.
901        assert_eq!(tl.state(), PlaybackState::Playing);
902    }
903
904    #[test]
905    fn overshoot_when_finished() {
906        let mut tl = Timeline::new()
907            .add(Duration::ZERO, Fade::new(MS_100))
908            .set_duration(MS_100);
909
910        tl.play();
911        tl.tick(MS_100);
912        assert!(tl.is_complete());
913        // LoopCount::Once clamps current_time to duration, so overshoot is 0.
914        assert_eq!(tl.overshoot(), Duration::ZERO);
915    }
916
917    #[test]
918    fn tick_when_idle_does_not_advance() {
919        let mut tl = Timeline::new()
920            .add(Duration::ZERO, Fade::new(MS_500))
921            .set_duration(MS_500);
922
923        tl.tick(MS_250);
924        assert_eq!(tl.current_time(), Duration::ZERO);
925        assert_eq!(tl.state(), PlaybackState::Idle);
926    }
927
928    #[test]
929    fn tick_when_paused_does_not_advance() {
930        let mut tl = Timeline::new()
931            .add(Duration::ZERO, Fade::new(SEC_1))
932            .set_duration(SEC_1);
933
934        tl.play();
935        tl.tick(MS_250);
936        tl.pause();
937        let paused_time = tl.current_time();
938
939        tl.tick(MS_500);
940        assert_eq!(tl.current_time(), paused_time);
941    }
942
943    #[test]
944    fn tick_when_finished_does_not_advance() {
945        let mut tl = Timeline::new()
946            .add(Duration::ZERO, Fade::new(MS_100))
947            .set_duration(MS_100);
948
949        tl.play();
950        tl.tick(MS_100);
951        assert!(tl.is_complete());
952
953        let time_at_finish = tl.current_time();
954        tl.tick(MS_500);
955        assert_eq!(tl.current_time(), time_at_finish);
956    }
957
958    #[test]
959    fn multiple_events_at_same_offset() {
960        let mut tl = Timeline::new()
961            .add(Duration::ZERO, Fade::new(MS_200))
962            .add(Duration::ZERO, Fade::new(MS_200))
963            .add(Duration::ZERO, Fade::new(MS_200))
964            .set_duration(MS_200);
965
966        assert_eq!(tl.event_count(), 3);
967        tl.play();
968        tl.tick(MS_100);
969
970        // All three should be at ~50%.
971        for i in 0..3 {
972            assert!(
973                (tl.event_value_at(i).unwrap() - 0.5).abs() < 0.02,
974                "event {i} should be at ~50%"
975            );
976        }
977    }
978
979    #[test]
980    fn auto_computed_duration_uses_max_offset() {
981        let tl = Timeline::new()
982            .add(MS_100, Fade::new(MS_100))
983            .add(MS_500, Fade::new(MS_100))
984            .add(MS_300, Fade::new(MS_100));
985
986        // Events sorted by offset: 100, 300, 500.
987        // Duration should be max offset = 500ms.
988        assert_eq!(tl.duration(), MS_500);
989    }
990
991    #[test]
992    fn explicit_duration_overrides_auto() {
993        let tl = Timeline::new()
994            .add(MS_100, Fade::new(MS_100))
995            .add(MS_500, Fade::new(MS_100))
996            .set_duration(SEC_1);
997
998        assert_eq!(tl.duration(), SEC_1);
999    }
1000
1001    #[test]
1002    fn seek_label_on_empty_timeline() {
1003        let mut tl = Timeline::new();
1004        assert!(!tl.seek_label("foo"));
1005    }
1006
1007    #[test]
1008    fn event_value_at_on_empty_timeline() {
1009        let tl = Timeline::new();
1010        assert!(tl.event_value_at(0).is_none());
1011    }
1012
1013    #[test]
1014    fn loop_times_zero_plays_once() {
1015        let mut tl = Timeline::new()
1016            .add(Duration::ZERO, Fade::new(MS_100))
1017            .set_duration(MS_100)
1018            .set_loop_count(LoopCount::Times(0));
1019
1020        tl.play();
1021        tl.tick(MS_100);
1022        assert!(tl.is_complete());
1023    }
1024
1025    #[test]
1026    fn loop_count_eq() {
1027        assert_eq!(LoopCount::Once, LoopCount::Once);
1028        assert_eq!(LoopCount::Times(5), LoopCount::Times(5));
1029        assert_ne!(LoopCount::Times(5), LoopCount::Times(3));
1030        assert_eq!(LoopCount::Infinite, LoopCount::Infinite);
1031        assert_ne!(LoopCount::Once, LoopCount::Infinite);
1032    }
1033
1034    #[test]
1035    fn playback_state_eq() {
1036        assert_eq!(PlaybackState::Idle, PlaybackState::Idle);
1037        assert_eq!(PlaybackState::Playing, PlaybackState::Playing);
1038        assert_eq!(PlaybackState::Paused, PlaybackState::Paused);
1039        assert_eq!(PlaybackState::Finished, PlaybackState::Finished);
1040        assert_ne!(PlaybackState::Idle, PlaybackState::Playing);
1041    }
1042
1043    #[test]
1044    fn loop_count_clone() {
1045        let lc = LoopCount::Times(3);
1046        let lc2 = lc;
1047        assert_eq!(lc, lc2);
1048    }
1049
1050    #[test]
1051    fn playback_state_clone() {
1052        let ps = PlaybackState::Paused;
1053        let ps2 = ps;
1054        assert_eq!(ps, ps2);
1055    }
1056
1057    #[test]
1058    fn play_after_stop_resets() {
1059        let mut tl = Timeline::new()
1060            .add(Duration::ZERO, Fade::new(MS_500))
1061            .set_duration(MS_500);
1062
1063        tl.play();
1064        tl.tick(MS_250);
1065        tl.stop();
1066        assert_eq!(tl.state(), PlaybackState::Idle);
1067
1068        tl.play();
1069        assert_eq!(tl.state(), PlaybackState::Playing);
1070        assert_eq!(tl.current_time(), Duration::ZERO);
1071    }
1072
1073    #[test]
1074    fn seek_past_end_then_resume_and_tick_finishes() {
1075        let mut tl = Timeline::new()
1076            .add(Duration::ZERO, Fade::new(MS_100))
1077            .set_duration(MS_100);
1078
1079        tl.play();
1080        tl.seek(MS_100); // Seek to end, stays Playing.
1081        tl.resume(); // No-op if already playing.
1082
1083        // Tick should detect we're at duration and finish.
1084        tl.tick(Duration::from_nanos(1));
1085        assert!(tl.is_complete());
1086    }
1087
1088    #[test]
1089    fn progress_clamps_to_zero_one() {
1090        let mut tl = Timeline::new()
1091            .add(Duration::ZERO, Fade::new(MS_100))
1092            .set_duration(MS_100);
1093
1094        // Before play: progress 0.0.
1095        assert!(tl.progress() >= 0.0);
1096        assert!(tl.progress() <= 1.0);
1097
1098        tl.play();
1099        tl.tick(MS_100);
1100        assert!(tl.progress() >= 0.0);
1101        assert!(tl.progress() <= 1.0);
1102    }
1103
1104    #[test]
1105    fn animation_trait_is_complete_false_while_playing() {
1106        let mut tl = Timeline::new()
1107            .add(Duration::ZERO, Fade::new(MS_500))
1108            .set_duration(MS_500);
1109
1110        tl.play();
1111        tl.tick(MS_250);
1112        assert!(!tl.is_complete());
1113    }
1114
1115    #[test]
1116    fn reset_from_finished() {
1117        let mut tl = Timeline::new()
1118            .add(Duration::ZERO, Fade::new(MS_100))
1119            .set_duration(MS_100);
1120
1121        tl.play();
1122        tl.tick(MS_100);
1123        assert!(tl.is_complete());
1124
1125        tl.reset();
1126        assert_eq!(tl.state(), PlaybackState::Idle);
1127        assert_eq!(tl.current_time(), Duration::ZERO);
1128        assert!(!tl.is_complete());
1129    }
1130
1131    #[test]
1132    fn reset_from_paused() {
1133        let mut tl = Timeline::new()
1134            .add(Duration::ZERO, Fade::new(MS_500))
1135            .set_duration(MS_500);
1136
1137        tl.play();
1138        tl.tick(MS_250);
1139        tl.pause();
1140        assert_eq!(tl.state(), PlaybackState::Paused);
1141
1142        tl.reset();
1143        assert_eq!(tl.state(), PlaybackState::Idle);
1144        assert_eq!(tl.current_time(), Duration::ZERO);
1145    }
1146
1147    #[test]
1148    fn labeled_event_value() {
1149        let mut tl = Timeline::new()
1150            .add_labeled("fade", Duration::ZERO, Fade::new(MS_200))
1151            .set_duration(MS_200);
1152
1153        tl.play();
1154        tl.tick(MS_100);
1155
1156        let v = tl.event_value("fade").unwrap();
1157        assert!((v - 0.5).abs() < 0.02);
1158    }
1159
1160    #[test]
1161    fn events_sorted_by_offset_on_insert() {
1162        let tl = Timeline::new()
1163            .add(MS_500, Fade::new(MS_100))
1164            .add(MS_100, Fade::new(MS_100))
1165            .add(MS_300, Fade::new(MS_100));
1166
1167        // Event at index 0 should be the one at smallest offset.
1168        // After ticking past 100ms, event_value_at(0) should advance.
1169        let mut tl = tl.set_duration(MS_500);
1170        tl.play();
1171        tl.tick(Duration::from_millis(150));
1172
1173        // Event 0 (offset 100ms): should be at 50ms into its 100ms fade = 50%.
1174        let v = tl.event_value_at(0).unwrap();
1175        assert!((v - 0.5).abs() < 0.02);
1176
1177        // Event 1 (offset 300ms): not started yet.
1178        let v = tl.event_value_at(1).unwrap();
1179        assert!(v < 0.01);
1180    }
1181
1182    #[test]
1183    fn debug_format_includes_fields() {
1184        let tl = Timeline::new()
1185            .add_labeled("intro", Duration::ZERO, Fade::new(MS_100))
1186            .set_duration(MS_100);
1187
1188        let dbg = format!("{:?}", tl);
1189        assert!(dbg.contains("event_count"));
1190        assert!(dbg.contains("total_duration"));
1191        assert!(dbg.contains("state"));
1192    }
1193
1194    #[test]
1195    fn loop_finite_with_overshoot_tick() {
1196        let mut tl = Timeline::new()
1197            .add(Duration::ZERO, Fade::new(MS_100))
1198            .set_duration(MS_100)
1199            .set_loop_count(LoopCount::Times(1));
1200
1201        tl.play();
1202        // Tick 150ms in one go — overshoots first play by 50ms.
1203        tl.tick(Duration::from_millis(150));
1204        // Should be in second play (first loop), not finished.
1205        assert!(!tl.is_complete());
1206        // Current time should be 50ms into the second play.
1207        assert!(tl.current_time() <= MS_100);
1208    }
1209}