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::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}