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 {
103                        data: &(),
104                        index: 0,
105                        value: 100.,
106                        start_angle: 0.,
107                        end_angle: TAU,
108                        pad_angle: 0.,
109                    },
110                    color.opacity(0.2),
111                    None,
112                    None,
113                    &prepaint.bounds,
114                    window,
115                );
116
117                if prepaint.end_value > 0. {
118                    let start_angle = (prepaint.start_value / 100.) * TAU;
119                    let end_angle = (prepaint.end_value / 100.) * TAU;
120                    arc.paint(
121                        &ArcData {
122                            data: &(),
123                            index: 1,
124                            value: prepaint.end_value,
125                            start_angle,
126                            end_angle,
127                            pad_angle: 0.,
128                        },
129                        color,
130                        None,
131                        None,
132                        &prepaint.bounds,
133                        window,
134                    );
135                }
136            },
137        )
138        .absolute()
139        .size_full()
140    }
141}
142
143impl Styled for ProgressCircle {
144    fn style(&mut self) -> &mut StyleRefinement {
145        &mut self.style
146    }
147}
148
149impl Sizable for ProgressCircle {
150    fn with_size(mut self, size: impl Into<Size>) -> Self {
151        self.size = size.into();
152        self
153    }
154}
155
156impl ParentElement for ProgressCircle {
157    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
158        self.children.extend(elements);
159    }
160}
161
162impl RenderOnce for ProgressCircle {
163    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
164        let value = self.value;
165        let loading = self.loading;
166        let accessibility_label = self.accessibility_label;
167        let animated_value = transition(
168            (self.id.clone(), "value"),
169            value,
170            Transition::new(cx.theme().motion_tokens().duration_normal)
171                .easing(cx.theme().motion_tokens().easing_move.clone()),
172            window,
173            cx,
174        );
175
176        let color = self.color.unwrap_or(cx.theme().progress_bar);
177
178        BaseProgress::new(self.id.clone())
179            .value(value)
180            .indeterminate(loading)
181            .when_some(accessibility_label, |this, label| {
182                this.accessibility_label(label)
183            })
184            .flex()
185            .items_center()
186            .justify_center()
187            .line_height(relative(1.))
188            .map(|this| match self.size {
189                Size::XSmall => this.size_2(),
190                Size::Small => this.size_3(),
191                Size::Medium => this.size_4(),
192                Size::Large => this.size_5(),
193                Size::Size(s) => this.size(s * 0.75),
194            })
195            .refine_style(&self.style)
196            .children(self.children)
197            .map(|this| {
198                if loading {
199                    this.with_animation(
200                        "progress-circle-loading",
201                        Animation::new(Duration::from_secs(1)).repeat(),
202                        move |this, delta| {
203                            let end = ease_in_out(delta) * 100.;
204                            let start = ease_in_out(((delta - 0.5) / 0.5).clamp(0., 1.)) * 100.;
205                            this.child(Self::render_circle(start, end, color))
206                        },
207                    )
208                    .into_any_element()
209                } else {
210                    this.child(Self::render_circle(0., animated_value, color))
211                        .into_any_element()
212                }
213            })
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn stores_an_explicit_accessibility_label() {
223        let plain = ProgressCircle::new("upload");
224        assert_eq!(plain.accessibility_label, None);
225
226        let named = ProgressCircle::new("upload").accessibility_label("Upload progress");
227        assert_eq!(
228            named.accessibility_label.as_deref(),
229            Some("Upload progress")
230        );
231    }
232}