Skip to main content

glassy_ui/
spinner.rs

1//! Paper hex+alpha is grouped as `RRGGBB_AA`.
2#![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/// Paper sizes: 16 / 20 / 24 / 32.
15#[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/// Paper Color row: Default / Muted / Inverse / Destructive.
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
46pub enum SpinnerTone {
47    #[default]
48    Default,
49    Muted,
50    Inverse,
51    Destructive,
52}
53
54/// Indeterminate spinner. Track + 120° arc, one turn every 700ms.
55#[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    /// Paint the arc with `color`. Track is the same hue at 22% alpha.
91    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
175/// `(track, arc)` from Paper Spinners, computed styles.
176pub 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}