Skip to main content

herogpui_components/
spinner.rs

1//! Spinner — port of `@heroui/spinner` (v3).
2//!
3//! `size` is `sm | md | lg | xl` and `color` is
4//! `current | accent | success | warning | danger`, where `current` inherits
5//! the surrounding text color (used inside a pending `Button`). A caller can
6//! also name the diameter in pixels (`size_px`) or replace the arc with its
7//! own glyph (`glyph`); both keep the rotation, the reduced-motion
8//! suppression and the `role="status"` root.
9
10use std::time::Duration;
11
12use gpui::{
13    prelude::*, px, svg, Animation, AnimationExt, AnyElement, App, IntoElement, RenderOnce, Svg,
14    Window,
15};
16use herogpui_core::Color;
17use herogpui_theme::ActiveTheme;
18
19use crate::a11y::A11y as _;
20use crate::icons;
21
22/// Spinner diameter (`size` prop).
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum SpinnerSize {
25    Sm,
26    #[default]
27    Md,
28    Lg,
29    Xl,
30}
31
32impl SpinnerSize {
33    pub const ALL: [SpinnerSize; 4] = [
34        SpinnerSize::Sm,
35        SpinnerSize::Md,
36        SpinnerSize::Lg,
37        SpinnerSize::Xl,
38    ];
39
40    pub fn px(self) -> gpui::Pixels {
41        match self {
42            SpinnerSize::Sm => px(16.0),
43            SpinnerSize::Md => px(24.0),
44            SpinnerSize::Lg => px(32.0),
45            SpinnerSize::Xl => px(40.0),
46        }
47    }
48
49    pub fn label(self) -> &'static str {
50        match self {
51            SpinnerSize::Sm => "Sm",
52            SpinnerSize::Md => "Md",
53            SpinnerSize::Lg => "Lg",
54            SpinnerSize::Xl => "Xl",
55        }
56    }
57}
58
59impl From<herogpui_core::Size> for SpinnerSize {
60    fn from(size: herogpui_core::Size) -> Self {
61        match size {
62            herogpui_core::Size::Sm => SpinnerSize::Sm,
63            herogpui_core::Size::Md => SpinnerSize::Md,
64            herogpui_core::Size::Lg => SpinnerSize::Lg,
65        }
66    }
67}
68
69/// A rotating arc spinner, animated on the GPU.
70#[derive(IntoElement)]
71pub struct Spinner {
72    id: gpui::ElementId,
73    size: SpinnerSize,
74    /// Explicit diameter from [`Spinner::size_px`], winning over `size` when
75    /// set.
76    size_px: Option<gpui::Pixels>,
77    color: Color,
78    /// Set by `color="current"`: the resolved colour of the surrounding text.
79    current_color: Option<gpui::Hsla>,
80    /// One full turn, in milliseconds. HeroUI's default `animate-spin-fast`
81    /// token is 750ms; the local setter also gives the gallery a deterministic
82    /// equivalent of its speed utility examples.
83    duration_ms: u64,
84    /// Caller glyph replacing the arc, from [`Spinner::glyph`].
85    glyph: Option<AnyElement>,
86    /// The `sx` slot, refined over the root style at the end of render.
87    sx: Option<Box<gpui::StyleRefinement>>,
88}
89
90impl Spinner {
91    pub fn new(id: impl Into<gpui::ElementId>) -> Self {
92        Self {
93            id: id.into(),
94            size: SpinnerSize::default(),
95            size_px: None,
96            color: Color::Accent,
97            current_color: None,
98            duration_ms: 750,
99            glyph: None,
100            sx: None,
101        }
102    }
103
104    /// How long one full turn takes. v3 sets it with an animation utility.
105    pub fn duration_ms(mut self, ms: u64) -> Self {
106        self.duration_ms = ms.max(1);
107        self
108    }
109
110    pub fn size(mut self, size: impl Into<SpinnerSize>) -> Self {
111        self.size = size.into();
112        self
113    }
114
115    /// Overrides the diameter `size` derives. HeroUI names the arc's box with
116    /// Tailwind size utilities, so a caller needing an in-between diameter
117    /// (12, 14, 20px…) names it in pixels instead. The explicit diameter wins
118    /// whether it is set before or after [`Spinner::size`].
119    pub fn size_px(mut self, diameter: impl Into<gpui::Pixels>) -> Self {
120        self.size_px = Some(diameter.into());
121        self
122    }
123
124    /// Replaces the arc glyph with a caller element. The spinner keeps owning
125    /// the box and the motion: the resolved diameter sizes the container the
126    /// glyph centers in, the resolved colour (`color`/`current_color`) is
127    /// applied to the glyph the same way the arc's svg is coloured, and the
128    /// same repeated rotation — with the same reduced-motion suppression and
129    /// the same `role="status"` root — wraps the caller's element.
130    ///
131    /// Pass the `svg()` element itself rather than a div wrapper: gpui can
132    /// transform only svgs, so an svg nested deeper would sit still inside
133    /// the animated container.
134    pub fn glyph(mut self, glyph: impl IntoElement) -> Self {
135        self.glyph = Some(glyph.into_any_element());
136        self
137    }
138
139    pub fn color(mut self, color: Color) -> Self {
140        self.color = color;
141        self.current_color = None;
142        self
143    }
144
145    /// `color="current"`. gpui svgs do not inherit `text_color`, so the caller
146    /// passes the surrounding text colour explicitly.
147    pub fn current_color(mut self, color: gpui::Hsla) -> Self {
148        self.current_color = Some(color);
149        self
150    }
151
152    /// The one slot for caller-owned low-level styling: GPUI's styling methods
153    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
154    /// applied to the spinner's root element after every value the size, the
155    /// colour and the active theme chose, so they win.
156    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
157        self.sx = Some(crate::util::capture_sx(style));
158        self
159    }
160
161    /// The rendered diameter: the explicit [`Spinner::size_px`] override when
162    /// set, the documented [`SpinnerSize`] step otherwise.
163    fn diameter(&self) -> gpui::Pixels {
164        self.size_px.unwrap_or_else(|| self.size.px())
165    }
166}
167
168/// The rotation's normalized turn fraction, clamped against the easing's
169/// non-finite edge.
170fn spin_fraction(delta: f32) -> f32 {
171    if delta.is_finite() {
172        delta.clamp(0.0, 1.0)
173    } else {
174        0.0
175    }
176}
177
178/// Rotates the caller's svg glyph to `t` of a full turn. gpui can transform
179/// only svgs (`Svg::with_transformation` is an inherent method), so any other
180/// glyph is returned unchanged and the animation keeps scheduling its frames
181/// on wall time exactly as for the arc.
182fn rotate_glyph(glyph: AnyElement, t: f32) -> AnyElement {
183    let mut glyph = glyph;
184    let Some(shell) = glyph.downcast_mut::<Svg>() else {
185        return glyph;
186    };
187    // No API hands the svg back out by value and the transformation field is
188    // private, so swap a fresh svg into the shell and keep the caller's.
189    let caller = std::mem::replace(shell, svg());
190    caller
191        .with_transformation(gpui::Transformation::rotate(gpui::percentage(t)))
192        .into_any_element()
193}
194
195impl RenderOnce for Spinner {
196    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
197        let color = self.current_color.unwrap_or(match self.color {
198            Color::Default => cx.colors().muted,
199            other => cx.role(other).color,
200        });
201        let diameter = self.diameter();
202
203        let glyph = match self.glyph {
204            None => {
205                let spinner = svg()
206                    .size(diameter)
207                    .flex_shrink_0()
208                    .path(icons::SPINNER)
209                    .text_color(color);
210                if ActiveTheme::reduce_motion(cx) {
211                    crate::util::apply_sx(spinner, &self.sx).into_any_element()
212                } else {
213                    // `with_animation` hands back an `AnimationElement`, which has no
214                    // style of its own to refine, so the slot lands on the svg the
215                    // rotation wraps.
216                    crate::util::apply_sx(spinner, &self.sx)
217                        .with_animation(
218                            self.id.clone(),
219                            Animation::new(Duration::from_millis(self.duration_ms)).repeat(),
220                            |svg, delta| {
221                                svg.with_transformation(gpui::Transformation::rotate(
222                                    gpui::percentage(spin_fraction(delta)),
223                                ))
224                            },
225                        )
226                        .into_any_element()
227                }
228            }
229            Some(mut custom) => {
230                // gpui svgs do not inherit the container's `text_color`, so
231                // the resolved colour reaches a caller svg the same way it
232                // reaches the arc: on the svg's own style. Every other glyph
233                // (text, divs) inherits it from the container below.
234                if let Some(svg) = custom.downcast_mut::<Svg>() {
235                    svg.style().text.color = Some(color);
236                }
237                let spinning = if ActiveTheme::reduce_motion(cx) {
238                    custom
239                } else {
240                    custom
241                        .with_animation(
242                            self.id.clone(),
243                            Animation::new(Duration::from_millis(self.duration_ms)).repeat(),
244                            |glyph, delta| rotate_glyph(glyph, spin_fraction(delta)),
245                        )
246                        .into_any_element()
247                };
248                let container = gpui::div()
249                    .flex()
250                    .items_center()
251                    .justify_center()
252                    .size(diameter)
253                    .flex_shrink_0()
254                    .text_color(color)
255                    .child(spinning);
256                // The slot lands on the spinner-owned container, the same
257                // "box around the glyph" the arc's svg occupies.
258                crate::util::apply_sx(container, &self.sx).into_any_element()
259            }
260        };
261
262        // The node sits on a box *around* the glyph rather than on the glyph
263        // itself, which is upstream's own anatomy:
264        // `@heroui/react/dist/components/spinner/spinner.js` imports no
265        // `react-aria-components` primitive at all and hard-codes
266        // `role: "status"` with `"aria-label": "Loading"` on its `dom.span`
267        // root, giving the `SpinnerPrimitive` svg inside it `aria-hidden: true`.
268        // (`Spinner` is a separate v3 export from `ProgressCircle`, which goes
269        // through `useProgressBar`; the two are not the same component.)
270        //
271        // It also has to be a separate element here: the rotation is applied
272        // by `Svg::with_transformation`, an inherent method on `Svg` that a
273        // `Stateful<Svg>` no longer exposes, so an id on the glyph and the
274        // animation cannot both survive. The `aria-hidden` half needs no
275        // builder — the glyph has no id, and an element with no id and no role
276        // produces no AccessKit node at all (`gpui-pre-0.3.3`'s
277        // `window/a11y.rs`), which is the stronger form of the same thing.
278        //
279        // Stated after the layout chain, not spliced into it:
280        // `.shots/design_audit.py` reads sizes and gaps out of builder chains
281        // with character-windowed regexes.
282        let root = gpui::div().flex().flex_shrink_0().child(glyph);
283        root.id(self.id).a11y_named(
284            crate::a11y::Role::Status,
285            &crate::a11y::Name::labelled("Loading"),
286        )
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::Spinner;
293
294    #[test]
295    fn default_speed_matches_heroui_spin_fast_token() {
296        assert_eq!(Spinner::new("spinner").duration_ms, 750);
297    }
298}