Skip to main content

gpui_kit/display/
loading.rs

1//! Loading indicators.
2//!
3//! Every animation here runs through GPUI's `with_animation`, which holds a
4//! single static frame when the platform asks for reduced motion.
5
6use gpui::{
7    App, IntoElement, ParentElement, RenderOnce, Styled, Window, div, linear_color_stop,
8    linear_gradient, px, relative,
9};
10use gpui_kit_semantics::{NodeSpec, Role, Semantic};
11use gpui_kit_theme::ActiveTheme;
12
13use crate::foundation::Ident;
14use crate::motion::{self, AnimationExt as _, MotionSpec};
15
16const PULSE_CELLS: usize = 5;
17const MATRIX_SIDE: usize = 3;
18/// How much of a placeholder row the moving highlight covers, and how far one
19/// row's sweep trails the row above it.
20const SHIMMER_BAND: f32 = 0.35;
21const SHIMMER_ROW_OFFSET: f32 = 0.08;
22
23/// A row of pulsing cells, used while a request is in flight.
24#[derive(Debug, IntoElement)]
25pub struct PulseLoader {
26    ident: Ident,
27    cell_size: f32,
28    label: Option<gpui::SharedString>,
29}
30
31impl PulseLoader {
32    pub fn new(ident: impl Into<Ident>) -> Self {
33        Self {
34            ident: ident.into(),
35            cell_size: 8.0,
36            label: None,
37        }
38    }
39
40    /// What the wait is for. Announced with the busy state.
41    pub fn label(mut self, label: impl Into<gpui::SharedString>) -> Self {
42        self.label = Some(label.into());
43        self
44    }
45
46    pub fn cell_size(mut self, cell_size: f32) -> Self {
47        self.cell_size = cell_size;
48        self
49    }
50}
51
52impl RenderOnce for PulseLoader {
53    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
54        let theme = cx.theme();
55        let color = theme.colors.accent;
56        let cell_size = self.cell_size;
57        let period = MotionSpec::new(
58            theme.motion.pulse_ms,
59            motion::CubicBezier::new(0.25, 0.1, 0.25, 1.0),
60        );
61        let ident = self.ident.clone();
62        let spec = busy_spec(&self.ident, self.label.clone());
63        div()
64            .flex()
65            .flex_row()
66            .items_center()
67            .gap(px(cell_size / 2.0))
68            .semantic_in(cx, spec)
69            .children((0..PULSE_CELLS).map(move |index| {
70                div()
71                    .size(px(cell_size))
72                    .flex()
73                    .items_center()
74                    .justify_center()
75                    .child(
76                        div()
77                            .size(px(cell_size))
78                            .rounded(px(cell_size / 4.0))
79                            .bg(color)
80                            .with_animation(
81                                ident.indexed_element_id(index),
82                                period.repeating(),
83                                move |element, delta| {
84                                    let phase = motion::staggered_phase(delta, index, 0.0625);
85                                    let wave = motion::pulse_wave(phase);
86                                    element
87                                        .opacity(0.08 + 0.92 * wave)
88                                        .size(px(cell_size * (0.9 + 0.1 * wave)))
89                                },
90                            ),
91                    )
92            }))
93    }
94}
95
96/// A three-by-three gradient matrix, used for longer indeterminate work.
97#[derive(Debug, IntoElement)]
98pub struct GradientSpinner {
99    ident: Ident,
100    cell_size: f32,
101    label: Option<gpui::SharedString>,
102}
103
104impl GradientSpinner {
105    pub fn new(ident: impl Into<Ident>) -> Self {
106        Self {
107            ident: ident.into(),
108            cell_size: 5.0,
109            label: None,
110        }
111    }
112
113    pub fn label(mut self, label: impl Into<gpui::SharedString>) -> Self {
114        self.label = Some(label.into());
115        self
116    }
117
118    pub fn cell_size(mut self, cell_size: f32) -> Self {
119        self.cell_size = cell_size;
120        self
121    }
122}
123
124impl RenderOnce for GradientSpinner {
125    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
126        let colors = cx.theme().colors.loader_gradient;
127        let cell_size = self.cell_size;
128        let period = MotionSpec::new(750, motion::CubicBezier::new(0.25, 0.1, 0.25, 1.0));
129        let ident = self.ident.clone();
130        let spec = busy_spec(&self.ident, self.label.clone());
131        div()
132            .flex()
133            .flex_col()
134            .gap(px(cell_size / 2.0))
135            .semantic_in(cx, spec)
136            .children((0..MATRIX_SIDE).map(move |row| {
137                let ident = ident.clone();
138                div()
139                    .flex()
140                    .flex_row()
141                    .gap(px(cell_size / 2.0))
142                    .children((0..MATRIX_SIDE).map(move |column| {
143                        let index = row * MATRIX_SIDE + column;
144                        let center = (MATRIX_SIDE as f32 - 1.0) / 2.0;
145                        let distance =
146                            MATRIX_SIDE as f32 - 1.0 - row as f32 + (column as f32 - center).abs();
147                        let phase = distance / (MATRIX_SIDE as f32 + center);
148                        div()
149                            .size(px(cell_size))
150                            .rounded_full()
151                            .bg(colors[row])
152                            .with_animation(
153                                ident.indexed_element_id(index),
154                                period.repeating(),
155                                move |element, delta| {
156                                    element.opacity(motion::gradient_opacity(delta + phase, 0.1))
157                                },
158                            )
159                    }))
160            }))
161    }
162}
163
164/// Placeholder rows shown while a list's real shape is unknown.
165#[derive(Debug, IntoElement)]
166pub struct Skeleton {
167    ident: Ident,
168    rows: usize,
169    row_height: f32,
170    label: Option<gpui::SharedString>,
171}
172
173impl Skeleton {
174    pub fn new(ident: impl Into<Ident>) -> Self {
175        Self {
176            ident: ident.into(),
177            rows: 3,
178            row_height: 28.0,
179            label: None,
180        }
181    }
182
183    pub fn label(mut self, label: impl Into<gpui::SharedString>) -> Self {
184        self.label = Some(label.into());
185        self
186    }
187
188    pub fn rows(mut self, rows: usize) -> Self {
189        self.rows = rows;
190        self
191    }
192
193    pub fn row_height(mut self, row_height: f32) -> Self {
194        self.row_height = row_height;
195        self
196    }
197}
198
199impl RenderOnce for Skeleton {
200    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
201        let theme = cx.theme();
202        let color = theme.colors.hover.opacity(0.28);
203        let highlight = theme.colors.hover.opacity(0.5);
204        let radius = theme.radii.control;
205        let row_height = self.row_height;
206        let period = MotionSpec::new(
207            theme.motion.shimmer_ms,
208            motion::CubicBezier::new(0.25, 0.1, 0.25, 1.0),
209        );
210        let ident = self.ident.clone();
211        let spec = busy_spec(&self.ident, self.label.clone());
212        div()
213            .flex()
214            .flex_col()
215            .gap(px(6.0))
216            .semantic_in(cx, spec)
217            .children((0..self.rows).map(move |index| {
218                div()
219                    .h(px(row_height))
220                    .rounded(px(radius))
221                    .bg(color)
222                    .relative()
223                    .overflow_hidden()
224                    // A band sweeping across the row, rather than the whole
225                    // row breathing: a sweep reads as work moving through the
226                    // list, where a pulse reads as the list itself blinking.
227                    // Under reduced motion the repeating animation holds delta
228                    // zero, which parks the band off the leading edge and
229                    // leaves a plain placeholder behind.
230                    .child(
231                        div()
232                            .absolute()
233                            .top_0()
234                            .bottom_0()
235                            .flex()
236                            .flex_row()
237                            .w(relative(SHIMMER_BAND))
238                            // Two halves rather than one block, because the
239                            // pinned GPUI takes two colour stops per gradient
240                            // and a band needs three: up, held, and back down.
241                            .child(div().h_full().w(relative(0.5)).bg(linear_gradient(
242                                90.0,
243                                linear_color_stop(highlight.opacity(0.0), 0.0),
244                                linear_color_stop(highlight, 1.0),
245                            )))
246                            .child(div().h_full().w(relative(0.5)).bg(linear_gradient(
247                                90.0,
248                                linear_color_stop(highlight, 0.0),
249                                linear_color_stop(highlight.opacity(0.0), 1.0),
250                            )))
251                            .with_animation(
252                                ident.indexed_element_id(index),
253                                period.repeating(),
254                                move |element, delta| {
255                                    let phase =
256                                        motion::staggered_phase(delta, index, SHIMMER_ROW_OFFSET);
257                                    element
258                                        .left(relative(motion::shimmer_offset(phase, SHIMMER_BAND)))
259                                },
260                            ),
261                    )
262            }))
263    }
264}
265
266/// Loading is a distinct state, so every indicator publishes it rather than
267/// leaving a test to infer a wait from an absence of content.
268fn busy_spec(ident: &Ident, label: Option<gpui::SharedString>) -> NodeSpec {
269    let mut spec = NodeSpec::new(ident.semantic_id(), Role::Progress).busy(true);
270    if let Some(label) = label {
271        spec = spec.text(label);
272    }
273    spec
274}