Skip to main content

gpui_component/progress/
progress_circle.rs

1use crate::{ActiveTheme, Sizable, Size, StyledExt};
2use gpui::Bounds;
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5    Animation, AnimationExt as _, AnyElement, App, ElementId, Hsla, IntoElement, ParentElement,
6    Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, ease_in_out, px,
7    relative,
8};
9use gpui_base::{Progress as BaseProgress, Transition, transition};
10use instant::Duration;
11use std::f32::consts::TAU;
12
13use crate::plot::shape::{Arc, ArcData};
14
15/// A circular progress indicator element.
16#[derive(IntoElement)]
17pub struct ProgressCircle {
18    id: ElementId,
19    style: StyleRefinement,
20    color: Option<Hsla>,
21    value: f32,
22    accessibility_label: Option<SharedString>,
23    size: Size,
24    children: Vec<AnyElement>,
25    loading: bool,
26}
27
28impl ProgressCircle {
29    /// Create a new circular progress indicator.
30    pub fn new(id: impl Into<ElementId>) -> Self {
31        Self {
32            id: id.into(),
33            value: Default::default(),
34            color: None,
35            accessibility_label: None,
36            style: StyleRefinement::default(),
37            size: Size::default(),
38            children: Vec::new(),
39            loading: false,
40        }
41    }
42
43    /// Enable indeterminate loading animation.
44    ///
45    /// When `loading` is `true`, the `value` is ignored and an infinite
46    /// rotating arc animation is shown instead.
47    pub fn loading(mut self, loading: bool) -> Self {
48        self.loading = loading;
49        self
50    }
51
52    /// Set the color of the progress circle.
53    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
54        self.color = Some(color.into());
55        self
56    }
57
58    /// Set the percentage value of the progress circle.
59    ///
60    /// The value should be between 0.0 and 100.0.
61    pub fn value(mut self, value: f32) -> Self {
62        self.value = value.clamp(0., 100.);
63        self
64    }
65
66    /// Set the accessible name exposed by the progress indicator.
67    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
68        self.accessibility_label = Some(label.into());
69        self
70    }
71
72    /// Render the arc canvas. `start_value` and `end_value` are in 0.0–100.0 percentage.
73    /// The progress arc is skipped when `end_value <= 0`.
74    fn render_circle(start_value: f32, end_value: f32, color: Hsla) -> impl IntoElement {
75        struct PrepaintState {
76            start_value: f32,
77            end_value: f32,
78            actual_inner_radius: f32,
79            actual_outer_radius: f32,
80            bounds: Bounds<Pixels>,
81        }
82
83        canvas(
84            move |bounds: Bounds<Pixels>, _window: &mut Window, _cx: &mut App| {
85                let stroke_width = (bounds.size.width * 0.15).min(px(5.));
86                let actual_size = bounds.size.width.min(bounds.size.height);
87                let actual_radius = (actual_size.as_f32() - stroke_width.as_f32()) / 2.;
88                PrepaintState {
89                    start_value,
90                    end_value,
91                    actual_inner_radius: actual_radius - stroke_width.as_f32() / 2.,
92                    actual_outer_radius: actual_radius + stroke_width.as_f32() / 2.,
93                    bounds,
94                }
95            },
96            move |_bounds, prepaint, window: &mut Window, _cx: &mut App| {
97                let arc = Arc::new()
98                    .inner_radius(prepaint.actual_inner_radius)
99                    .outer_radius(prepaint.actual_outer_radius);
100
101                arc.paint(
102                    &ArcData::new(&(), 0, 100., 0., TAU),
103                    color.opacity(0.2),
104                    &prepaint.bounds,
105                    window,
106                );
107
108                if prepaint.end_value > 0. {
109                    let start_angle = (prepaint.start_value / 100.) * TAU;
110                    let end_angle = (prepaint.end_value / 100.) * TAU;
111                    arc.paint(
112                        &ArcData::new(&(), 1, prepaint.end_value, start_angle, end_angle),
113                        color,
114                        &prepaint.bounds,
115                        window,
116                    );
117                }
118            },
119        )
120        .absolute()
121        .size_full()
122    }
123}
124
125impl Styled for ProgressCircle {
126    fn style(&mut self) -> &mut StyleRefinement {
127        &mut self.style
128    }
129}
130
131impl Sizable for ProgressCircle {
132    fn with_size(mut self, size: impl Into<Size>) -> Self {
133        self.size = size.into();
134        self
135    }
136}
137
138impl ParentElement for ProgressCircle {
139    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
140        self.children.extend(elements);
141    }
142}
143
144impl RenderOnce for ProgressCircle {
145    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
146        let value = self.value;
147        let loading = self.loading;
148        let accessibility_label = self.accessibility_label;
149        let animated_value = transition(
150            (self.id.clone(), "value"),
151            value,
152            Transition::new(cx.theme().motion_tokens().duration_normal)
153                .easing(cx.theme().motion_tokens().easing_move.clone()),
154            window,
155            cx,
156        )
157        // An overshooting theme curve may carry the value past either end, but
158        // the ring cannot sweep more than once around.
159        .clamp(0., 100.);
160
161        let color = self.color.unwrap_or(cx.theme().progress_bar);
162
163        BaseProgress::new(self.id.clone())
164            .value(value)
165            .indeterminate(loading)
166            .when_some(accessibility_label, |this, label| {
167                this.accessibility_label(label)
168            })
169            .flex()
170            .items_center()
171            .justify_center()
172            .line_height(relative(1.))
173            .map(|this| match self.size {
174                Size::XSmall => this.size_2(),
175                Size::Small => this.size_3(),
176                Size::Medium => this.size_4(),
177                Size::Large => this.size_5(),
178                Size::Size(s) => this.size(s * 0.75),
179            })
180            .refine_style(&self.style)
181            .children(self.children)
182            .map(|this| {
183                if loading {
184                    this.with_animation(
185                        "progress-circle-loading",
186                        Animation::new(Duration::from_secs(1)).repeat(),
187                        move |this, delta| {
188                            let end = ease_in_out(delta) * 100.;
189                            let start = ease_in_out(((delta - 0.5) / 0.5).clamp(0., 1.)) * 100.;
190                            this.child(Self::render_circle(start, end, color))
191                        },
192                    )
193                    .into_any_element()
194                } else {
195                    this.child(Self::render_circle(0., animated_value, color))
196                        .into_any_element()
197                }
198            })
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn stores_an_explicit_accessibility_label() {
208        let plain = ProgressCircle::new("upload");
209        assert_eq!(plain.accessibility_label, None);
210
211        let named = ProgressCircle::new("upload").accessibility_label("Upload progress");
212        assert_eq!(
213            named.accessibility_label.as_deref(),
214            Some("Upload progress")
215        );
216    }
217}