#[cfg(not(feature = "std"))]
use alloc::{
boxed::Box,
format,
string::{String, ToString},
vec::Vec,
};
use crate::keyframe::Loop;
use crate::traits::Update;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum TimelineState {
Idle,
Playing,
Paused,
Completed,
}
#[derive(Debug, Clone, PartialEq)]
pub enum At<'a> {
Start,
End,
Label(&'a str),
Offset(f32),
}
#[derive(Debug, Clone, PartialEq)]
enum EntryKind {
Animation,
#[cfg(feature = "std")]
Callback,
Pause,
}
struct TimelineEntry {
#[allow(dead_code)]
label: String,
animation: Box<dyn Update>,
start_at: f32,
duration: f32,
started: bool,
completed: bool,
kind: EntryKind,
#[cfg(feature = "std")]
callback: Option<Box<dyn FnMut()>>,
}
impl core::fmt::Debug for TimelineEntry {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("TimelineEntry")
.field("label", &self.label)
.field("start_at", &self.start_at)
.field("duration", &self.duration)
.field("started", &self.started)
.field("completed", &self.completed)
.field("kind", &self.kind)
.finish()
}
}
pub struct Timeline {
entries: Vec<TimelineEntry>,
elapsed: f32,
state: TimelineState,
looping: Loop,
time_scale: f32,
#[cfg(feature = "std")]
on_finish_callbacks: Vec<Box<dyn FnMut()>>,
}
impl core::fmt::Debug for Timeline {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Timeline")
.field("entries", &self.entries)
.field("elapsed", &self.elapsed)
.field("state", &self.state)
.field("looping", &self.looping)
.field("time_scale", &self.time_scale)
.finish()
}
}
impl Timeline {
pub fn new() -> Self {
Self {
entries: Vec::new(),
elapsed: 0.0,
state: TimelineState::Idle,
looping: Loop::Once,
time_scale: 1.0,
#[cfg(feature = "std")]
on_finish_callbacks: Vec::new(),
}
}
pub fn add<A: Update + 'static>(mut self, label: &str, animation: A, start_at: f32) -> Self {
self.entries.push(TimelineEntry {
label: label.to_string(),
animation: Box::new(animation),
start_at,
duration: 0.0, started: false,
completed: false,
kind: EntryKind::Animation,
#[cfg(feature = "std")]
callback: None,
});
self
}
pub fn add_with_duration<A: Update + 'static>(
mut self,
label: &str,
animation: A,
start_at: f32,
duration: f32,
) -> Self {
self.entries.push(TimelineEntry {
label: label.to_string(),
animation: Box::new(animation),
start_at,
duration,
started: false,
completed: false,
kind: EntryKind::Animation,
#[cfg(feature = "std")]
callback: None,
});
self
}
pub fn add_at<A: Update + 'static>(
&mut self,
label: &str,
animation: A,
duration: f32,
at: At<'_>,
) {
let start_at = match at {
At::Start => 0.0,
At::End => {
self.entries
.iter()
.map(|e| e.start_at + e.duration)
.fold(0.0_f32, f32::max)
}
At::Label(target) => {
self.entries
.iter()
.find(|e| e.label == target)
.map(|e| e.start_at)
.unwrap_or(0.0)
}
At::Offset(offset) => {
self.entries
.last()
.map(|e| e.start_at + e.duration + offset)
.unwrap_or(offset.max(0.0))
}
};
self.entries.push(TimelineEntry {
label: label.to_string(),
animation: Box::new(animation),
start_at: start_at.max(0.0),
duration,
started: false,
completed: false,
kind: EntryKind::Animation,
#[cfg(feature = "std")]
callback: None,
});
}
pub fn looping(mut self, mode: Loop) -> Self {
self.looping = mode;
self
}
pub fn play(&mut self) {
self.state = TimelineState::Playing;
}
pub fn pause(&mut self) {
if self.state == TimelineState::Playing {
self.state = TimelineState::Paused;
}
}
pub fn resume(&mut self) {
if self.state == TimelineState::Paused {
self.state = TimelineState::Playing;
}
}
pub fn seek(&mut self, t: f32) {
self.elapsed = t.max(0.0);
for entry in &mut self.entries {
entry.started = false;
entry.completed = false;
}
}
pub fn reset(&mut self) {
self.elapsed = 0.0;
self.state = TimelineState::Idle;
for entry in &mut self.entries {
entry.started = false;
entry.completed = false;
}
}
pub fn duration(&self) -> f32 {
self.entries
.iter()
.map(|e| e.start_at + e.duration)
.fold(0.0_f32, f32::max)
}
pub fn progress(&self) -> f32 {
let dur = self.duration();
if dur <= 0.0 {
return 1.0;
}
(self.elapsed / dur).clamp(0.0, 1.0)
}
pub fn state(&self) -> &TimelineState {
&self.state
}
#[cfg(feature = "std")]
pub fn on_finish<F: FnMut() + 'static>(&mut self, callback: F) {
self.on_finish_callbacks.push(Box::new(callback));
}
pub fn set_time_scale(&mut self, scale: f32) {
self.time_scale = scale;
}
pub fn time_scale(&self) -> f32 {
self.time_scale
}
pub fn total_duration(&self) -> f32 {
self.duration()
}
pub fn total_progress(&self) -> f32 {
self.progress()
}
pub fn get_entries_by_label<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a str>
where
F: Fn(&str) -> bool + 'a,
{
self.entries
.iter()
.filter(move |e| predicate(&e.label))
.map(|e| e.label.as_str())
}
#[cfg(feature = "std")]
pub fn call<F: FnMut() + 'static>(&mut self, time: f32, callback: F) {
struct NoOp;
impl Update for NoOp {
fn update(&mut self, _dt: f32) -> bool {
false
}
}
self.entries.push(TimelineEntry {
label: format!("__call_{:.3}", time),
animation: Box::new(NoOp),
start_at: time.max(0.0),
duration: 0.0,
started: false,
completed: false,
kind: EntryKind::Callback,
callback: Some(Box::new(callback)),
});
}
pub fn add_pause(&mut self, time: f32) {
struct NoOp;
impl Update for NoOp {
fn update(&mut self, _dt: f32) -> bool {
false
}
}
self.entries.push(TimelineEntry {
label: format!("__pause_{:.3}", time),
animation: Box::new(NoOp),
start_at: time.max(0.0),
duration: 0.0,
started: false,
completed: false,
kind: EntryKind::Pause,
#[cfg(feature = "std")]
callback: None,
});
}
}
impl Default for Timeline {
fn default() -> Self {
Self::new()
}
}
impl Update for Timeline {
fn update(&mut self, dt: f32) -> bool {
if self.state != TimelineState::Playing {
return self.state != TimelineState::Completed;
}
let dt = (dt * self.time_scale).max(0.0);
self.elapsed += dt;
let mut all_done = true;
let mut should_pause = false;
for entry in &mut self.entries {
if entry.completed {
continue;
}
if self.elapsed >= entry.start_at {
match entry.kind {
EntryKind::Pause => {
if !entry.started {
entry.started = true;
entry.completed = true;
should_pause = true;
self.elapsed = entry.start_at;
}
}
#[cfg(feature = "std")]
EntryKind::Callback => {
if !entry.started {
entry.started = true;
entry.completed = true;
if let Some(ref mut cb) = entry.callback {
cb();
}
}
}
EntryKind::Animation => {
let entry_dt = if !entry.started {
entry.started = true;
(self.elapsed - entry.start_at).min(dt)
} else {
dt
};
let still_running = entry.animation.update(entry_dt);
if !still_running {
entry.completed = true;
} else {
all_done = false;
}
}
}
} else {
all_done = false;
}
}
if should_pause {
self.state = TimelineState::Paused;
return true;
}
if all_done && !self.entries.is_empty() {
self.state = TimelineState::Completed;
#[cfg(feature = "std")]
{
for cb in &mut self.on_finish_callbacks {
cb();
}
}
return false;
}
true
}
}
pub struct Sequence {
entries: Vec<(String, Box<dyn Update>, f32, f32)>, cursor: f32,
label_counter: u32,
looping: Loop,
}
impl Sequence {
pub fn new() -> Self {
Self {
entries: Vec::new(),
cursor: 0.0,
label_counter: 0,
looping: Loop::Once,
}
}
pub fn then<A: Update + 'static>(mut self, animation: A, duration: f32) -> Self {
let label = format!("seq_{}", self.label_counter);
self.label_counter += 1;
let start_at = self.cursor;
self.entries
.push((label, Box::new(animation), start_at, duration));
self.cursor += duration;
self
}
pub fn gap(mut self, seconds: f32) -> Self {
self.cursor += seconds;
self
}
pub fn looping(mut self, mode: Loop) -> Self {
self.looping = mode;
self
}
pub fn build(self) -> Timeline {
let mut timeline = Timeline {
entries: Vec::new(),
elapsed: 0.0,
state: TimelineState::Idle,
looping: self.looping,
time_scale: 1.0,
#[cfg(feature = "std")]
on_finish_callbacks: Vec::new(),
};
for (label, animation, start_at, duration) in self.entries {
timeline.entries.push(TimelineEntry {
label,
animation,
start_at,
duration,
started: false,
completed: false,
kind: EntryKind::Animation,
#[cfg(feature = "std")]
callback: None,
});
}
timeline
}
}
impl Default for Sequence {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Debug for Sequence {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Sequence")
.field("cursor", &self.cursor)
.field("entries_count", &self.entries.len())
.finish()
}
}
pub fn stagger<A: Update + 'static>(animations: Vec<(A, f32)>, stagger_delay: f32) -> Timeline {
let mut timeline = Timeline {
entries: Vec::new(),
elapsed: 0.0,
state: TimelineState::Idle,
looping: Loop::Once,
time_scale: 1.0,
#[cfg(feature = "std")]
on_finish_callbacks: Vec::new(),
};
for (i, (animation, duration)) in animations.into_iter().enumerate() {
let start_at = i as f32 * stagger_delay;
let label = format!("stagger_{}", i);
timeline.entries.push(TimelineEntry {
label,
animation: Box::new(animation),
start_at,
duration,
started: false,
completed: false,
kind: EntryKind::Animation,
#[cfg(feature = "std")]
callback: None,
});
}
timeline
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tween::Tween;
#[test]
fn timeline_plays_to_completion() {
let mut tl = Timeline::new().add("t1", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0);
tl.play();
assert!(tl.update(0.3));
assert!(!tl.update(0.3));
assert_eq!(*tl.state(), TimelineState::Completed);
}
#[test]
fn timeline_concurrent_entries() {
let mut tl = Timeline::new()
.add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0)
.add("b", Tween::new(0.0_f32, 1.0).duration(1.0).build(), 0.0);
tl.play();
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Playing);
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Completed);
}
#[test]
fn timeline_staggered_start() {
let mut tl = Timeline::new()
.add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0)
.add("b", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.5);
tl.play();
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Playing);
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Completed);
}
#[test]
fn timeline_pause_and_resume() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(1.0).build(), 0.0);
tl.play();
tl.update(0.3);
tl.pause();
assert_eq!(*tl.state(), TimelineState::Paused);
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Paused);
tl.resume();
assert_eq!(*tl.state(), TimelineState::Playing);
}
#[test]
fn timeline_reset() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0);
tl.play();
tl.update(0.5);
assert_eq!(*tl.state(), TimelineState::Completed);
tl.reset();
assert_eq!(*tl.state(), TimelineState::Idle);
}
#[test]
fn sequence_chains_animations() {
let seq = Sequence::new()
.then(Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.5)
.gap(0.1)
.then(Tween::new(0.0_f32, 1.0).duration(0.3).build(), 0.3);
let mut tl = seq.build();
tl.play();
tl.update(0.5); assert_eq!(*tl.state(), TimelineState::Playing);
tl.update(0.1); tl.update(0.3); assert_eq!(*tl.state(), TimelineState::Completed);
}
#[test]
fn empty_timeline_does_not_panic() {
let mut tl = Timeline::new();
tl.play();
tl.update(1.0);
}
#[cfg(feature = "std")]
#[test]
fn on_finish_callback_fires() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let fired = Arc::new(AtomicBool::new(false));
let fired_clone = fired.clone();
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0);
tl.on_finish(move || {
fired_clone.store(true, Ordering::SeqCst);
});
tl.play();
tl.update(0.5);
assert!(fired.load(Ordering::SeqCst));
}
#[test]
fn timeline_time_scale_double_speed() {
let mut tl = Timeline::new().add("t1", Tween::new(0.0_f32, 1.0).duration(1.0).build(), 0.0);
tl.set_time_scale(2.0);
tl.play();
assert!(!tl.update(0.5)); assert_eq!(*tl.state(), TimelineState::Completed);
}
#[test]
fn timeline_time_scale_half_speed() {
let mut tl = Timeline::new().add("t1", Tween::new(0.0_f32, 1.0).duration(1.0).build(), 0.0);
tl.set_time_scale(0.5);
tl.play();
tl.update(1.0); assert_eq!(*tl.state(), TimelineState::Playing);
}
#[test]
fn stagger_creates_offset_timeline() {
let tweens: Vec<_> = (0..3)
.map(|_| (Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.5))
.collect();
let mut tl = stagger(tweens, 0.2);
tl.play();
tl.update(0.2);
assert_eq!(*tl.state(), TimelineState::Playing);
let mut total = 0.2;
while tl.update(0.01) {
total += 0.01;
if total > 5.0 {
panic!("Stagger timeline didn't complete");
}
}
assert!(
total >= 0.6 && total <= 1.0,
"Expected ~0.7-0.9s, got {total}"
);
}
#[test]
fn stagger_empty_vec_does_not_panic() {
let tl = stagger::<Tween<f32>>(Vec::new(), 0.1);
assert!((tl.duration() - 0.0).abs() < 1e-6);
}
#[test]
fn at_start_places_at_zero() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.5);
tl.add_at(
"b",
Tween::new(0.0_f32, 1.0).duration(0.3).build(),
0.3,
At::Start,
);
assert!(
(tl.entries[1].start_at - 0.0).abs() < 1e-6,
"Expected start_at 0.0, got {}",
tl.entries[1].start_at
);
}
#[test]
fn at_end_places_after_last() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0);
tl.entries[0].duration = 0.5;
tl.add_at(
"b",
Tween::new(0.0_f32, 1.0).duration(0.3).build(),
0.3,
At::End,
);
assert!(
(tl.entries[1].start_at - 0.5).abs() < 1e-6,
"Expected start_at 0.5, got {}",
tl.entries[1].start_at
);
}
#[test]
fn at_label_places_at_same_time() {
let mut tl =
Timeline::new().add("fade", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.3);
tl.add_at(
"scale",
Tween::new(1.0_f32, 2.0).duration(0.3).build(),
0.3,
At::Label("fade"),
);
assert!(
(tl.entries[1].start_at - 0.3).abs() < 1e-6,
"Expected start_at 0.3, got {}",
tl.entries[1].start_at
);
}
#[test]
fn at_offset_places_relative_to_previous() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.0);
tl.entries[0].duration = 0.5;
tl.add_at(
"b",
Tween::new(0.0_f32, 1.0).duration(0.3).build(),
0.3,
At::Offset(0.2),
);
assert!(
(tl.entries[1].start_at - 0.7).abs() < 1e-6,
"Expected start_at 0.7, got {}",
tl.entries[1].start_at
);
}
#[test]
fn at_offset_negative_overlaps() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(1.0).build(), 0.0);
tl.entries[0].duration = 1.0;
tl.add_at(
"b",
Tween::new(0.0_f32, 1.0).duration(0.5).build(),
0.5,
At::Offset(-0.3),
);
assert!(
(tl.entries[1].start_at - 0.7).abs() < 1e-6,
"Expected start_at 0.7, got {}",
tl.entries[1].start_at
);
}
#[test]
fn at_label_unknown_falls_back_to_zero() {
let mut tl = Timeline::new().add("a", Tween::new(0.0_f32, 1.0).duration(0.5).build(), 0.5);
tl.add_at(
"b",
Tween::new(0.0_f32, 1.0).duration(0.3).build(),
0.3,
At::Label("nonexistent"),
);
assert!(
(tl.entries[1].start_at - 0.0).abs() < 1e-6,
"Expected fallback to 0.0, got {}",
tl.entries[1].start_at
);
}
}