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}