Skip to main content

gpui_kit/motion/
easing.rs

1//! Named curves and the CSS-compatible evaluator behind them.
2//!
3//! The cubic-bezier evaluator is derived from Comet's MIT licensed motion
4//! catalog at `fb22e269…`; see `PROVENANCE.md`.
5
6use gpui_kit_theme::Theme;
7use gpui_kit_tokens::MotionEasing;
8
9/// A CSS-compatible cubic-bezier curve, evaluated in pure Rust so a curve can
10/// be asserted without a window.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct CubicBezier {
13    pub x1: f32,
14    pub y1: f32,
15    pub x2: f32,
16    pub y2: f32,
17}
18
19impl CubicBezier {
20    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
21        Self { x1, y1, x2, y2 }
22    }
23
24    pub const fn from_points(points: [f32; 4]) -> Self {
25        Self::new(points[0], points[1], points[2], points[3])
26    }
27
28    fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
29        let c = 3.0 * a;
30        let second = 3.0 * (b - a) - c;
31        (1.0 - c - second, second, c)
32    }
33
34    fn sample_x(self, t: f32) -> f32 {
35        let (a, b, c) = Self::coefficients(self.x1, self.x2);
36        ((a * t + b) * t + c) * t
37    }
38
39    fn sample_y(self, t: f32) -> f32 {
40        let (a, b, c) = Self::coefficients(self.y1, self.y2);
41        ((a * t + b) * t + c) * t
42    }
43
44    fn derivative(self, t: f32) -> f32 {
45        let (a, b, c) = Self::coefficients(self.x1, self.x2);
46        (3.0 * a * t + 2.0 * b) * t + c
47    }
48
49    /// Evaluates the curve at `input`.
50    ///
51    /// The result is not clamped to 0..1, because overshoot curves are
52    /// expected to exceed one before settling.
53    pub fn eval(self, input: f32) -> f32 {
54        if input <= 0.0 {
55            return 0.0;
56        }
57        if input >= 1.0 {
58            return 1.0;
59        }
60        let mut t = input;
61        let mut solved = false;
62        for _ in 0..8 {
63            let error = self.sample_x(t) - input;
64            if error.abs() < 1e-6 {
65                solved = true;
66                break;
67            }
68            let derivative = self.derivative(t);
69            if derivative.abs() < 1e-6 {
70                break;
71            }
72            t -= error / derivative;
73        }
74        if !solved {
75            let (mut low, mut high) = (0.0, 1.0);
76            for _ in 0..32 {
77                let middle = (low + high) / 2.0;
78                if self.sample_x(middle) < input {
79                    low = middle;
80                } else {
81                    high = middle;
82                }
83            }
84            t = (low + high) / 2.0;
85        }
86        self.sample_y(t)
87    }
88}
89
90/// A curve named by role rather than by control points.
91#[derive(Debug, Clone, Copy, PartialEq, Default)]
92pub enum Easing {
93    Linear,
94    /// The default for state changes that start and end on screen.
95    #[default]
96    Standard,
97    EaseIn,
98    EaseOut,
99    EaseInOut,
100    /// Fast start with a long settle, for entrances that need attention.
101    Emphasized,
102    /// Passes its target and returns; the only curve that leaves 0..1.
103    Overshoot,
104    Exit,
105    Settle,
106    Custom(CubicBezier),
107}
108
109impl Easing {
110    pub fn curve(self, theme: &Theme) -> CubicBezier {
111        let named = |easing: MotionEasing| CubicBezier::from_points(theme.easing(easing));
112        match self {
113            Self::Linear => named(MotionEasing::Linear),
114            Self::Standard => named(MotionEasing::Standard),
115            Self::EaseIn => named(MotionEasing::EaseIn),
116            Self::EaseOut => named(MotionEasing::EaseOut),
117            Self::EaseInOut => named(MotionEasing::EaseInOut),
118            Self::Emphasized => named(MotionEasing::Emphasized),
119            Self::Overshoot => named(MotionEasing::Overshoot),
120            Self::Exit => named(MotionEasing::Exit),
121            Self::Settle => named(MotionEasing::Settle),
122            Self::Custom(curve) => curve,
123        }
124    }
125}
126
127impl From<CubicBezier> for Easing {
128    fn from(curve: CubicBezier) -> Self {
129        Self::Custom(curve)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn bezier_has_exact_endpoints() {
139        let curve = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
140        assert_eq!(curve.eval(0.0), 0.0);
141        assert_eq!(curve.eval(1.0), 1.0);
142    }
143
144    #[test]
145    fn a_linear_curve_is_the_identity() {
146        let curve = CubicBezier::new(0.0, 0.0, 1.0, 1.0);
147        for step in 0..=20 {
148            let input = step as f32 / 20.0;
149            assert!((curve.eval(input) - input).abs() < 1e-3);
150        }
151    }
152
153    #[test]
154    fn an_overshoot_curve_passes_its_target_before_returning() {
155        let curve = CubicBezier::new(0.34, 1.56, 0.64, 1.0);
156        let peak = (0..=100)
157            .map(|step| curve.eval(step as f32 / 100.0))
158            .fold(f32::MIN, f32::max);
159        assert!(
160            peak > 1.0,
161            "overshoot must exceed its target, peaked at {peak}"
162        );
163        assert_eq!(curve.eval(1.0), 1.0);
164    }
165
166    #[test]
167    fn every_named_curve_resolves_from_the_theme() {
168        let theme = Theme::studio_dark();
169        for easing in [
170            Easing::Linear,
171            Easing::Standard,
172            Easing::EaseIn,
173            Easing::EaseOut,
174            Easing::EaseInOut,
175            Easing::Emphasized,
176            Easing::Overshoot,
177            Easing::Exit,
178            Easing::Settle,
179        ] {
180            let curve = easing.curve(&theme);
181            assert_eq!(curve.eval(0.0), 0.0);
182            assert_eq!(curve.eval(1.0), 1.0);
183        }
184    }
185}