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    #[must_use]
317    pub fn progress(&self) -> f32 {
318        if self.events.is_empty() {
319            return 1.0;
320        }
321        let t = self.current_time.as_secs_f64() / self.total_duration.as_secs_f64();
322        (t as f32).clamp(0.0, 1.0)
323    }
324
325    /// Current playback state.
326    #[must_use]
327    pub fn state(&self) -> PlaybackState {
328        self.state
329    }
330
331    /// Current time position.
332    #[must_use]
333    pub fn current_time(&self) -> Duration {
334        self.current_time
335    }
336
337    /// Total duration.
338    #[must_use]
339    pub fn duration(&self) -> Duration {
340        self.total_duration
341    }
342
343    /// Number of events in the timeline.
344    #[must_use]
345    pub fn event_count(&self) -> usize {
346        self.events.len()
347    }
348
349    /// Get the animation value for a specific labeled event.
350    ///
351    /// Returns `None` if the label doesn't exist.
352    #[must_use]
353    pub fn event_value(&self, label: &str) -> Option<f32> {
354        self.events
355            .iter()
356            .find(|e| e.label.as_deref() == Some(label))
357            .map(|e| e.animation.value())
358    }
359
360    /// Get the animation value for an event by index.
361    ///
362    /// Returns `None` if index is out of bounds.
363    #[must_use]
364    pub fn event_value_at(&self, index: usize) -> Option<f32> {
365        self.events.get(index).map(|e| e.animation.value())
366    }
367}
368
369// ---------------------------------------------------------------------------
370// Animation trait implementation
371// ---------------------------------------------------------------------------
372
373impl Animation for Timeline {
374    fn tick(&mut self, dt: Duration) {
375        if self.state != PlaybackState::Playing {
376            return;
377        }
378
379        let new_time = self.current_time.saturating_add(dt);
380
381        // Tick each event that overlaps with [current_time, new_time].
382        for event in &mut self.events {
383            if new_time > event.offset && !event.animation.is_complete() {
384                // How much time has elapsed for this event.
385                let event_start = event.offset;
386                if self.current_time >= event_start {
387                    // Already past offset — just forward dt.
388                    event.animation.tick(dt);
389                } else {
390                    // Event starts within this tick — forward only the portion after offset.
391                    let partial = new_time.saturating_sub(event_start);
392                    event.animation.tick(partial);
393                }
394            }
395        }
396
397        self.current_time = new_time;
398
399        // Check if we've reached the end of the timeline.
400        if self.current_time >= self.total_duration {
401            match self.loop_count {
402                LoopCount::Once => {
403                    self.current_time = self.total_duration;
404                    self.state = PlaybackState::Finished;
405                }
406                LoopCount::Times(_) | LoopCount::Infinite => {
407                    if self.loops_remaining > 0 {
408                        if self.loop_count != LoopCount::Infinite {
409                            self.loops_remaining -= 1;
410                        }
411                        // Calculate overshoot to carry into next loop.
412                        let overshoot = self.current_time.saturating_sub(self.total_duration);
413                        self.current_time = Duration::ZERO;
414                        for event in &mut self.events {
415                            event.animation.reset();
416                        }
417                        // Apply overshoot to next loop.
418                        if !overshoot.is_zero() {
419                            self.tick(overshoot);
420                        }
421                    } else {
422                        self.current_time = self.total_duration;
423                        self.state = PlaybackState::Finished;
424                    }
425                }
426            }
427        }
428    }
429
430    fn is_complete(&self) -> bool {
431        self.state == PlaybackState::Finished
432    }
433
434    fn value(&self) -> f32 {
435        self.progress()
436    }
437
438    fn reset(&mut self) {
439        self.current_time = Duration::ZERO;
440        self.loops_remaining = match self.loop_count {
441            LoopCount::Once => 0,
442            LoopCount::Times(n) => n,
443            LoopCount::Infinite => u32::MAX,
444        };
445        self.state = PlaybackState::Idle;
446        for event in &mut self.events {
447            event.animation.reset();
448        }
449    }
450
451    fn overshoot(&self) -> Duration {
452        if self.state == PlaybackState::Finished {
453            self.current_time.saturating_sub(self.total_duration)
454        } else {
455            Duration::ZERO
456        }
457    }
458}
459
460// ---------------------------------------------------------------------------
461// Tests
462// ---------------------------------------------------------------------------
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::animation::Fade;
468
469    const MS_100: Duration = Duration::from_millis(100);
470    const MS_200: Duration = Duration::from_millis(200);
471    const MS_250: Duration = Duration::from_millis(250);
472    const MS_300: Duration = Duration::from_millis(300);
473    const MS_500: Duration = Duration::from_millis(500);
474    const SEC_1: Duration = Duration::from_secs(1);
475
476    #[test]
477    fn empty_timeline_is_immediately_complete() {
478        let tl = Timeline::new();
479        assert_eq!(tl.progress(), 1.0);
480        assert_eq!(tl.event_count(), 0);
481    }
482
483    #[test]
484    fn sequential_events() {
485        let mut tl = Timeline::new()
486            .add(Duration::ZERO, Fade::new(MS_200))
487            .add(MS_200, Fade::new(MS_200))
488            .add(Duration::from_millis(400), Fade::new(MS_200))
489            .set_duration(Duration::from_millis(600));
490
491        tl.play();
492
493        // At 100ms: first event at 50%, others not started
494        tl.tick(MS_100);
495        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.01);
496        assert!((tl.event_value_at(1).unwrap() - 0.0).abs() < 0.01);
497
498        // At 300ms: first complete, second at 50%
499        tl.tick(MS_200);
500        assert!(tl.event_value_at(0).unwrap() > 0.99);
501        assert!((tl.event_value_at(1).unwrap() - 0.5).abs() < 0.01);
502
503        // At 600ms: all complete
504        tl.tick(MS_300);
505        assert!(tl.is_complete());
506        assert!((tl.progress() - 1.0).abs() < f32::EPSILON);
507    }
508
509    #[test]
510    fn overlapping_events() {
511        let mut tl = Timeline::new()
512            .add(Duration::ZERO, Fade::new(MS_500))
513            .add(MS_200, Fade::new(MS_500))
514            .set_duration(Duration::from_millis(700));
515
516        tl.play();
517
518        // At 300ms: first at 60%, second at 20%
519        tl.tick(MS_300);
520        assert!((tl.event_value_at(0).unwrap() - 0.6).abs() < 0.02);
521        assert!((tl.event_value_at(1).unwrap() - 0.2).abs() < 0.02);
522    }
523
524    #[test]
525    fn labeled_events_and_seek() {
526        let mut tl = Timeline::new()
527            .add_labeled("intro", Duration::ZERO, Fade::new(MS_500))
528            .add_labeled("main", MS_500, Fade::new(MS_500))
529            .set_duration(SEC_1);
530
531        tl.play();
532
533        // Seek to "main"
534        assert!(tl.seek_label("main"));
535        // First animation should have been ticked for full 500ms
536        assert!(tl.event_value("intro").unwrap() > 0.99);
537        // Main just started
538        assert!((tl.event_value("main").unwrap() - 0.0).abs() < f32::EPSILON);
539
540        // Unknown label returns false
541        assert!(!tl.seek_label("nonexistent"));
542    }
543
544    #[test]
545    fn loop_finite() {
546        let mut tl = Timeline::new()
547            .add(Duration::ZERO, Fade::new(MS_100))
548            .set_duration(MS_100)
549            .set_loop_count(LoopCount::Times(2));
550
551        tl.play();
552
553        // First play-through
554        tl.tick(MS_100);
555        assert!(!tl.is_complete());
556        assert_eq!(tl.state(), PlaybackState::Playing);
557
558        // Second play-through (first loop)
559        tl.tick(MS_100);
560        assert!(!tl.is_complete());
561
562        // Third play-through (second loop) — should finish
563        tl.tick(MS_100);
564        assert!(tl.is_complete());
565    }
566
567    #[test]
568    fn loop_infinite_never_finishes() {
569        let mut tl = Timeline::new()
570            .add(Duration::ZERO, Fade::new(MS_100))
571            .set_duration(MS_100)
572            .set_loop_count(LoopCount::Infinite);
573
574        tl.play();
575
576        // Run through many cycles
577        for _ in 0..100 {
578            tl.tick(MS_100);
579        }
580        assert!(!tl.is_complete());
581        assert_eq!(tl.state(), PlaybackState::Playing);
582    }
583
584    #[test]
585    fn pause_resume() {
586        let mut tl = Timeline::new()
587            .add(Duration::ZERO, Fade::new(SEC_1))
588            .set_duration(SEC_1);
589
590        tl.play();
591        tl.tick(MS_250);
592
593        tl.pause();
594        assert_eq!(tl.state(), PlaybackState::Paused);
595        let time_at_pause = tl.current_time();
596
597        // Tick while paused — should not advance
598        tl.tick(MS_500);
599        assert_eq!(tl.current_time(), time_at_pause);
600
601        tl.resume();
602        assert_eq!(tl.state(), PlaybackState::Playing);
603
604        // Now ticks advance again
605        tl.tick(MS_250);
606        assert!(tl.current_time() > time_at_pause);
607    }
608
609    #[test]
610    fn seek_clamps_to_duration() {
611        let mut tl = Timeline::new()
612            .add(Duration::ZERO, Fade::new(MS_500))
613            .set_duration(MS_500);
614
615        tl.play();
616        tl.seek(SEC_1); // Past end
617        assert_eq!(tl.current_time(), MS_500);
618    }
619
620    #[test]
621    fn seek_resets_and_reticks_animations() {
622        let mut tl = Timeline::new()
623            .add(Duration::ZERO, Fade::new(SEC_1))
624            .set_duration(SEC_1);
625
626        tl.play();
627        tl.tick(MS_500);
628        // Event at ~50%
629        assert!((tl.event_value_at(0).unwrap() - 0.5).abs() < 0.02);
630
631        // Seek back to 250ms
632        tl.seek(MS_250);
633        assert!((tl.event_value_at(0).unwrap() - 0.25).abs() < 0.02);
634    }
635
636    #[test]
637    fn stop_resets_everything() {
638        let mut tl = Timeline::new()
639            .add(Duration::ZERO, Fade::new(SEC_1))
640            .set_duration(SEC_1);
641
642        tl.play();
643        tl.tick(MS_500);
644        tl.stop();
645
646        assert_eq!(tl.state(), PlaybackState::Idle);
647        assert_eq!(tl.current_time(), Duration::ZERO);
648        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
649    }
650
651    #[test]
652    fn play_restarts_from_beginning() {
653        let mut tl = Timeline::new()
654            .add(Duration::ZERO, Fade::new(SEC_1))
655            .set_duration(SEC_1);
656
657        tl.play();
658        tl.tick(SEC_1);
659        assert!(tl.is_complete());
660
661        tl.play();
662        assert_eq!(tl.state(), PlaybackState::Playing);
663        assert_eq!(tl.current_time(), Duration::ZERO);
664        assert!((tl.event_value_at(0).unwrap() - 0.0).abs() < f32::EPSILON);
665    }
666
667    #[test]
668    fn then_chains_at_same_offset() {
669        let tl = Timeline::new()
670            .add(MS_100, Fade::new(MS_100))
671            .then(Fade::new(MS_100)); // Should be at offset 100ms too
672
673        assert_eq!(tl.event_count(), 2);
674    }
675
676    #[test]
677    fn progress_tracks_time() {
678        let mut tl = Timeline::new()
679            .add(Duration::ZERO, Fade::new(SEC_1))
680            .set_duration(SEC_1);
681
682        tl.play();
683        assert!((tl.progress() - 0.0).abs() < f32::EPSILON);
684
685        tl.tick(MS_250);
686        assert!((tl.progress() - 0.25).abs() < 0.02);
687
688        tl.tick(MS_250);
689        assert!((tl.progress() - 0.5).abs() < 0.02);
690    }
691
692    #[test]
693    fn animation_trait_value_matches_progress() {
694        let mut tl = Timeline::new()
695            .add(Duration::ZERO, Fade::new(SEC_1))
696            .set_duration(SEC_1);
697
698        tl.play();
699        tl.tick(MS_500);
700
701        assert!((tl.value() - tl.progress()).abs() < f32::EPSILON);
702    }
703
704    #[test]
705    fn animation_trait_reset() {
706        let mut tl = Timeline::new()
707            .add(Duration::ZERO, Fade::new(SEC_1))
708            .set_duration(SEC_1);
709
710        tl.play();
711        tl.tick(SEC_1);
712        assert!(tl.is_complete());
713
714        tl.reset();
715        assert_eq!(tl.state(), PlaybackState::Idle);
716        assert!(!tl.is_complete());
717    }
718
719    #[test]
720    fn debug_format() {
721        let tl = Timeline::new()
722            .add(Duration::ZERO, Fade::new(MS_100))
723            .set_duration(MS_100);
724
725        let dbg = format!("{:?}", tl);
726        assert!(dbg.contains("Timeline"));
727        assert!(dbg.contains("event_count"));
728    }
729
730    #[test]
731    fn loop_once_plays_exactly_once() {
732        let mut tl = Timeline::new()
733            .add(Duration::ZERO, Fade::new(MS_100))
734            .set_duration(MS_100)
735            .set_loop_count(LoopCount::Once);
736
737        tl.play();
738        tl.tick(MS_100);
739        assert!(tl.is_complete());
740    }
741
742    #[test]
743    fn event_value_by_label_missing_returns_none() {
744        let tl = Timeline::new()
745            .add(Duration::ZERO, Fade::new(MS_100))
746            .set_duration(MS_100);
747
748        assert!(tl.event_value("nope").is_none());
749    }
750
751    #[test]
752    fn event_value_at_out_of_bounds() {
753        let tl = Timeline::new()
754            .add(Duration::ZERO, Fade::new(MS_100))
755            .set_duration(MS_100);
756
757        assert!(tl.event_value_at(5).is_none());
758    }
759}