Skip to main content

glassy_ui/
progress.rs

1//! Paper hex+alpha is grouped as `RRGGBB_AA`.
2#![allow(clippy::unusual_byte_groupings)]
3
4use std::f32::consts::{PI, TAU};
5
6use crate::motion::StyledSlot;
7use crate::theme::{ActiveTheme, Theme};
8use gpui::{
9    canvas, div, point, prelude::FluentBuilder, px, relative, App, Bounds, Hsla, IntoElement,
10    ParentElement, PathBuilder, Pixels, RenderOnce, StyleRefinement, Styled, Window,
11};
12
13use crate::button::ButtonVariant;
14use crate::chrome::{box_shadow, button_chrome};
15use crate::spinner::{paint_dot, paint_ring, point_on_circle, spinner_paints, SpinnerTone};
16
17/// Determinate linear progress matching Paper `Glassy UI` → Progress.
18///
19/// Values are clamped to `0.0..=1.0`. The default geometry is 280×8; width can
20/// be changed through [`Styled`] and the fill remains proportional.
21#[derive(IntoElement)]
22pub struct Progress {
23    value: f32,
24    theme: Option<Theme>,
25    style: StyleRefinement,
26}
27
28impl Progress {
29    pub fn new(value: f32) -> Self {
30        Self {
31            value: normalize(value),
32            theme: None,
33            style: StyleRefinement::default(),
34        }
35    }
36
37    /// Override the active app theme for this progress indicator only.
38    pub fn theme(mut self, theme: Theme) -> Self {
39        self.theme = Some(theme);
40        self
41    }
42}
43
44impl Styled for Progress {
45    fn style(&mut self) -> &mut StyleRefinement {
46        &mut self.style
47    }
48}
49
50impl RenderOnce for Progress {
51    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
52        let theme = self.theme.unwrap_or_else(|| cx.theme());
53        let track = button_chrome(theme, ButtonVariant::Outline);
54        let fill = button_chrome(theme, ButtonVariant::Primary);
55
56        div()
57            .w(px(280.))
58            .h(px(8.))
59            .flex_shrink_0()
60            .overflow_hidden()
61            .rounded(px(4.))
62            .border_1()
63            .border_color(track.border)
64            .bg(track.bg)
65            .shadow(vec![
66                box_shadow(0., 1., track.inset, 0., 0.),
67                box_shadow(0., track.shadow_y, track.shadow, track.shadow_blur, 0.),
68            ])
69            .refine_style(&self.style)
70            .when(self.value > 0.0, |el| {
71                el.child(
72                    div()
73                        .h_full()
74                        .w(relative(self.value))
75                        .flex_shrink_0()
76                        .rounded(px(4.))
77                        .bg(fill.bg)
78                        .shadow(vec![box_shadow(0., 1., fill.inset, 0., 0.)]),
79                )
80            })
81    }
82}
83
84/// Determinate 24px circular progress matching Paper `Glassy UI` → Progress.
85#[derive(IntoElement)]
86pub struct CircularProgress {
87    value: f32,
88    theme: Option<Theme>,
89    style: StyleRefinement,
90}
91
92impl CircularProgress {
93    pub fn new(value: f32) -> Self {
94        Self {
95            value: normalize(value),
96            theme: None,
97            style: StyleRefinement::default().flex_shrink_0(),
98        }
99    }
100
101    /// Override the active app theme for this progress indicator only.
102    pub fn theme(mut self, theme: Theme) -> Self {
103        self.theme = Some(theme);
104        self
105    }
106}
107
108impl Styled for CircularProgress {
109    fn style(&mut self) -> &mut StyleRefinement {
110        &mut self.style
111    }
112}
113
114impl RenderOnce for CircularProgress {
115    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
116        let theme = self.theme.unwrap_or_else(|| cx.theme());
117        let (track, fill) = spinner_paints(theme, SpinnerTone::Default);
118        let value = self.value;
119
120        div()
121            .size(px(24.))
122            .flex_shrink_0()
123            .overflow_hidden()
124            .refine_style(&self.style)
125            .child(
126                canvas(
127                    |_, _, _| {},
128                    move |bounds, _, window, _| {
129                        paint_circular_progress(window, bounds, value, track, fill);
130                    },
131                )
132                .size_full(),
133            )
134    }
135}
136
137fn normalize(value: f32) -> f32 {
138    if value.is_finite() {
139        value.clamp(0.0, 1.0)
140    } else {
141        0.0
142    }
143}
144
145fn paint_circular_progress(
146    window: &mut Window,
147    bounds: Bounds<Pixels>,
148    value: f32,
149    track: Hsla,
150    fill: Hsla,
151) {
152    let stroke = px(2.);
153    let center = bounds.center();
154    let radius = (bounds.size.width.min(bounds.size.height) / 2.0) * 0.75;
155    paint_ring(window, center, radius, stroke, track);
156
157    if value <= 0.0 {
158        return;
159    }
160    if value >= 1.0 {
161        paint_ring(window, center, radius, stroke, fill);
162        return;
163    }
164
165    let start = point_on_circle(center, radius, -PI / 2.0);
166    let end = point_on_circle(center, radius, -PI / 2.0 + TAU * value);
167    let mut path = PathBuilder::stroke(stroke);
168    path.move_to(start);
169    path.arc_to(point(radius, radius), px(0.), value > 0.5, true, end);
170    if let Ok(path) = path.build() {
171        window.paint_path(path, fill);
172    }
173
174    let cap = stroke / 2.0;
175    paint_dot(window, start, cap, fill);
176    paint_dot(window, end, cap, fill);
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::theme::{paint, rgb};
183
184    #[test]
185    fn values_are_clamped() {
186        assert_eq!(normalize(-1.0), 0.0);
187        assert_eq!(normalize(0.4), 0.4);
188        assert_eq!(normalize(2.0), 1.0);
189        assert_eq!(normalize(f32::NAN), 0.0);
190    }
191
192    #[test]
193    fn linear_material_matches_paper() {
194        let light_track = button_chrome(Theme::light(), ButtonVariant::Outline);
195        let light_fill = button_chrome(Theme::light(), ButtonVariant::Primary);
196        assert_eq!(light_track.bg, paint(0xFFFFFF_47));
197        assert_eq!(light_track.border, paint(0xFFFFFF_9E));
198        assert_eq!(light_fill.bg, paint(0x18181B_B8));
199        assert_eq!(light_fill.inset, paint(0xFFFFFF_38));
200
201        let dark_track = button_chrome(Theme::dark(), ButtonVariant::Outline);
202        let dark_fill = button_chrome(Theme::dark(), ButtonVariant::Primary);
203        assert_eq!(dark_track.bg, paint(0xFFFFFF_0A));
204        assert_eq!(dark_track.border, paint(0xFFFFFF_24));
205        assert_eq!(dark_fill.bg, paint(0xFFFFFF_29));
206        assert_eq!(dark_fill.inset, paint(0xFFFFFF_47));
207    }
208
209    #[test]
210    fn circular_paints_match_paper() {
211        assert_eq!(
212            spinner_paints(Theme::light(), SpinnerTone::Default),
213            (paint(0x18181B_33), rgb(0x18181B))
214        );
215        assert_eq!(
216            spinner_paints(Theme::dark(), SpinnerTone::Default),
217            (paint(0xFAFAFA_2E), rgb(0xFAFAFA))
218        );
219    }
220}