Skip to main content

gpui_base/
animation.rs

1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4    Animation, AnimationExt, ElementId, Hsla, IntoElement, Pixels, Point, Styled, point,
5    prelude::FluentBuilder, px,
6};
7use smallvec::SmallVec;
8
9/// A cubic bezier function like CSS `cubic-bezier`.
10///
11/// Builder:
12///
13/// https://cubic-bezier.com
14pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
15    // Keep the [0, 1] output this function has always had for
16    // `Animation::with_easing` callers; clamp away solver and rounding error.
17    let ease = unclamped_cubic_bezier(x1, y1, x2, y2);
18    move |t: f32| ease(t).clamp(0.0, 1.0)
19}
20
21/// [`cubic_bezier`] without the output clamp, so y control points outside
22/// `0..=1` keep their anticipation and overshoot. Input progress stays clamped.
23pub(crate) fn unclamped_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
24    // Polynomial form of the unit bezier, where p0 = (0, 0) and p3 = (1, 1).
25    let (cx, cy) = (3.0 * x1, 3.0 * y1);
26    let (bx, by) = (3.0 * (x2 - x1) - cx, 3.0 * (y2 - y1) - cy);
27    let (ax, ay) = (1.0 - cx - bx, 1.0 - cy - by);
28    let sample_x = move |t: f32| ((ax * t + bx) * t + cx) * t;
29    let sample_y = move |t: f32| ((ay * t + by) * t + cy) * t;
30    let slope_x = move |t: f32| (3.0 * ax * t + 2.0 * bx) * t + cx;
31
32    // Solve `x(s) = t` for the curve parameter `s`.
33    let solve_s = move |t: f32| {
34        let mut s = t;
35        for _ in 0..8 {
36            let error = sample_x(s) - t;
37            if error.abs() < 1e-6 {
38                return s;
39            }
40            let slope = slope_x(s);
41            if slope.abs() < 1e-6 {
42                break;
43            }
44            s = (s - error / slope).clamp(0.0, 1.0);
45        }
46
47        let (mut low, mut high) = (0.0, 1.0);
48        let mut s = t;
49        for _ in 0..32 {
50            let x = sample_x(s);
51            if (x - t).abs() < 1e-6 {
52                break;
53            }
54            if x < t {
55                low = s;
56            } else {
57                high = s;
58            }
59            s = (low + high) / 2.0;
60        }
61        s
62    };
63
64    move |t: f32| {
65        let t = t.clamp(0.0, 1.0);
66        // `t` is elapsed progress along x, not the curve parameter: solve
67        // `x(s) = t` before sampling y, otherwise the curve reads much slower
68        // than the same control points do in CSS.
69        sample_y(solve_s(t))
70    }
71}
72
73// ── Easing presets ──────────────────────────────────────────────────────────
74
75/// Cubic ease-out — fast start, slow end. Good for enter animations.
76pub fn ease_out_cubic(t: f32) -> f32 {
77    let t = t.clamp(0.0, 1.0);
78    1.0 - (1.0 - t).powi(3)
79}
80
81/// Cubic ease-in — slow start, fast end. Good for exit animations.
82pub fn ease_in_cubic(t: f32) -> f32 {
83    let t = t.clamp(0.0, 1.0);
84    t * t * t
85}
86
87/// Cubic ease-in-out — slow start and end. Good for position transitions.
88pub fn ease_in_out_cubic(t: f32) -> f32 {
89    let t = t.clamp(0.0, 1.0);
90    if t < 0.5 {
91        4.0 * t * t * t
92    } else {
93        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
94    }
95}
96
97// ── Lerp trait ──────────────────────────────────────────────────────────────
98
99/// Trait for types that support linear interpolation.
100pub trait Lerp: Clone {
101    fn lerp(&self, target: &Self, t: f32) -> Self;
102}
103
104impl Lerp for f32 {
105    fn lerp(&self, target: &Self, t: f32) -> Self {
106        self + (target - self) * t
107    }
108}
109
110impl Lerp for Pixels {
111    fn lerp(&self, target: &Self, t: f32) -> Self {
112        let a: f32 = (*self).into();
113        let b: f32 = (*target).into();
114        px(a + (b - a) * t)
115    }
116}
117
118impl Lerp for Point<Pixels> {
119    fn lerp(&self, target: &Self, t: f32) -> Self {
120        point(
121            Lerp::lerp(&self.x, &target.x, t),
122            Lerp::lerp(&self.y, &target.y, t),
123        )
124    }
125}
126
127impl Lerp for Hsla {
128    /// Interpolate each channel linearly. Intended for transitions between
129    /// near-grayscale UI colors (e.g. text colors), where hue interpolation is
130    /// irrelevant.
131    fn lerp(&self, target: &Self, t: f32) -> Self {
132        Hsla {
133            h: self.h.lerp(&target.h, t),
134            s: self.s.lerp(&target.s, t),
135            l: self.l.lerp(&target.l, t),
136            a: self.a.lerp(&target.a, t),
137        }
138    }
139}
140
141// ── Transition combinator ───────────────────────────────────────────────────
142
143/// A composable transition that applies concrete fade, slide, and size effects
144/// to an element.
145///
146/// This is distinct from [`crate::motion::Transition`], which is a timing
147/// policy for a caller-chosen value and never picks a visual property. Prefer
148/// `motion` for new code.
149///
150/// # Example
151///
152/// ```ignore
153/// EffectTransition::new(Duration::from_millis(150))
154///     .ease(ease_out_cubic)
155///     .slide_y(px(-4.), px(0.))
156///     .fade(0.0, 1.0)
157///     .apply(element, "enter-anim")
158/// ```
159#[derive(Clone)]
160pub struct EffectTransition {
161    pub duration: Duration,
162    easing: Rc<dyn Fn(f32) -> f32>,
163    effects: SmallVec<[TransitionEffect; 2]>,
164}
165
166#[derive(Clone, Copy)]
167enum TransitionEffect {
168    SlideY(Pixels, Pixels),
169    SlideX(Pixels, Pixels),
170    Fade(f32, f32),
171    Width(Pixels, Pixels),
172    Height(Pixels, Pixels),
173}
174
175impl EffectTransition {
176    pub fn new(duration: Duration) -> Self {
177        Self {
178            duration,
179            easing: Rc::new(ease_out_cubic),
180            effects: SmallVec::new(),
181        }
182    }
183
184    /// Set the easing function.
185    pub fn ease(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
186        self.easing = Rc::new(easing);
187        self
188    }
189
190    /// Animate vertical offset from `from` to `to`.
191    pub fn slide_y(mut self, from: Pixels, to: Pixels) -> Self {
192        self.effects.push(TransitionEffect::SlideY(from, to));
193        self
194    }
195
196    /// Animate horizontal offset from `from` to `to`.
197    pub fn slide_x(mut self, from: Pixels, to: Pixels) -> Self {
198        self.effects.push(TransitionEffect::SlideX(from, to));
199        self
200    }
201
202    /// Animate opacity from `from` to `to`.
203    pub fn fade(mut self, from: f32, to: f32) -> Self {
204        self.effects.push(TransitionEffect::Fade(from, to));
205        self
206    }
207
208    /// Animate width from `from` to `to`.
209    pub fn width(mut self, from: Pixels, to: Pixels) -> Self {
210        self.effects.push(TransitionEffect::Width(from, to));
211        self
212    }
213
214    /// Animate height from `from` to `to`.
215    pub fn height(mut self, from: Pixels, to: Pixels) -> Self {
216        self.effects.push(TransitionEffect::Height(from, to));
217        self
218    }
219
220    /// Apply this transition to a Styled element, returning an AnimationElement.
221    pub fn apply<E: IntoElement + Styled + 'static>(
222        self,
223        element: E,
224        id: impl Into<ElementId>,
225    ) -> gpui::AnimationElement<E> {
226        let animation = Animation::new(self.duration).with_easing({
227            let easing = self.easing.clone();
228            move |t| easing(t)
229        });
230        let effects = self.effects;
231        element.with_animation(id, animation, move |el, delta| {
232            let mut el = el;
233            for effect in &effects {
234                match effect {
235                    TransitionEffect::SlideY(from, to) => {
236                        el = el.top(Lerp::lerp(from, to, delta));
237                    }
238                    TransitionEffect::SlideX(from, to) => {
239                        el = el.left(Lerp::lerp(from, to, delta));
240                    }
241                    TransitionEffect::Fade(from, to) => {
242                        el = el.opacity(Lerp::lerp(from, to, delta));
243                    }
244                    TransitionEffect::Width(from, to) => {
245                        el = el.w(Lerp::lerp(from, to, delta));
246                    }
247                    TransitionEffect::Height(from, to) => {
248                        el = el.h(Lerp::lerp(from, to, delta));
249                    }
250                }
251            }
252            el
253        })
254    }
255}
256
257impl FluentBuilder for EffectTransition {}
258
259/// Former name of [`EffectTransition`].
260///
261/// Renamed because `motion::Transition` and this type were two different
262/// concepts sharing one name.
263#[deprecated(since = "0.5.2", note = "renamed to `EffectTransition`")]
264pub type Transition = EffectTransition;
265
266#[cfg(test)]
267mod tests {
268    use super::cubic_bezier;
269
270    #[test]
271    fn cubic_bezier_matches_engine_published_values() {
272        // Reference values sampled from the CSS `ease` curve.
273        let ease = cubic_bezier(0.25, 0.1, 0.25, 1.);
274        for (t, expected) in [
275            (0.0, 0.0),
276            (0.2, 0.295),
277            (0.5, 0.802),
278            (0.8, 0.976),
279            (1.0, 1.0),
280        ] {
281            assert!(
282                (ease(t) - expected).abs() < 1e-3,
283                "ease({t}) = {}, expected {expected}",
284                ease(t)
285            );
286        }
287
288        // Chromium's CubicBezier(0.25, 0, 0.75, 1) expectations, from
289        // ui/gfx/geometry/cubic_bezier_unittest.cc (epsilon 0.00015 there;
290        // widened for our f32 arithmetic).
291        let curve = cubic_bezier(0.25, 0., 0.75, 1.);
292        for (t, expected) in [
293            (0.05, 0.01136),
294            (0.1, 0.03978),
295            (0.15, 0.07978),
296            (0.2, 0.12803),
297            (0.25, 0.18235),
298            (0.3, 0.24115),
299            (0.35, 0.30323),
300            (0.4, 0.36761),
301            (0.45, 0.43345),
302            (0.5, 0.5),
303            (0.6, 0.63238),
304            (0.65, 0.69676),
305            (0.7, 0.75884),
306            (0.75, 0.81764),
307            (0.8, 0.87196),
308            (0.85, 0.92021),
309            (0.9, 0.96021),
310            (0.95, 0.98863),
311        ] {
312            assert!(
313                (curve(t) - expected).abs() < 3e-4,
314                "curve({t}) = {}, Chromium says {expected}",
315                curve(t)
316            );
317        }
318    }
319
320    #[test]
321    fn cubic_bezier_with_thirds_x_maps_time_identically() {
322        // x1 = 1/3, x2 = 2/3 collapse the x solve to the identity, making the
323        // output the plain y polynomial; Dialog relies on this to keep the
324        // trajectory it was tuned with before `cubic_bezier` solved for x.
325        let ease = cubic_bezier(1. / 3., 0.72, 2. / 3., 1.);
326        for step in 0..=100 {
327            let t = step as f32 / 100.;
328            let one_t = 1. - t;
329            let expected = 3. * 0.72 * one_t * one_t * t + 3. * one_t * t * t + t * t * t;
330            assert!(
331                (ease(t) - expected).abs() < 1e-4,
332                "ease({t}) = {}, expected {expected}",
333                ease(t)
334            );
335        }
336    }
337
338    #[test]
339    fn cubic_bezier_matches_the_css_definition() {
340        // Reference solver written straight off the CSS Easing Functions
341        // definition — solve `x(s) = t` by bisection in f64, then sample
342        // `y(s)` — independent of the production Newton solver.
343        fn css_reference(x1: f64, y1: f64, x2: f64, y2: f64, t: f64) -> f64 {
344            let sample = |p1: f64, p2: f64, s: f64| {
345                3. * p1 * (1. - s) * (1. - s) * s + 3. * p2 * (1. - s) * s * s + s * s * s
346            };
347            let (mut low, mut high) = (0f64, 1f64);
348            for _ in 0..64 {
349                let mid = (low + high) / 2.;
350                if sample(x1, x2, mid) < t {
351                    low = mid;
352                } else {
353                    high = mid;
354                }
355            }
356            sample(y1, y2, (low + high) / 2.)
357        }
358
359        let curves = [
360            // The runtime curves in this repo.
361            (0.25, 0.1, 0.25, 1.),
362            (1. / 3., 0.72, 2. / 3., 1.),
363            // CSS keyword curves.
364            (0.42, 0., 1., 1.),
365            (0., 0., 0.58, 1.),
366            (0.42, 0., 0.58, 1.),
367            (0., 0., 1., 1.),
368            // Degenerate x slopes: zero at s = 0.5, 0, and 1, forcing the
369            // Newton solve to fall back to bisection.
370            (1., 0., 0., 1.),
371            (0., 0., 0., 1.),
372            (1., 0.5, 1., 0.5),
373        ];
374        for (x1, y1, x2, y2) in curves {
375            let ease = cubic_bezier(x1, y1, x2, y2);
376            for step in 0..=1000 {
377                let t = step as f32 / 1000.;
378                let y = ease(t);
379                let expected =
380                    css_reference(x1 as f64, y1 as f64, x2 as f64, y2 as f64, t as f64) as f32;
381                assert!(
382                    (y - expected).abs() < 5e-4,
383                    "cubic_bezier({x1}, {y1}, {x2}, {y2})({t}) = {y}, CSS = {expected}"
384                );
385                // The public `cubic_bezier` keeps its output in [0, 1].
386                assert!((0.0..=1.0).contains(&y), "ease({t}) = {y} out of range");
387            }
388        }
389    }
390
391    #[test]
392    fn cubic_bezier_is_monotonic_and_clamped() {
393        let ease = cubic_bezier(0.32, 0.72, 0., 1.);
394        assert_eq!(ease(-1.), 0.);
395        assert_eq!(ease(2.), 1.);
396
397        let mut previous = 0.;
398        for step in 0..=100 {
399            let current = ease(step as f32 / 100.);
400            assert!(current >= previous - 1e-4, "not monotonic at {step}");
401            previous = current;
402        }
403    }
404}