use std::time::Duration;
use kurbo::{Point, Rect, Size};
use peniko::Color;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FrameTime(u64);
impl FrameTime {
pub const ZERO: FrameTime = FrameTime(0);
pub const fn from_nanos(nanos: u64) -> Self {
FrameTime(nanos)
}
pub const fn as_nanos(self) -> u64 {
self.0
}
pub fn as_secs_f64(self) -> f64 {
self.0 as f64 / 1_000_000_000.0
}
pub fn saturating_sub(self, earlier: FrameTime) -> Duration {
Duration::from_nanos(self.0.saturating_sub(earlier.0))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Curve {
Linear,
EaseIn,
EaseOut,
EaseInOut,
Emphasized,
Cubic(f64, f64, f64, f64),
}
impl Curve {
pub fn transform(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Curve::Linear => t,
Curve::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t),
Curve::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t),
Curve::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t),
Curve::Emphasized => cubic_bezier(0.2, 0.0, 0.0, 1.0, t),
Curve::Cubic(x1, y1, x2, y2) => cubic_bezier(x1, y1, x2, y2, t),
}
}
pub fn interval(self, start: f64, end: f64) -> SegmentedCurve {
SegmentedCurve {
start,
end,
inner: self,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SegmentedCurve {
start: f64,
end: f64,
inner: Curve,
}
impl SegmentedCurve {
pub fn transform(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
if self.end <= self.start {
return if t < self.start { 0.0 } else { 1.0 };
}
if t <= self.start {
0.0
} else if t >= self.end {
1.0
} else {
let local = (t - self.start) / (self.end - self.start);
self.inner.transform(local)
}
}
}
fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
let cx = 3.0 * x1;
let bx = 3.0 * (x2 - x1) - cx;
let ax = 1.0 - cx - bx;
let cy = 3.0 * y1;
let by = 3.0 * (y2 - y1) - cy;
let ay = 1.0 - cy - by;
let sample_x = |s: f64| ((ax * s + bx) * s + cx) * s;
let sample_y = |s: f64| ((ay * s + by) * s + cy) * s;
let sample_dx = |s: f64| (3.0 * ax * s + 2.0 * bx) * s + cx;
let mut s = x;
for _ in 0..8 {
let err = sample_x(s) - x;
if err.abs() < 1e-9 {
return sample_y(s);
}
let d = sample_dx(s);
if d.abs() < 1e-9 {
break;
}
s -= err / d;
}
let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
let mut s = x.clamp(lo, hi);
for _ in 0..32 {
let cur = sample_x(s);
if (cur - x).abs() < 1e-9 {
break;
}
if cur < x {
lo = s;
} else {
hi = s;
}
s = 0.5 * (lo + hi);
}
sample_y(s)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StaggerSpec {
pub per_item_delay: f64,
pub item_duration: f64,
pub item_curve: Curve,
}
impl StaggerSpec {
pub fn total_duration(&self, n: usize) -> f64 {
if n == 0 {
return 0.0;
}
(n as f64 - 1.0) * self.per_item_delay + self.item_duration
}
pub fn item_progress(&self, overall: f64, i: usize, n: usize) -> f64 {
let total = self.total_duration(n);
if total <= 0.0 || i >= n {
return 1.0;
}
let overall = overall.clamp(0.0, 1.0);
let elapsed = overall * total;
let item_start = i as f64 * self.per_item_delay;
let item_end = item_start + self.item_duration;
if self.item_duration <= 0.0 {
return if elapsed < item_start { 0.0 } else { 1.0 };
}
if elapsed <= item_start {
0.0
} else if elapsed >= item_end {
1.0
} else {
let local = (elapsed - item_start) / self.item_duration;
self.item_curve.transform(local)
}
}
}
pub trait Lerp {
fn lerp(&self, other: &Self, t: f64) -> Self;
}
impl Lerp for f64 {
fn lerp(&self, other: &Self, t: f64) -> Self {
self + (other - self) * t
}
}
impl Lerp for Point {
fn lerp(&self, other: &Self, t: f64) -> Self {
Point::new(self.x.lerp(&other.x, t), self.y.lerp(&other.y, t))
}
}
impl Lerp for Size {
fn lerp(&self, other: &Self, t: f64) -> Self {
Size::new(
self.width.lerp(&other.width, t),
self.height.lerp(&other.height, t),
)
}
}
impl Lerp for Rect {
fn lerp(&self, other: &Self, t: f64) -> Self {
Rect::new(
self.x0.lerp(&other.x0, t),
self.y0.lerp(&other.y0, t),
self.x1.lerp(&other.x1, t),
self.y1.lerp(&other.y1, t),
)
}
}
impl Lerp for Color {
fn lerp(&self, other: &Self, t: f64) -> Self {
let a = self.components;
let b = other.components;
let t = t as f32;
Color::new([
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
])
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween<T> {
pub begin: T,
pub end: T,
}
impl<T: Lerp> Tween<T> {
pub fn new(begin: T, end: T) -> Self {
Tween { begin, end }
}
pub fn lerp(&self, t: f64) -> T {
self.begin.lerp(&self.end, t)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpringDesc {
pub mass: f64,
pub stiffness: f64,
pub damping_ratio: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct Spring {
kind: SpringKind,
}
#[derive(Clone, Copy, Debug)]
enum SpringKind {
Under {
w0: f64,
wd: f64,
zeta: f64,
a: f64,
b: f64,
},
Critical { w0: f64, a: f64, b: f64 },
Over { r1: f64, r2: f64, c1: f64, c2: f64 },
}
impl Spring {
pub fn new(desc: SpringDesc, x0: f64, v0: f64) -> Self {
let mass = if desc.mass > 0.0 { desc.mass } else { 1.0 };
let stiffness = if desc.stiffness > 0.0 {
desc.stiffness
} else {
1.0
};
let zeta = desc.damping_ratio.max(0.0);
let w0 = (stiffness / mass).sqrt();
let kind = if (zeta - 1.0).abs() < 1e-6 {
let a = x0;
let b = v0 + w0 * x0;
SpringKind::Critical { w0, a, b }
} else if zeta < 1.0 {
let wd = w0 * (1.0 - zeta * zeta).sqrt();
let a = x0;
let b = (v0 + zeta * w0 * x0) / wd;
SpringKind::Under { w0, wd, zeta, a, b }
} else {
let s = (zeta * zeta - 1.0).sqrt();
let r1 = -w0 * (zeta - s);
let r2 = -w0 * (zeta + s);
let c1 = (v0 - r2 * x0) / (r1 - r2);
let c2 = x0 - c1;
SpringKind::Over { r1, r2, c1, c2 }
};
Spring { kind }
}
pub fn position(&self, t: f64) -> f64 {
match self.kind {
SpringKind::Under {
w0, wd, zeta, a, b, ..
} => {
let e = (-zeta * w0 * t).exp();
e * (a * (wd * t).cos() + b * (wd * t).sin())
}
SpringKind::Critical { w0, a, b } => {
let e = (-w0 * t).exp();
e * (a + b * t)
}
SpringKind::Over { r1, r2, c1, c2 } => c1 * (r1 * t).exp() + c2 * (r2 * t).exp(),
}
}
pub fn velocity(&self, t: f64) -> f64 {
match self.kind {
SpringKind::Under {
w0, wd, zeta, a, b, ..
} => {
let e = (-zeta * w0 * t).exp();
let c = (wd * t).cos();
let s = (wd * t).sin();
e * ((b * wd - zeta * w0 * a) * c - (a * wd + zeta * w0 * b) * s)
}
SpringKind::Critical { w0, a, b } => {
let e = (-w0 * t).exp();
e * (b - w0 * (a + b * t))
}
SpringKind::Over { r1, r2, c1, c2 } => {
r1 * c1 * (r1 * t).exp() + r2 * c2 * (r2 * t).exp()
}
}
}
pub fn is_at_rest(&self, t: f64, epsilon: f64) -> bool {
self.position(t).abs() < epsilon && self.velocity(t).abs() < epsilon
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnimationStatus {
Idle,
Forward,
Reverse,
Completed,
Dismissed,
}
const SPRING_REST_EPSILON: f64 = 1e-3;
#[derive(Clone, Copy, Debug)]
pub struct AnimationController {
value: f64,
duration: Duration,
curve: Curve,
status: AnimationStatus,
drive: Drive,
last_time: Option<FrameTime>,
}
#[derive(Clone, Copy, Debug)]
enum Drive {
Idle,
Duration {
start: f64,
target: f64,
elapsed: f64,
duration: f64,
},
Repeat { elapsed: f64, period: f64 },
Fling {
spring: Spring,
target: f64,
elapsed: f64,
},
}
impl AnimationController {
pub fn new(duration: Duration) -> Self {
AnimationController {
value: 0.0,
duration,
curve: Curve::Linear,
status: AnimationStatus::Idle,
drive: Drive::Idle,
last_time: None,
}
}
pub fn with_curve(mut self, curve: Curve) -> Self {
self.curve = curve;
self
}
pub fn value(&self) -> f64 {
self.value
}
pub fn value_clamped(&self) -> f64 {
self.value.clamp(0.0, 1.0)
}
pub fn status(&self) -> AnimationStatus {
self.status
}
pub fn is_animating(&self) -> bool {
!matches!(self.drive, Drive::Idle)
}
pub fn forward(&mut self) {
self.start_duration(self.value, 1.0, AnimationStatus::Forward);
}
pub fn reverse(&mut self) {
self.start_duration(self.value, 0.0, AnimationStatus::Reverse);
}
pub fn animate_to(&mut self, target: f64) {
let target = target.clamp(0.0, 1.0);
let status = if target >= self.value {
AnimationStatus::Forward
} else {
AnimationStatus::Reverse
};
self.start_duration(self.value, target, status);
}
pub fn repeat(&mut self) {
let period = self.duration.as_secs_f64();
self.value = 0.0;
self.status = AnimationStatus::Forward;
self.last_time = None;
self.drive = if period > 0.0 {
Drive::Repeat {
elapsed: 0.0,
period,
}
} else {
Drive::Idle
};
}
pub fn fling(&mut self, velocity: f64, spring: SpringDesc) {
let target = if velocity >= 0.0 { 1.0 } else { 0.0 };
let x0 = self.value - target;
self.status = if target >= self.value {
AnimationStatus::Forward
} else {
AnimationStatus::Reverse
};
self.last_time = None;
self.drive = Drive::Fling {
spring: Spring::new(spring, x0, velocity),
target,
elapsed: 0.0,
};
}
pub fn stop(&mut self) {
self.drive = Drive::Idle;
self.status = AnimationStatus::Idle;
self.last_time = None;
}
pub fn advance(&mut self, now: FrameTime) -> bool {
let dt = match self.last_time {
Some(last) => now.saturating_sub(last).as_secs_f64(),
None => 0.0,
};
self.last_time = Some(now);
let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 };
match &mut self.drive {
Drive::Idle => false,
Drive::Duration {
start,
target,
elapsed,
duration,
} => {
let (start, target, duration) = (*start, *target, *duration);
*elapsed += dt;
let elapsed = *elapsed;
if duration <= 0.0 || elapsed >= duration {
self.value = target;
self.status = if target >= start {
AnimationStatus::Completed
} else {
AnimationStatus::Dismissed
};
self.drive = Drive::Idle;
false
} else {
let frac = (elapsed / duration).clamp(0.0, 1.0);
let eased = self.curve.transform(frac);
self.value = start + (target - start) * eased;
true
}
}
Drive::Repeat { elapsed, period } => {
let period = *period;
*elapsed += dt;
let frac = if period > 0.0 {
(*elapsed / period).rem_euclid(1.0)
} else {
0.0
};
self.value = self.curve.transform(frac);
true
}
Drive::Fling {
spring,
target,
elapsed,
} => {
let spring = *spring;
let target = *target;
*elapsed += dt;
let elapsed = *elapsed;
if spring.is_at_rest(elapsed, SPRING_REST_EPSILON) {
self.value = target;
self.status = if target >= 0.5 {
AnimationStatus::Completed
} else {
AnimationStatus::Dismissed
};
self.drive = Drive::Idle;
false
} else {
self.value = target + spring.position(elapsed);
true
}
}
}
}
fn start_duration(&mut self, start: f64, target: f64, status: AnimationStatus) {
let duration = self.duration.as_secs_f64();
self.status = status;
self.last_time = None;
if duration <= 0.0 || (target - start).abs() < f64::EPSILON {
self.value = target;
self.status = if target >= start {
AnimationStatus::Completed
} else {
AnimationStatus::Dismissed
};
self.drive = Drive::Idle;
} else {
self.drive = Drive::Duration {
start,
target,
elapsed: 0.0,
duration,
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
#[test]
fn frame_time_differences_only() {
let a = FrameTime::from_nanos(1_000);
let b = FrameTime::from_nanos(3_500);
assert_eq!(b.saturating_sub(a), Duration::from_nanos(2_500));
assert_eq!(a.saturating_sub(b), Duration::ZERO);
assert!((ft_secs(2.0).as_secs_f64() - 2.0).abs() < 1e-9);
}
#[test]
fn ease_in_out_matches_css_reference_at_half() {
assert!((Curve::EaseInOut.transform(0.5) - 0.5).abs() < 1e-4);
assert!((Curve::EaseInOut.transform(0.0)).abs() < 1e-9);
assert!((Curve::EaseInOut.transform(1.0) - 1.0).abs() < 1e-9);
assert!(Curve::EaseIn.transform(0.5) < 0.5);
assert!(Curve::EaseOut.transform(0.5) > 0.5);
assert!((Curve::EaseIn.transform(0.5) + Curve::EaseOut.transform(0.5) - 1.0).abs() < 1e-4);
}
#[test]
fn linear_curve_is_identity() {
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!((Curve::Linear.transform(t) - t).abs() < 1e-12);
}
assert_eq!(Curve::Linear.transform(-1.0), 0.0);
assert_eq!(Curve::Linear.transform(2.0), 1.0);
}
#[test]
fn interval_endpoints_and_midpoint() {
let seg = Curve::EaseInOut.interval(0.3, 0.7);
assert_eq!(seg.transform(0.0), 0.0);
assert_eq!(seg.transform(0.3), 0.0);
assert_eq!(seg.transform(0.1), 0.0);
assert_eq!(seg.transform(0.7), 1.0);
assert_eq!(seg.transform(0.9), 1.0);
assert_eq!(seg.transform(1.0), 1.0);
let local_mid = 0.3 + 0.5 * (0.7 - 0.3);
assert!((seg.transform(local_mid) - 0.5).abs() < 1e-4);
let local_quarter = 0.3 + 0.25 * (0.7 - 0.3);
assert!((seg.transform(local_quarter) - Curve::EaseInOut.transform(0.25)).abs() < 1e-9);
}
#[test]
fn interval_linear_matches_flutter_fade_through_shape() {
let out_seg = Curve::Linear.interval(0.0, 0.3);
let in_seg = Curve::Linear.interval(0.3, 1.0);
assert_eq!(out_seg.transform(0.0), 0.0);
assert!((out_seg.transform(0.15) - 0.5).abs() < 1e-9);
assert_eq!(out_seg.transform(0.3), 1.0);
assert_eq!(in_seg.transform(0.3), 0.0);
assert!((in_seg.transform(0.65) - 0.5).abs() < 1e-9);
assert_eq!(in_seg.transform(1.0), 1.0);
}
#[test]
fn interval_degenerate_start_equals_end_is_an_instant_step() {
let seg = Curve::Linear.interval(0.3, 0.3);
assert_eq!(seg.transform(0.0), 0.0);
assert_eq!(seg.transform(0.2), 0.0);
assert_eq!(seg.transform(0.3), 1.0);
assert_eq!(seg.transform(0.5), 1.0);
assert_eq!(seg.transform(1.0), 1.0);
}
#[test]
fn interval_degenerate_inverted_is_an_instant_step_at_start() {
let seg = Curve::Linear.interval(0.6, 0.2);
assert_eq!(seg.transform(0.0), 0.0);
assert_eq!(seg.transform(0.59), 0.0);
assert_eq!(seg.transform(0.6), 1.0);
assert_eq!(seg.transform(1.0), 1.0);
}
#[test]
fn interval_out_of_range_t_clamps_first() {
let seg = Curve::Linear.interval(0.3, 0.7);
assert_eq!(seg.transform(-1.0), 0.0);
assert_eq!(seg.transform(2.0), 1.0);
}
fn glyph_stagger() -> StaggerSpec {
StaggerSpec {
per_item_delay: 90.0,
item_duration: 150.0,
item_curve: Curve::Linear,
}
}
#[test]
fn stagger_total_duration_spans_last_items_window() {
let spec = glyph_stagger();
assert_eq!(spec.total_duration(5), 510.0);
assert_eq!(spec.total_duration(1), 150.0);
assert_eq!(spec.total_duration(0), 0.0);
}
#[test]
fn stagger_item_progress_hand_computed_table() {
let spec = glyph_stagger();
let n = 5;
let total = spec.total_duration(n);
for i in 0..n {
assert_eq!(spec.item_progress(0.0, i, n), 0.0, "i={i} at overall=0.0");
}
for i in 0..n {
assert_eq!(spec.item_progress(1.0, i, n), 1.0, "i={i} at overall=1.0");
}
let overall_270 = 270.0 / total;
assert_eq!(spec.item_progress(overall_270, 0, n), 1.0);
assert_eq!(spec.item_progress(overall_270, 1, n), 1.0);
assert!((spec.item_progress(overall_270, 2, n) - 0.6).abs() < 1e-9);
assert_eq!(spec.item_progress(overall_270, 3, n), 0.0);
assert_eq!(spec.item_progress(overall_270, 4, n), 0.0);
let overall_45 = 45.0 / total;
assert!((spec.item_progress(overall_45, 0, n) - 0.3).abs() < 1e-9);
assert_eq!(spec.item_progress(overall_45, 1, n), 0.0);
let overall_135 = 135.0 / total;
assert!((spec.item_progress(overall_135, 0, n) - 0.9).abs() < 1e-9);
assert!((spec.item_progress(overall_135, 1, n) - 0.3).abs() < 1e-9);
}
#[test]
fn stagger_item_progress_out_of_range_index_or_empty_set_is_complete() {
let spec = glyph_stagger();
assert_eq!(spec.item_progress(0.5, 5, 5), 1.0);
assert_eq!(spec.item_progress(0.5, 0, 0), 1.0);
}
#[test]
fn stagger_zero_duration_item_is_an_instant_step() {
let spec = StaggerSpec {
per_item_delay: 100.0,
item_duration: 0.0,
item_curve: Curve::Linear,
};
let n = 3;
let total = spec.total_duration(n); assert_eq!(total, 200.0);
assert_eq!(spec.item_progress(99.0 / total, 1, n), 0.0);
assert_eq!(spec.item_progress(100.0 / total, 1, n), 1.0);
}
#[test]
fn tween_interpolates_value_types() {
assert!((Tween::new(0.0_f64, 10.0).lerp(0.25) - 2.5).abs() < 1e-12);
assert_eq!(
Tween::new(Point::new(0.0, 0.0), Point::new(4.0, 8.0)).lerp(0.5),
Point::new(2.0, 4.0)
);
assert_eq!(
Tween::new(Size::new(0.0, 0.0), Size::new(10.0, 20.0)).lerp(0.1),
Size::new(1.0, 2.0)
);
assert_eq!(
Tween::new(Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(2.0, 2.0, 6.0, 6.0)).lerp(0.5),
Rect::new(1.0, 1.0, 4.0, 4.0)
);
let c = Tween::new(Color::BLACK, Color::WHITE).lerp(0.5);
for ch in &c.components[..3] {
assert!((ch - 0.5).abs() < 1e-6);
}
}
fn integrate_spring(desc: SpringDesc, x0: f64, v0: f64, t_end: f64) -> f64 {
let m = desc.mass;
let k = desc.stiffness;
let c = 2.0 * desc.damping_ratio * (k * m).sqrt();
let accel = |x: f64, v: f64| -(k * x + c * v) / m;
let dt = 1e-5;
let steps = (t_end / dt).round() as usize;
let (mut x, mut v) = (x0, v0);
for _ in 0..steps {
let (k1x, k1v) = (v, accel(x, v));
let (k2x, k2v) = (
v + 0.5 * dt * k1v,
accel(x + 0.5 * dt * k1x, v + 0.5 * dt * k1v),
);
let (k3x, k3v) = (
v + 0.5 * dt * k2v,
accel(x + 0.5 * dt * k2x, v + 0.5 * dt * k2v),
);
let (k4x, k4v) = (v + dt * k3v, accel(x + dt * k3x, v + dt * k3v));
x += dt / 6.0 * (k1x + 2.0 * k2x + 2.0 * k3x + k4x);
v += dt / 6.0 * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
}
x
}
#[test]
fn spring_analytic_matches_numeric_under_critical_over() {
let cases = [
SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
},
SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 1.0,
},
SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 1.5,
},
];
for desc in cases {
let spring = Spring::new(desc, 1.0, 0.0);
for &t in &[0.005, 0.01, 0.02, 0.03] {
let analytic = spring.position(t);
let numeric = integrate_spring(desc, 1.0, 0.0, t);
assert!(
(analytic - numeric).abs() < 1e-6,
"regime ζ={} at t={t}: analytic {analytic} vs numeric {numeric}",
desc.damping_ratio
);
}
}
}
#[test]
fn spring_velocity_matches_finite_difference() {
let desc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
let spring = Spring::new(desc, 1.0, 0.0);
let t = 0.01;
let h = 1e-7;
let fd = (spring.position(t + h) - spring.position(t - h)) / (2.0 * h);
assert!((spring.velocity(t) - fd).abs() < 1e-4);
}
#[test]
fn duration_forward_reaches_completed() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.forward();
assert_eq!(c.status(), AnimationStatus::Forward);
assert!(c.advance(ft_secs(0.0)));
assert!(c.advance(ft_secs(0.05)));
assert!((c.value() - 0.5).abs() < 1e-6);
assert!(!c.advance(ft_secs(0.2)));
assert_eq!(c.value(), 1.0);
assert_eq!(c.status(), AnimationStatus::Completed);
}
#[test]
fn reverse_reaches_dismissed() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.forward();
c.advance(ft_secs(0.0));
c.advance(ft_secs(0.2));
assert_eq!(c.value(), 1.0);
c.reverse();
assert_eq!(c.status(), AnimationStatus::Reverse);
c.advance(ft_secs(1.0));
assert!(!c.advance(ft_secs(1.2)));
assert_eq!(c.value(), 0.0);
assert_eq!(c.status(), AnimationStatus::Dismissed);
}
#[test]
fn repeat_wraps_and_never_completes() {
let mut c = AnimationController::new(Duration::from_secs(1));
c.repeat();
assert!(c.advance(ft_secs(0.0)));
assert!(c.advance(ft_secs(0.5)));
assert!((c.value() - 0.5).abs() < 1e-6);
assert!(c.advance(ft_secs(1.5)));
assert!((c.value() - 0.5).abs() < 1e-6);
assert!(c.advance(ft_secs(2.0)));
assert!(c.value() < 1e-6);
assert!(c.advance(ft_secs(100.0)));
}
#[test]
fn animate_to_partial_target() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.animate_to(0.3);
assert_eq!(c.status(), AnimationStatus::Forward);
c.advance(ft_secs(0.0));
assert!(!c.advance(ft_secs(0.2)));
assert!((c.value() - 0.3).abs() < 1e-6);
assert_eq!(c.status(), AnimationStatus::Completed);
}
#[test]
fn fling_settles_at_target() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.fling(
2.0,
SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
},
);
let mut t = 0.0;
let mut running = true;
for _ in 0..100_000 {
running = c.advance(ft_secs(t));
if !running {
break;
}
t += 1.0 / 120.0;
}
assert!(!running, "fling failed to settle");
assert!((c.value() - 1.0).abs() < 1e-6);
assert_eq!(c.status(), AnimationStatus::Completed);
}
#[test]
fn fling_overshoots_past_target_for_underdamped_spring() {
let desc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
let mut c = AnimationController::new(Duration::from_millis(100));
c.fling(0.0, desc);
let mut max_value = f64::MIN;
let mut t = 0.0;
let mut running = true;
for _ in 0..100_000 {
running = c.advance(ft_secs(t));
if !running {
break;
}
max_value = max_value.max(c.value());
t += 1.0 / 120.0;
}
assert!(!running, "fling failed to settle");
assert!(
max_value > 1.0 + 1e-3,
"expected a demonstrable overshoot past 1.0, got max {max_value}"
);
assert_eq!(c.value(), 1.0);
assert_eq!(c.status(), AnimationStatus::Completed);
}
#[test]
fn fling_overshoot_values_match_analytic_spring() {
let desc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
let mut c = AnimationController::new(Duration::from_millis(100));
c.fling(0.0, desc);
let spring = Spring::new(desc, -1.0, 0.0);
assert!(c.advance(ft_secs(0.0)));
for &t in &[0.01, 0.02, 0.03, 0.05, 0.08] {
assert!(c.advance(ft_secs(t)));
let expected = 1.0 + spring.position(t);
assert!(
(c.value() - expected).abs() < 1e-6,
"at t={t}: controller {} vs analytic {expected}",
c.value()
);
}
}
#[test]
fn effects_spring_never_exceeds_target() {
let desc = SpringDesc {
mass: 1.0,
stiffness: 1600.0,
damping_ratio: 1.0,
};
let mut c = AnimationController::new(Duration::from_millis(100));
c.fling(0.0, desc);
let mut t = 0.0;
let mut running = true;
for _ in 0..100_000 {
running = c.advance(ft_secs(t));
assert!(
c.value() <= 1.0 + 1e-9,
"critically damped spring released from rest overshot: value {} at t={t}",
c.value()
);
if !running {
break;
}
t += 1.0 / 120.0;
}
assert!(!running, "fling failed to settle");
assert_eq!(c.value(), 1.0);
assert_eq!(c.status(), AnimationStatus::Completed);
}
#[test]
fn value_clamped_bounds_an_overshooting_fling() {
let desc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
let mut c = AnimationController::new(Duration::from_millis(100));
c.fling(0.0, desc);
c.advance(ft_secs(0.0));
c.advance(ft_secs(0.02));
assert!(c.value_clamped() >= 0.0 && c.value_clamped() <= 1.0);
assert_eq!(c.value_clamped(), c.value().clamp(0.0, 1.0));
}
#[test]
fn advance_is_safe_on_equal_and_backward_timestamps() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.forward();
c.advance(ft_secs(0.05));
let v_seed = c.value();
c.advance(ft_secs(0.05));
assert_eq!(c.value(), v_seed);
assert!(c.value().is_finite());
c.advance(ft_secs(0.01));
assert_eq!(c.value(), v_seed);
assert!(c.value().is_finite());
}
#[test]
fn stop_halts_progress() {
let mut c = AnimationController::new(Duration::from_millis(100));
c.forward();
c.advance(ft_secs(0.0));
c.advance(ft_secs(0.05));
let v = c.value();
c.stop();
assert_eq!(c.status(), AnimationStatus::Idle);
assert!(!c.advance(ft_secs(0.5)));
assert_eq!(c.value(), v);
}
}