1use gpui_kit_theme::Theme;
7use gpui_kit_tokens::MotionEasing;
8
9#[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 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#[derive(Debug, Clone, Copy, PartialEq, Default)]
92pub enum Easing {
93 Linear,
94 #[default]
96 Standard,
97 EaseIn,
98 EaseOut,
99 EaseInOut,
100 Emphasized,
102 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}