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}