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