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}