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#[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 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 pub fn loading(mut self, loading: bool) -> Self {
48 self.loading = loading;
49 self
50 }
51
52 pub fn color(mut self, color: impl Into<Hsla>) -> Self {
54 self.color = Some(color.into());
55 self
56 }
57
58 pub fn value(mut self, value: f32) -> Self {
62 self.value = value.clamp(0., 100.);
63 self
64 }
65
66 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
68 self.accessibility_label = Some(label.into());
69 self
70 }
71
72 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}