Skip to main content

retroglyph_core/animate/
tween.rs

1//! [`Tween`]: a finite, retargetable transition between two `f32` values.
2
3use super::easing::Easing;
4use core::time::Duration;
5
6/// A retargetable animation from one `f32` value to another over a fixed duration, reshaped by
7/// an [`Easing`] curve.
8///
9/// See the `08_animation` example for `Tween` in action:
10/// <https://main.retroglyph.dev/examples/08_animation/terminal/>.
11///
12/// ```
13/// use core::time::Duration;
14/// use retroglyph_core::{Easing, Tween};
15///
16/// let mut fade = Tween::new(0.0, 1.0)
17///     .duration(Duration::from_millis(200))
18///     .easing(Easing::EaseOutCubic);
19///
20/// fade.update(Duration::from_millis(100)); // halfway through, by elapsed time
21/// assert!(fade.value() > 0.5); // EaseOutCubic front-loads motion, so it's already past halfway
22/// assert!(!fade.is_finished());
23///
24/// fade.update(Duration::from_millis(100)); // now fully elapsed
25/// assert_eq!(fade.value(), 1.0);
26/// assert!(fade.is_finished());
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct Tween {
30    from: f32,
31    to: f32,
32    elapsed: Duration,
33    duration: Duration,
34    easing: Easing,
35}
36
37impl Tween {
38    /// [`duration`](Self::duration)'s default if never overridden: 200ms, a typical UI
39    /// micro-interaction length: noticeable, but not sluggish.
40    pub const DEFAULT_DURATION: Duration = Duration::from_millis(200);
41
42    /// A new tween animating from `from` to `to` over [`DEFAULT_DURATION`](Self::DEFAULT_DURATION)
43    /// with [`Easing::Linear`]. Chain [`duration`](Self::duration)/[`easing`](Self::easing) to
44    /// override either, then call [`update`](Self::update) once per frame.
45    #[must_use]
46    pub const fn new(from: f32, to: f32) -> Self {
47        Self {
48            from,
49            to,
50            elapsed: Duration::ZERO,
51            duration: Self::DEFAULT_DURATION,
52            easing: Easing::Linear,
53        }
54    }
55
56    /// Overrides the total duration of the animation.
57    #[must_use]
58    pub const fn duration(mut self, duration: Duration) -> Self {
59        self.duration = duration;
60        self
61    }
62
63    /// Overrides the easing curve.
64    #[must_use]
65    pub const fn easing(mut self, easing: Easing) -> Self {
66        self.easing = easing;
67        self
68    }
69
70    /// Advances the animation by `dt`: call once per frame with
71    /// [`Frame::delta`](crate::Frame::delta). Clamped to `duration`: calling this after the
72    /// animation has already finished is a no-op, not an overshoot into negative "time left."
73    pub fn update(&mut self, dt: Duration) {
74        self.elapsed = (self.elapsed + dt).min(self.duration);
75    }
76
77    /// Linear progress through the animation: `0.0` at the start, `1.0` once
78    /// [`is_finished`](Self::is_finished). Doesn't have the easing curve applied yet; see
79    /// [`value`](Self::value) for that.
80    #[must_use]
81    pub fn progress(&self) -> f32 {
82        if self.duration.is_zero() {
83            return 1.0;
84        }
85        self.elapsed.as_secs_f32() / self.duration.as_secs_f32()
86    }
87
88    /// The current animated value: [`progress`](Self::progress) run through this tween's
89    /// [`Easing`] curve, then used to interpolate between `from` and `to`.
90    #[must_use]
91    pub fn value(&self) -> f32 {
92        let t = self.easing.apply(self.progress());
93        libm::fmaf(self.to - self.from, t, self.from)
94    }
95
96    /// `true` once [`update`](Self::update) has accumulated at least `duration` of elapsed time.
97    #[must_use]
98    pub fn is_finished(&self) -> bool {
99        self.elapsed >= self.duration
100    }
101
102    /// Redirects the animation toward a new target, smoothly: the current
103    /// [`value`](Self::value) becomes the new start, elapsed time resets to zero, and `target`
104    /// becomes the new end. `duration`/`easing` are unchanged.
105    ///
106    /// Calling this repeatedly (e.g. once every time a pointer re-enters or leaves a hover
107    /// rect, faster than any single fade finishes) never causes a visible snap to some earlier
108    /// value: each retarget starts from wherever the animation actually is *right now*, not from
109    /// its original `from`.
110    pub fn retarget(&mut self, target: f32) {
111        self.from = self.value();
112        self.to = target;
113        self.elapsed = Duration::ZERO;
114    }
115}
116
117#[cfg(test)]
118#[allow(clippy::float_cmp)] // exact float equality is intentional throughout: every value
119// under test here is produced by simple, exactly-representable arithmetic (0.0, 1.0, halves),
120// not an accumulated or transcendental result where an epsilon comparison would be appropriate.
121mod tests {
122    use super::*;
123
124    #[test]
125    fn starts_at_from_and_ends_at_to() {
126        let mut tween = Tween::new(10.0, 20.0).duration(Duration::from_millis(100));
127        assert_eq!(tween.value(), 10.0);
128        assert!(!tween.is_finished());
129
130        tween.update(Duration::from_millis(100));
131        assert_eq!(tween.value(), 20.0);
132        assert!(tween.is_finished());
133    }
134
135    #[test]
136    fn update_past_duration_clamps_instead_of_overshooting() {
137        let mut tween = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
138        tween.update(Duration::from_millis(500)); // way more than the duration
139        assert_eq!(tween.value(), 1.0);
140        assert!(tween.is_finished());
141
142        tween.update(Duration::from_millis(500)); // finished tweens stay finished
143        assert!(tween.is_finished());
144        assert_eq!(tween.value(), 1.0);
145    }
146
147    #[test]
148    fn easing_reshapes_the_midpoint() {
149        let mut linear = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
150        let mut eased = Tween::new(0.0, 1.0)
151            .duration(Duration::from_millis(100))
152            .easing(Easing::EaseInQuad);
153
154        linear.update(Duration::from_millis(50));
155        eased.update(Duration::from_millis(50));
156
157        assert_eq!(linear.value(), 0.5);
158        assert!(eased.value() < linear.value()); // EaseInQuad front-loads less motion
159    }
160
161    #[test]
162    fn zero_duration_finishes_immediately() {
163        let tween = Tween::new(0.0, 5.0).duration(Duration::ZERO);
164        assert!(tween.is_finished());
165        assert_eq!(tween.value(), 5.0);
166    }
167
168    #[test]
169    fn retarget_starts_from_the_current_value_not_the_original_from() {
170        let mut tween = Tween::new(0.0, 10.0).duration(Duration::from_millis(100));
171        tween.update(Duration::from_millis(50)); // halfway: value() == 5.0
172        assert_eq!(tween.value(), 5.0);
173
174        tween.retarget(20.0);
175        // No snap: retargeting mid-flight starts from wherever the tween already was.
176        assert_eq!(tween.value(), 5.0);
177        assert!(!tween.is_finished());
178
179        tween.update(Duration::from_millis(100));
180        assert_eq!(tween.value(), 20.0);
181    }
182
183    #[test]
184    fn repeated_retargets_never_snap() {
185        let mut tween = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
186        tween.update(Duration::from_millis(30));
187        let before = tween.value();
188        tween.retarget(0.0);
189        assert_eq!(tween.value(), before);
190
191        tween.update(Duration::from_millis(10));
192        let before = tween.value();
193        tween.retarget(1.0);
194        assert_eq!(tween.value(), before);
195    }
196}