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
158        let color = self.color.unwrap_or(cx.theme().progress_bar);
159
160        BaseProgress::new(self.id.clone())
161            .value(value)
162            .indeterminate(loading)
163            .when_some(accessibility_label, |this, label| {
164                this.accessibility_label(label)
165            })
166            .flex()
167            .items_center()
168            .justify_center()
169            .line_height(relative(1.))
170            .map(|this| match self.size {
171                Size::XSmall => this.size_2(),
172                Size::Small => this.size_3(),
173                Size::Medium => this.size_4(),
174                Size::Large => this.size_5(),
175                Size::Size(s) => this.size(s * 0.75),
176            })
177            .refine_style(&self.style)
178            .children(self.children)
179            .map(|this| {
180                if loading {
181                    this.with_animation(
182                        "progress-circle-loading",
183                        Animation::new(Duration::from_secs(1)).repeat(),
184                        move |this, delta| {
185                            let end = ease_in_out(delta) * 100.;
186                            let start = ease_in_out(((delta - 0.5) / 0.5).clamp(0., 1.)) * 100.;
187                            this.child(Self::render_circle(start, end, color))
188                        },
189                    )
190                    .into_any_element()
191                } else {
192                    this.child(Self::render_circle(0., animated_value, color))
193                        .into_any_element()
194                }
195            })
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn stores_an_explicit_accessibility_label() {
205        let plain = ProgressCircle::new("upload");
206        assert_eq!(plain.accessibility_label, None);
207
208        let named = ProgressCircle::new("upload").accessibility_label("Upload progress");
209        assert_eq!(
210            named.accessibility_label.as_deref(),
211            Some("Upload progress")
212        );
213    }
214}