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