1#![allow(clippy::unusual_byte_groupings)]
3
4use std::f32::consts::PI;
5use std::time::Duration;
6
7use crate::motion::StyledSlot;
8use crate::theme::{paint, rgb, ActiveTheme, Theme, ThemeKind};
9use gpui::{
10 canvas, div, point, px, Animation, AnimationExt as _, App, Bounds, Hsla, IntoElement,
11 ParentElement, PathBuilder, Pixels, Point, RenderOnce, StyleRefinement, Styled, Window,
12};
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum SpinnerSize {
17 Small,
18 #[default]
19 Default,
20 Large,
21 Display,
22}
23
24impl SpinnerSize {
25 pub fn px(self) -> f32 {
26 match self {
27 Self::Small => 16.0,
28 Self::Default => 20.0,
29 Self::Large => 24.0,
30 Self::Display => 32.0,
31 }
32 }
33
34 pub fn stroke(self) -> f32 {
35 match self {
36 Self::Small => 1.5,
37 Self::Default => 1.75,
38 Self::Large => 2.0,
39 Self::Display => 2.25,
40 }
41 }
42}
43
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
46pub enum SpinnerTone {
47 #[default]
48 Default,
49 Muted,
50 Inverse,
51 Destructive,
52}
53
54#[derive(IntoElement)]
56pub struct Spinner {
57 size_px: f32,
58 tone: SpinnerTone,
59 arc: Option<Hsla>,
60 track: Option<Hsla>,
61 style: StyleRefinement,
62}
63
64impl Spinner {
65 pub fn new() -> Self {
66 Self {
67 size_px: SpinnerSize::Default.px(),
68 tone: SpinnerTone::Default,
69 arc: None,
70 track: None,
71 style: StyleRefinement::default().flex_shrink_0(),
72 }
73 }
74
75 pub fn size(mut self, size: SpinnerSize) -> Self {
76 self.size_px = size.px();
77 self
78 }
79
80 pub fn px(mut self, size: impl Into<Pixels>) -> Self {
81 self.size_px = f32::from(size.into());
82 self
83 }
84
85 pub fn tone(mut self, tone: SpinnerTone) -> Self {
86 self.tone = tone;
87 self
88 }
89
90 pub fn color(mut self, color: Hsla) -> Self {
92 self.arc = Some(color);
93 self
94 }
95
96 pub fn track_color(mut self, color: Hsla) -> Self {
97 self.track = Some(color);
98 self
99 }
100}
101
102impl Default for Spinner {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl Styled for Spinner {
109 fn style(&mut self) -> &mut StyleRefinement {
110 &mut self.style
111 }
112}
113
114impl RenderOnce for Spinner {
115 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
116 let size_px = self.size_px;
117 let stroke = stroke_for_size(size_px);
118 let (track, arc) = resolve_paints(cx.theme(), self.tone, self.arc, self.track);
119
120 div()
121 .size(px(size_px))
122 .flex_shrink_0()
123 .overflow_hidden()
124 .refine_style(&self.style)
125 .with_animation(
126 "spinner-rotate",
127 Animation::new(Duration::from_millis(700)).repeat(),
128 move |el, delta| {
129 el.child(
130 canvas(
131 |_, _, _| {},
132 move |bounds, _, window, _| {
133 paint_spinner(window, bounds, stroke, track, arc, delta);
134 },
135 )
136 .size_full(),
137 )
138 },
139 )
140 }
141}
142
143fn stroke_for_size(size: f32) -> f32 {
144 if (size - 14.0).abs() < f32::EPSILON || (size - 16.0).abs() < f32::EPSILON {
145 1.5
146 } else if (size - 20.0).abs() < f32::EPSILON {
147 1.75
148 } else if (size - 24.0).abs() < f32::EPSILON {
149 2.0
150 } else if (size - 32.0).abs() < f32::EPSILON {
151 2.25
152 } else {
153 size * 1.75 / 20.0
154 }
155}
156
157fn resolve_paints(
158 theme: Theme,
159 tone: SpinnerTone,
160 arc: Option<Hsla>,
161 track: Option<Hsla>,
162) -> (Hsla, Hsla) {
163 let (paper_track, paper_arc) = spinner_paints(theme, tone);
164 let arc = arc.unwrap_or(paper_arc);
165 let track = track.unwrap_or_else(|| {
166 if arc == paper_arc {
167 paper_track
168 } else {
169 arc.alpha(0.22)
170 }
171 });
172 (track, arc)
173}
174
175pub fn spinner_paints(theme: Theme, tone: SpinnerTone) -> (Hsla, Hsla) {
177 match (theme.kind, tone) {
178 (ThemeKind::Light, SpinnerTone::Default) => (paint(0x18181B_33), rgb(0x18181B)),
179 (ThemeKind::Light, SpinnerTone::Muted) => (paint(0x52525B_47), rgb(0x52525B)),
180 (ThemeKind::Light, SpinnerTone::Inverse) => (paint(0xFAFAFA_38), rgb(0xFAFAFA)),
181 (ThemeKind::Light, SpinnerTone::Destructive) => (paint(0xDC2626_33), rgb(0xDC2626)),
182 (ThemeKind::Dark, SpinnerTone::Default) => (paint(0xFAFAFA_2E), rgb(0xFAFAFA)),
183 (ThemeKind::Dark, SpinnerTone::Muted) => (paint(0xA1A1AA_47), rgb(0xA1A1AA)),
184 (ThemeKind::Dark, SpinnerTone::Inverse) => (paint(0x18181B_2E), rgb(0x18181B)),
185 (ThemeKind::Dark, SpinnerTone::Destructive) => (paint(0xF87171_47), rgb(0xF87171)),
186 }
187}
188
189fn paint_spinner(
190 window: &mut Window,
191 bounds: Bounds<Pixels>,
192 stroke: f32,
193 track: Hsla,
194 arc: Hsla,
195 delta: f32,
196) {
197 let stroke = px(stroke);
198 let center = bounds.center();
199 let radius = (bounds.size.width.min(bounds.size.height) / 2.0) * 0.75;
200
201 paint_ring(window, center, radius, stroke, track);
202
203 let turn = delta * 2.0 * PI;
204 let start_angle = -PI / 2.0 + turn;
205 let end_angle = start_angle + (2.0 * PI / 3.0);
206 let start = point_on_circle(center, radius, start_angle);
207 let end = point_on_circle(center, radius, end_angle);
208
209 let mut arc_path = PathBuilder::stroke(stroke);
210 arc_path.move_to(start);
211 arc_path.arc_to(point(radius, radius), px(0.), false, true, end);
212 if let Ok(path) = arc_path.build() {
213 window.paint_path(path, arc);
214 }
215
216 let cap = stroke / 2.0;
217 paint_dot(window, start, cap, arc);
218 paint_dot(window, end, cap, arc);
219}
220
221pub(crate) fn paint_ring(
222 window: &mut Window,
223 center: Point<Pixels>,
224 radius: Pixels,
225 stroke: Pixels,
226 color: Hsla,
227) {
228 let mut builder = PathBuilder::stroke(stroke);
229 builder.move_to(point(center.x + radius, center.y));
230 builder.arc_to(
231 point(radius, radius),
232 px(0.),
233 false,
234 true,
235 point(center.x - radius, center.y),
236 );
237 builder.arc_to(
238 point(radius, radius),
239 px(0.),
240 false,
241 true,
242 point(center.x + radius, center.y),
243 );
244 builder.close();
245 if let Ok(path) = builder.build() {
246 window.paint_path(path, color);
247 }
248}
249
250pub(crate) fn paint_dot(window: &mut Window, origin: Point<Pixels>, radius: Pixels, color: Hsla) {
251 if radius <= px(0.) {
252 return;
253 }
254 let mut builder = PathBuilder::fill();
255 builder.move_to(point(origin.x + radius, origin.y));
256 builder.arc_to(
257 point(radius, radius),
258 px(0.),
259 false,
260 true,
261 point(origin.x - radius, origin.y),
262 );
263 builder.arc_to(
264 point(radius, radius),
265 px(0.),
266 false,
267 true,
268 point(origin.x + radius, origin.y),
269 );
270 builder.close();
271 if let Ok(path) = builder.build() {
272 window.paint_path(path, color);
273 }
274}
275
276pub(crate) fn point_on_circle(center: Point<Pixels>, radius: Pixels, angle: f32) -> Point<Pixels> {
277 point(
278 center.x + radius * angle.cos(),
279 center.y + radius * angle.sin(),
280 )
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn sizes_match_paper() {
289 assert_eq!(SpinnerSize::Small.px(), 16.0);
290 assert_eq!(SpinnerSize::Small.stroke(), 1.5);
291 assert_eq!(SpinnerSize::Default.px(), 20.0);
292 assert_eq!(SpinnerSize::Default.stroke(), 1.75);
293 assert_eq!(SpinnerSize::Large.px(), 24.0);
294 assert_eq!(SpinnerSize::Large.stroke(), 2.0);
295 assert_eq!(SpinnerSize::Display.px(), 32.0);
296 assert_eq!(SpinnerSize::Display.stroke(), 2.25);
297 assert_eq!(stroke_for_size(14.0), 1.5);
298 }
299
300 #[test]
301 fn light_default_matches_paper() {
302 let (track, arc) = spinner_paints(Theme::light(), SpinnerTone::Default);
303 assert_eq!(arc, rgb(0x18181B));
304 assert_eq!(track, paint(0x18181B_33));
305 }
306
307 #[test]
308 fn dark_muted_matches_paper() {
309 let (track, arc) = spinner_paints(Theme::dark(), SpinnerTone::Muted);
310 assert_eq!(arc, rgb(0xA1A1AA));
311 assert_eq!(track, paint(0xA1A1AA_47));
312 }
313}