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}