Skip to main content

ling_ui/
anim.rs

1//! Animation toolkit — easing curves, springs, tweens and a tiny animator.
2//!
3//! Everything is `f32`, allocation-free, and `no_std`-friendly in spirit. Drive
4//! it by feeding elapsed time (`t` in 0..1 for easings) or `dt` (seconds) for
5//! springs/animators.
6
7use core::f32::consts::PI;
8
9/// Standard easing curves (Penner-style), all mapping `t∈[0,1] → [0,1]`.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Easing {
12    Linear,
13    QuadIn, QuadOut, QuadInOut,
14    CubicIn, CubicOut, CubicInOut,
15    QuartOut,
16    SineInOut,
17    ExpoOut,
18    BackOut,
19    ElasticOut,
20    BounceOut,
21}
22
23impl Easing {
24    /// Parse a case-insensitive name (e.g. `"ease_out"`, `"elastic"`, `"bounce"`).
25    pub fn from_name(s: &str) -> Easing {
26        match s.to_ascii_lowercase().replace('-', "_").as_str() {
27            "linear" => Easing::Linear,
28            "quad_in" | "ease_in"            => Easing::QuadIn,
29            "quad_out" | "ease_out"          => Easing::QuadOut,
30            "quad_inout" | "ease" | "smooth" => Easing::QuadInOut,
31            "cubic_in"    => Easing::CubicIn,
32            "cubic_out"   => Easing::CubicOut,
33            "cubic_inout" => Easing::CubicInOut,
34            "quart_out" | "quartout" => Easing::QuartOut,
35            "sine" | "sine_inout"    => Easing::SineInOut,
36            "expo" | "expo_out"      => Easing::ExpoOut,
37            "back" | "back_out"      => Easing::BackOut,
38            "elastic" | "elastic_out"=> Easing::ElasticOut,
39            "bounce" | "bounce_out"  => Easing::BounceOut,
40            _ => Easing::QuadInOut,
41        }
42    }
43
44    /// Evaluate the curve. `t` is clamped to `[0,1]`.
45    pub fn apply(self, t: f32) -> f32 {
46        let t = t.clamp(0.0, 1.0);
47        match self {
48            Easing::Linear     => t,
49            Easing::QuadIn     => t * t,
50            Easing::QuadOut    => 1.0 - (1.0 - t) * (1.0 - t),
51            Easing::QuadInOut  => if t < 0.5 { 2.0*t*t } else { 1.0 - (-2.0*t+2.0).powi(2)/2.0 },
52            Easing::CubicIn    => t*t*t,
53            Easing::CubicOut   => 1.0 - (1.0 - t).powi(3),
54            Easing::CubicInOut => if t < 0.5 { 4.0*t*t*t } else { 1.0 - (-2.0*t+2.0).powi(3)/2.0 },
55            Easing::QuartOut   => 1.0 - (1.0 - t).powi(4),
56            Easing::SineInOut  => -(PI * t).cos() * 0.5 + 0.5,
57            Easing::ExpoOut    => if t >= 1.0 { 1.0 } else { 1.0 - 2.0_f32.powf(-10.0 * t) },
58            Easing::BackOut    => {
59                let c1 = 1.70158; let c3 = c1 + 1.0;
60                1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
61            }
62            Easing::ElasticOut => {
63                if t == 0.0 || t == 1.0 { return t; }
64                let c4 = (2.0 * PI) / 3.0;
65                2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
66            }
67            Easing::BounceOut  => bounce_out(t),
68        }
69    }
70}
71
72fn bounce_out(t: f32) -> f32 {
73    let n1 = 7.5625; let d1 = 2.75;
74    if t < 1.0 / d1 { n1 * t * t }
75    else if t < 2.0 / d1 { let t = t - 1.5/d1; n1 * t * t + 0.75 }
76    else if t < 2.5 / d1 { let t = t - 2.25/d1; n1 * t * t + 0.9375 }
77    else { let t = t - 2.625/d1; n1 * t * t + 0.984375 }
78}
79
80/// Convenience: ease a value between `a` and `b` by curve at parameter `t`.
81pub fn ease(curve: Easing, a: f32, b: f32, t: f32) -> f32 {
82    a + (b - a) * curve.apply(t)
83}
84
85/// A critically-dampable spring (implicit-ish) — great for snappy UI motion.
86///
87/// `stiffness` ≈ how hard it pulls toward the target, `damping` ≈ how fast it
88/// settles. Step with `update(dt)` each frame.
89#[derive(Clone, Copy, Debug)]
90pub struct Spring {
91    pub value: f32,
92    pub target: f32,
93    pub velocity: f32,
94    pub stiffness: f32,
95    pub damping: f32,
96}
97
98impl Spring {
99    pub fn new(value: f32, stiffness: f32, damping: f32) -> Self {
100        Self { value, target: value, velocity: 0.0, stiffness, damping }
101    }
102    /// A pleasant default for UI (snappy, slightly springy).
103    pub fn ui(value: f32) -> Self { Self::new(value, 180.0, 18.0) }
104
105    pub fn set_target(&mut self, target: f32) { self.target = target; }
106
107    /// Advance by `dt` seconds (sub-stepped for stability).
108    pub fn update(&mut self, dt: f32) -> f32 {
109        let steps = (dt / 0.004).ceil().max(1.0) as u32;
110        let h = dt / steps as f32;
111        for _ in 0..steps {
112            let force = (self.target - self.value) * self.stiffness - self.velocity * self.damping;
113            self.velocity += force * h;
114            self.value += self.velocity * h;
115        }
116        self.value
117    }
118
119    pub fn is_settled(&self) -> bool {
120        (self.target - self.value).abs() < 1e-3 && self.velocity.abs() < 1e-3
121    }
122}
123
124/// A one-shot tween from `from`→`to` over `duration` seconds along a curve.
125#[derive(Clone, Copy, Debug)]
126pub struct Tween {
127    pub from: f32,
128    pub to: f32,
129    pub duration: f32,
130    pub elapsed: f32,
131    pub curve: Easing,
132}
133
134impl Tween {
135    pub fn new(from: f32, to: f32, duration: f32, curve: Easing) -> Self {
136        Self { from, to, duration: duration.max(1e-4), elapsed: 0.0, curve }
137    }
138    /// Advance and return the current value.
139    pub fn update(&mut self, dt: f32) -> f32 {
140        self.elapsed = (self.elapsed + dt).min(self.duration);
141        self.value()
142    }
143    pub fn value(&self) -> f32 {
144        let t = self.elapsed / self.duration;
145        ease(self.curve, self.from, self.to, t)
146    }
147    pub fn done(&self) -> bool { self.elapsed >= self.duration }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn easings_pin_endpoints() {
156        for c in [Easing::Linear, Easing::QuadInOut, Easing::CubicOut, Easing::ExpoOut,
157                  Easing::BackOut, Easing::ElasticOut, Easing::BounceOut, Easing::SineInOut] {
158            assert!((c.apply(0.0) - 0.0).abs() < 1e-3, "{c:?} f(0)≠0");
159            assert!((c.apply(1.0) - 1.0).abs() < 1e-3, "{c:?} f(1)≠1");
160        }
161    }
162
163    #[test]
164    fn from_name_aliases() {
165        assert_eq!(Easing::from_name("ease-out"), Easing::QuadOut);
166        assert_eq!(Easing::from_name("ELASTIC"), Easing::ElasticOut);
167        assert_eq!(Easing::from_name("nonsense"), Easing::QuadInOut);
168    }
169
170    #[test]
171    fn spring_settles_at_target() {
172        let mut s = Spring::ui(0.0);
173        s.set_target(1.0);
174        for _ in 0..600 { s.update(1.0/60.0); }
175        assert!(s.is_settled(), "value={} vel={}", s.value, s.velocity);
176        assert!((s.value - 1.0).abs() < 1e-2);
177    }
178
179    #[test]
180    fn tween_runs_to_completion() {
181        let mut tw = Tween::new(10.0, 20.0, 0.5, Easing::CubicOut);
182        assert!(!tw.done());
183        for _ in 0..60 { tw.update(1.0/60.0); }
184        assert!(tw.done());
185        assert!((tw.value() - 20.0).abs() < 1e-3);
186    }
187}