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