Skip to main content

retroglyph_ui/animate/
tween.rs

1use super::easing::Easing;
2use core::time::Duration;
3
4/// A retargetable animation from one `f32` value to another over a fixed duration, reshaped by
5/// an [`Easing`](crate::animate::Easing) curve.
6///
7/// See the `08_animation` example for `Tween` in action:
8/// <https://main.retroglyph.dev/examples/08_animation/terminal/>.
9///
10/// ```
11/// use core::time::Duration;
12/// use retroglyph_ui::{Easing, Tween};
13///
14/// let mut fade = Tween::new(0.0, 1.0)
15///     .duration(Duration::from_millis(200))
16///     .easing(Easing::EaseOutCubic);
17///
18/// fade.update(Duration::from_millis(100)); // halfway through, by elapsed time
19/// assert!(fade.value() > 0.5); // EaseOutCubic front-loads motion, so it's already past halfway
20/// assert!(!fade.is_finished());
21///
22/// fade.update(Duration::from_millis(100)); // now fully elapsed
23/// assert_eq!(fade.value(), 1.0);
24/// assert!(fade.is_finished());
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Tween {
28    from: f32,
29    to: f32,
30    elapsed: Duration,
31    duration: Duration,
32    easing: Easing,
33}
34
35impl Tween {
36    /// [`duration`](Self::duration)'s default if never overridden: 200ms.
37    ///
38    /// 200ms is the usual "fast UI transition" window: long enough to register as motion rather than a
39    /// snap, short enough that it does not feel sluggish on a hover/focus change. It is a default for
40    /// convenience, not a constraint; any call that cares sets [`duration`](Self::duration) explicitly.
41    pub const DEFAULT_DURATION: Duration = Duration::from_millis(200);
42
43    /// A new tween animating from `from` to `to` over [`DEFAULT_DURATION`](Self::DEFAULT_DURATION)
44    /// with [`Easing::Linear`](crate::animate::Easing::Linear). Chain [`duration`](Self::duration)/[`easing`](Self::easing) to
45    /// override either, then call [`update`](Self::update) once per frame.
46    #[must_use]
47    pub const fn new(from: f32, to: f32) -> Self {
48        Self {
49            from,
50            to,
51            elapsed: Duration::ZERO,
52            duration: Self::DEFAULT_DURATION,
53            easing: Easing::Linear,
54        }
55    }
56
57    /// Overrides the total duration of the animation.
58    #[must_use]
59    pub const fn duration(mut self, duration: Duration) -> Self {
60        self.duration = duration;
61        self
62    }
63
64    /// Overrides the easing curve.
65    #[must_use]
66    pub const fn easing(mut self, easing: Easing) -> Self {
67        self.easing = easing;
68        self
69    }
70
71    /// Advances the animation by `dt`: call once per frame with
72    /// [`Frame::delta`](retroglyph_core::app::Frame::delta). Clamped to `duration`: calling this after the
73    /// animation has already finished is a no-op, not an overshoot into negative "time left."
74    pub fn update(&mut self, dt: Duration) {
75        self.elapsed = (self.elapsed + dt).min(self.duration);
76    }
77
78    /// Linear progress through the animation: `0.0` at the start, `1.0` once
79    /// [`is_finished`](Self::is_finished). Doesn't have the easing curve applied yet; see
80    /// [`value`](Self::value) for that.
81    #[must_use]
82    pub fn progress(&self) -> f32 {
83        if self.duration.is_zero() {
84            return 1.0;
85        }
86        self.elapsed.as_secs_f32() / self.duration.as_secs_f32()
87    }
88
89    /// The current animated value: [`progress`](Self::progress) run through this tween's
90    /// [`Easing`](crate::animate::Easing) curve, then used to interpolate between `from` and `to`.
91    #[must_use]
92    pub fn value(&self) -> f32 {
93        let t = self.easing.apply(self.progress());
94        retroglyph_core::math::mul_add(self.to - self.from, t, self.from)
95    }
96
97    /// `true` once [`update`](Self::update) has accumulated at least `duration` of elapsed time.
98    #[must_use]
99    pub fn is_finished(&self) -> bool {
100        self.elapsed >= self.duration
101    }
102
103    /// The value this tween is animating toward: what [`value`](Self::value) equals once
104    /// [`is_finished`](Self::is_finished), and what [`retarget`](Self::retarget) last set it to.
105    #[must_use]
106    pub const fn target(&self) -> f32 {
107        self.to
108    }
109
110    /// The value this tween started animating from: either the `from` passed to [`new`](Self::new)
111    /// or, after a [`retarget`](Self::retarget), the [`value`](Self::value) at the moment of that
112    /// retarget.
113    #[must_use]
114    pub const fn origin(&self) -> f32 {
115        self.from
116    }
117
118    /// Redirects the animation toward a new target, smoothly: the current
119    /// [`value`](Self::value) becomes the new start, elapsed time resets to zero, and `target`
120    /// becomes the new end. `duration`/`easing` are unchanged.
121    ///
122    /// Calling this repeatedly (e.g. once every time a pointer re-enters or leaves a hover
123    /// rect, faster than any single fade finishes) never causes a visible snap to some earlier
124    /// value: each retarget starts from wherever the animation actually is *right now*, not from
125    /// its original `from`.
126    pub fn retarget(&mut self, target: f32) {
127        self.from = self.value();
128        self.to = target;
129        self.elapsed = Duration::ZERO;
130    }
131}
132
133#[cfg(test)]
134#[allow(clippy::float_cmp)] // exact float equality is intentional throughout: every value
135// under test here is produced by simple, exactly-representable arithmetic (0.0, 1.0, halves),
136// not an accumulated or transcendental result where an epsilon comparison would be appropriate.
137mod tests {
138    use super::*;
139
140    #[test]
141    fn starts_at_from_and_ends_at_to() {
142        let mut tween = Tween::new(10.0, 20.0).duration(Duration::from_millis(100));
143        assert_eq!(tween.value(), 10.0);
144        assert!(!tween.is_finished());
145
146        tween.update(Duration::from_millis(100));
147        assert_eq!(tween.value(), 20.0);
148        assert!(tween.is_finished());
149    }
150
151    #[test]
152    fn update_past_duration_clamps_instead_of_overshooting() {
153        let mut tween = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
154        tween.update(Duration::from_millis(500)); // way more than the duration
155        assert_eq!(tween.value(), 1.0);
156        assert!(tween.is_finished());
157
158        tween.update(Duration::from_millis(500)); // finished tweens stay finished
159        assert!(tween.is_finished());
160        assert_eq!(tween.value(), 1.0);
161    }
162
163    #[test]
164    fn easing_reshapes_the_midpoint() {
165        let mut linear = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
166        let mut eased = Tween::new(0.0, 1.0)
167            .duration(Duration::from_millis(100))
168            .easing(Easing::EaseInQuad);
169
170        linear.update(Duration::from_millis(50));
171        eased.update(Duration::from_millis(50));
172
173        assert_eq!(linear.value(), 0.5);
174        assert!(eased.value() < linear.value()); // EaseInQuad front-loads less motion
175    }
176
177    #[test]
178    fn zero_duration_finishes_immediately() {
179        let tween = Tween::new(0.0, 5.0).duration(Duration::ZERO);
180        assert!(tween.is_finished());
181        assert_eq!(tween.value(), 5.0);
182    }
183
184    #[test]
185    fn retarget_starts_from_the_current_value_not_the_original_from() {
186        let mut tween = Tween::new(0.0, 10.0).duration(Duration::from_millis(100));
187        tween.update(Duration::from_millis(50)); // halfway: value() == 5.0
188        assert_eq!(tween.value(), 5.0);
189
190        tween.retarget(20.0);
191        // No snap: retargeting mid-flight starts from wherever the tween already was.
192        assert_eq!(tween.value(), 5.0);
193        assert!(!tween.is_finished());
194
195        tween.update(Duration::from_millis(100));
196        assert_eq!(tween.value(), 20.0);
197    }
198
199    #[test]
200    fn target_and_origin_report_to_and_from() {
201        let tween = Tween::new(10.0, 20.0).duration(Duration::from_millis(100));
202        assert_eq!(tween.origin(), 10.0);
203        assert_eq!(tween.target(), 20.0);
204    }
205
206    #[test]
207    fn retarget_updates_target_and_origin_to_where_the_tween_actually_is() {
208        let mut tween = Tween::new(0.0, 10.0).duration(Duration::from_millis(100));
209        tween.update(Duration::from_millis(50)); // halfway: value() == 5.0
210
211        tween.retarget(20.0);
212        // `origin` becomes wherever the tween actually was, not the original `from`.
213        assert_eq!(tween.origin(), 5.0);
214        assert_eq!(tween.target(), 20.0);
215    }
216
217    #[test]
218    #[allow(clippy::cast_precision_loss)] // i in 0..100 is always exactly representable in f32
219    fn retarget_from_an_elastic_overshoot_still_finishes_at_the_new_target() {
220        const DURATION: Duration = Duration::from_millis(100);
221
222        let mut tween = Tween::new(0.0, 1.0)
223            .duration(DURATION)
224            .easing(Easing::EaseOutElastic);
225
226        // Find a millisecond offset that lands mid-overshoot, the same sampling approach
227        // `elastic_overshoots_past_the_target` (easing.rs) uses to prove the curve overshoots at
228        // all: this test additionally needs the *specific* offset, to retarget from it.
229        let overshoot_millis = (0..100)
230            .find(|&i| !(0.0..=1.0).contains(&Easing::EaseOutElastic.apply(i as f32 / 100.0)))
231            .expect("EaseOutElastic should overshoot somewhere in its first 100 samples");
232        tween.update(Duration::from_millis(overshoot_millis));
233        let overshot = tween.value();
234        assert!(
235            !(0.0..=1.0).contains(&overshot),
236            "expected an overshot value, got {overshot}"
237        );
238
239        tween.retarget(5.0);
240        // No snap: retargeting starts from the overshot value, even though it's outside the
241        // original 0.0..=1.0 endpoints.
242        assert_eq!(tween.value(), overshot);
243
244        tween.update(DURATION);
245        // Regardless of how far from the endpoints the retarget started, a full duration later
246        // the tween has converged exactly on the new target.
247        assert_eq!(tween.value(), 5.0);
248        assert!(tween.is_finished());
249    }
250
251    #[test]
252    fn repeated_retargets_never_snap() {
253        let mut tween = Tween::new(0.0, 1.0).duration(Duration::from_millis(100));
254        tween.update(Duration::from_millis(30));
255        let before = tween.value();
256        tween.retarget(0.0);
257        assert_eq!(tween.value(), before);
258
259        tween.update(Duration::from_millis(10));
260        let before = tween.value();
261        tween.retarget(1.0);
262        assert_eq!(tween.value(), before);
263    }
264}