use std::f64::consts::PI;
pub fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
pub fn ease_linear(t: f64) -> f64 {
clamp01(t)
}
pub fn ease_in_quad(t: f64) -> f64 {
let t = clamp01(t);
t * t
}
pub fn ease_out_quad(t: f64) -> f64 {
let t = clamp01(t);
1.0 - (1.0 - t) * (1.0 - t)
}
pub fn ease_in_out_quad(t: f64) -> f64 {
let t = clamp01(t);
if t < 0.5 {
2.0 * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
}
}
pub fn ease_in_cubic(t: f64) -> f64 {
let t = clamp01(t);
t * t * t
}
pub fn ease_out_cubic(t: f64) -> f64 {
let t = clamp01(t);
1.0 - (1.0 - t).powi(3)
}
pub fn ease_in_out_cubic(t: f64) -> f64 {
let t = clamp01(t);
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
pub fn ease_out_elastic(t: f64) -> f64 {
let t = clamp01(t);
if t == 0.0 {
0.0
} else if t == 1.0 {
1.0
} else {
let c4 = (2.0 * PI) / 3.0;
2f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
}
}
pub fn ease_out_bounce(t: f64) -> f64 {
let t = clamp01(t);
let n1 = 7.5625;
let d1 = 2.75;
if t < 1.0 / d1 {
n1 * t * t
} else if t < 2.0 / d1 {
let t = t - 1.5 / d1;
n1 * t * t + 0.75
} else if t < 2.5 / d1 {
let t = t - 2.25 / d1;
n1 * t * t + 0.9375
} else {
let t = t - 2.625 / d1;
n1 * t * t + 0.984_375
}
}
pub struct Tween {
from: f64,
to: f64,
duration_ticks: u64,
start_tick: u64,
easing: fn(f64) -> f64,
done: bool,
on_complete: Option<Box<dyn FnMut()>>,
}
impl Tween {
pub fn new(from: f64, to: f64, duration_ticks: u64) -> Self {
Self {
from,
to,
duration_ticks,
start_tick: 0,
easing: ease_linear,
done: false,
on_complete: None,
}
}
pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
self.easing = f;
self
}
pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
self.on_complete = Some(Box::new(f));
self
}
pub fn value(&mut self, tick: u64) -> f64 {
if self.done {
return self.to;
}
if self.duration_ticks == 0 {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return self.to;
}
let elapsed = tick.wrapping_sub(self.start_tick);
if elapsed >= self.duration_ticks {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return self.to;
}
let progress = elapsed as f64 / self.duration_ticks as f64;
let eased = (self.easing)(clamp01(progress));
lerp(self.from, self.to, eased)
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn reset(&mut self, tick: u64) {
self.start_tick = tick;
self.done = false;
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopMode {
Once,
Repeat,
PingPong,
}
#[derive(Clone, Copy)]
struct KeyframeStop {
position: f64,
value: f64,
}
pub struct Keyframes {
duration_ticks: u64,
start_tick: u64,
stops: Vec<KeyframeStop>,
default_easing: fn(f64) -> f64,
segment_easing: Vec<fn(f64) -> f64>,
loop_mode: LoopMode,
done: bool,
on_complete: Option<Box<dyn FnMut()>>,
}
impl Keyframes {
pub fn new(duration_ticks: u64) -> Self {
Self {
duration_ticks,
start_tick: 0,
stops: Vec::new(),
default_easing: ease_linear,
segment_easing: Vec::new(),
loop_mode: LoopMode::Once,
done: false,
on_complete: None,
}
}
pub fn stop(mut self, position: f64, value: f64) -> Self {
self.stops.push(KeyframeStop {
position: clamp01(position),
value,
});
if self.stops.len() >= 2 {
self.segment_easing.push(self.default_easing);
}
self.stops.sort_by(|a, b| a.position.total_cmp(&b.position));
self
}
pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
self.default_easing = f;
self.segment_easing.fill(f);
self
}
pub fn segment_easing(mut self, segment_index: usize, f: fn(f64) -> f64) -> Self {
debug_assert!(
segment_index < self.segment_easing.len(),
"Keyframes::segment_easing: index {} is out of range \
(only {} segments defined; call stop() first to add more stops)",
segment_index,
self.segment_easing.len(),
);
if let Some(slot) = self.segment_easing.get_mut(segment_index) {
*slot = f;
}
self
}
pub fn loop_mode(mut self, mode: LoopMode) -> Self {
self.loop_mode = mode;
self
}
pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
self.on_complete = Some(Box::new(f));
self
}
pub fn value(&mut self, tick: u64) -> f64 {
if self.stops.is_empty() {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return 0.0;
}
if self.stops.len() == 1 {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return self.stops[0].value;
}
let stops = &self.stops;
let end_value = stops.last().map_or(0.0, |s| s.value);
let loop_tick = match map_loop_tick(
tick,
self.start_tick,
self.duration_ticks,
self.loop_mode,
&mut self.done,
) {
Some(v) => v,
None => {
if let Some(cb) = &mut self.on_complete {
cb();
}
return end_value;
}
};
if self.duration_ticks == 0 {
return stops.last().map_or(0.0, |s| s.value);
}
let progress = loop_tick as f64 / self.duration_ticks as f64;
if progress <= stops[0].position {
return stops[0].value;
}
if progress >= 1.0 {
return end_value;
}
for i in 0..(stops.len() - 1) {
let a = stops[i];
let b = stops[i + 1];
if progress <= b.position {
let span = b.position - a.position;
if span <= f64::EPSILON {
return b.value;
}
let local = clamp01((progress - a.position) / span);
let easing = self
.segment_easing
.get(i)
.copied()
.unwrap_or(self.default_easing);
let eased = easing(local);
return lerp(a.value, b.value, eased);
}
}
end_value
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn reset(&mut self, tick: u64) {
self.start_tick = tick;
self.done = false;
}
}
#[derive(Clone, Copy)]
struct SequenceSegment {
from: f64,
to: f64,
duration_ticks: u64,
easing: fn(f64) -> f64,
}
pub struct Sequence {
segments: Vec<SequenceSegment>,
loop_mode: LoopMode,
start_tick: u64,
done: bool,
on_complete: Option<Box<dyn FnMut()>>,
}
impl Default for Sequence {
fn default() -> Self {
Self::new()
}
}
impl Sequence {
pub fn new() -> Self {
Self {
segments: Vec::new(),
loop_mode: LoopMode::Once,
start_tick: 0,
done: false,
on_complete: None,
}
}
pub fn then(mut self, from: f64, to: f64, duration_ticks: u64, easing: fn(f64) -> f64) -> Self {
self.segments.push(SequenceSegment {
from,
to,
duration_ticks,
easing,
});
self
}
pub fn loop_mode(mut self, mode: LoopMode) -> Self {
self.loop_mode = mode;
self
}
pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
self.on_complete = Some(Box::new(f));
self
}
pub fn value(&mut self, tick: u64) -> f64 {
if self.segments.is_empty() {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return 0.0;
}
let total_duration = self
.segments
.iter()
.fold(0_u64, |acc, s| acc.saturating_add(s.duration_ticks));
let end_value = self.segments.last().map_or(0.0, |s| s.to);
let loop_tick = match map_loop_tick(
tick,
self.start_tick,
total_duration,
self.loop_mode,
&mut self.done,
) {
Some(v) => v,
None => {
if let Some(cb) = &mut self.on_complete {
cb();
}
return end_value;
}
};
let mut remaining = loop_tick;
for segment in &self.segments {
if segment.duration_ticks == 0 {
continue;
}
if remaining < segment.duration_ticks {
let progress = remaining as f64 / segment.duration_ticks as f64;
let eased = (segment.easing)(clamp01(progress));
return lerp(segment.from, segment.to, eased);
}
remaining -= segment.duration_ticks;
}
end_value
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn reset(&mut self, tick: u64) {
self.start_tick = tick;
self.done = false;
}
}
pub struct Stagger {
from: f64,
to: f64,
duration_ticks: u64,
start_tick: u64,
delay_ticks: u64,
easing: fn(f64) -> f64,
loop_mode: LoopMode,
item_count: usize,
done: bool,
on_complete: Option<Box<dyn FnMut()>>,
}
impl Stagger {
pub fn new(from: f64, to: f64, duration_ticks: u64) -> Self {
Self {
from,
to,
duration_ticks,
start_tick: 0,
delay_ticks: 0,
easing: ease_linear,
loop_mode: LoopMode::Once,
item_count: 0,
done: false,
on_complete: None,
}
}
pub fn easing(mut self, f: fn(f64) -> f64) -> Self {
self.easing = f;
self
}
pub fn delay(mut self, ticks: u64) -> Self {
self.delay_ticks = ticks;
self
}
pub fn loop_mode(mut self, mode: LoopMode) -> Self {
self.loop_mode = mode;
self
}
pub fn on_complete(mut self, f: impl FnMut() + 'static) -> Self {
self.on_complete = Some(Box::new(f));
self
}
pub fn items(mut self, count: usize) -> Self {
self.item_count = count;
self
}
pub fn value(&mut self, tick: u64, item_index: usize) -> f64 {
if item_index >= self.item_count {
self.item_count = item_index + 1;
}
let total_cycle = self.total_cycle_ticks();
let effective_tick = if self.loop_mode == LoopMode::Once {
tick
} else {
let elapsed = tick.wrapping_sub(self.start_tick);
let mapped = match self.loop_mode {
LoopMode::Repeat => {
if total_cycle == 0 {
0
} else {
elapsed % total_cycle
}
}
LoopMode::PingPong => {
if total_cycle == 0 {
0
} else {
let full = total_cycle.saturating_mul(2);
let phase = elapsed % full;
if phase < total_cycle {
phase
} else {
full - phase
}
}
}
LoopMode::Once => unreachable!(),
};
self.start_tick.wrapping_add(mapped)
};
let delay = self.delay_ticks.wrapping_mul(item_index as u64);
let item_start = self.start_tick.wrapping_add(delay);
if effective_tick < item_start {
self.done = false;
return self.from;
}
if self.duration_ticks == 0 {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return self.to;
}
let elapsed = effective_tick - item_start;
if elapsed >= self.duration_ticks {
self.done = true;
if let Some(cb) = &mut self.on_complete {
cb();
}
return self.to;
}
self.done = false;
let progress = elapsed as f64 / self.duration_ticks as f64;
let eased = (self.easing)(clamp01(progress));
lerp(self.from, self.to, eased)
}
fn total_cycle_ticks(&self) -> u64 {
let max_delay = self
.delay_ticks
.wrapping_mul(self.item_count.saturating_sub(1) as u64);
self.duration_ticks.saturating_add(max_delay)
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn is_all_done(&self, tick: u64, item_count: usize) -> bool {
if item_count == 0 {
return true;
}
let last_start = self.start_tick.saturating_add(
self.delay_ticks
.saturating_mul(item_count.saturating_sub(1) as u64),
);
tick >= last_start.saturating_add(self.duration_ticks)
}
pub fn reset(&mut self, tick: u64) {
self.start_tick = tick;
self.done = false;
}
}
fn map_loop_tick(
tick: u64,
start_tick: u64,
duration_ticks: u64,
loop_mode: LoopMode,
done: &mut bool,
) -> Option<u64> {
if duration_ticks == 0 {
*done = true;
return None;
}
let elapsed = tick.wrapping_sub(start_tick);
match loop_mode {
LoopMode::Once => {
if elapsed >= duration_ticks {
*done = true;
None
} else {
*done = false;
Some(elapsed)
}
}
LoopMode::Repeat => {
*done = false;
Some(elapsed % duration_ticks)
}
LoopMode::PingPong => {
*done = false;
let cycle = duration_ticks.saturating_mul(2);
if cycle == 0 {
return Some(0);
}
let phase = elapsed % cycle;
if phase < duration_ticks {
Some(phase)
} else {
Some(cycle - phase)
}
}
}
}
pub struct Spring {
value: f64,
target: f64,
velocity: f64,
stiffness: f64,
damping: f64,
settled: bool,
on_settle: Option<Box<dyn FnMut()>>,
}
impl Spring {
pub fn new(initial: f64, stiffness: f64, damping: f64) -> Self {
debug_assert!(
damping > 0.0 && damping < 1.0,
"Spring::new: damping must be in (0, 1), got {damping}. \
Values >= 1.0 conserve or amplify energy and never settle."
);
Self {
value: initial,
target: initial,
velocity: 0.0,
stiffness,
damping,
settled: true,
on_settle: None,
}
}
pub fn on_settle(mut self, f: impl FnMut() + 'static) -> Self {
self.on_settle = Some(Box::new(f));
self
}
pub fn set_target(&mut self, target: f64) {
self.target = target;
self.settled = self.is_settled();
}
pub fn tick(&mut self) {
let displacement = self.target - self.value;
let spring_force = displacement * self.stiffness;
self.velocity = (self.velocity + spring_force) * self.damping;
self.value += self.velocity;
let is_settled = self.is_settled();
if !self.settled && is_settled {
self.settled = true;
if let Some(cb) = &mut self.on_settle {
cb();
}
}
}
pub fn value(&self) -> f64 {
self.value
}
pub fn is_settled(&self) -> bool {
(self.target - self.value).abs() < 0.01 && self.velocity.abs() < 0.01
}
}
fn clamp01(t: f64) -> f64 {
t.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::rc::Rc;
fn assert_endpoints(f: fn(f64) -> f64) {
assert_eq!(f(0.0), 0.0);
assert_eq!(f(1.0), 1.0);
}
#[test]
fn easing_functions_have_expected_endpoints() {
let easing_functions: [fn(f64) -> f64; 9] = [
ease_linear,
ease_in_quad,
ease_out_quad,
ease_in_out_quad,
ease_in_cubic,
ease_out_cubic,
ease_in_out_cubic,
ease_out_elastic,
ease_out_bounce,
];
for easing in easing_functions {
assert_endpoints(easing);
}
}
#[test]
fn tween_returns_start_middle_end_values() {
let mut tween = Tween::new(0.0, 10.0, 10);
tween.reset(100);
assert_eq!(tween.value(100), 0.0);
assert_eq!(tween.value(105), 5.0);
assert_eq!(tween.value(110), 10.0);
assert!(tween.is_done());
}
#[test]
fn tween_reset_restarts_animation() {
let mut tween = Tween::new(0.0, 1.0, 10);
tween.reset(0);
let _ = tween.value(10);
assert!(tween.is_done());
tween.reset(20);
assert!(!tween.is_done());
assert_eq!(tween.value(20), 0.0);
assert_eq!(tween.value(30), 1.0);
assert!(tween.is_done());
}
#[test]
fn tween_on_complete_fires_once() {
let count = Rc::new(Cell::new(0));
let callback_count = Rc::clone(&count);
let mut tween = Tween::new(0.0, 10.0, 10).on_complete(move || {
callback_count.set(callback_count.get() + 1);
});
tween.reset(0);
assert_eq!(count.get(), 0);
assert_eq!(tween.value(5), 5.0);
assert_eq!(count.get(), 0);
assert_eq!(tween.value(10), 10.0);
assert_eq!(count.get(), 1);
assert_eq!(tween.value(11), 10.0);
assert_eq!(count.get(), 1);
}
#[test]
fn spring_settles_to_target() {
let mut spring = Spring::new(0.0, 0.2, 0.85);
spring.set_target(10.0);
for _ in 0..300 {
spring.tick();
if spring.is_settled() {
break;
}
}
assert!(spring.is_settled());
assert!((spring.value() - 10.0).abs() < 0.01);
}
#[test]
fn spring_on_settle_fires_once() {
let count = Rc::new(Cell::new(0));
let callback_count = Rc::clone(&count);
let mut spring = Spring::new(0.0, 0.2, 0.85).on_settle(move || {
callback_count.set(callback_count.get() + 1);
});
spring.set_target(10.0);
for _ in 0..500 {
spring.tick();
if spring.is_settled() {
break;
}
}
assert!(spring.is_settled());
assert_eq!(count.get(), 1);
for _ in 0..50 {
spring.tick();
}
assert_eq!(count.get(), 1);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "damping must be in (0, 1)")]
fn spring_damping_one_panics_in_debug() {
let _ = Spring::new(0.0, 0.5, 1.0);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "damping must be in (0, 1)")]
fn spring_damping_gt_one_panics_in_debug() {
let _ = Spring::new(0.0, 0.5, 2.0);
}
#[test]
fn spring_valid_damping_settles() {
for &d in &[0.5_f64, 0.7, 0.85, 0.95] {
let mut s = Spring::new(0.0, 0.2, d);
s.set_target(100.0);
for _ in 0..1000 {
s.tick();
if s.is_settled() {
break;
}
}
assert!(s.is_settled(), "damping={d} should settle");
assert!((s.value() - 100.0).abs() < 0.01, "damping={d} value off");
}
}
#[test]
fn lerp_interpolates_values() {
assert_eq!(lerp(0.0, 10.0, 0.0), 0.0);
assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
assert_eq!(lerp(0.0, 10.0, 1.0), 10.0);
}
#[test]
fn keyframes_interpolates_across_multiple_stops() {
let mut keyframes = Keyframes::new(100)
.stop(0.0, 0.0)
.stop(0.3, 100.0)
.stop(0.7, 50.0)
.stop(1.0, 80.0)
.easing(ease_linear);
keyframes.reset(0);
assert_eq!(keyframes.value(0), 0.0);
assert_eq!(keyframes.value(15), 50.0);
assert_eq!(keyframes.value(30), 100.0);
assert_eq!(keyframes.value(50), 75.0);
assert_eq!(keyframes.value(70), 50.0);
assert_eq!(keyframes.value(85), 65.0);
assert_eq!(keyframes.value(100), 80.0);
assert!(keyframes.is_done());
}
#[test]
fn keyframes_repeat_loop_restarts() {
let mut keyframes = Keyframes::new(10)
.stop(0.0, 0.0)
.stop(1.0, 10.0)
.loop_mode(LoopMode::Repeat);
keyframes.reset(0);
assert_eq!(keyframes.value(5), 5.0);
assert_eq!(keyframes.value(10), 0.0);
assert_eq!(keyframes.value(12), 2.0);
assert!(!keyframes.is_done());
}
#[test]
fn keyframes_pingpong_reverses_direction() {
let mut keyframes = Keyframes::new(10)
.stop(0.0, 0.0)
.stop(1.0, 10.0)
.loop_mode(LoopMode::PingPong);
keyframes.reset(0);
assert_eq!(keyframes.value(8), 8.0);
assert_eq!(keyframes.value(10), 10.0);
assert_eq!(keyframes.value(12), 8.0);
assert_eq!(keyframes.value(15), 5.0);
assert!(!keyframes.is_done());
}
#[test]
fn sequence_chains_segments_in_order() {
let mut sequence = Sequence::new()
.then(0.0, 100.0, 30, ease_linear)
.then(100.0, 50.0, 20, ease_linear)
.then(50.0, 200.0, 40, ease_linear);
sequence.reset(0);
assert_eq!(sequence.value(15), 50.0);
assert_eq!(sequence.value(30), 100.0);
assert_eq!(sequence.value(40), 75.0);
assert_eq!(sequence.value(50), 50.0);
assert_eq!(sequence.value(70), 125.0);
assert_eq!(sequence.value(90), 200.0);
assert!(sequence.is_done());
}
#[test]
fn sequence_loop_modes_repeat_and_pingpong_work() {
let mut repeat = Sequence::new()
.then(0.0, 10.0, 10, ease_linear)
.loop_mode(LoopMode::Repeat);
repeat.reset(0);
assert_eq!(repeat.value(12), 2.0);
assert!(!repeat.is_done());
let mut pingpong = Sequence::new()
.then(0.0, 10.0, 10, ease_linear)
.loop_mode(LoopMode::PingPong);
pingpong.reset(0);
assert_eq!(pingpong.value(12), 8.0);
assert!(!pingpong.is_done());
}
#[test]
fn stagger_applies_per_item_delay() {
let mut stagger = Stagger::new(0.0, 100.0, 20).easing(ease_linear).delay(5);
stagger.reset(0);
assert_eq!(stagger.value(4, 3), 0.0);
assert_eq!(stagger.value(15, 3), 0.0);
assert_eq!(stagger.value(20, 3), 25.0);
assert_eq!(stagger.value(35, 3), 100.0);
assert!(stagger.is_done());
}
#[test]
fn stagger_is_all_done_returns_false_mid_animation() {
let stagger = Stagger::new(0.0, 100.0, 10).delay(5);
assert!(!stagger.is_all_done(15, 5), "items still in progress");
}
#[test]
fn stagger_is_all_done_returns_true_after_last_item() {
let stagger = Stagger::new(0.0, 100.0, 10).delay(5);
assert!(stagger.is_all_done(31, 5), "all items done by tick 31");
}
#[test]
fn stagger_is_done_reflects_last_sampled_item_only() {
let mut stagger = Stagger::new(0.0, 100.0, 10).delay(5);
stagger.value(100, 4); assert!(stagger.is_done());
stagger.value(15, 2);
assert!(!stagger.is_done(), "is_done reflects last sampled item");
}
#[test]
fn stagger_is_all_done_zero_items() {
let stagger = Stagger::new(0.0, 100.0, 10);
assert!(stagger.is_all_done(0, 0));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "out of range")]
fn keyframes_segment_easing_oob_panics_in_debug() {
let _ = Keyframes::new(60)
.stop(0.0, 0.0)
.stop(1.0, 100.0)
.segment_easing(5, ease_linear);
}
#[test]
fn keyframes_segment_easing_valid_index() {
let kf = Keyframes::new(60)
.stop(0.0, 0.0)
.stop(0.5, 50.0)
.stop(1.0, 100.0)
.segment_easing(0, ease_in_quad)
.segment_easing(1, ease_out_quad);
let _ = kf;
}
}