Skip to main content

gpui_kit/motion/
animated.rs

1//! One call that puts a token-backed entrance on any element.
2//!
3//! The primitives underneath this module are complete, and were almost unused
4//! for a reason that had nothing to do with them: reaching for one meant
5//! naming an id, borrowing the theme, choosing a specification and writing the
6//! closure that applies the progress. Four decisions is enough friction that
7//! the honest answer at most call sites was to skip the motion, which is why
8//! most of this library arrived instantly and only a handful of components
9//! moved at all.
10//!
11//! [`Animated`] collapses those four decisions into one. The element says
12//! which arrival it is making and everything else comes from the token
13//! document, so adding motion to a component is a single call and stays as
14//! reviewable as the rest of the styling around it.
15
16use gpui::{AnimationElement, AnimationExt, App, ElementId, IntoElement, Styled, px};
17use gpui_kit_theme::{ActiveTheme, SpringPreset, Theme};
18
19use super::{MotionSpec, Spring, Stagger, dialog_arrival, entrance, menu, spec::state_change};
20
21/// How an element arrives.
22///
23/// Each variant is a claim about what the element is, not about how far it
24/// should travel: a menu answering a click and a dialog taking the page over
25/// arrive differently because they mean different things, and the distances
26/// and curves that express that live in the token document.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Entrance {
29    /// Opacity alone.
30    ///
31    /// The arrival for anything whose position is load-bearing while it
32    /// arrives — a row in a list, a cell in a grid — because a rise is a
33    /// layout input and an element that slid into place would publish a
34    /// moving box the whole way.
35    Fade,
36    /// Opacity with a short rise: a block of content replacing what was there.
37    #[default]
38    Rise,
39    /// The opening a menu makes: quick, and downward from its anchor.
40    Menu,
41    /// The arrival a modal makes, on a spring, so it lands with weight.
42    Dialog,
43}
44
45impl Entrance {
46    fn spec(self, theme: &Theme) -> MotionSpec {
47        match self {
48            Self::Fade => entrance(theme),
49            Self::Rise => entrance(theme),
50            Self::Menu => menu(theme),
51            Self::Dialog => dialog_arrival(theme),
52        }
53    }
54
55    /// How far the element travels, in pixels, and in which direction.
56    ///
57    /// Negative is downward from the settled position, which is what a menu
58    /// dropping from its anchor does.
59    fn travel(self) -> f32 {
60        match self {
61            Self::Fade => 0.0,
62            Self::Rise => 6.0,
63            Self::Menu => -2.0,
64            Self::Dialog => 8.0,
65        }
66    }
67
68    /// The opacity the element starts from.
69    ///
70    /// A menu starts partly visible because it is answering a click that has
71    /// already happened, and a menu that faded up from nothing would read as
72    /// slower than the click that asked for it.
73    fn opening_opacity(self) -> f32 {
74        match self {
75            Self::Menu => 0.3,
76            _ => 0.0,
77        }
78    }
79}
80
81/// A token-backed entrance in one call.
82pub trait Animated: Styled + IntoElement + Sized + 'static {
83    /// Runs `entrance` on this element.
84    ///
85    /// The travel is applied as a relative offset, so the element occupies its
86    /// settled box for the whole run and nothing beside it moves. Under
87    /// [`gpui::App::reduce_motion`] GPUI finishes the animation immediately,
88    /// which lands the element exactly where it was going to be anyway.
89    fn animate_in(
90        self,
91        id: impl Into<ElementId>,
92        cx: &App,
93        entrance: Entrance,
94    ) -> AnimationElement<Self> {
95        self.animate_with(id, entrance, entrance.spec(cx.theme()))
96    }
97
98    /// The same arrival, delayed into place as one member of a group.
99    ///
100    /// The wave is capped however long the group is, so a five hundred row
101    /// list finishes arriving in the same window an eight row one does.
102    fn animate_in_staggered(
103        self,
104        id: impl Into<ElementId>,
105        cx: &App,
106        entrance: Entrance,
107        index: usize,
108        count: usize,
109    ) -> AnimationElement<Self> {
110        let spec = Stagger::rows().spec(index, count, entrance.spec(cx.theme()));
111        self.animate_with(id, entrance, spec)
112    }
113
114    /// The arrival with a specification the caller has already composed —
115    /// sequenced after another, delayed, or re-sprung.
116    fn animate_with(
117        self,
118        id: impl Into<ElementId>,
119        entrance: Entrance,
120        spec: MotionSpec,
121    ) -> AnimationElement<Self> {
122        let travel = entrance.travel();
123        let from = entrance.opening_opacity();
124        self.with_animation(id, spec.animation(), move |element, progress| {
125            let element = element.opacity(from + (1.0 - from) * progress);
126            if travel == 0.0 {
127                element
128            } else {
129                element.relative().top(px(travel * (1.0 - progress)))
130            }
131        })
132    }
133
134    /// The response an element gives when the value it is showing changes.
135    ///
136    /// Short and opacity-only, because it is answering something the user has
137    /// just done and the element is not going anywhere.
138    fn animate_change(self, id: impl Into<ElementId>, cx: &App) -> AnimationElement<Self> {
139        self.animate_with(id, Entrance::Fade, state_change(cx.theme()))
140    }
141
142    /// The arrival on a named spring rather than along a curve.
143    fn animate_sprung(
144        self,
145        id: impl Into<ElementId>,
146        cx: &App,
147        entrance: Entrance,
148        preset: SpringPreset,
149    ) -> AnimationElement<Self> {
150        let spec = MotionSpec::sprung(Spring::preset(cx.theme(), preset));
151        self.animate_with(id, entrance, spec)
152    }
153}
154
155impl<T: Styled + IntoElement + Sized + 'static> Animated for T {}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn a_faded_arrival_never_moves_the_element() {
163        assert_eq!(Entrance::Fade.travel(), 0.0);
164    }
165
166    #[test]
167    fn a_menu_drops_from_its_anchor_and_the_rest_rise_to_theirs() {
168        assert!(Entrance::Menu.travel() < 0.0);
169        assert!(Entrance::Rise.travel() > 0.0);
170        assert!(Entrance::Dialog.travel() > 0.0);
171    }
172
173    #[test]
174    fn a_dialog_arrives_on_a_spring_and_a_menu_on_a_curve() {
175        let theme = Theme::studio_dark();
176        assert!(Entrance::Dialog.spec(&theme).is_sprung());
177        assert!(!Entrance::Menu.spec(&theme).is_sprung());
178    }
179
180    #[test]
181    fn every_arrival_ends_fully_opaque() {
182        let theme = Theme::studio_dark();
183        for entrance in [
184            Entrance::Fade,
185            Entrance::Rise,
186            Entrance::Menu,
187            Entrance::Dialog,
188        ] {
189            let spec = entrance.spec(&theme);
190            let from = entrance.opening_opacity();
191            let settled = from + (1.0 - from) * spec.progress(1.0);
192            assert!(
193                (settled - 1.0).abs() < f32::EPSILON,
194                "{entrance:?} settled at {settled}"
195            );
196        }
197    }
198
199    /// A menu is answering a click that already happened, so it may not start
200    /// from nothing; everything else may.
201    #[test]
202    fn only_a_menu_starts_partly_visible() {
203        assert!(Entrance::Menu.opening_opacity() > 0.0);
204        for entrance in [Entrance::Fade, Entrance::Rise, Entrance::Dialog] {
205            assert_eq!(entrance.opening_opacity(), 0.0);
206        }
207    }
208
209    #[test]
210    fn a_staggered_group_finishes_within_the_row_cap() {
211        let theme = Theme::studio_dark();
212        let spec = Entrance::Fade.spec(&theme);
213        let stagger = Stagger::rows();
214        for count in [2, 8, 50, 500] {
215            let last = stagger.spec(count - 1, count, spec);
216            assert!(
217                last.delay_ms <= super::super::ROW_STAGGER_CAP.as_millis() as u64,
218                "{count} rows waited {}ms",
219                last.delay_ms
220            );
221        }
222    }
223}