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