Skip to main content

retroglyph_ui/animate/
easing.rs

1/// A normalized easing curve: reshapes a linear progress fraction (`0.0..=1.0`) into an eased
2/// one, the same named curves as CSS transitions and <https://easings.net>.
3///
4/// [`Linear`](Self::Linear) is the default. The `In` variants start slow, `Out` variants end
5/// slow, and `InOut` variants do both (matching the usual naming convention: "In" describes the
6/// *start* of the motion, not a direction).
7///
8/// [`EaseOutElastic`](Self::EaseOutElastic) and [`EaseOutBounce`](Self::EaseOutBounce) are the
9/// only curves in their families: both are used for a settle/overshoot effect at the *end* of a
10/// motion, and the in/in-out variants (the same shape mirrored to the start) are uncommon enough
11/// in practice that this curated set omits them.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13#[non_exhaustive]
14pub enum Easing {
15    /// Constant speed: `t` unchanged.
16    ///
17    /// ```text
18    /// 1 |           ,-'
19    ///   |        ,-'
20    ///   |     ,-'
21    ///   |  ,-'
22    /// 0 +-'------------
23    ///   0             1
24    /// ```
25    #[default]
26    Linear,
27    /// Starts slow, accelerates, quadratically.
28    ///
29    /// ```text
30    /// 1 |                 ,'
31    ///   |               ,'
32    ///   |             ,'
33    ///   |       __,-''
34    /// 0 +------''-------
35    ///   0             1
36    /// ```
37    EaseInQuad,
38    /// Starts fast, decelerates, quadratically.
39    ///
40    /// ```text
41    /// 1 |       __,,-------
42    ///   |    ,-'
43    ///   |   ,'
44    ///   | ,'
45    /// 0 +'---------------
46    ///   0             1
47    /// ```
48    EaseOutQuad,
49    /// Slow -> fast -> slow, quadratically.
50    ///
51    /// ```text
52    /// 1 |             ___,,--
53    ///   |          ,-'
54    ///   |        ,'
55    ///   |   __,-'
56    /// 0 +-''-------------
57    ///   0             1
58    /// ```
59    EaseInOutQuad,
60    /// Starts slow, accelerates, cubically: a stronger version of [`EaseInQuad`](Self::EaseInQuad).
61    ///
62    /// ```text
63    /// 1 |                  ,'
64    ///   |                 ,
65    ///   |               ,'
66    ///   |        ____,-'
67    /// 0 +-------''--------
68    ///   0             1
69    /// ```
70    EaseInCubic,
71    /// Starts fast, decelerates, cubically: a stronger version of [`EaseOutQuad`](Self::EaseOutQuad).
72    ///
73    /// ```text
74    /// 1 |   __,,-----------
75    ///   | ,'
76    ///   |,
77    ///   |'
78    /// 0 +-----------------
79    ///   0             1
80    /// ```
81    EaseOutCubic,
82    /// Slow -> fast -> slow, cubically: a stronger version of [`EaseInOutQuad`](Self::EaseInOutQuad).
83    ///
84    /// ```text
85    /// 1 |              ___,---
86    ///   |            ,'
87    ///   |           ,
88    ///   |       __,'
89    /// 0 +-----''----------
90    ///   0              1
91    /// ```
92    EaseInOutCubic,
93    /// A gentle sine-shaped start.
94    ///
95    /// ```text
96    /// 1 |               ,--'
97    ///   |            ,-'
98    ///   |         ,-'
99    ///   |     ,--'
100    /// 0 +--''-------------
101    ///   0              1
102    /// ```
103    EaseInSine,
104    /// A gentle sine-shaped end.
105    ///
106    /// ```text
107    /// 1 |    ,--''''''''''''
108    ///   |  ,'
109    ///   | ,'
110    ///   |,'
111    /// 0 +-----------------
112    ///   0              1
113    /// ```
114    EaseOutSine,
115    /// A gentle sine-shaped start and end.
116    ///
117    /// ```text
118    /// 1 |           ____,----
119    ///   |         ,'
120    ///   |       ,'
121    ///   |   __,'
122    /// 0 +--''--------------
123    ///   0              1
124    /// ```
125    EaseInOutSine,
126    /// Springs past the target and oscillates back before settling, going outside `0.0..=1.0`
127    /// for part of the curve.
128    ///
129    /// ```text
130    ///     ,-.
131    /// 1 -+   \      ______________
132    ///     |    \    /
133    ///     |     `--'
134    /// 0 -+
135    ///     0                     1
136    /// ```
137    EaseOutElastic,
138    /// Bounces (like a dropped ball) to a stop at the target.
139    ///
140    /// ```text
141    /// 1 |        __       _   _.
142    ///   |       /  \     / |_/ |
143    ///   |      /    \   /      |
144    ///   |     /      \_/       |
145    /// 0 +----'                 |
146    ///   0                    1
147    /// ```
148    EaseOutBounce,
149}
150
151impl Easing {
152    /// Applies this curve to `t` (clamped to `0.0..=1.0` first), returning the eased fraction.
153    #[must_use]
154    pub fn apply(self, t: f32) -> f32 {
155        let t = t.clamp(0.0, 1.0);
156        match self {
157            Self::Linear => t,
158            Self::EaseInQuad => t * t,
159            Self::EaseOutQuad => retroglyph_core::math::mul_add(t, -t, 2.0 * t),
160            Self::EaseInOutQuad => {
161                if t < 0.5 {
162                    2.0 * t * t
163                } else {
164                    let u = retroglyph_core::math::mul_add(-2.0, t, 2.0);
165                    1.0 - u * u / 2.0
166                }
167            }
168            Self::EaseInCubic => t * t * t,
169            Self::EaseOutCubic => {
170                let u = 1.0 - t;
171                retroglyph_core::math::mul_add(u * u, -u, 1.0)
172            }
173            Self::EaseInOutCubic => {
174                if t < 0.5 {
175                    4.0 * t * t * t
176                } else {
177                    let u = retroglyph_core::math::mul_add(-2.0, t, 2.0);
178                    1.0 - u * u * u / 2.0
179                }
180            }
181            Self::EaseInSine => 1.0 - retroglyph_core::math::cos(t * core::f32::consts::FRAC_PI_2),
182            Self::EaseOutSine => retroglyph_core::math::sin(t * core::f32::consts::FRAC_PI_2),
183            Self::EaseInOutSine => {
184                -(retroglyph_core::math::cos(core::f32::consts::PI * t) - 1.0) / 2.0
185            }
186            Self::EaseOutElastic => ease_out_elastic(t),
187            Self::EaseOutBounce => ease_out_bounce(t),
188        }
189    }
190}
191
192/// `2^(-10t)` scaling the sine of `(10*t - 0.75) * (2*pi/3)`, plus 1: an exponentially
193/// decaying oscillation that overshoots 1 and rings back down to settle exactly on it. See
194/// <https://easings.net/#easeOutElastic>.
195fn ease_out_elastic(t: f32) -> f32 {
196    const C4: f32 = 2.0 * core::f32::consts::PI / 3.0;
197
198    if t <= 0.0 {
199        return 0.0;
200    }
201    if t >= 1.0 {
202        return 1.0;
203    }
204    retroglyph_core::math::mul_add(
205        retroglyph_core::math::powf(2.0, -10.0 * t),
206        retroglyph_core::math::sin(retroglyph_core::math::mul_add(10.0, t, -0.75) * C4),
207        1.0,
208    )
209}
210
211/// Four piecewise quadratic segments, each bouncing to a smaller peak: see
212/// <https://easings.net/#easeOutBounce>.
213fn ease_out_bounce(t: f32) -> f32 {
214    // Standard easings.net bounce coefficients: D1 partitions t into four arcs (boundaries at
215    // 1/D1, 2/D1, 2.5/D1) and N1 = 121/16 fixes their shared steepness so each arc lands exactly
216    // on the next segment's floor. They are a matched set; changing one alone breaks continuity.
217    const N1: f32 = 7.5625;
218    const D1: f32 = 2.75;
219    if t < 1.0 / D1 {
220        N1 * t * t
221    } else if t < 2.0 / D1 {
222        let t = t - 1.5 / D1;
223        retroglyph_core::math::mul_add(N1 * t, t, 0.75)
224    } else if t < 2.5 / D1 {
225        let t = t - 2.25 / D1;
226        retroglyph_core::math::mul_add(N1 * t, t, 0.9375)
227    } else {
228        let t = t - 2.625 / D1;
229        retroglyph_core::math::mul_add(N1 * t, t, 0.984_375)
230    }
231}
232
233#[cfg(test)]
234#[allow(clippy::float_cmp)] // exact float equality is intentional throughout: every value
235// under test here is produced by simple, exactly-representable arithmetic (0.0, 1.0, halves),
236// not an accumulated or transcendental result where an epsilon comparison would be appropriate.
237mod tests {
238    use super::*;
239
240    #[test]
241    fn linear_is_identity() {
242        assert_eq!(Easing::Linear.apply(0.0), 0.0);
243        assert_eq!(Easing::Linear.apply(0.5), 0.5);
244        assert_eq!(Easing::Linear.apply(1.0), 1.0);
245    }
246
247    #[test]
248    fn every_curve_starts_at_0_and_ends_at_1() {
249        for easing in [
250            Easing::Linear,
251            Easing::EaseInQuad,
252            Easing::EaseOutQuad,
253            Easing::EaseInOutQuad,
254            Easing::EaseInCubic,
255            Easing::EaseOutCubic,
256            Easing::EaseInOutCubic,
257            Easing::EaseInSine,
258            Easing::EaseOutSine,
259            Easing::EaseInOutSine,
260            Easing::EaseOutElastic,
261            Easing::EaseOutBounce,
262        ] {
263            assert!(
264                (easing.apply(0.0) - 0.0).abs() < 1e-5,
265                "{easing:?} should start at 0"
266            );
267            assert!(
268                (easing.apply(1.0) - 1.0).abs() < 1e-5,
269                "{easing:?} should end at 1"
270            );
271        }
272    }
273
274    #[test]
275    fn ease_in_quad_starts_slower_than_linear() {
276        // "In" curves front-load less motion than linear during the first half.
277        assert!(Easing::EaseInQuad.apply(0.25) < 0.25);
278    }
279
280    #[test]
281    fn ease_out_quad_starts_faster_than_linear() {
282        assert!(Easing::EaseOutQuad.apply(0.25) > 0.25);
283    }
284
285    #[test]
286    fn out_of_range_input_is_clamped() {
287        assert_eq!(Easing::Linear.apply(-1.0), 0.0);
288        assert_eq!(Easing::Linear.apply(2.0), 1.0);
289    }
290
291    #[test]
292    fn ease_out_bounce_covers_its_middle_two_segments() {
293        // `every_curve_starts_at_0_and_ends_at_1` only samples t=0.0/t=1.0, which land in
294        // `ease_out_bounce`'s first and last piecewise segments (boundaries at 1/D1 ~= 0.364,
295        // 2/D1 ~= 0.727, 2.5/D1 ~= 0.909); these two values land in the second and third
296        // segments instead, so their `retroglyph_core::math::mul_add` calls get exercised too.
297        assert!((Easing::EaseOutBounce.apply(0.5) - 0.765_625).abs() < 1e-5);
298        assert!((Easing::EaseOutBounce.apply(0.8) - 0.94).abs() < 1e-5);
299    }
300
301    #[test]
302    #[allow(clippy::cast_precision_loss)] // i in 0..100 is always exactly representable in f32
303    fn elastic_overshoots_past_the_target() {
304        // The defining feature of an elastic curve: some t produces a value outside 0..=1.
305        let overshoots = (0..100)
306            .map(|i| Easing::EaseOutElastic.apply(i as f32 / 100.0))
307            .any(|v| !(0.0..=1.0).contains(&v));
308        assert!(overshoots);
309    }
310}