1mod circular;
6mod indicator;
7mod linear;
8mod paint;
9pub use circular::{
10 CircularProgressFrame, CircularProgressPlayback, circular_indeterminate_frame,
11 circular_progress, indeterminate_circular_progress,
12 indeterminate_circular_progress_with_playback, sample_circular_progress,
13};
14pub use indicator::{CircularProgress, circular_progress_size};
15pub use linear::{
16 LINEAR_INDETERMINATE_MS, LinearProgress, PROGRESS_THICK_THICKNESS, PROGRESS_THICKNESS,
17 ProgressShape, linear_indeterminate_lines,
18};
19
20use crate::color::ColorRole;
21use crate::icons::{Icon, IconName};
22use crate::theme::color;
23use gpui::{prelude::*, *};
24use gpui_base::Progress;
25use std::time::Duration;
26
27const SPINNER_TURN_MS: u64 = 1000;
28
29pub fn spinner(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> AnyElement {
32 spin(
33 IconName::LoaderCircle.icon().size(px(16.)),
34 id,
35 label,
36 None,
37 cx,
38 )
39}
40
41pub fn icon_spinner(
43 id: impl Into<ElementId>,
44 label: impl Into<SharedString>,
45 size: Rems,
46 cx: &App,
47) -> AnyElement {
48 spin(
49 IconName::LoaderCircle.icon().size(size),
50 id,
51 label,
52 None,
53 cx,
54 )
55}
56
57pub fn spinner_with_playback(
63 id: impl Into<ElementId>,
64 label: impl Into<SharedString>,
65 playback: RepeatAnimationPlayback,
66 cx: &App,
67) -> AnyElement {
68 spin(
69 IconName::LoaderCircle.icon().size(px(16.)),
70 id,
71 label,
72 Some(playback),
73 cx,
74 )
75}
76
77pub fn icon_spinner_with_playback(
79 id: impl Into<ElementId>,
80 label: impl Into<SharedString>,
81 size: Rems,
82 playback: RepeatAnimationPlayback,
83 cx: &App,
84) -> AnyElement {
85 spin(
86 IconName::LoaderCircle.icon().size(size),
87 id,
88 label,
89 Some(playback),
90 cx,
91 )
92}
93
94fn spin(
95 icon: Icon,
96 id: impl Into<ElementId>,
97 label: impl Into<SharedString>,
98 playback: Option<RepeatAnimationPlayback>,
99 cx: &App,
100) -> AnyElement {
101 let id = id.into();
102 let icon = icon.color(color(ColorRole::PRIMARY));
103 let root = Progress::new(id.clone())
105 .accessibility_label(label)
106 .indeterminate(true)
107 .flex_shrink_0()
108 .flex()
109 .items_center()
110 .justify_center();
111 if cx.reduce_motion() {
112 return root.child(icon).into_any_element();
113 }
114 root.child(
117 div()
118 .flex_shrink_0()
119 .with_animation(
120 id,
121 Animation::new(Duration::from_millis(SPINNER_TURN_MS)).repeat(),
122 move |slot, t| {
123 #[cfg(test)]
124 tests::record_repeat_phase(t);
125 slot.child(icon.clone().rotate(std::f32::consts::TAU * t))
126 },
127 )
128 .when_some(playback, |animation, playback| {
129 animation.with_repeat_playback(playback)
130 })
131 .repeat_when_visible(),
132 )
133 .into_any_element()
134}
135
136pub fn indeterminate_progress(id: impl Into<ElementId>, _cx: &App) -> AnyElement {
141 LinearProgress::unlabelled(id).into_any_element()
142}
143
144pub fn indeterminate_progress_with_label(
147 id: impl Into<ElementId>,
148 label: impl Into<SharedString>,
149 _cx: &App,
150) -> AnyElement {
151 LinearProgress::new(id, label).into_any_element()
152}
153
154pub fn indeterminate_progress_with_playback(
157 id: impl Into<ElementId>,
158 playback: RepeatAnimationPlayback,
159 _cx: &App,
160) -> AnyElement {
161 LinearProgress::unlabelled(id)
162 .playback(playback)
163 .into_any_element()
164}
165
166pub fn indeterminate_linear_progress_with_playback(
169 id: impl Into<ElementId>,
170 label: impl Into<SharedString>,
171 thickness: Pixels,
172 muted: bool,
173 playback: RepeatAnimationPlayback,
174 _cx: &App,
175) -> AnyElement {
176 LinearProgress::new(id, label)
177 .thickness(thickness)
178 .muted(muted)
179 .playback(playback)
180 .into_any_element()
181}
182
183pub fn progress(
188 id: impl Into<ElementId>,
189 progress: f32,
190 _window: &mut Window,
191 _cx: &mut App,
192) -> AnyElement {
193 LinearProgress::unlabelled(id)
194 .value(Some(progress))
195 .into_any_element()
196}
197
198pub fn progress_with_label(
200 id: impl Into<ElementId>,
201 label: impl Into<SharedString>,
202 progress: f32,
203 _window: &mut Window,
204 _cx: &mut App,
205) -> AnyElement {
206 LinearProgress::new(id, label)
207 .value(Some(progress))
208 .into_any_element()
209}
210
211pub fn linear_progress(
217 id: impl Into<ElementId>,
218 label: impl Into<SharedString>,
219 fraction: Option<f32>,
220 thickness: Pixels,
221 muted: bool,
222 _window: &mut Window,
223 _cx: &mut App,
224) -> AnyElement {
225 LinearProgress::new(id, label)
226 .value(fraction)
227 .thickness(thickness)
228 .muted(muted)
229 .into_any_element()
230}
231
232#[cfg(test)]
233mod tests {
234 use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px};
235 use std::time::Duration;
236
237 thread_local! {
238 static REPEAT_PHASES: std::cell::RefCell<Vec<f32>> = const { std::cell::RefCell::new(Vec::new()) };
239 }
240
241 pub(super) fn record_repeat_phase(phase: f32) {
242 REPEAT_PHASES.with_borrow_mut(|phases| phases.push(phase));
243 }
244
245 struct RepeatHarness {
246 kind: usize,
247 mounted: bool,
248 playback: gpui::RepeatAnimationPlayback,
249 }
250
251 impl Render for RepeatHarness {
252 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
253 let mut root = div().w(px(200.)).h(px(40.));
254 if self.mounted {
255 let playback = self.playback.clone();
256 root = root.child(match self.kind {
257 0 => super::spinner_with_playback("repeat", "Loading", playback, cx),
258 1 => super::icon_spinner_with_playback(
259 "repeat",
260 "Loading",
261 gpui::rems(1.),
262 playback,
263 cx,
264 ),
265 2 => super::indeterminate_progress_with_playback("repeat", playback, cx),
266 _ => super::indeterminate_linear_progress_with_playback(
267 "repeat",
268 "Loading",
269 px(6.),
270 true,
271 playback,
272 cx,
273 ),
274 });
275 }
276 root
277 }
278 }
279
280 struct WavyHarness {
282 value: f32,
283 circular: bool,
284 }
285
286 impl Render for WavyHarness {
287 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
288 let value = Some(self.value);
289 div().w(px(200.)).child(if self.circular {
290 super::CircularProgress::new("wavy", "Upload")
291 .wavy()
292 .value(value)
293 .into_any_element()
294 } else {
295 super::LinearProgress::new("wavy", "Upload")
296 .wavy()
297 .value(value)
298 .into_any_element()
299 })
300 }
301 }
302
303 fn frames_after_settling(cx: &mut TestAppContext, value: f32, circular: bool) -> usize {
305 let (_, cx) = cx.add_window_view(|_, _| WavyHarness { value, circular });
306 cx.update(|window, cx| {
307 window.refresh();
308 window.draw(cx).clear(cx);
309 window.simulate_next_frame(cx);
311 window.refresh();
312 window.draw(cx).clear(cx);
313 window.simulate_next_frame(cx)
314 })
315 }
316
317 #[gpui::test]
318 fn a_flat_wave_requests_no_frames(cx: &mut TestAppContext) {
319 for circular in [false, true] {
320 for value in [0.05, 0.1, 0.95, 0.97, 1.] {
321 assert_eq!(
322 frames_after_settling(cx, value, circular),
323 0,
324 "circular {circular}, {value}: a flat wave kept the window busy"
325 );
326 }
327 assert!(
328 frames_after_settling(cx, 0.5, circular) > 0,
329 "a visible wave moves"
330 );
331 }
332 }
333
334 #[gpui::test]
335 fn explicit_repeat_clocks_survive_unmount_and_reduced_motion(cx: &mut TestAppContext) {
336 for kind in 0..4 {
337 let (view, cx) = cx.add_window_view(|_, _| RepeatHarness {
338 kind,
339 mounted: false,
340 playback: gpui::RepeatAnimationPlayback::default(),
341 });
342 REPEAT_PHASES.with_borrow_mut(Vec::clear);
343 cx.executor().advance_clock(Duration::from_secs(2));
345 cx.update(|window, cx| {
346 cx.set_reduce_motion(true);
347 view.update(cx, |view, _| view.mounted = true);
348 window.refresh();
349 window.draw(cx).clear(cx);
350 });
351 assert!(REPEAT_PHASES.with_borrow(|phases| phases.is_empty()));
352 cx.executor().advance_clock(Duration::from_secs(1));
353 cx.update(|window, cx| {
354 cx.set_reduce_motion(false);
355 window.refresh();
356 window.draw(cx).clear(cx);
357 });
358 assert_eq!(
359 REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap()),
360 0.
361 );
362 cx.update(|window, cx| {
363 view.update(cx, |view, _| view.mounted = false);
364 window.refresh();
365 window.draw(cx).clear(cx);
366 window.refresh();
367 window.draw(cx).clear(cx);
368 });
369 let count = REPEAT_PHASES.with_borrow(Vec::len);
370 cx.executor().advance_clock(Duration::from_millis(350));
371 cx.update(|window, cx| {
372 window.refresh();
373 window.draw(cx).clear(cx);
374 });
375 assert_eq!(REPEAT_PHASES.with_borrow(Vec::len), count);
376 cx.update(|window, cx| {
377 view.update(cx, |view, _| view.mounted = true);
378 window.refresh();
379 window.draw(cx).clear(cx);
380 });
381 let duration = if kind < 2 { 1000. } else { 1750. };
382 let phase = REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap());
383 assert!(
384 (phase - 350. / duration).abs() < 0.001,
385 "kind {kind}: {phase}"
386 );
387 cx.update(|window, cx| {
388 cx.set_reduce_motion(true);
389 window.refresh();
390 window.draw(cx).clear(cx);
391 });
392 let count = REPEAT_PHASES.with_borrow(Vec::len);
393 cx.executor().advance_clock(Duration::from_millis(350));
394 cx.update(|window, cx| {
395 window.refresh();
396 window.draw(cx).clear(cx);
397 });
398 assert_eq!(REPEAT_PHASES.with_borrow(Vec::len), count);
399 cx.update(|window, cx| {
400 cx.set_reduce_motion(false);
401 window.refresh();
402 window.draw(cx).clear(cx);
403 });
404 let phase = REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap());
405 assert!(
406 (phase - 700. / duration).abs() < 0.001,
407 "kind {kind}: {phase}"
408 );
409 cx.update(|window, cx| {
411 view.update(cx, |view, _| view.mounted = false);
412 window.refresh();
413 window.draw(cx).clear(cx);
414 });
415 }
416 }
417}
418
419#[cfg(test)]
420mod layout_tests {
421 use gpui::{
422 Context, ElementId, IntoElement, Render, Role, TestAppContext, Window, div, prelude::*, px,
423 };
424 use gpui_base::test_support::{ElementSnapshot, find};
425
426 struct Rendered;
428 impl Render for Rendered {
429 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
430 div()
431 .w(px(200.))
432 .flex()
433 .flex_col()
434 .child(super::linear_progress(
435 "measured",
436 "Upload",
437 Some(0.25),
438 px(4.),
439 false,
440 window,
441 cx,
442 ))
443 .child(super::linear_progress(
444 "unmeasured",
445 "Sync",
446 None,
447 px(4.),
448 false,
449 window,
450 cx,
451 ))
452 .child(super::progress_with_label("named", "Copy", 0.5, window, cx))
453 .child(super::indeterminate_progress_with_label(
454 "named-busy",
455 "Index",
456 cx,
457 ))
458 .child(super::progress("legacy", 0.5, window, cx))
459 .child(super::CircularProgress::new("ring", "Render").value(Some(0.75)))
460 }
461 }
462
463 fn node(window: &Window, id: &'static str) -> ElementSnapshot {
464 find(window, &[], &ElementId::from(id)).unwrap_or_else(|| panic!("{id} was not rendered"))
465 }
466
467 #[gpui::test]
468 fn rendered_progress_nodes_carry_role_and_caller_label(cx: &mut TestAppContext) {
469 cx.update(|cx| cx.set_reduce_motion(true));
470 let (_, cx) = cx.add_window_view(|_, _| Rendered);
471 cx.update(|window, cx| window.draw(cx).clear(cx));
472 cx.update(|window, _| {
473 for (id, label) in [
474 ("measured", "Upload"),
475 ("unmeasured", "Sync"),
476 ("named", "Copy"),
477 ("named-busy", "Index"),
478 ("ring", "Render"),
479 ] {
480 let node = node(window, id);
481 assert_eq!(node.role(), Some(Role::ProgressIndicator), "{id}");
482 assert_eq!(node.label(), Some(label), "{id}");
483 assert!(node.visible(), "{id}");
484 }
485 let measured = node(window, "measured").bounds();
487 assert_eq!(measured.size.width, px(200.));
488 assert_eq!(measured.size.height, px(4.));
489 let legacy = node(window, "legacy");
491 assert_eq!(legacy.role(), Some(Role::ProgressIndicator));
492 assert_eq!(legacy.label(), None);
493 });
494 }
495}