use std::time::Duration;
use super::Easing;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum LoopMode {
#[default]
Once,
Loop,
PingPong,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct TrackId(usize);
#[derive(Copy, Clone, Debug)]
struct Track {
start: Duration,
duration: Duration,
easing: Easing,
}
#[derive(Clone, Debug, Default)]
pub struct Timeline {
tracks: Vec<Track>,
mode: LoopMode,
}
impl Timeline {
pub fn new(mode: LoopMode) -> Timeline {
Timeline {
tracks: Vec::new(),
mode,
}
}
pub fn track(&mut self, start: Duration, duration: Duration, easing: Easing) -> TrackId {
self.tracks.push(Track {
start,
duration,
easing,
});
TrackId(self.tracks.len() - 1)
}
pub fn track_after(
&mut self,
prev: TrackId,
gap: Duration,
duration: Duration,
easing: Easing,
) -> TrackId {
let p = self.tracks[prev.0];
self.track(p.start + p.duration + gap, duration, easing)
}
pub fn stagger(
&mut self,
count: usize,
start: Duration,
duration: Duration,
step: Duration,
easing: Easing,
) -> TrackId {
let first = TrackId(self.tracks.len());
for i in 0..count {
self.track(start + step * i as u32, duration, easing);
}
first
}
pub fn nth(&self, first: TrackId, i: usize) -> TrackId {
TrackId(first.0 + i)
}
pub fn is_empty(&self) -> bool {
self.tracks.is_empty()
}
pub fn duration(&self) -> Duration {
self.tracks
.iter()
.map(|t| t.start + t.duration)
.max()
.unwrap_or(Duration::ZERO)
}
pub fn is_finished(&self, t: Duration) -> bool {
match self.mode {
LoopMode::Once => t >= self.duration(),
LoopMode::Loop | LoopMode::PingPong => false,
}
}
pub fn progress(&self, track: TrackId, t: Duration) -> f32 {
let Some(tr) = self.tracks.get(track.0) else {
return 0.0; };
let t = self.fold(t);
if t < tr.start {
return tr.easing.eval(0.0);
}
if tr.duration.is_zero() {
return tr.easing.eval(1.0);
}
let raw = (t - tr.start).as_secs_f32() / tr.duration.as_secs_f32();
tr.easing.eval(raw.clamp(0.0, 1.0))
}
pub fn seek(&self, t: Duration) -> Seek<'_> {
Seek { timeline: self, t }
}
pub fn seek_reversed(&self, t: Duration) -> Seek<'_> {
Seek {
timeline: self,
t: self.duration().saturating_sub(t),
}
}
fn fold(&self, t: Duration) -> Duration {
let total = self.duration();
if total.is_zero() {
return Duration::ZERO;
}
match self.mode {
LoopMode::Once => t.min(total),
LoopMode::Loop => {
if t == total {
total
} else {
nanos_mod(t, total)
}
}
LoopMode::PingPong => {
let period = total * 2;
let folded = if t == period {
period
} else {
nanos_mod(t, period)
};
if folded <= total {
folded
} else {
period - folded
}
}
}
}
}
fn nanos_mod(t: Duration, m: Duration) -> Duration {
Duration::from_nanos((t.as_nanos() % m.as_nanos()) as u64)
}
#[derive(Copy, Clone, Debug)]
pub struct Seek<'a> {
timeline: &'a Timeline,
t: Duration,
}
impl Seek<'_> {
pub fn clock(&self) -> Duration {
self.t
}
pub fn progress(&self, track: TrackId) -> f32 {
self.timeline.progress(track, self.t)
}
pub fn is_finished(&self) -> bool {
self.timeline.is_finished(self.t)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MS: fn(u64) -> Duration = Duration::from_millis;
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn parallel_and_sequenced_tracks() {
let mut tl = Timeline::new(LoopMode::Once);
let a = tl.track(MS(0), MS(100), Easing::Linear);
let b = tl.track(MS(0), MS(200), Easing::Linear); let c = tl.track_after(a, MS(50), MS(100), Easing::Linear); assert_eq!(tl.duration(), MS(250));
assert!(close(tl.progress(a, MS(50)), 0.5));
assert!(
close(tl.progress(b, MS(50)), 0.25),
"parallel track on its own pace"
);
assert!(close(tl.progress(c, MS(50)), 0.0), "sequenced track waits");
assert!(close(tl.progress(c, MS(200)), 0.5));
assert!(tl.is_finished(MS(250)));
assert!(!tl.is_finished(MS(249)));
}
#[test]
fn stagger_offsets_each_member() {
let mut tl = Timeline::new(LoopMode::Once);
let first = tl.stagger(4, MS(100), MS(100), MS(30), Easing::Linear);
assert!(close(tl.progress(first, MS(150)), 0.5));
assert!(close(tl.progress(tl.nth(first, 1), MS(150)), 0.2));
assert!(close(tl.progress(tl.nth(first, 2), MS(150)), 0.0));
assert!(close(tl.progress(tl.nth(first, 3), MS(190)), 0.0));
assert!(close(tl.progress(tl.nth(first, 3), MS(290)), 1.0));
assert_eq!(tl.duration(), MS(290));
}
#[test]
fn easing_applies_per_track_and_before_start_is_eased_zero() {
let mut tl = Timeline::new(LoopMode::Once);
let t = tl.track(MS(100), MS(100), Easing::EaseIn);
assert!(close(tl.progress(t, MS(150)), 0.125), "t^3 at midpoint");
assert!(close(tl.progress(t, MS(0)), 0.0));
}
#[test]
fn loop_mode_wraps_the_whole_board() {
let mut tl = Timeline::new(LoopMode::Loop);
let a = tl.track(MS(0), MS(100), Easing::Linear);
let b = tl.track(MS(100), MS(100), Easing::Linear);
assert!(close(tl.progress(a, MS(250)), 0.5), "t=250 folds to 50");
assert!(
close(tl.progress(b, MS(250)), 0.0),
"second track restarts too"
);
assert!(!tl.is_finished(MS(10_000)));
}
#[test]
fn pingpong_mirrors() {
let mut tl = Timeline::new(LoopMode::PingPong);
let a = tl.track(MS(0), MS(100), Easing::Linear);
assert!(close(tl.progress(a, MS(60)), 0.6));
assert!(
close(tl.progress(a, MS(140)), 0.6),
"mirrored on the way back"
);
assert!(close(tl.progress(a, MS(260)), 0.6), "period 2*total");
}
#[test]
fn degenerate_shapes_are_inert() {
let tl = Timeline::new(LoopMode::Once);
assert_eq!(tl.duration(), Duration::ZERO);
assert!(tl.is_finished(Duration::ZERO));
assert_eq!(tl.progress(TrackId(9), MS(50)), 0.0, "stale id is inert");
let mut tl = Timeline::new(LoopMode::Once);
let z = tl.track(MS(10), MS(0), Easing::Linear);
assert!(close(tl.progress(z, MS(5)), 0.0));
assert!(
close(tl.progress(z, MS(10)), 1.0),
"zero-duration track is a step"
);
}
#[test]
fn seek_binds_one_clock_and_reverse_mirrors_the_pass() {
let mut tl = Timeline::new(LoopMode::Once);
let a = tl.track(MS(0), MS(100), Easing::Linear);
let b = tl.track(MS(100), MS(100), Easing::Linear);
let s = tl.seek(MS(150));
assert!(close(s.progress(a), 1.0));
assert!(close(s.progress(b), 0.5));
assert_eq!(s.clock(), MS(150));
assert!(!s.is_finished());
assert!(tl.seek(MS(200)).is_finished());
assert!(close(s.progress(b), tl.progress(b, MS(150))));
let r = tl.seek_reversed(MS(50));
assert_eq!(r.clock(), MS(150), "200ms board, 50ms into reverse");
assert!(close(r.progress(b), 0.5));
assert_eq!(tl.seek_reversed(MS(500)).clock(), Duration::ZERO);
}
#[test]
fn identity_storyboard_shape_composes() {
let mut tl = Timeline::new(LoopMode::Once);
let arrival = tl.track(MS(0), MS(900), Easing::bezier(0.16, 1.0, 0.30, 1.0));
let align = tl.track(MS(900), MS(500), Easing::bezier(0.83, 0.0, 0.17, 1.0));
let letters = tl.stagger(
11,
MS(1400),
MS(180),
MS(30),
Easing::bezier(0.33, 1.0, 0.68, 1.0),
);
assert_eq!(tl.duration(), MS(1880));
assert!(tl.progress(arrival, MS(900)) >= 1.0 - 1e-4);
assert!(tl.progress(align, MS(1150)) > 0.0);
assert!(tl.progress(tl.nth(letters, 10), MS(1879)) < 1.0);
assert!(tl.is_finished(MS(1880)));
}
}