1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
//! Spinner — port of `@heroui/spinner` (v3).
//!
//! `size` is `sm | md | lg | xl` and `color` is
//! `current | accent | success | warning | danger`, where `current` inherits
//! the surrounding text color (used inside a pending `Button`).
use std::time::Duration;
use gpui::{prelude::*, px, svg, Animation, AnimationExt, App, IntoElement, RenderOnce, Window};
use herogpui_core::Color;
use herogpui_theme::ActiveTheme;
use crate::a11y::A11y as _;
use crate::icons;
/// Spinner diameter (`size` prop).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SpinnerSize {
Sm,
#[default]
Md,
Lg,
Xl,
}
impl SpinnerSize {
pub const ALL: [SpinnerSize; 4] = [
SpinnerSize::Sm,
SpinnerSize::Md,
SpinnerSize::Lg,
SpinnerSize::Xl,
];
pub fn px(self) -> gpui::Pixels {
match self {
SpinnerSize::Sm => px(16.0),
SpinnerSize::Md => px(24.0),
SpinnerSize::Lg => px(32.0),
SpinnerSize::Xl => px(40.0),
}
}
pub fn label(self) -> &'static str {
match self {
SpinnerSize::Sm => "Sm",
SpinnerSize::Md => "Md",
SpinnerSize::Lg => "Lg",
SpinnerSize::Xl => "Xl",
}
}
}
impl From<herogpui_core::Size> for SpinnerSize {
fn from(size: herogpui_core::Size) -> Self {
match size {
herogpui_core::Size::Sm => SpinnerSize::Sm,
herogpui_core::Size::Md => SpinnerSize::Md,
herogpui_core::Size::Lg => SpinnerSize::Lg,
}
}
}
/// A rotating arc spinner, animated on the GPU.
#[derive(IntoElement)]
pub struct Spinner {
id: gpui::ElementId,
size: SpinnerSize,
color: Color,
/// Set by `color="current"`: the resolved colour of the surrounding text.
current_color: Option<gpui::Hsla>,
/// One full turn, in milliseconds. HeroUI's default `animate-spin-fast`
/// token is 750ms; the local setter also gives the gallery a deterministic
/// equivalent of its speed utility examples.
duration_ms: u64,
/// The `sx` slot, refined over the root style at the end of render.
sx: Option<Box<gpui::StyleRefinement>>,
}
impl Spinner {
pub fn new(id: impl Into<gpui::ElementId>) -> Self {
Self {
id: id.into(),
size: SpinnerSize::default(),
color: Color::Accent,
current_color: None,
duration_ms: 750,
sx: None,
}
}
/// How long one full turn takes. v3 sets it with an animation utility.
pub fn duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = ms.max(1);
self
}
pub fn size(mut self, size: impl Into<SpinnerSize>) -> Self {
self.size = size.into();
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self.current_color = None;
self
}
/// `color="current"`. gpui svgs do not inherit `text_color`, so the caller
/// passes the surrounding text colour explicitly.
pub fn current_color(mut self, color: gpui::Hsla) -> Self {
self.current_color = Some(color);
self
}
/// The one slot for caller-owned low-level styling: GPUI's styling methods
/// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
/// applied to the spinner's root element after every value the size, the
/// colour and the active theme chose, so they win.
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
self.sx = Some(crate::util::capture_sx(style));
self
}
}
impl RenderOnce for Spinner {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let color = self.current_color.unwrap_or(match self.color {
Color::Default => cx.colors().muted,
other => cx.role(other).color,
});
let spinner = svg()
.size(self.size.px())
.flex_shrink_0()
.path(icons::SPINNER)
.text_color(color);
let glyph = if ActiveTheme::reduce_motion(cx) {
crate::util::apply_sx(spinner, &self.sx).into_any_element()
} else {
// `with_animation` hands back an `AnimationElement`, which has no
// style of its own to refine, so the slot lands on the svg the
// rotation wraps.
crate::util::apply_sx(spinner, &self.sx)
.with_animation(
self.id.clone(),
Animation::new(Duration::from_millis(self.duration_ms)).repeat(),
|svg, delta| {
let t = if delta.is_finite() {
delta.clamp(0.0, 1.0)
} else {
0.0
};
svg.with_transformation(gpui::Transformation::rotate(gpui::percentage(t)))
},
)
.into_any_element()
};
// The node sits on a box *around* the glyph rather than on the glyph
// itself, which is upstream's own anatomy:
// `@heroui/react/dist/components/spinner/spinner.js` imports no
// `react-aria-components` primitive at all and hard-codes
// `role: "status"` with `"aria-label": "Loading"` on its `dom.span`
// root, giving the `SpinnerPrimitive` svg inside it `aria-hidden: true`.
// (`Spinner` is a separate v3 export from `ProgressCircle`, which goes
// through `useProgressBar`; the two are not the same component.)
//
// It also has to be a separate element here: the rotation is applied
// by `Svg::with_transformation`, an inherent method on `Svg` that a
// `Stateful<Svg>` no longer exposes, so an id on the glyph and the
// animation cannot both survive. The `aria-hidden` half needs no
// builder — the glyph has no id, and an element with no id and no role
// produces no AccessKit node at all (`gpui-pre-0.3.3`'s
// `window/a11y.rs`), which is the stronger form of the same thing.
//
// Stated after the layout chain, not spliced into it:
// `.shots/design_audit.py` reads sizes and gaps out of builder chains
// with character-windowed regexes.
let root = gpui::div().flex().flex_shrink_0().child(glyph);
root.id(self.id).a11y_named(
crate::a11y::Role::Status,
&crate::a11y::Name::labelled("Loading"),
)
}
}
#[cfg(test)]
mod tests {
use super::Spinner;
#[test]
fn default_speed_matches_heroui_spin_fast_token() {
assert_eq!(Spinner::new("spinner").duration_ms, 750);
}
}