Skip to main content

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}