Skip to main content

gpui_kit/motion/
spec.rs

1//! Duration, delay and curve bundled as one reusable specification.
2
3use std::time::Duration;
4
5use gpui::{Animation, AnimationElement, AnimationExt, ElementId, IntoElement, Styled, px};
6use gpui_kit_theme::{SpringPreset, Theme};
7
8use super::Spring;
9use super::easing::{CubicBezier, Easing};
10
11/// A curve with a duration and a delay: everything one animation needs, taken
12/// from the token document rather than written beside the element.
13///
14/// A specification can carry a spring instead of a curve. Its duration is then
15/// the spring's settle time, so everything that already knows how to run a
16/// specification — [`Transition`](super::Transition),
17/// [`Presence`](super::Presence), [`Stagger`](super::Stagger) and GPUI's
18/// `with_animation` — runs a spring without knowing it is one.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct MotionSpec {
21    pub duration_ms: u64,
22    pub delay_ms: u64,
23    pub curve: CubicBezier,
24    /// Set when the specification is sprung; the curve is then unused.
25    spring: Option<Spring>,
26}
27
28impl MotionSpec {
29    pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
30        Self {
31            duration_ms,
32            delay_ms: 0,
33            curve,
34            spring: None,
35        }
36    }
37
38    /// A specification that arrives on a spring rather than along a curve.
39    ///
40    /// The duration is the spring's settle time, which is where the weight
41    /// comes from: a spring is still moving after a curve of the same nominal
42    /// length has stopped.
43    pub fn sprung(spring: Spring) -> Self {
44        let settle = spring.settle_time().as_millis() as u64;
45        Self {
46            duration_ms: settle.max(1),
47            delay_ms: 0,
48            curve: CubicBezier::new(0.0, 0.0, 1.0, 1.0),
49            spring: Some(spring),
50        }
51    }
52
53    pub const fn with_delay(mut self, delay_ms: u64) -> Self {
54        self.delay_ms = delay_ms;
55        self
56    }
57
58    pub fn is_sprung(self) -> bool {
59        self.spring.is_some()
60    }
61
62    /// The spring behind a sprung specification, for callers that need more of
63    /// it than an eased fraction — velocity across a retarget, for one.
64    pub fn spring(self) -> Option<Spring> {
65        self.spring
66    }
67
68    pub fn total(self) -> Duration {
69        Duration::from_millis(self.duration_ms + self.delay_ms)
70    }
71
72    pub fn progress(self, raw: f32) -> f32 {
73        let total = (self.duration_ms + self.delay_ms) as f32;
74        if total == 0.0 || self.duration_ms == 0 {
75            return 1.0;
76        }
77        let local = ((raw.clamp(0.0, 1.0) * total - self.delay_ms as f32)
78            / self.duration_ms as f32)
79            .clamp(0.0, 1.0);
80        match self.spring {
81            Some(spring) => {
82                if local >= 1.0 {
83                    return 1.0;
84                }
85                spring.value(Duration::from_secs_f32(
86                    local * self.duration_ms as f32 / 1000.0,
87                ))
88            }
89            None => self.curve.eval(local),
90        }
91    }
92
93    /// The earliest point in the run at which the specification has reached
94    /// `value`: the inverse of [`MotionSpec::progress`], on the same clock.
95    ///
96    /// Sampled at a millisecond rather than solved, because a cubic bezier has
97    /// no closed-form inverse and a spring is not even monotonic — an
98    /// underdamped one passes its target and comes back, so "the earliest time
99    /// it was there" is the only well-defined answer. A millisecond is the
100    /// granularity a specification is written in anyway.
101    ///
102    /// A value never reached returns the whole run.
103    pub fn time_at(self, value: f32) -> Duration {
104        let total_ms = self.duration_ms + self.delay_ms;
105        if value <= 0.0 || total_ms == 0 {
106            return Duration::ZERO;
107        }
108        for ms in 0..=total_ms {
109            if self.progress(ms as f32 / total_ms as f32) >= value {
110                return Duration::from_millis(ms);
111            }
112        }
113        Duration::from_millis(total_ms)
114    }
115
116    /// The specification moved to start when `previous` has finished.
117    ///
118    /// Its own delay is kept and counted from there, so "80ms after the panel
119    /// has opened" is `content.with_delay(80).after(panel)` and the caller
120    /// never adds two numbers together. [`Sequence`](super::Sequence) is the
121    /// same composition for more than two, and can report the total.
122    pub fn after(mut self, previous: MotionSpec) -> Self {
123        self.delay_ms += previous.total().as_millis() as u64;
124        self
125    }
126
127    /// Adapts the specification to GPUI's animation driver.
128    ///
129    /// GPUI requires an eased delta inside 0..1, so an overshooting curve or
130    /// an underdamped spring is clamped here. Overshoot survives in
131    /// [`Transition`](super::Transition) and [`Presence`](super::Presence),
132    /// which sample [`MotionSpec::progress`] directly.
133    pub fn animation(self) -> Animation {
134        Animation::new(self.total()).with_easing(move |delta| self.progress(delta).clamp(0.0, 1.0))
135    }
136
137    pub fn repeating(self) -> Animation {
138        Animation::new(self.total()).repeat()
139    }
140}
141
142/// How a surface that is part of the page arrives.
143pub fn entrance(theme: &Theme) -> MotionSpec {
144    MotionSpec::new(theme.motion.entrance_ms, Easing::Settle.curve(theme))
145}
146
147/// How a menu opens: short, because it is answering a click.
148pub fn menu(theme: &Theme) -> MotionSpec {
149    MotionSpec::new(theme.motion.menu_ms, Easing::Standard.curve(theme))
150}
151
152/// How a modal arrives: slower, because it is taking the page over.
153pub fn dialog(theme: &Theme) -> MotionSpec {
154    MotionSpec::new(theme.motion.dialog_ms, Easing::Standard.curve(theme))
155}
156
157/// How a modal arrives when it should arrive with weight: on the smooth
158/// spring, which keeps moving after a curve of the same length has stopped.
159pub fn dialog_arrival(theme: &Theme) -> MotionSpec {
160    MotionSpec::sprung(Spring::preset(theme, SpringPreset::Smooth))
161}
162
163/// How one control moves to a new state: short, because it is answering a
164/// pointer that is still on it.
165pub fn state_change(theme: &Theme) -> MotionSpec {
166    MotionSpec::new(theme.motion.quick_ms, Easing::Standard.curve(theme))
167}
168
169/// How a value that has a position moves to a new one, such as a progress
170/// fill or an opening section.
171pub fn resize(theme: &Theme) -> MotionSpec {
172    MotionSpec::new(theme.motion.resize_ms, Easing::Standard.curve(theme))
173}
174
175/// How a control that is being manipulated follows the value: the tight
176/// spring, so it feels attached rather than trailing.
177pub fn tracking(theme: &Theme) -> MotionSpec {
178    MotionSpec::sprung(Spring::preset(theme, SpringPreset::Grab))
179}
180
181/// Fades `element` in over the entrance specification.
182pub fn fade_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
183where
184    E: Styled + IntoElement + 'static,
185{
186    element.with_animation(id, entrance(theme).animation(), |element, progress| {
187        element
188            .relative()
189            .opacity(progress)
190            .top(px(4.0 * (1.0 - progress)))
191    })
192}
193
194/// The opening a menu makes: a fade with a small rise.
195pub fn menu_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
196where
197    E: Styled + IntoElement + 'static,
198{
199    element.with_animation(id, menu(theme).animation(), |element, progress| {
200        element
201            .relative()
202            .opacity(0.3 + 0.7 * progress)
203            .top(px(-2.0 * (1.0 - progress)))
204    })
205}
206
207/// The arrival a modal makes: it rises onto the page on a spring, so it
208/// arrives with weight rather than simply appearing at full strength.
209pub fn dialog_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
210where
211    E: Styled + IntoElement + 'static,
212{
213    let spec = dialog_arrival(theme);
214    element.with_animation(id, spec.animation(), |element, progress| {
215        element
216            .relative()
217            .opacity(progress)
218            .top(px(8.0 * (1.0 - progress)))
219    })
220}
221
222/// The arrival one row of a menu-shaped list makes.
223///
224/// Opacity only, and deliberately so: a rise is a layout input, so a row that
225/// slid into place would publish a moving box while it travelled. The wave is
226/// [`Stagger::rows`](super::Stagger::rows), whose window is capped however
227/// long the list is.
228pub fn row_in<E>(
229    id: impl Into<ElementId>,
230    theme: &Theme,
231    index: usize,
232    count: usize,
233    element: E,
234) -> AnimationElement<E>
235where
236    E: Styled + IntoElement + 'static,
237{
238    let spec = super::Stagger::rows().spec(index, count, menu(theme));
239    element.with_animation(id, spec.animation(), |element, progress| {
240        element.opacity(progress)
241    })
242}
243
244/// The arrival a block of content makes when it replaces what was there.
245///
246/// The rise belongs on a wrapper inside the element that publishes the node,
247/// so the published box is the settled one and only the pixels travel.
248pub fn content_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
249where
250    E: Styled + IntoElement + 'static,
251{
252    element.with_animation(id, entrance(theme).animation(), |element, progress| {
253        element
254            .relative()
255            .opacity(progress)
256            .top(px(6.0 * (1.0 - progress)))
257    })
258}
259
260/// The sweep a loading placeholder's highlight travels on, as a fraction of
261/// the placeholder's own width.
262///
263/// The band starts fully off the leading edge and finishes fully off the
264/// trailing one, so a row is never left with a bright edge parked on it.
265pub fn shimmer_offset(phase: f32, band: f32) -> f32 {
266    phase.rem_euclid(1.0) * (1.0 + band) - band
267}
268
269/// The repeating wave a loading placeholder breathes on.
270pub fn pulse_wave(phase: f32) -> f32 {
271    0.5 - 0.5 * (phase * std::f32::consts::TAU).cos()
272}
273
274/// The repeating opacity a gradient spinner turns on.
275pub fn gradient_opacity(phase: f32, dim: f32) -> f32 {
276    let phase = phase.rem_euclid(1.0);
277    if phase < 0.45 {
278        1.0 + (dim - 1.0) * (phase / 0.45)
279    } else if phase < 0.92 {
280        dim
281    } else {
282        dim + (1.0 - dim) * ((phase - 0.92) / 0.08)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn delayed_specs_hold_then_finish() {
292        let spec = MotionSpec::new(500, CubicBezier::new(0.0, 0.0, 1.0, 1.0)).with_delay(500);
293        assert_eq!(spec.progress(0.25), 0.0);
294        assert_eq!(spec.progress(1.0), 1.0);
295    }
296
297    #[test]
298    fn a_zero_duration_spec_is_already_complete() {
299        let spec = MotionSpec::new(0, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
300        assert_eq!(spec.progress(0.0), 1.0);
301    }
302
303    #[test]
304    fn theme_presets_carry_their_token_durations() {
305        let theme = Theme::studio_dark();
306        assert_eq!(menu(&theme).duration_ms, theme.motion.menu_ms);
307        assert_eq!(dialog(&theme).duration_ms, theme.motion.dialog_ms);
308        assert_eq!(entrance(&theme).duration_ms, theme.motion.entrance_ms);
309    }
310
311    #[test]
312    fn a_time_inverts_the_progress_that_produced_it() {
313        let spec = MotionSpec::new(200, Easing::Standard.curve(&Theme::studio_dark()));
314        for step in 1..10 {
315            let value = step as f32 / 10.0;
316            let reached = spec.time_at(value);
317            let there = spec.progress(reached.as_secs_f32() / spec.total().as_secs_f32());
318            assert!(
319                (there - value).abs() < 0.02,
320                "{value} was found at {reached:?}, which is {there}"
321            );
322        }
323    }
324
325    #[test]
326    fn the_ends_of_a_run_are_where_they_belong() {
327        let spec = MotionSpec::new(200, CubicBezier::new(0.0, 0.0, 1.0, 1.0)).with_delay(100);
328        assert_eq!(spec.time_at(0.0), Duration::ZERO);
329        assert_eq!(spec.time_at(1.0), spec.total());
330        // Nothing has moved until the delay is over.
331        assert!(spec.time_at(0.001) > Duration::from_millis(100));
332        assert_eq!(spec.time_at(0.5), Duration::from_millis(200));
333    }
334
335    #[test]
336    fn a_sprung_time_finds_the_first_crossing_rather_than_the_last() {
337        let spec = MotionSpec::sprung(Spring::new(400.0, 28.0, 1.0));
338        let crossed = spec.time_at(1.0);
339        assert!(
340            crossed < spec.total(),
341            "an underdamped spring reaches its target before it settles on it"
342        );
343    }
344
345    #[test]
346    fn a_spec_that_follows_another_starts_when_it_ends() {
347        let first = MotionSpec::new(200, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
348        let second = MotionSpec::new(100, CubicBezier::new(0.0, 0.0, 1.0, 1.0))
349            .with_delay(50)
350            .after(first);
351        assert_eq!(second.delay_ms, 250);
352        assert_eq!(second.total(), Duration::from_millis(350));
353    }
354
355    #[test]
356    fn gradient_pulse_stays_in_range() {
357        for step in 0..200 {
358            let opacity = gradient_opacity(step as f32 / 100.0, 0.1);
359            assert!((0.1..=1.0).contains(&opacity));
360        }
361    }
362}