Skip to main content

gpui_kit_material/components/progress/
circular.rs

1//! Circular progress from the pinned reference's canvas and motion functions.
2use 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/// A circular arc's start in clockwise degrees from three o'clock and its fraction.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct CircularProgressFrame {
14    pub rotation_degrees: f32,
15    pub sweep: f32,
16}
17
18impl CircularProgressFrame {
19    /// Measured progress begins at twelve o'clock. This does not animate the value.
20    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
36/// The reference's six-second growing arc, including four eased rotation kicks.
37pub 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
57/// Sample independently of painting so a hidden indicator keeps its phase.
58///
59/// Keep calling this after first showing the indicator, including while hidden.
60/// The reference polls reduced motion in its frame callback: a running loop draws
61/// one last frame then stops. Changing back does not restart it. A new ID starts
62/// a fresh playback, as explicitly restarting the reference's indicator does.
63///
64/// `visible` states whether the caller paints the returned frame. Only a visible,
65/// running sample requests the next animation frame; a hidden sample advances
66/// and observes reduced motion without keeping the window out of idle.
67pub 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/// Caller-owned circular playback that survives virtual unmounting.
81///
82/// A new handle starts on its first sample (normally the first mounted render).
83/// Clones share one logical indicator's start time, stopped flag and last frame;
84/// no window, pointer state or scheduled callbacks are retained. Use within one
85/// app, and create a new handle to restart a stopped playback.
86///
87/// Reduced motion stops an existing playback permanently after one final sample.
88/// An owner whose indicator is unmounted must call [`Self::observe_reduced_motion`]
89/// when the preference changes, so an offscreen enable/disable is not missed.
90#[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    /// Sample the reference clock without requesting a frame. Calling this for
103    /// the first time starts playback, even when no drawing element is mounted.
104    pub fn sample(&self, cx: &App) -> CircularProgressFrame {
105        self.sample_running(cx).0
106    }
107
108    /// Observe a reduced-motion preference change without starting an unused
109    /// indicator or requesting frames. Call after updating the app preference,
110    /// including while unmounted. Disabling reduced motion never restarts it.
111    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
140// The default API keeps its existing consecutive-frame keyed lifetime.
141fn 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    // The fixed 24-unit canvas has a 2-unit stroke and a 4-unit clear gap.
157    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
168/// Filled outline of a radius-11, thickness-2 arc with circular end caps.
169/// Filling the outline before scaling preserves the reference's elliptical
170/// strokes and caps when its 24-unit bitmap is squeezed into a 20×24 CSS box.
171fn 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    // SVG arcs with coincident endpoints omit a full circle, so split it.
188    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
232/// Paint a sampled circular frame in a caller-sized box, using the reference's
233/// 24-unit canvas and 2-unit stroke. Pass a 20×24 rem box for xs/s button slots,
234/// or 24×24 for larger buttons. The parent owns the surrounding icon slot.
235///
236/// `fraction` supplies the measured accessibility value; `None` is indeterminate.
237/// The caller supplies the accessible label and opacity (0.7 for loading buttons).
238pub 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
257/// Animate a bounded circular indicator while its box intersects the viewport
258/// and ancestor clip. Its original clock continues offscreen, so scrolling back
259/// does not restart playback. Reduced motion retains the sampler's stop policy.
260pub 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
281/// Draw with a caller-owned clock, retaining phase across virtual unmounts.
282/// Frame requests remain limited to visible paint. The owner must forward
283/// offscreen reduced-motion changes to [`CircularProgressPlayback::observe_reduced_motion`].
284pub 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            // Drain the one previously scheduled visible-frame callback.
428            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            // Backends differ in how many requests one visible draw queues
564            // (upstream also schedules its own frame); hidden must be zero.
565            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            // Drain the request queued by the last visible sample.
570            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        // No paint element was mounted: the sampled clock still advances.
609        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            // The prior visible frame already queued one callback. It may fire
680            // once after clipping; the stopped clock must not enqueue another.
681            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}