Skip to main content

gpui_cn/
spinner.rs

1use gpui_kit::{
2    App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
3    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, TestSupportExt as _,
4    Window, base::StyledExt as _, div,
5};
6
7use crate::Progress;
8
9/// A shadcn-style spinner: an arc turning in place, for work whose length
10/// is not known. Use a circular [`Progress`] for a known percentage; this
11/// is that ring with no value.
12///
13/// The look is shadcn's `Spinner`: `size-4` (16px at the default font
14/// size), painted with the inherited text color, and turning once a
15/// second at a linear pace, Tailwind's `animate-spin`. `Styled` sizes and
16/// colors it:
17///
18/// ```
19/// use gpui_cn::Spinner;
20/// use gpui_kit::Styled as _;
21///
22/// let _ = Spinner::new("saving");
23/// let _ = Spinner::new("sync").size_6().accessibility_label("Syncing");
24/// ```
25///
26/// Assistive technology reads it as a status named "Loading", as shadcn's
27/// `role="status" aria-label="Loading"`.
28///
29/// The turn repaints at 30 fps on the app's shared loop clock, not at the
30/// display rate: a 16px arc turning once a second moves 12 degrees a
31/// step, which reads as smooth, and every spinner on screen repaints on
32/// the same tick. Under reduced motion it holds still and asks for no
33/// frames. A spinner is a thin wrapper: a [`Progress`] ring with no value
34/// draws the arc.
35#[derive(IntoElement)]
36pub struct Spinner {
37    id: ElementId,
38    style: StyleRefinement,
39    accessibility_label: Option<SharedString>,
40}
41
42impl Spinner {
43    /// A spinner with a stable id.
44    pub fn new(id: impl Into<ElementId>) -> Self {
45        Self {
46            id: id.into(),
47            style: StyleRefinement::default(),
48            accessibility_label: None,
49        }
50    }
51
52    /// The name a screen reader announces. The default is "Loading".
53    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
54        self.accessibility_label = Some(label.into());
55        self
56    }
57}
58
59impl Styled for Spinner {
60    fn style(&mut self) -> &mut StyleRefinement {
61        &mut self.style
62    }
63}
64
65/// The accessible name when none is given, as shadcn's.
66const DEFAULT_LABEL: &str = "Loading";
67
68impl RenderOnce for Spinner {
69    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
70        let label = self
71            .accessibility_label
72            .unwrap_or_else(|| DEFAULT_LABEL.into());
73        div()
74            .id(self.id.clone())
75            .role(Role::Status)
76            .aria_label(label.clone())
77            .test_support()
78            .flex_shrink_0()
79            .size_4()
80            .refine_style(&self.style)
81            .child(
82                Progress::new(ElementId::NamedChild(self.id.into(), "arc".into()))
83                    .circular()
84                    .accessibility_label(label)
85                    .size_full(),
86            )
87    }
88}