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        if self.total_duration.is_zero() {
323            return 1.0;
324        }
325        let t = self.current_time.as_secs_f64() / self.total_duration.as_secs_f64();
326        (t as f32).clamp(0.0, 1.0)
327    }
328
329    /// Current playback state.
330    #[inline]
331    #[must_use]
332    pub fn state(&self) -> PlaybackState {
333        self.state
334    }
335
336    /// Current time position.
337    #[inline]
338    #[must_use]
339    pub fn current_time(&self) -> Duration {
340        self.current_time
341    }
342
343    /// Total duration.
344    #[inline]
345    #[must_use]
346    pub fn duration(&self) -> Duration {
347        self.total_duration
348    }
349
350    /// Number of events in the timeline.
351    #[inline]
352    #[must_use]
353    pub fn event_count(&self) -> usize {
354        self.events.len()
355    }
356
357    /// Get the animation value for a specific labeled event.
358    ///
359    /// Returns `None` if the label doesn't exist.
360    #[must_use]
361    pub fn event_value(&self, label: &str) -> Option<f32> {
362        self.events
363            .iter()
364            .find(|e| e.label.as_deref() == Some(label))
365            .map(|e| e.animation.value())
366    }
367
368    /// Get the animation value for an event by index.
369    ///
370    /// Returns `None` if index is out of bounds.
371    #[must_use]
372    pub fn event_value_at(&self, index: usize) -> Option<f32> {
373        self.events.get(index).map(|e| e.animation.value())
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Animation trait implementation
379// ---------------------------------------------------------------------------
380
381impl Animation for Timeline {
382    fn tick(&mut self, dt: Duration) {
383        if self.state != PlaybackState::Playing {
384            return;
385        }
386
387        let new_time = self.current_time.saturating_add(dt);
388
389        // Tick each event that overlaps with [current_time, new_time].
390        for event in &mut self.events {
391            if new_time > event.offset && !event.animation.is_complete() {
392                // How much time has elapsed for this event.
393                let event_start = event.offset;
394                if self.current_time >= event_start {
395                    // Already past offset — just forward dt.
396                    event.animation.tick(dt);
397                } else {
398                    // Event starts within this tick — forward only the portion after offset.
399                    let partial = new_time.saturating_sub(event_start);
400                    event.animation.tick(partial);
401                }
402            }
403        }
404
405        self.current_time = new_time;
406
407        // Check if we've reached the end of the timeline.
408        if self.current_time >= self.total_duration {
409            match self.loop_count {
410                LoopCount::Once => {
411                    self.current_time = self.total_duration;
412                    self.state = PlaybackState::Finished;
413                }
414                LoopCount::Times(_) | LoopCount::Infinite => {
415                    if self.loops_remaining > 0 {
416                        if self.loop_count != LoopCount::Infinite {
417                            self.loops_remaining -= 1;
418                        }
419                        // Calculate overshoot to carry into next loop.
420                        let overshoot = self.current_time.saturating_sub(self.total_duration);
421                        self.current_time = Duration::ZERO;
422                        for event in &mut self.events {
423                            event.animation.reset();
424                        }
425                        // Apply overshoot to next loop.
426                        if !overshoot.is_zero() {
427                            self.tick(overshoot);
428                        }
429                    } else {
430                        self.current_time = self.total_duration;
431                        self.state = PlaybackState::Finished;
432                    }
433                }
434            }
435        }
436    }
437
438    fn is_complete(&self) -> bool {
439        self.state == PlaybackState::Finished
440    }
441
442    fn value(&self) -> f32 {
443        self.progress()
444    }
445
446    fn reset(&mut self) {
447        self.current_time = Duration::ZERO;
448        self.loops_remaining = match self.loop_count {
449            LoopCount::Once => 0,
450            LoopCount::Times(n) => n,
451            LoopCount::Infinite => u32::MAX,
452        };
453        self.state = PlaybackState::Idle;
454        for event in &mut self.events {
455            event.animation.reset();
456        }
457    }
458
459    fn overshoot(&self) -> Duration {
460        if self.state == PlaybackState::Finished {
461            self.current_time.saturating_sub(self.total_duration)
462        } else {
463            Duration::ZERO
464        }
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Tests
470// ---------------------------------------------------------------------------
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::animation::Fade;
476
477    const MS_100: Duration = Duration::from_millis(100);
478    const MS_200: Duration = Duration::from_millis(200);
479    const MS_250: Duration = Duration::from_millis(250);
480    const MS_300: Duration = Duration::from_millis(300);
481    const MS_500: Duration = Duration::from_millis(500);
482    const SEC_1: Duration = Duration::from_secs(1);
483
484    #[test]
485    fn empty_timeline_is_immediately_complete() {
486        let tl = Timeline::new();
487        assert_eq!(tl.progress(), 1.0);
488        assert_eq!(tl.event_count(), 0);
489    }
490
491    #[test]
492    fn sequential_events() {
493        let mut tl = Timeline::new()
494            .add(Duration::ZERO, Fade::new(MS_200))
495            .add(MS_200, Fade::new(MS_200))
496            .add(Duration::from_millis(400), Fade::new(MS_200))
497            .set_duration(Duration::from_millis(600));
498
499        tl.play();
500
501        // At 100ms: first event at 50%, others not started
502        tl.tick(MS_100);
503        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.01);
504        assert!((tl.event_value_at(1).unwrap() - 0.0).abs() < 0.01);
505
506        // At 300ms: first complete, second at 50%
507        tl.tick(MS_200);
508        assert!(tl.event_value_at(0).unwrap() > 0.99);
509        assert!((tl.event_value_at(1).unwrap() - 0.5).abs() < 0.01);
510
511        // At 600ms: all complete
512        tl.tick(MS_300);
513        assert!(tl.is_complete());
514        assert!((tl.progress() - 1.0).abs() < f32::EPSILON);
515    }
516
517    #[test]
518    fn overlapping_events() {
519        let mut tl = Timeline::new()
520            .add(Duration::ZERO, Fade::new(MS_500))
521            .add(MS_200, Fade::new(MS_500))
522            .set_duration(Duration::from_millis(700));
523
524        tl.play();
525
526        // At 300ms: first at 60%, second at 20%
527        tl.tick(MS_300);
528        assert!((tl.event_value_at(0).unwrap() - 0.6).abs() < 0.02);
529        assert!((tl.event_value_at(1).unwrap() - 0.2).abs() < 0.02);
530    }
531
532    #[test]
533    fn labeled_events_and_seek() {
534        let mut tl = Timeline::new()
535            .add_labeled("intro", Duration::ZERO, Fade::new(MS_500))
536            .add_labeled("main", MS_500, Fade::new(MS_500))
537            .set_duration(SEC_1);
538
539        tl.play();
540
541        // Seek to "main"
542        assert!(tl.seek_label("main"));
543        // First animation should have been ticked for full 500ms
544        assert!(tl.event_value("intro").unwrap() > 0.99);
545        // Main just started
546        assert!((tl.event_value("main").unwrap() - 0.0).abs() < f32::EPSILON);
547
548        // Unknown label returns false
549        assert!(!tl.seek_label("nonexistent"));
550    }
551
552    #[test]
553    fn loop_finite() {
554        let mut tl = Timeline::new()
555            .add(Duration::ZERO, Fade::new(MS_100))
556            .set_duration(MS_100)
557            .set_loop_count(LoopCount::Times(2));
558
559        tl.play();
560
561        // First play-through
562        tl.tick(MS_100);
563        assert!(!tl.is_complete());
564        assert_eq!(tl.state(), PlaybackState::Playing);
565
566        // Second play-through (first loop)
567        tl.tick(MS_100);
568        assert!(!tl.is_complete());
569
570        // Third play-through (second loop) — should finish
571        tl.tick(MS_100);
572        assert!(tl.is_complete());
573    }
574
575    #[test]
576    fn loop_infinite_never_finishes() {
577        let mut tl = Timeline::new()
578            .add(Duration::ZERO, Fade::new(MS_100))
579            .set_duration(MS_100)
580            .set_loop_count(LoopCount::Infinite);
581
582        tl.play();
583
584        // Run through many cycles
585        for _ in 0..100 {
586            tl.tick(MS_100);
587        }
588        assert!(!tl.is_complete());
589        assert_eq!(tl.state(), PlaybackState::Playing);
590    }
591
592    #[test]
593    fn pause_resume() {
594        let mut tl = Timeline::new()
595            .add(Duration::ZERO, Fade::new(SEC_1))
596            .set_duration(SEC_1);
597
598        tl.play();
599        tl.tick(MS_250);
600
601        tl.pause();
602        assert_eq!(tl.state(), PlaybackState::Paused);
603        let time_at_pause = tl.current_time();
604
605        // Tick while paused — should not advance
606        tl.tick(MS_500);
607        assert_eq!(tl.current_time(), time_at_pause);
608
609        tl.resume();
610        assert_eq!(tl.state(), PlaybackState::Playing);
611
612        // Now ticks advance again
613        tl.tick(MS_250);
614        assert!(tl.current_time() > time_at_pause);
615    }
616
617    #[test]
618    fn seek_clamps_to_duration() {
619        let mut tl = Timeline::new()
620            .add(Duration::ZERO, Fade::new(MS_500))
621            .set_duration(MS_500);
622
623        tl.play();
624        tl.seek(SEC_1); // Past end
625        assert_eq!(tl.current_time(), MS_500);
626    }
627
628    #[test]
629    fn seek_resets_and_reticks_animations() {
630        let mut tl = Timeline::new()
631            .add(Duration::ZERO, Fade::new(SEC_1))
632            .set_duration(SEC_1);
633
634        tl.play();
635        tl.tick(MS_500);
636        // Event at ~50%
637        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.02);
638
639        // Seek back to 250ms
640        tl.seek(MS_250);
641        assert!((tl.event_value_at(0).unwrap() - 0.25).abs() < 0.02);
642    }
643
644    #[test]
645    fn stop_resets_everything() {
646        let mut tl = Timeline::new()
647            .add(Duration::ZERO, Fade::new(SEC_1))
648            .set_duration(SEC_1);
649
650        tl.play();
651        tl.tick(MS_500);
652        tl.stop();
653
654        assert_eq!(tl.state(), PlaybackState::Idle);
655        assert_eq!(tl.current_time(), Duration::ZERO);
656        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
657    }
658
659    #[test]
660    fn play_restarts_from_beginning() {
661        let mut tl = Timeline::new()
662            .add(Duration::ZERO, Fade::new(SEC_1))
663            .set_duration(SEC_1);
664
665        tl.play();
666        tl.tick(SEC_1);
667        assert!(tl.is_complete());
668
669        tl.play();
670        assert_eq!(tl.state(), PlaybackState::Playing);
671        assert_eq!(tl.current_time(), Duration::ZERO);
672        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
673    }
674
675    #[test]
676    fn then_chains_at_same_offset() {
677        let tl = Timeline::new()
678            .add(MS_100, Fade::new(MS_100))
679            .then(Fade::new(MS_100)); // Should be at offset 100ms too
680
681        assert_eq!(tl.event_count(), 2);
682    }
683
684    #[test]
685    fn progress_tracks_time() {
686        let mut tl = Timeline::new()
687            .add(Duration::ZERO, Fade::new(SEC_1))
688            .set_duration(SEC_1);
689
690        tl.play();
691        assert!((tl.progress() - 0.0).abs() < f32::EPSILON);
692
693        tl.tick(MS_250);
694        assert!((tl.progress() - 0.25).abs() < 0.02);
695
696        tl.tick(MS_250);
697        assert!((tl.progress() - 0.5).abs() < 0.02);
698    }
699
700    #[test]
701    fn animation_trait_value_matches_progress() {
702        let mut tl = Timeline::new()
703            .add(Duration::ZERO, Fade::new(SEC_1))
704            .set_duration(SEC_1);
705
706        tl.play();
707        tl.tick(MS_500);
708
709        assert!((tl.value() - tl.progress()).abs() < f32::EPSILON);
710    }
711
712    #[test]
713    fn animation_trait_reset() {
714        let mut tl = Timeline::new()
715            .add(Duration::ZERO, Fade::new(SEC_1))
716            .set_duration(SEC_1);
717
718        tl.play();
719        tl.tick(SEC_1);
720        assert!(tl.is_complete());
721
722        tl.reset();
723        assert_eq!(tl.state(), PlaybackState::Idle);
724        assert!(!tl.is_complete());
725    }
726
727    #[test]
728    fn debug_format() {
729        let tl = Timeline::new()
730            .add(Duration::ZERO, Fade::new(MS_100))
731            .set_duration(MS_100);
732
733        let dbg = format!("{:?}", tl);
734        assert!(dbg.contains("Timeline"));
735        assert!(dbg.contains("event_count"));
736    }
737
738    #[test]
739    fn loop_once_plays_exactly_once() {
740        let mut tl = Timeline::new()
741            .add(Duration::ZERO, Fade::new(MS_100))
742            .set_duration(MS_100)
743            .set_loop_count(LoopCount::Once);
744
745        tl.play();
746        tl.tick(MS_100);
747        assert!(tl.is_complete());
748    }
749
750    #[test]
751    fn event_value_by_label_missing_returns_none() {
752        let tl = Timeline::new()
753            .add(Duration::ZERO, Fade::new(MS_100))
754            .set_duration(MS_100);
755
756        assert!(tl.event_value("nope").is_none());
757    }
758
759    #[test]
760    fn event_value_at_out_of_bounds() {
761        let tl = Timeline::new()
762            .add(Duration::ZERO, Fade::new(MS_100))
763            .set_duration(MS_100);
764
765        assert!(tl.event_value_at(5).is_none());
766    }
767
768    #[test]
769    fn idle_timeline_value_is_zero() {
770        let tl = Timeline::new()
771            .add(Duration::ZERO, Fade::new(MS_500))
772            .set_duration(MS_500);
773
774        // Not yet played: value should be 0.0
775        assert!((tl.value() - 0.0).abs() < f32::EPSILON);
776        assert_eq!(tl.state(), PlaybackState::Idle);
777    }
778
779    #[test]
780    fn overshoot_is_zero_while_playing() {
781        let mut tl = Timeline::new()
782            .add(Duration::ZERO, Fade::new(MS_500))
783            .set_duration(MS_500);
784
785        tl.play();
786        tl.tick(MS_250);
787
788        assert_eq!(tl.overshoot(), Duration::ZERO);
789    }
790
791    #[test]
792    fn seek_to_zero_resets_animations() {
793        let mut tl = Timeline::new()
794            .add(Duration::ZERO, Fade::new(MS_500))
795            .set_duration(MS_500);
796
797        tl.play();
798        tl.tick(MS_250);
799        assert!(tl.event_value_at(0).unwrap() > 0.0);
800
801        tl.seek(Duration::ZERO);
802        assert_eq!(tl.current_time(), Duration::ZERO);
803    }
804
805    // ── Edge-case tests (bd-nq7yt) ──────────────────────────────────
806
807    #[test]
808    fn default_trait() {
809        let tl = Timeline::default();
810        assert_eq!(tl.event_count(), 0);
811        assert_eq!(tl.state(), PlaybackState::Idle);
812        assert_eq!(tl.progress(), 1.0); // empty timeline is immediately complete
813    }
814
815    #[test]
816    fn zero_duration_clamped_to_1ns() {
817        let tl = Timeline::new()
818            .add(Duration::ZERO, Fade::new(MS_100))
819            .set_duration(Duration::ZERO);
820        assert_eq!(tl.duration(), Duration::from_nanos(1));
821    }
822
823    #[test]
824    fn then_on_empty_timeline_uses_zero_offset() {
825        let tl = Timeline::new().then(Fade::new(MS_100));
826        assert_eq!(tl.event_count(), 1);
827        // Auto-computed duration from a single event at offset 0 → 1ns.
828        assert_eq!(tl.duration(), Duration::from_nanos(1));
829    }
830
831    #[test]
832    fn pause_when_not_playing_is_noop() {
833        let mut tl = Timeline::new()
834            .add(Duration::ZERO, Fade::new(MS_100))
835            .set_duration(MS_100);
836
837        // Pause from Idle → still Idle.
838        tl.pause();
839        assert_eq!(tl.state(), PlaybackState::Idle);
840
841        // Pause from Finished → still Finished.
842        tl.play();
843        tl.tick(MS_100);
844        assert_eq!(tl.state(), PlaybackState::Finished);
845        tl.pause();
846        assert_eq!(tl.state(), PlaybackState::Finished);
847    }
848
849    #[test]
850    fn resume_when_not_paused_is_noop() {
851        let mut tl = Timeline::new()
852            .add(Duration::ZERO, Fade::new(MS_100))
853            .set_duration(MS_100);
854
855        // Resume from Idle → still Idle.
856        tl.resume();
857        assert_eq!(tl.state(), PlaybackState::Idle);
858
859        // Resume from Playing → still Playing.
860        tl.play();
861        tl.resume();
862        assert_eq!(tl.state(), PlaybackState::Playing);
863    }
864
865    #[test]
866    fn seek_from_idle_transitions_to_paused() {
867        let mut tl = Timeline::new()
868            .add(Duration::ZERO, Fade::new(MS_500))
869            .set_duration(MS_500);
870
871        assert_eq!(tl.state(), PlaybackState::Idle);
872        tl.seek(MS_250);
873        assert_eq!(tl.state(), PlaybackState::Paused);
874        assert_eq!(tl.current_time(), MS_250);
875    }
876
877    #[test]
878    fn seek_from_finished_transitions_to_paused() {
879        let mut tl = Timeline::new()
880            .add(Duration::ZERO, Fade::new(MS_500))
881            .set_duration(MS_500);
882
883        tl.play();
884        tl.tick(MS_500);
885        assert_eq!(tl.state(), PlaybackState::Finished);
886
887        tl.seek(MS_250);
888        assert_eq!(tl.state(), PlaybackState::Paused);
889        assert_eq!(tl.current_time(), MS_250);
890    }
891
892    #[test]
893    fn seek_from_playing_stays_playing() {
894        let mut tl = Timeline::new()
895            .add(Duration::ZERO, Fade::new(MS_500))
896            .set_duration(MS_500);
897
898        tl.play();
899        tl.tick(MS_100);
900        assert_eq!(tl.state(), PlaybackState::Playing);
901
902        tl.seek(MS_300);
903        // Seek doesn't change Playing state.
904        assert_eq!(tl.state(), PlaybackState::Playing);
905    }
906
907    #[test]
908    fn overshoot_when_finished() {
909        let mut tl = Timeline::new()
910            .add(Duration::ZERO, Fade::new(MS_100))
911            .set_duration(MS_100);
912
913        tl.play();
914        tl.tick(MS_100);
915        assert!(tl.is_complete());
916        // LoopCount::Once clamps current_time to duration, so overshoot is 0.
917        assert_eq!(tl.overshoot(), Duration::ZERO);
918    }
919
920    #[test]
921    fn tick_when_idle_does_not_advance() {
922        let mut tl = Timeline::new()
923            .add(Duration::ZERO, Fade::new(MS_500))
924            .set_duration(MS_500);
925
926        tl.tick(MS_250);
927        assert_eq!(tl.current_time(), Duration::ZERO);
928        assert_eq!(tl.state(), PlaybackState::Idle);
929    }
930
931    #[test]
932    fn tick_when_paused_does_not_advance() {
933        let mut tl = Timeline::new()
934            .add(Duration::ZERO, Fade::new(SEC_1))
935            .set_duration(SEC_1);
936
937        tl.play();
938        tl.tick(MS_250);
939        tl.pause();
940        let paused_time = tl.current_time();
941
942        tl.tick(MS_500);
943        assert_eq!(tl.current_time(), paused_time);
944    }
945
946    #[test]
947    fn tick_when_finished_does_not_advance() {
948        let mut tl = Timeline::new()
949            .add(Duration::ZERO, Fade::new(MS_100))
950            .set_duration(MS_100);
951
952        tl.play();
953        tl.tick(MS_100);
954        assert!(tl.is_complete());
955
956        let time_at_finish = tl.current_time();
957        tl.tick(MS_500);
958        assert_eq!(tl.current_time(), time_at_finish);
959    }
960
961    #[test]
962    fn multiple_events_at_same_offset() {
963        let mut tl = Timeline::new()
964            .add(Duration::ZERO, Fade::new(MS_200))
965            .add(Duration::ZERO, Fade::new(MS_200))
966            .add(Duration::ZERO, Fade::new(MS_200))
967            .set_duration(MS_200);
968
969        assert_eq!(tl.event_count(), 3);
970        tl.play();
971        tl.tick(MS_100);
972
973        // All three should be at ~50%.
974        for i in 0..3 {
975            assert!(
976                (tl.event_value_at(i).unwrap() - 0.5).abs() < 0.02,
977                "event {i} should be at ~50%"
978            );
979        }
980    }
981
982    #[test]
983    fn auto_computed_duration_uses_max_offset() {
984        let tl = Timeline::new()
985            .add(MS_100, Fade::new(MS_100))
986            .add(MS_500, Fade::new(MS_100))
987            .add(MS_300, Fade::new(MS_100));
988
989        // Events sorted by offset: 100, 300, 500.
990        // Duration should be max offset = 500ms.
991        assert_eq!(tl.duration(), MS_500);
992    }
993
994    #[test]
995    fn explicit_duration_overrides_auto() {
996        let tl = Timeline::new()
997            .add(MS_100, Fade::new(MS_100))
998            .add(MS_500, Fade::new(MS_100))
999            .set_duration(SEC_1);
1000
1001        assert_eq!(tl.duration(), SEC_1);
1002    }
1003
1004    #[test]
1005    fn seek_label_on_empty_timeline() {
1006        let mut tl = Timeline::new();
1007        assert!(!tl.seek_label("foo"));
1008    }
1009
1010    #[test]
1011    fn event_value_at_on_empty_timeline() {
1012        let tl = Timeline::new();
1013        assert!(tl.event_value_at(0).is_none());
1014    }
1015
1016    #[test]
1017    fn loop_times_zero_plays_once() {
1018        let mut tl = Timeline::new()
1019            .add(Duration::ZERO, Fade::new(MS_100))
1020            .set_duration(MS_100)
1021            .set_loop_count(LoopCount::Times(0));
1022
1023        tl.play();
1024        tl.tick(MS_100);
1025        assert!(tl.is_complete());
1026    }
1027
1028    #[test]
1029    fn loop_count_eq() {
1030        assert_eq!(LoopCount::Once, LoopCount::Once);
1031        assert_eq!(LoopCount::Times(5), LoopCount::Times(5));
1032        assert_ne!(LoopCount::Times(5), LoopCount::Times(3));
1033        assert_eq!(LoopCount::Infinite, LoopCount::Infinite);
1034        assert_ne!(LoopCount::Once, LoopCount::Infinite);
1035    }
1036
1037    #[test]
1038    fn playback_state_eq() {
1039        assert_eq!(PlaybackState::Idle, PlaybackState::Idle);
1040        assert_eq!(PlaybackState::Playing, PlaybackState::Playing);
1041        assert_eq!(PlaybackState::Paused, PlaybackState::Paused);
1042        assert_eq!(PlaybackState::Finished, PlaybackState::Finished);
1043        assert_ne!(PlaybackState::Idle, PlaybackState::Playing);
1044    }
1045
1046    #[test]
1047    fn loop_count_clone() {
1048        let lc = LoopCount::Times(3);
1049        let lc2 = lc;
1050        assert_eq!(lc, lc2);
1051    }
1052
1053    #[test]
1054    fn playback_state_clone() {
1055        let ps = PlaybackState::Paused;
1056        let ps2 = ps;
1057        assert_eq!(ps, ps2);
1058    }
1059
1060    #[test]
1061    fn play_after_stop_resets() {
1062        let mut tl = Timeline::new()
1063            .add(Duration::ZERO, Fade::new(MS_500))
1064            .set_duration(MS_500);
1065
1066        tl.play();
1067        tl.tick(MS_250);
1068        tl.stop();
1069        assert_eq!(tl.state(), PlaybackState::Idle);
1070
1071        tl.play();
1072        assert_eq!(tl.state(), PlaybackState::Playing);
1073        assert_eq!(tl.current_time(), Duration::ZERO);
1074    }
1075
1076    #[test]
1077    fn seek_past_end_then_resume_and_tick_finishes() {
1078        let mut tl = Timeline::new()
1079            .add(Duration::ZERO, Fade::new(MS_100))
1080            .set_duration(MS_100);
1081
1082        tl.play();
1083        tl.seek(MS_100); // Seek to end, stays Playing.
1084        tl.resume(); // No-op if already playing.
1085
1086        // Tick should detect we're at duration and finish.
1087        tl.tick(Duration::from_nanos(1));
1088        assert!(tl.is_complete());
1089    }
1090
1091    #[test]
1092    fn progress_clamps_to_zero_one() {
1093        let mut tl = Timeline::new()
1094            .add(Duration::ZERO, Fade::new(MS_100))
1095            .set_duration(MS_100);
1096
1097        // Before play: progress 0.0.
1098        assert!(tl.progress() >= 0.0);
1099        assert!(tl.progress() <= 1.0);
1100
1101        tl.play();
1102        tl.tick(MS_100);
1103        assert!(tl.progress() >= 0.0);
1104        assert!(tl.progress() <= 1.0);
1105    }
1106
1107    #[test]
1108    fn animation_trait_is_complete_false_while_playing() {
1109        let mut tl = Timeline::new()
1110            .add(Duration::ZERO, Fade::new(MS_500))
1111            .set_duration(MS_500);
1112
1113        tl.play();
1114        tl.tick(MS_250);
1115        assert!(!tl.is_complete());
1116    }
1117
1118    #[test]
1119    fn reset_from_finished() {
1120        let mut tl = Timeline::new()
1121            .add(Duration::ZERO, Fade::new(MS_100))
1122            .set_duration(MS_100);
1123
1124        tl.play();
1125        tl.tick(MS_100);
1126        assert!(tl.is_complete());
1127
1128        tl.reset();
1129        assert_eq!(tl.state(), PlaybackState::Idle);
1130        assert_eq!(tl.current_time(), Duration::ZERO);
1131        assert!(!tl.is_complete());
1132    }
1133
1134    #[test]
1135    fn reset_from_paused() {
1136        let mut tl = Timeline::new()
1137            .add(Duration::ZERO, Fade::new(MS_500))
1138            .set_duration(MS_500);
1139
1140        tl.play();
1141        tl.tick(MS_250);
1142        tl.pause();
1143        assert_eq!(tl.state(), PlaybackState::Paused);
1144
1145        tl.reset();
1146        assert_eq!(tl.state(), PlaybackState::Idle);
1147        assert_eq!(tl.current_time(), Duration::ZERO);
1148    }
1149
1150    #[test]
1151    fn labeled_event_value() {
1152        let mut tl = Timeline::new()
1153            .add_labeled("fade", Duration::ZERO, Fade::new(MS_200))
1154            .set_duration(MS_200);
1155
1156        tl.play();
1157        tl.tick(MS_100);
1158
1159        let v = tl.event_value("fade").unwrap();
1160        assert!((v - 0.5).abs() < 0.02);
1161    }
1162
1163    #[test]
1164    fn events_sorted_by_offset_on_insert() {
1165        let tl = Timeline::new()
1166            .add(MS_500, Fade::new(MS_100))
1167            .add(MS_100, Fade::new(MS_100))
1168            .add(MS_300, Fade::new(MS_100));
1169
1170        // Event at index 0 should be the one at smallest offset.
1171        // After ticking past 100ms, event_value_at(0) should advance.
1172        let mut tl = tl.set_duration(MS_500);
1173        tl.play();
1174        tl.tick(Duration::from_millis(150));
1175
1176        // Event 0 (offset 100ms): should be at 50ms into its 100ms fade = 50%.
1177        let v = tl.event_value_at(0).unwrap();
1178        assert!((v - 0.5).abs() < 0.02);
1179
1180        // Event 1 (offset 300ms): not started yet.
1181        let v = tl.event_value_at(1).unwrap();
1182        assert!(v < 0.01);
1183    }
1184
1185    #[test]
1186    fn debug_format_includes_fields() {
1187        let tl = Timeline::new()
1188            .add_labeled("intro", Duration::ZERO, Fade::new(MS_100))
1189            .set_duration(MS_100);
1190
1191        let dbg = format!("{:?}", tl);
1192        assert!(dbg.contains("event_count"));
1193        assert!(dbg.contains("total_duration"));
1194        assert!(dbg.contains("state"));
1195    }
1196
1197    #[test]
1198    fn loop_finite_with_overshoot_tick() {
1199        let mut tl = Timeline::new()
1200            .add(Duration::ZERO, Fade::new(MS_100))
1201            .set_duration(MS_100)
1202            .set_loop_count(LoopCount::Times(1));
1203
1204        tl.play();
1205        // Tick 150ms in one go — overshoots first play by 50ms.
1206        tl.tick(Duration::from_millis(150));
1207        // Should be in second play (first loop), not finished.
1208        assert!(!tl.is_complete());
1209        // Current time should be 50ms into the second play.
1210        assert!(tl.current_time() <= MS_100);
1211    }
1212}