gpui_kit/motion/busy.rs
1//! What work in progress looks like.
2//!
3//! Every surface in this library that can be waiting had been deciding for
4//! itself whether to say so, and most of them decided not to: a running tool
5//! call drew a rotation glyph that did not rotate, an indeterminate ring drew
6//! a full circle that did not move, and a thinking block drew nothing at all.
7//! A still picture of a rotation arrow does not read as "working", it reads as
8//! "stuck", which is the one thing it was there to rule out.
9//!
10//! So the choice is made once, here, and the three answers are three claims
11//! about the work rather than three decorations:
12//!
13//! - [`Activity::Advancing`] — the extent is known and the work is moving
14//! through it. A band sweeps across, in the direction the work is going.
15//! - [`Activity::Working`] — it is definitely running and nobody can say how
16//! much is left. A mark turns, because a turn has no end to imply.
17//! - [`Activity::Deliberating`] — something is being weighed and there is no
18//! progress to report at all. It breathes, because a breath claims even
19//! less than a turn does.
20//!
21//! Picking the wrong one is not a style mistake, it is a false statement: a
22//! sweep on work of unknown extent draws a finish line that does not exist.
23//!
24//! # Reduced motion
25//!
26//! Every helper here checks [`reduce_motion`] and returns the element
27//! unanimated when it is set. That is deliberately not the same as letting the
28//! repeating animation hold frame zero: frame zero of a turn is a rotation
29//! glyph sitting still, which is exactly the "stuck" reading this module
30//! exists to remove. Under reduced motion the state is carried by the colour
31//! and the published `busy` flag, which were carrying it anyway.
32
33use gpui::{
34 AnimationExt as _, AnyElement, App, ElementId, IntoElement, ParentElement, Styled, Svg,
35 Transformation, div, percentage, px, relative,
36};
37use gpui_kit_theme::Theme;
38
39use super::spec::{MotionSpec, pulse_wave, shimmer_offset};
40use super::{CubicBezier, Easing, reduce_motion};
41
42/// How faint a breathing element gets at the bottom of its breath.
43///
44/// It never reaches nothing: an element that vanished would read as having
45/// been removed rather than as still being there and still working.
46const BREATH_FLOOR: f32 = 0.45;
47/// How much of the swept element the travelling band covers.
48const SWEEP_BAND: f32 = 0.35;
49
50/// The shape of a piece of work that is currently under way.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Activity {
53 /// The extent is known and the work is moving through it.
54 Advancing,
55 /// It is running, and how much is left is not known.
56 Working,
57 /// Something is being weighed, with no progress to report.
58 Deliberating,
59}
60
61impl Activity {
62 /// How long one repetition takes.
63 pub fn period_ms(self, theme: &Theme) -> u64 {
64 match self {
65 Self::Advancing => theme.motion.shimmer_ms,
66 Self::Working => theme.motion.spin_ms,
67 Self::Deliberating => theme.motion.pulse_ms,
68 }
69 }
70
71 /// The curve the repetition runs on.
72 ///
73 /// A turn is linear because any easing on a loop puts a stall at the seam,
74 /// and a mark that hesitates once per revolution reads as a mark that is
75 /// catching on something. The other two ease, because both of them have a
76 /// natural turning point where slowing down is the honest shape.
77 pub fn curve(self, theme: &Theme) -> CubicBezier {
78 match self {
79 Self::Working => Easing::Linear.curve(theme),
80 Self::Advancing | Self::Deliberating => Easing::EaseInOut.curve(theme),
81 }
82 }
83
84 fn spec(self, theme: &Theme) -> MotionSpec {
85 MotionSpec::new(self.period_ms(theme), self.curve(theme))
86 }
87}
88
89/// Turns a glyph, for work whose remaining extent is unknown.
90///
91/// Takes an [`Svg`] rather than any element because rotation is a transform
92/// and GPUI carries transforms on `Svg` alone. [`crate::display::icon::paint`]
93/// is the supported way to get one that already honours reading direction.
94pub fn spin(icon: Svg, id: impl Into<ElementId>, theme: &Theme, cx: &App) -> AnyElement {
95 if reduce_motion(cx) {
96 return icon.into_any_element();
97 }
98 let activity = Activity::Working;
99 icon.with_animation(
100 id.into(),
101 activity.spec(theme).repeating(),
102 |element, progress| {
103 element.with_transformation(Transformation::rotate(percentage(progress)))
104 },
105 )
106 .into_any_element()
107}
108
109/// Breathes an element, for work that has nothing to report yet.
110///
111/// Opacity only. A breath that also moved or resized would shift the text
112/// beside it on every cycle, and the whole point of this one is that it is the
113/// quietest of the three.
114pub fn breathe<E>(element: E, id: impl Into<ElementId>, theme: &Theme, cx: &App) -> AnyElement
115where
116 E: Styled + IntoElement + 'static,
117{
118 if reduce_motion(cx) {
119 return element.into_any_element();
120 }
121 let activity = Activity::Deliberating;
122 element
123 .with_animation(
124 id.into(),
125 activity.spec(theme).repeating(),
126 |element, progress| {
127 element.opacity(BREATH_FLOOR + (1.0 - BREATH_FLOOR) * pulse_wave(progress))
128 },
129 )
130 .into_any_element()
131}
132
133/// A band travelling across whatever it is placed in, for work of known extent.
134///
135/// Returns an absolutely positioned overlay, so the caller adds it as a child
136/// of a `relative().overflow_hidden()` element and nothing it already draws
137/// moves. It paints in `color`, which callers set to the accent or to the
138/// track they are sweeping over.
139pub fn sweep(
140 id: impl Into<ElementId>,
141 theme: &Theme,
142 color: gpui::Hsla,
143 cx: &App,
144) -> Option<AnyElement> {
145 if reduce_motion(cx) {
146 return None;
147 }
148 let activity = Activity::Advancing;
149 // Two halves rather than one block: a band needs three stops — up, held,
150 // and back down — and this renderer takes two per gradient.
151 let band = div()
152 .absolute()
153 .top_0()
154 .bottom_0()
155 .flex()
156 .flex_row()
157 .w(relative(SWEEP_BAND))
158 .child(div().h_full().w(relative(0.5)).bg(gpui::linear_gradient(
159 90.0,
160 gpui::linear_color_stop(color.opacity(0.0), 0.0),
161 gpui::linear_color_stop(color, 1.0),
162 )))
163 .child(div().h_full().w(relative(0.5)).bg(gpui::linear_gradient(
164 90.0,
165 gpui::linear_color_stop(color, 0.0),
166 gpui::linear_color_stop(color.opacity(0.0), 1.0),
167 )))
168 .with_animation(
169 id.into(),
170 activity.spec(theme).repeating(),
171 |element, progress| element.left(relative(shimmer_offset(progress, SWEEP_BAND))),
172 );
173 Some(band.into_any_element())
174}
175
176/// A dot that breathes, for a place with room for a mark but not a glyph.
177///
178/// The size is the caller's, because this sits inside layouts that have
179/// already decided how much room the mark gets.
180pub fn breathing_dot(
181 id: impl Into<ElementId>,
182 theme: &Theme,
183 color: gpui::Hsla,
184 size: f32,
185 cx: &App,
186) -> AnyElement {
187 breathe(div().size(px(size)).rounded_full().bg(color), id, theme, cx)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn theme() -> Theme {
195 Theme::studio_dark()
196 }
197
198 /// The three answers are three different claims, so no two of them may
199 /// run on the same period: a reader who learns the rhythm of one is
200 /// entitled to read a different rhythm as a different statement.
201 #[test]
202 fn the_three_activities_run_at_three_different_rates() {
203 let theme = theme();
204 let periods = [
205 Activity::Advancing.period_ms(&theme),
206 Activity::Working.period_ms(&theme),
207 Activity::Deliberating.period_ms(&theme),
208 ];
209 for (index, period) in periods.iter().enumerate() {
210 for other in &periods[index + 1..] {
211 assert_ne!(period, other);
212 }
213 }
214 }
215
216 /// A loop that eases has to stall somewhere, and on a turn that stall
217 /// lands at the seam and reads as the mark catching on something.
218 #[test]
219 fn a_turn_runs_linear_and_the_others_do_not() {
220 let theme = theme();
221 let linear = Easing::Linear.curve(&theme);
222 assert_eq!(Activity::Working.curve(&theme), linear);
223 assert_ne!(Activity::Deliberating.curve(&theme), linear);
224 assert_ne!(Activity::Advancing.curve(&theme), linear);
225 }
226
227 /// Every period comes from the theme, so a host that retunes motion
228 /// retunes these with it rather than finding three numbers welded in.
229 #[test]
230 fn every_period_is_a_token_the_theme_carries() {
231 let theme = theme();
232 assert_eq!(
233 Activity::Advancing.period_ms(&theme),
234 theme.motion.shimmer_ms
235 );
236 assert_eq!(Activity::Working.period_ms(&theme), theme.motion.spin_ms);
237 assert_eq!(
238 Activity::Deliberating.period_ms(&theme),
239 theme.motion.pulse_ms
240 );
241 }
242
243 /// A breath must not reach nothing: an element that vanished would read
244 /// as having been removed rather than as still working.
245 #[test]
246 fn a_breath_never_fades_to_nothing() {
247 const { assert!(BREATH_FLOOR > 0.0) };
248 for step in 0..=8 {
249 let opacity = BREATH_FLOOR + (1.0 - BREATH_FLOOR) * pulse_wave(step as f32 / 8.0);
250 assert!(opacity >= BREATH_FLOOR, "{opacity}");
251 assert!(opacity <= 1.0, "{opacity}");
252 }
253 }
254}