1use crate::{color::ColorRole, theme::color};
3use gpui::{prelude::*, *};
4use gpui_base::{Easing, IterationCount, Progress, Timing};
5#[cfg(not(target_family = "wasm"))]
6use std::time::Instant;
7use std::{cell::RefCell, f32::consts::TAU, rc::Rc, time::Duration};
8#[cfg(target_family = "wasm")]
9use web_time::Instant;
10
11#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct CircularProgressFrame {
14 pub rotation_degrees: f32,
15 pub sweep: f32,
16}
17
18impl CircularProgressFrame {
19 pub fn determinate(fraction: f32) -> Self {
21 Self {
22 rotation_degrees: -90.,
23 sweep: normalized_fraction(fraction),
24 }
25 }
26}
27
28fn normalized_fraction(fraction: f32) -> f32 {
29 if fraction.is_finite() {
30 fraction.clamp(0., 1.)
31 } else {
32 0.
33 }
34}
35
36pub fn circular_indeterminate_frame(elapsed_ms: f32) -> CircularProgressFrame {
38 let t = if elapsed_ms.is_finite() {
39 elapsed_ms.rem_euclid(6000.)
40 } else {
41 0.
42 };
43 let completed = (t / 1500.).floor();
44 let kick = ((t - completed * 1500.) / 300.).min(1.);
45 let decelerate = Easing::cubic_bezier(0.05, 0.7, 0.1, 1.).unwrap();
46 let standard = Easing::cubic_bezier(0.2, 0., 0., 1.).unwrap();
47 CircularProgressFrame {
48 rotation_degrees: t * 0.18 + completed * 90. + 90. * decelerate.sample(kick),
49 sweep: if t <= 3000. {
50 0.1 + 0.77 * t / 3000.
51 } else {
52 0.87 - 0.77 * standard.sample((t - 3000.) / 3000.)
53 },
54 }
55}
56
57pub fn sample_circular_progress(
68 id: impl Into<ElementId>,
69 visible: bool,
70 window: &mut Window,
71 cx: &mut App,
72) -> CircularProgressFrame {
73 let (frame, running) = sample_clock(id.into(), window, cx);
74 if visible && running {
75 window.request_animation_frame();
76 }
77 frame
78}
79
80#[derive(Clone, Default)]
91pub struct CircularProgressPlayback {
92 state: Rc<RefCell<Option<CircularClock>>>,
93}
94
95struct CircularClock {
96 started: Instant,
97 stopped: bool,
98 last_frame: CircularProgressFrame,
99}
100
101impl CircularProgressPlayback {
102 pub fn sample(&self, cx: &App) -> CircularProgressFrame {
105 self.sample_running(cx).0
106 }
107
108 pub fn observe_reduced_motion(&self, cx: &App) {
112 if cx.reduce_motion() && self.state.borrow().is_some() {
113 self.sample_running(cx);
114 }
115 }
116
117 fn sample_running(&self, cx: &App) -> (CircularProgressFrame, bool) {
118 let now = cx.background_executor().now();
119 let reduced = cx.reduce_motion();
120 let mut state = self.state.borrow_mut();
121 let state = state.get_or_insert_with(|| CircularClock {
122 started: now,
123 stopped: reduced,
124 last_frame: circular_indeterminate_frame(0.),
125 });
126 if state.stopped {
127 return (state.last_frame, false);
128 }
129 let timing = Timing::new(Duration::from_millis(6000)).iterations(IterationCount::Infinite);
130 let time = timing
131 .sample(now.saturating_duration_since(state.started))
132 .directed_progress
133 * 6000.;
134 state.last_frame = circular_indeterminate_frame(time);
135 state.stopped = reduced;
136 (state.last_frame, !reduced)
137 }
138}
139
140fn sample_clock(id: ElementId, window: &mut Window, cx: &mut App) -> (CircularProgressFrame, bool) {
142 let state = window.use_keyed_state((id, "circular-progress-clock"), cx, |_, _| {
143 CircularProgressPlayback::default()
144 });
145 state.read(cx).clone().sample_running(cx)
146}
147
148#[derive(Clone, Copy, Debug)]
149struct ArcGeometry {
150 start: f32,
151 sweep: f32,
152}
153
154fn geometry(frame: CircularProgressFrame) -> (ArcGeometry, ArcGeometry) {
155 let sweep = frame.sweep.clamp(0., 1.) * TAU;
156 let gap = sweep.min(0.5);
158 let start = frame.rotation_degrees.to_radians();
159 (
160 ArcGeometry {
161 start: start + sweep + gap,
162 sweep: (TAU - sweep - 2. * gap).max(0.),
163 },
164 ArcGeometry { start, sweep },
165 )
166}
167
168fn arc_path(bounds: Bounds<Pixels>, arc: ArcGeometry) -> Option<Path<Pixels>> {
172 if arc.sweep <= 0. || bounds.size.width <= px(0.) || bounds.size.height <= px(0.) {
173 return None;
174 }
175 let scale_x = f32::from(bounds.size.width) / 24.;
176 let scale_y = f32::from(bounds.size.height) / 24.;
177 let point_at = |radius: f32, angle: f32| {
178 point(
179 px(12. + radius * angle.cos()),
180 px(12. + radius * angle.sin()),
181 )
182 };
183 let mut path = PathBuilder::fill();
184 let start = arc.start;
185 let end = start + arc.sweep;
186 path.move_to(point_at(12., start));
187 for i in 1..=2 {
189 path.arc_to(
190 point(px(12.), px(12.)),
191 px(0.),
192 false,
193 true,
194 point_at(12., start + arc.sweep * i as f32 / 2.),
195 );
196 }
197 path.arc_to(
198 point(px(1.), px(1.)),
199 px(0.),
200 false,
201 true,
202 point_at(10., end),
203 );
204 for i in 1..=2 {
205 path.arc_to(
206 point(px(10.), px(10.)),
207 px(0.),
208 false,
209 false,
210 point_at(10., end - arc.sweep * i as f32 / 2.),
211 );
212 }
213 path.arc_to(
214 point(px(1.), px(1.)),
215 px(0.),
216 false,
217 true,
218 point_at(12., start),
219 );
220 path.close();
221 path.transform(Transform::new(
222 scale_x,
223 0.,
224 0.,
225 scale_y,
226 f32::from(bounds.origin.x),
227 f32::from(bounds.origin.y),
228 ));
229 path.build().ok()
230}
231
232pub fn circular_progress(
239 id: impl Into<ElementId>,
240 label: impl Into<SharedString>,
241 frame: CircularProgressFrame,
242 fraction: Option<f32>,
243 display_size: Size<Rems>,
244 opacity: f32,
245) -> AnyElement {
246 paint_progress(
247 id.into(),
248 label.into(),
249 frame,
250 fraction,
251 display_size,
252 opacity,
253 false,
254 )
255}
256
257pub fn indeterminate_circular_progress(
261 id: impl Into<ElementId>,
262 label: impl Into<SharedString>,
263 display_size: Size<Rems>,
264 opacity: f32,
265 window: &mut Window,
266 cx: &mut App,
267) -> AnyElement {
268 let id = id.into();
269 let (frame, running) = sample_clock(id.clone(), window, cx);
270 paint_progress(
271 id,
272 label.into(),
273 frame,
274 None,
275 display_size,
276 opacity,
277 running,
278 )
279}
280
281pub fn indeterminate_circular_progress_with_playback(
285 id: impl Into<ElementId>,
286 label: impl Into<SharedString>,
287 display_size: Size<Rems>,
288 opacity: f32,
289 playback: &CircularProgressPlayback,
290 cx: &App,
291) -> AnyElement {
292 let (frame, running) = playback.sample_running(cx);
293 paint_progress(
294 id.into(),
295 label.into(),
296 frame,
297 None,
298 display_size,
299 opacity,
300 running,
301 )
302}
303
304fn paint_progress(
305 id: ElementId,
306 label: SharedString,
307 frame: CircularProgressFrame,
308 fraction: Option<f32>,
309 display_size: Size<Rems>,
310 opacity: f32,
311 request_frame: bool,
312) -> AnyElement {
313 let (track, active) = geometry(frame);
314 let track_color = color(ColorRole::SECONDARY_CONTAINER);
315 let active_color = color(ColorRole::PRIMARY);
316 Progress::new(id)
317 .accessibility_label(label)
318 .indeterminate(fraction.is_none())
319 .value(normalized_fraction(fraction.unwrap_or(0.)) * 100.)
320 .flex_shrink_0()
321 .w(display_size.width)
322 .h(display_size.height)
323 .opacity(opacity.clamp(0., 1.))
324 .child(
325 canvas(
326 move |bounds, window, _| {
327 let visible = bounds
328 .intersect(&window.content_mask().bounds)
329 .intersect(&Bounds::new(point(px(0.), px(0.)), window.viewport_size()));
330 request_frame && visible.size.width > px(0.) && visible.size.height > px(0.)
331 },
332 move |bounds, request_frame, window, _| {
333 for (arc, ink) in [(track, track_color), (active, active_color)] {
334 if let Some(path) = arc_path(bounds, arc) {
335 window.paint_path(path, ink);
336 }
337 }
338 if request_frame {
339 window.request_animation_frame();
340 }
341 },
342 )
343 .size_full(),
344 )
345 .into_any_element()
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{
351 ArcGeometry, CircularProgressFrame, arc_path, circular_indeterminate_frame, geometry,
352 normalized_fraction, sample_circular_progress,
353 };
354 use gpui::{
355 Bounds, Context, IntoElement, Render, TestAppContext, Window, div, point, px, size,
356 };
357 use std::f32::consts::{FRAC_PI_2, PI, TAU};
358 use std::time::Duration;
359
360 #[test]
361 fn matches_pinned_browser_motion_samples() {
362 let fixture: serde_json::Value = serde_json::from_str(include_str!(
363 "../../../../../conformance/reference/button-loading.json"
364 ))
365 .unwrap();
366 for item in fixture["modes"]["ordinary"]["motion"].as_array().unwrap() {
367 let frame = circular_indeterminate_frame(item["time"].as_f64().unwrap() as f32);
368 assert!(
369 (f64::from(frame.rotation_degrees) - item["rotation"].as_f64().unwrap()).abs()
370 < 0.002
371 );
372 assert!((f64::from(frame.sweep) - item["sweep"].as_f64().unwrap()).abs() < 0.00002);
373 }
374 }
375
376 struct RetainedClockHarness {
377 mounted: bool,
378 playback: super::CircularProgressPlayback,
379 }
380 impl Render for RetainedClockHarness {
381 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
382 use gpui::prelude::*;
383 div().size(px(100.)).when(self.mounted, |root| {
384 root.child(super::indeterminate_circular_progress_with_playback(
385 "retained-circular",
386 "Loading",
387 size(gpui::rems(1.), gpui::rems(1.)),
388 1.,
389 &self.playback,
390 cx,
391 ))
392 })
393 }
394 }
395
396 #[gpui::test]
397 fn caller_clock_survives_unmount_and_observes_offscreen_reduced_motion(
398 cx: &mut TestAppContext,
399 ) {
400 let playback = super::CircularProgressPlayback::default();
401 let (view, cx) = cx.add_window_view(|_, _| RetainedClockHarness {
402 mounted: false,
403 playback: playback.clone(),
404 });
405 cx.executor().advance_clock(Duration::from_millis(200));
406 cx.update(|window, cx| {
407 cx.set_reduce_motion(true);
408 playback.observe_reduced_motion(cx);
409 assert!(
410 playback.state.borrow().is_none(),
411 "preference events must not start an unused clock"
412 );
413 cx.set_reduce_motion(false);
414 view.update(cx, |view, _| view.mounted = true);
415 window.refresh();
416 window.draw(cx).clear(cx);
417 });
418 assert_eq!(
419 playback.state.borrow().as_ref().unwrap().last_frame,
420 circular_indeterminate_frame(0.)
421 );
422 cx.executor().advance_clock(Duration::from_millis(150));
423 cx.update(|window, cx| {
424 view.update(cx, |view, _| view.mounted = false);
425 window.refresh();
426 window.draw(cx).clear(cx);
427 window.simulate_next_frame(cx);
429 window.refresh();
430 window.draw(cx).clear(cx);
431 assert_eq!(window.simulate_next_frame(cx), 0);
432 });
433 cx.executor().advance_clock(Duration::from_millis(350));
434 cx.update(|window, cx| {
435 window.refresh();
436 window.draw(cx).clear(cx);
437 assert_eq!(window.simulate_next_frame(cx), 0);
438 view.update(cx, |view, _| view.mounted = true);
439 window.refresh();
440 window.draw(cx).clear(cx);
441 });
442 let frame = playback.state.borrow().as_ref().unwrap().last_frame;
443 let expected = circular_indeterminate_frame(500.);
444 assert!((frame.rotation_degrees - expected.rotation_degrees).abs() < 0.002);
445 assert!((frame.sweep - expected.sweep).abs() < 0.00002);
446 cx.update(|window, cx| {
447 view.update(cx, |view, _| view.mounted = false);
448 window.refresh();
449 window.draw(cx).clear(cx);
450 window.simulate_next_frame(cx);
451 window.refresh();
452 window.draw(cx).clear(cx);
453 });
454 cx.executor().advance_clock(Duration::from_millis(200));
455 cx.update(|window, cx| {
456 cx.set_reduce_motion(true);
457 playback.observe_reduced_motion(cx);
458 assert_eq!(
459 window.simulate_next_frame(cx),
460 0,
461 "preference observation does not schedule"
462 );
463 cx.set_reduce_motion(false);
464 playback.observe_reduced_motion(cx);
465 });
466 let stopped = playback.state.borrow().as_ref().unwrap().last_frame;
467 assert!(
468 (stopped.rotation_degrees - circular_indeterminate_frame(700.).rotation_degrees).abs()
469 < 0.002
470 );
471 assert!(playback.state.borrow().as_ref().unwrap().stopped);
472 cx.executor().advance_clock(Duration::from_secs(2));
473 cx.update(|window, cx| {
474 view.update(cx, |view, _| view.mounted = true);
475 window.refresh();
476 window.draw(cx).clear(cx);
477 assert_eq!(window.simulate_next_frame(cx), 0);
478 });
479 assert_eq!(
480 playback.state.borrow().as_ref().unwrap().last_frame,
481 stopped
482 );
483 }
484
485 #[gpui::test]
486 fn caller_clock_initial_reduced_mount_stays_stopped_and_new_handle_restarts(
487 cx: &mut TestAppContext,
488 ) {
489 cx.update(|cx| cx.set_reduce_motion(true));
490 let playback = super::CircularProgressPlayback::default();
491 let (view, cx) = cx.add_window_view(|_, _| RetainedClockHarness {
492 mounted: true,
493 playback: playback.clone(),
494 });
495 cx.update(|window, cx| {
496 window.refresh();
497 window.draw(cx).clear(cx);
498 });
499 cx.executor().advance_clock(Duration::from_secs(1));
500 cx.update(|window, cx| {
501 cx.set_reduce_motion(false);
502 window.refresh();
503 window.draw(cx).clear(cx);
504 assert_eq!(window.simulate_next_frame(cx), 0);
505 });
506 assert_eq!(
507 playback.state.borrow().as_ref().unwrap().last_frame,
508 circular_indeterminate_frame(0.)
509 );
510 let fresh = super::CircularProgressPlayback::default();
511 cx.update(|window, cx| {
512 view.update(cx, |view, _| view.playback = fresh.clone());
513 window.refresh();
514 window.draw(cx).clear(cx);
515 });
516 cx.executor().advance_clock(Duration::from_millis(150));
517 cx.update(|window, cx| {
518 window.refresh();
519 window.draw(cx).clear(cx);
520 });
521 assert!(!fresh.state.borrow().as_ref().unwrap().stopped);
522 assert!(
523 (fresh
524 .state
525 .borrow()
526 .as_ref()
527 .unwrap()
528 .last_frame
529 .rotation_degrees
530 - circular_indeterminate_frame(150.).rotation_degrees)
531 .abs()
532 < 0.002
533 );
534 assert!(playback.state.borrow().as_ref().unwrap().stopped);
535 }
536
537 struct ClockHarness {
538 visible: bool,
539 samples: std::rc::Rc<std::cell::RefCell<Vec<CircularProgressFrame>>>,
540 }
541
542 impl Render for ClockHarness {
543 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
544 self.samples.borrow_mut().push(sample_circular_progress(
545 "clock",
546 self.visible,
547 window,
548 cx,
549 ));
550 div()
551 }
552 }
553
554 #[gpui::test]
555 fn hidden_samples_advance_without_requesting_frames(cx: &mut TestAppContext) {
556 let samples = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
557 let (view, cx) = cx.add_window_view(|_, _| ClockHarness {
558 visible: true,
559 samples: samples.clone(),
560 });
561 cx.update(|window, cx| {
562 window.draw(cx).clear(cx);
563 assert!(window.simulate_next_frame(cx) >= 1);
566 view.update(cx, |view, _| view.visible = false);
567 window.refresh();
568 window.draw(cx).clear(cx);
569 window.simulate_next_frame(cx);
571 window.refresh();
572 window.draw(cx).clear(cx);
573 assert_eq!(window.simulate_next_frame(cx), 0);
574 });
575 cx.executor().advance_clock(Duration::from_millis(150));
576 cx.update(|window, cx| {
577 window.refresh();
578 window.draw(cx).clear(cx);
579 assert_eq!(window.simulate_next_frame(cx), 0);
580 });
581 let sampled = *samples.borrow().last().unwrap();
582 assert!(
583 (sampled.rotation_degrees - circular_indeterminate_frame(150.).rotation_degrees).abs()
584 < 0.002
585 );
586 }
587
588 #[gpui::test]
589 fn clock_keeps_hidden_phase_and_stops_without_restarting_on_media_change(
590 cx: &mut TestAppContext,
591 ) {
592 let samples = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
593 let (_, cx) = cx.add_window_view(|_, _| ClockHarness {
594 visible: false,
595 samples: samples.clone(),
596 });
597 cx.update(|window, cx| window.draw(cx).clear(cx));
598 cx.executor().advance_clock(Duration::from_millis(150));
599 cx.update(|window, cx| {
600 window.refresh();
601 window.draw(cx).clear(cx);
602 });
603 let sampled = *samples.borrow().last().unwrap();
604 assert!(
605 (sampled.rotation_degrees - circular_indeterminate_frame(150.).rotation_degrees).abs()
606 < 0.002
607 );
608 cx.update(|window, cx| {
610 cx.set_reduce_motion(true);
611 window.refresh();
612 window.draw(cx).clear(cx);
613 });
614 let stopped = *samples.borrow().last().unwrap();
615 cx.executor().advance_clock(Duration::from_millis(800));
616 cx.update(|window, cx| {
617 cx.set_reduce_motion(false);
618 window.refresh();
619 window.draw(cx).clear(cx);
620 });
621 assert_eq!(*samples.borrow().last().unwrap(), stopped);
622 }
623
624 struct VisibleClockHarness {
625 visible: bool,
626 samples: std::rc::Rc<std::cell::RefCell<Vec<CircularProgressFrame>>>,
627 }
628 impl Render for VisibleClockHarness {
629 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
630 use gpui::prelude::*;
631 let indicator = super::indeterminate_circular_progress(
632 "visible-clock",
633 "Loading",
634 size(gpui::rems(1.), gpui::rems(1.)),
635 1.,
636 window,
637 cx,
638 );
639 self.samples
640 .borrow_mut()
641 .push(super::sample_clock("visible-clock".into(), window, cx).0);
642 div().relative().size(px(100.)).overflow_hidden().child(
643 div()
644 .absolute()
645 .top(px(if self.visible { 10. } else { 150. }))
646 .child(indicator),
647 )
648 }
649 }
650
651 #[gpui::test]
652 fn clipped_indicator_stops_requests_preserves_phase_and_reduced_stop(cx: &mut TestAppContext) {
653 let samples = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
654 let (view, cx) = cx.add_window_view(|_, _| VisibleClockHarness {
655 visible: false,
656 samples: samples.clone(),
657 });
658 cx.update(|window, cx| {
659 window.draw(cx).clear(cx);
660 assert_eq!(window.simulate_next_frame(cx), 0);
661 });
662 cx.executor().advance_clock(Duration::from_millis(150));
663 cx.update(|window, cx| {
664 view.update(cx, |this, _| this.visible = true);
665 window.refresh();
666 window.draw(cx).clear(cx);
667 assert_eq!(window.simulate_next_frame(cx), 1);
668 });
669 let frame = *samples.borrow().last().unwrap();
670 assert!(
671 (frame.rotation_degrees - circular_indeterminate_frame(150.).rotation_degrees).abs()
672 < 0.002
673 );
674 cx.update(|window, cx| {
675 view.update(cx, |this, _| this.visible = false);
676 cx.set_reduce_motion(true);
677 window.refresh();
678 window.draw(cx).clear(cx);
679 assert_eq!(window.simulate_next_frame(cx), 1);
682 window.refresh();
683 window.draw(cx).clear(cx);
684 assert_eq!(window.simulate_next_frame(cx), 0);
685 });
686 let stopped = *samples.borrow().last().unwrap();
687 cx.executor().advance_clock(Duration::from_secs(2));
688 cx.update(|window, cx| {
689 view.update(cx, |this, _| this.visible = true);
690 cx.set_reduce_motion(false);
691 window.refresh();
692 window.draw(cx).clear(cx);
693 assert_eq!(window.simulate_next_frame(cx), 0);
694 });
695 assert_eq!(*samples.borrow().last().unwrap(), stopped);
696 }
697
698 #[test]
699 fn measured_accessibility_and_paint_normalize_nonfinite_values_together() {
700 for (input, expected) in [
701 (f32::NAN, 0.),
702 (f32::INFINITY, 0.),
703 (f32::NEG_INFINITY, 0.),
704 (-1., 0.),
705 (2., 1.),
706 (0.5, 0.5),
707 ] {
708 assert_eq!(normalized_fraction(input), expected);
709 assert_eq!(CircularProgressFrame::determinate(input).sweep, expected);
710 }
711 }
712
713 #[test]
714 fn track_clears_round_caps_and_measured_arc_starts_at_twelve() {
715 let (track, active) = geometry(circular_indeterminate_frame(0.));
716 assert_eq!(active.start, 0.);
717 assert!((active.sweep - TAU * 0.1).abs() < 0.00001);
718 assert!((track.start - active.sweep - 0.5).abs() < 0.00001);
719 assert!((track.sweep + active.sweep + 1. - TAU).abs() < 0.00001);
720 assert_eq!(geometry(CircularProgressFrame::determinate(1.)).0.sweep, 0.);
721 assert_eq!(geometry(CircularProgressFrame::determinate(0.)).1.sweep, 0.);
722 assert!(
723 (geometry(CircularProgressFrame::determinate(0.5)).1.start + FRAC_PI_2).abs() < 0.00001
724 );
725 for width in [20., 24.] {
726 assert!(
727 arc_path(
728 Bounds::new(point(px(0.), px(0.)), size(px(width), px(24.))),
729 active
730 )
731 .is_some()
732 );
733 assert!(
734 arc_path(
735 Bounds::new(point(px(0.), px(0.)), size(px(width), px(24.))),
736 ArcGeometry {
737 start: -FRAC_PI_2,
738 sweep: 2. * PI
739 }
740 )
741 .is_some()
742 );
743 }
744 }
745}