1use std::time::Duration;
2
3use std::f32::consts::TAU;
4
5use gpui_kit::{
6 App, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, PathBuilder,
7 Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, TestSupportExt as _, Window,
8 base::{self, ProgressIndicator, ProgressTrack, StyledExt as _, transition},
9 canvas, div, ease_in_out, point,
10 prelude::FluentBuilder as _,
11 px, relative,
12};
13
14use crate::{ActiveTheme as _, Theme, looping};
15
16#[derive(IntoElement)]
52pub struct Progress {
53 id: ElementId,
54 style: StyleRefinement,
55 value: Option<f32>,
56 accessibility_label: Option<SharedString>,
57 circular: bool,
58}
59
60impl Progress {
61 pub fn new(id: impl Into<ElementId>) -> Self {
63 Self {
64 id: id.into(),
65 style: StyleRefinement::default(),
66 value: None,
67 accessibility_label: None,
68 circular: false,
69 }
70 }
71
72 pub fn circular(mut self) -> Self {
76 self.circular = true;
77 self
78 }
79
80 pub fn is_circular(&self) -> bool {
82 self.circular
83 }
84
85 pub fn value(mut self, value: impl Into<Option<f32>>) -> Self {
88 self.value = value.into().map(|value| value.clamp(0., 100.));
89 self
90 }
91
92 pub fn percentage(&self) -> Option<f32> {
95 self.value
96 }
97
98 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
100 self.accessibility_label = Some(label.into());
101 self
102 }
103}
104
105impl Styled for Progress {
106 fn style(&mut self) -> &mut StyleRefinement {
107 &mut self.style
108 }
109}
110
111const SWEEP: Duration = Duration::from_secs(1);
114
115const SWEEP_FPS: u32 = 60;
118
119const STILL_INSET: f32 = 0.325;
123
124fn sweep(delta: f32) -> (f32, f32) {
128 let start = ease_in_out(((delta - 0.5) / 0.5).clamp(0., 1.));
129 let end = 1. - ease_in_out(1. - delta);
130 (start, end.max(start))
131}
132
133const TURN: Duration = Duration::from_secs(1);
136
137const TURN_FPS: u32 = 30;
139
140const SPINNER_ARC: f32 = 0.75;
143
144pub(crate) fn indeterminate_arc(phase: f32) -> (f32, f32) {
148 (phase.rem_euclid(1.), SPINNER_ARC)
149}
150
151fn on_circle(center: Point<Pixels>, radius: Pixels, fraction: f32) -> Point<Pixels> {
153 let angle = fraction * TAU;
154 point(
155 center.x + radius * angle.sin(),
156 center.y - radius * angle.cos(),
157 )
158}
159
160fn paint_arc(
164 center: Point<Pixels>,
165 radius: Pixels,
166 (start, length): (f32, f32),
167 width: Pixels,
168 color: Hsla,
169 window: &mut Window,
170) {
171 if length <= 0. {
172 return;
173 }
174 let mut path = PathBuilder::stroke(width);
175 let radii = point(radius, radius);
176 path.move_to(on_circle(center, radius, start));
177 if length >= 1. {
178 path.arc_to(
179 radii,
180 px(0.),
181 false,
182 true,
183 on_circle(center, radius, start + 0.5),
184 );
185 path.arc_to(radii, px(0.), false, true, on_circle(center, radius, start));
186 } else {
187 path.arc_to(
188 radii,
189 px(0.),
190 length > 0.5,
191 true,
192 on_circle(center, radius, start + length),
193 );
194 }
195 if let Ok(path) = path.build() {
196 window.paint_path(path, color);
197 }
198}
199
200impl Progress {
201 fn render_ring(self, window: &mut Window, cx: &mut App) -> gpui_kit::AnyElement {
204 let slide = Theme::global(cx).motion.slide_transition();
205 let color = self
206 .style
207 .text
208 .color
209 .unwrap_or_else(|| window.text_style().color);
210 let (size, stroke, track_strength) = {
211 let theme = cx.theme();
212 (
213 theme.metrics.ring_size,
214 theme.metrics.ring_stroke,
215 theme.progress_track.a,
216 )
217 };
218 let id = self.id.clone();
219 let arc = match self.value {
220 Some(value) => {
221 let fraction = transition(
222 ElementId::NamedChild(id.clone().into(), "value".into()),
223 value / 100.,
224 slide,
225 window,
226 cx,
227 )
228 .clamp(0., 1.);
229 Some((0., fraction))
230 }
231 None if Theme::holds_still(cx) => Some(indeterminate_arc(0.)),
232 None => Some(indeterminate_arc(looping::phase(
233 TURN, TURN_FPS, window, cx,
234 ))),
235 };
236 let has_track = self.value.is_some();
237 let progress_id = ElementId::NamedChild(id.clone().into(), "progress".into());
238 div()
239 .id(id)
240 .test_support()
241 .flex_shrink_0()
242 .size(size)
243 .refine_style(&self.style)
244 .child(
245 base::Progress::new(progress_id)
246 .value(self.value.unwrap_or(0.))
247 .indeterminate(self.value.is_none())
248 .when_some(self.accessibility_label, |this, label| {
249 this.accessibility_label(label)
250 })
251 .size_full()
252 .child(
253 canvas(
254 |_, _, _| (),
255 move |bounds, _, window, _| {
256 let radius =
257 (bounds.size.width.min(bounds.size.height) - stroke) / 2.;
258 let center = bounds.center();
259 if has_track {
260 let track = color.opacity(track_strength);
261 paint_arc(center, radius, (0., 1.), stroke, track, window);
262 }
263 if let Some(arc) = arc {
264 paint_arc(center, radius, arc, stroke, color, window);
265 }
266 },
267 )
268 .size_full(),
269 ),
270 )
271 .into_any_element()
272 }
273}
274
275impl RenderOnce for Progress {
276 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
277 if self.circular {
278 return self.render_ring(window, cx);
279 }
280 let still = Theme::holds_still(cx);
281 let slide = Theme::global(cx).motion.slide_transition();
282 let (track, fill, radius) = {
283 let theme = cx.theme();
284 (theme.progress_track, theme.primary(), theme.radius_full())
285 };
286 let id = self.id;
287 let child_id = |name: &'static str| ElementId::NamedChild(id.clone().into(), name.into());
288 let fraction = self.value.map(|value| {
289 transition(child_id("value"), value / 100., slide, window, cx).clamp(0., 1.)
290 });
291
292 let indicator = ProgressIndicator::new()
295 .absolute()
296 .top_0()
297 .bottom_0()
298 .left_0()
299 .child(
300 div()
301 .id(child_id("indicator"))
302 .test_support()
303 .size_full()
304 .rounded(radius)
305 .bg(fill),
306 );
307 let indicator = match fraction {
308 Some(fraction) => indicator.w(relative(fraction)).into_any_element(),
309 None if still => indicator
310 .left(relative(STILL_INSET))
311 .w(relative(1. - STILL_INSET * 2.))
312 .into_any_element(),
313 None => {
314 let (start, end) = sweep(looping::phase(SWEEP, SWEEP_FPS, window, cx));
315 indicator
316 .left(relative(start))
317 .w(relative(end - start))
318 .into_any_element()
319 }
320 };
321
322 div()
323 .id(id.clone())
324 .test_support()
325 .flex()
326 .w_full()
327 .h_2()
328 .refine_style(&self.style)
329 .child(
330 base::Progress::new(child_id("progress"))
331 .value(self.value.unwrap_or(0.))
332 .indeterminate(self.value.is_none())
333 .when_some(self.accessibility_label, |this, label| {
334 this.accessibility_label(label)
335 })
336 .relative()
337 .size_full()
338 .child(
339 ProgressTrack::new()
340 .absolute()
341 .size_full()
342 .rounded(radius)
343 .bg(track),
344 )
345 .child(indicator),
346 )
347 .into_any_element()
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn values_clamp_and_none_is_indeterminate() {
357 assert_eq!(Progress::new("p").percentage(), None);
358 assert_eq!(Progress::new("p").value(40.).percentage(), Some(40.));
359 assert_eq!(Progress::new("p").value(150.).percentage(), Some(100.));
360 assert_eq!(Progress::new("p").value(-5.).percentage(), Some(0.));
361 assert_eq!(Progress::new("p").value(40.).value(None).percentage(), None);
362 }
363
364 #[test]
365 fn the_ring_arc_starts_at_the_top_and_runs_clockwise() {
366 let center = point(px(10.), px(10.));
367 let radius = px(8.);
368 let top = on_circle(center, radius, 0.);
369 assert_eq!((top.x, top.y), (px(10.), px(2.)));
370 let right = on_circle(center, radius, 0.25);
371 assert!((right.x - px(18.)).abs() < px(0.001) && (right.y - px(10.)).abs() < px(0.001));
372 let bottom = on_circle(center, radius, 0.5);
373 assert!((bottom.x - px(10.)).abs() < px(0.001) && (bottom.y - px(18.)).abs() < px(0.001));
374 }
375
376 #[test]
377 fn the_indeterminate_arc_turns_once_and_keeps_its_length() {
378 assert_eq!(indeterminate_arc(0.), (0., SPINNER_ARC));
379 assert_eq!(indeterminate_arc(0.25), (0.25, SPINNER_ARC));
380 assert_eq!(indeterminate_arc(1.25).0, 0.25);
381 }
382
383 #[test]
384 fn circular_is_a_shape_not_a_value() {
385 assert!(!Progress::new("p").is_circular());
386 let ring = Progress::new("p").circular().value(40.);
387 assert!(ring.is_circular());
388 assert_eq!(ring.percentage(), Some(40.));
389 }
390
391 #[test]
392 fn a_sweep_grows_from_the_start_and_leaves_by_the_end() {
393 assert_eq!(sweep(0.), (0., 0.));
394 assert_eq!(sweep(0.5).0, 0.);
395 assert_eq!(sweep(1.), (1., 1.));
396 let (start, end) = sweep(0.75);
397 assert!(start > 0. && end > start && end <= 1.);
398 }
399}