Skip to main content

herogpui_components/
progress.rs

1//! ProgressBar / ProgressCircle — port of `@heroui/progress`.
2
3use gpui::{
4    prelude::*, px, AnimationExt, App, IntoElement, Pixels, RenderOnce, SharedString, Styled,
5    Window,
6};
7use herogpui_core::{element_id, Color, Size};
8use herogpui_theme::ActiveTheme;
9
10use crate::a11y::A11y as _;
11
12#[derive(Clone)]
13struct ProgressBarMotion {
14    target: f32,
15    generation: usize,
16    from: f32,
17    width: std::rc::Rc<std::cell::Cell<f32>>,
18}
19
20impl ProgressBarMotion {
21    fn retarget(&mut self, target: f32, animate: bool) -> bool {
22        let mut changed = false;
23        if (self.target - target).abs() > f32::EPSILON {
24            self.target = target;
25            self.generation = self.generation.wrapping_add(1);
26            self.from = self.width.get();
27            changed = true;
28        }
29        if !animate && (self.width.get() - target).abs() > f32::EPSILON {
30            self.from = target;
31            self.width.set(target);
32            changed = true;
33        }
34        changed
35    }
36}
37
38struct ProgressBarMotionFrame {
39    /// The ProgressBar's own id, so two bars animate on separate timelines.
40    id: gpui::ElementId,
41    generation: usize,
42    from: f32,
43    to: f32,
44    width: std::rc::Rc<std::cell::Cell<f32>>,
45    animate: bool,
46}
47
48impl ProgressBarMotionFrame {
49    fn render(self, fill: gpui::Div) -> gpui::AnyElement {
50        if !self.animate {
51            self.width.set(self.to);
52            return fill.w(gpui::relative(self.to)).into_any_element();
53        }
54
55        let width = self.width;
56        let from = self.from;
57        let to = self.to;
58        fill.with_animation(
59            element_id::indexed(&self.id, "fill-width", self.generation),
60            gpui::Animation::new(std::time::Duration::from_millis(
61                crate::anim::PROGRESS_BAR_FILL_MS,
62            ))
63            .with_easing(|t| crate::anim::Curve::Out.at(t)),
64            move |fill, delta| {
65                let next = from + (to - from) * delta;
66                width.set(next);
67                fill.w(gpui::relative(next))
68            },
69        )
70        .into_any_element()
71    }
72}
73
74fn progress_bar_motion(
75    id: &gpui::ElementId,
76    target: f32,
77    animate: bool,
78    window: &mut Window,
79    cx: &mut App,
80) -> ProgressBarMotionFrame {
81    let state = window.use_keyed_state(element_id::scoped(id, "fill-motion"), cx, |_, _| {
82        ProgressBarMotion {
83            target,
84            generation: 0,
85            from: target,
86            width: std::rc::Rc::new(std::cell::Cell::new(target)),
87        }
88    });
89    let mut current = state.read(cx).clone();
90    if current.retarget(target, animate) {
91        state.update(cx, |stored, _| *stored = current.clone());
92    }
93    let should_animate =
94        animate && current.generation != 0 && (current.width.get() - target).abs() > f32::EPSILON;
95    ProgressBarMotionFrame {
96        id: id.clone(),
97        generation: current.generation,
98        from: current.from,
99        to: target,
100        width: current.width,
101        animate: should_animate,
102    }
103}
104
105/// Linear progress bar.
106#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
107#[derive(IntoElement)]
108pub struct ProgressBar {
109    id: gpui::ElementId,
110    value: f32,
111    min_value: f32,
112    max_value: f32,
113    size: Size,
114    color: Color,
115    is_indeterminate: bool,
116    label: Option<String>,
117    show_value: bool,
118    value_label: Option<SharedString>,
119    /// `ProgressBar.ValueLabel`'s render props: the closure is handed
120    /// `percentage`, `valueText`, and `isIndeterminate`.
121    value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
122    /// `formatOptions` — how the generated value label is written.
123    format: Option<herogpui_core::NumberFormat>,
124    /// The `sx` slot, refined over the root style at the end of render.
125    sx: Option<Box<gpui::StyleRefinement>>,
126    /// The corner radius, in place of the size-step's radius. The track and
127    /// every fill segment paint it.
128    radius: Option<Pixels>,
129    /// `useMeter` is `useProgressBar` with one thing changed — the role — and
130    /// [`crate::meter::Meter`] delegates its whole rendering here, so the role
131    /// travels with the delegation rather than being duplicated.
132    a11y_role: crate::a11y::Role,
133}
134
135impl ProgressBar {
136    /// Reports this bar as a meter rather than a progress indicator.
137    ///
138    /// Upstream's `role="meter progressbar"` is a pair: `meter` with
139    /// `progressbar` as a fallback for browsers that do not implement it.
140    /// AccessKit roles are a single enum, so the port reports the half that is
141    /// true.
142    pub(crate) fn as_meter(mut self) -> Self {
143        self.a11y_role = crate::a11y::Role::Meter;
144        self
145    }
146
147    /// Creates a progress bar with the given element id.
148    pub fn new(id: impl Into<gpui::ElementId>) -> Self {
149        Self {
150            id: id.into(),
151            value: 0.0,
152            min_value: 0.0,
153            max_value: 100.0,
154            size: Size::Md,
155            color: Color::Accent,
156            is_indeterminate: false,
157            label: None,
158            show_value: false,
159            value_label: None,
160            value_content: None,
161            format: None,
162            sx: None,
163            radius: None,
164            a11y_role: crate::a11y::Role::ProgressIndicator,
165        }
166    }
167
168    /// Hands a captured refinement down from a component that renders no root
169    /// of its own: [`crate::meter::Meter`] delegates its whole element tree to
170    /// this bar, so the bar's root is the meter's root.
171    pub(crate) fn sx_refinement(mut self, sx: Box<gpui::StyleRefinement>) -> Self {
172        self.sx = Some(sx);
173        self
174    }
175
176    /// Sets the current value (v3 `value`).
177    pub fn value(mut self, v: f32) -> Self {
178        self.value = v;
179        self
180    }
181
182    /// Sets the minimum value (v3 `minValue`).
183    pub fn min_value(mut self, v: f32) -> Self {
184        self.min_value = v;
185        self
186    }
187
188    /// Sets the maximum value (v3 `maxValue`).
189    pub fn max_value(mut self, v: f32) -> Self {
190        self.max_value = v;
191        self
192    }
193
194    /// `isIndeterminate` — an unbounded operation; the bar sweeps instead of
195    /// filling to a fraction.
196    pub fn is_indeterminate(mut self, v: bool) -> Self {
197        self.is_indeterminate = v;
198        self
199    }
200
201    /// `ProgressBar.ValueLabel`'s render function — handed `percentage`,
202    /// `valueText`, and `isIndeterminate`.
203    pub fn value_content(
204        mut self,
205        render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
206    ) -> Self {
207        self.value_content = Some(std::sync::Arc::new(render));
208        self
209    }
210
211    /// `valueLabel` — replaces the generated percentage.
212    pub fn value_label(mut self, text: impl Into<SharedString>) -> Self {
213        self.value_label = Some(text.into());
214        self
215    }
216
217    /// `formatOptions` — v3 defaults to `{style: "percent"}`.
218    pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
219        self.format = Some(format);
220        self
221    }
222
223    /// Sets the size (v3 `size`).
224    pub fn size(mut self, s: Size) -> Self {
225        self.size = s;
226        self
227    }
228
229    /// Sets the color (v3 `color`).
230    pub fn color(mut self, c: Color) -> Self {
231        self.color = c;
232        self
233    }
234
235    /// The corner radius, in place of the size-step's radius. Both the track
236    /// and the fill segments that run inside it follow the override. Not a v3
237    /// prop; the removed v2 `radius` prop is prohibited and this is a
238    /// per-component repository extension.
239    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
240        self.radius = Some(radius.into());
241        self
242    }
243
244    /// The one slot for caller-owned low-level styling: GPUI's styling methods
245    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
246    /// applied to the bar's root element after every value the size and the
247    /// active theme chose, so they win.
248    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
249        crate::util::refine_sx(&mut self.sx, style);
250        self
251    }
252
253    /// Label rendered above the track (`label` + `showValueLabel`).
254    pub fn label(mut self, l: impl Into<String>) -> Self {
255        self.label = Some(l.into());
256        self
257    }
258
259    /// Sets whether the value label is shown.
260    pub fn show_value_label(mut self, v: bool) -> Self {
261        self.show_value = v;
262        self
263    }
264}
265
266impl RenderOnce for ProgressBar {
267    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
268        let colors = cx.colors();
269        let progress_fill_color = if self.color == Color::Default {
270            colors.default.foreground
271        } else {
272            cx.role(self.color).color
273        };
274        let progress_track_color = colors.default.color;
275        let text_color = colors.foreground;
276        let (h, radius) = match self.size {
277            Size::Sm => (px(4.), crate::util::micro_radius(cx)),
278            Size::Md => (px(8.), crate::util::hairline_radius(cx)),
279            Size::Lg => (px(12.), crate::util::mark_radius(cx)),
280        };
281        // The size step stays the fallback; an instance radius replaces it for
282        // the track and every fill segment below.
283        let radius = self.radius.unwrap_or(radius);
284        // Clamp once at entry so the fill, percentage and every formatted
285        // label use the same value, matching React Aria's clamp-before-format
286        // behavior. Guarded because
287        // `f32::clamp` panics when min > max, which `fraction_of` tolerates.
288        let value = if self.min_value <= self.max_value {
289            self.value.clamp(self.min_value, self.max_value)
290        } else {
291            self.value
292        };
293        let fraction = fraction_of(self.value, self.min_value, self.max_value);
294        // `useProgressBar` formats the value label whether or not anything
295        // displays it, because `aria-valuetext` carries it either way. An
296        // indeterminate bar has neither.
297        let announced_value_text: Option<SharedString> = (!self.is_indeterminate).then(|| {
298            self.value_label.clone().unwrap_or_else(|| {
299                let format = self
300                    .format
301                    .clone()
302                    .unwrap_or_else(herogpui_core::NumberFormat::percent);
303                let n = if format.style == herogpui_core::NumberStyle::Percent {
304                    fraction as f64
305                } else {
306                    value as f64
307                };
308                SharedString::from(format.format(n))
309            })
310        });
311        let fill_motion = progress_bar_motion(
312            &self.id,
313            if self.is_indeterminate { 0.4 } else { fraction },
314            !self.is_indeterminate && !ActiveTheme::reduce_motion(cx),
315            window,
316            cx,
317        );
318
319        // `useProgressBar` keeps `aria-valuemin`/`aria-valuemax` in every
320        // case and drops `aria-valuenow`/`aria-valuetext` when indeterminate.
321        let range = if self.is_indeterminate {
322            crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
323        } else {
324            crate::a11y::Range::new(
325                self.min_value as f64,
326                self.max_value as f64,
327                self.value as f64,
328            )
329            .text(announced_value_text.clone())
330        };
331        // The accessibility calls come after the layout chain deliberately:
332        // `design_audit.py` reads this wrapper's gap through a pattern
333        // anchored on `gpui::div()` followed by its layout calls.
334        let mut el = gpui::div()
335            .flex()
336            .flex_col()
337            .gap(px(4.))
338            .w_full()
339            .id(self.id.clone())
340            .a11y_named(
341                self.a11y_role,
342                &crate::a11y::Name::maybe(self.label.clone().map(SharedString::from)),
343            )
344            .a11y_range(&range);
345
346        // `.progress-bar__output` / `.meter__output` is the value beside the
347        // label, in the row above the track.
348        if self.label.is_some() || self.show_value {
349            // The same text the node announces; an indeterminate bar shows
350            // nothing where the value would be.
351            let value_text = announced_value_text.unwrap_or_default();
352            let percentage = if self.is_indeterminate {
353                0.0
354            } else {
355                fraction * 100.
356            };
357            el = el.child(
358                gpui::div()
359                    .flex()
360                    .justify_between()
361                    .text_size(px(14.))
362                    .line_height(px(20.))
363                    .font_weight(gpui::FontWeight::MEDIUM)
364                    .text_color(text_color)
365                    .child(self.label.clone().unwrap_or_default())
366                    .when(self.show_value, |l| {
367                        // `percentage` is 0-100, with 0 standing in for v3's
368                        // undefined indeterminate percentage. The output
369                        // wrapper carries HeroUI's tabular-nums feature while
370                        // leaving the label on the normal font metrics.
371                        let output = match &self.value_content {
372                            Some(render) => render(percentage, &value_text, self.is_indeterminate),
373                            None => value_text.to_string().into_any_element(),
374                        };
375                        l.child(
376                            gpui::div()
377                                .font_features(crate::util::tabular_font_features())
378                                .child(output),
379                        )
380                    }),
381            );
382        }
383
384        // `.progress-bar__track` / `.meter__track`, with
385        // `.progress-bar__fill` / `.meter__fill` inside it.
386        let track = gpui::div()
387            .w_full()
388            .h(h)
389            .overflow_hidden()
390            .rounded(radius)
391            .bg(progress_track_color);
392
393        // Indeterminate bars sweep a 40% segment; reduced motion leaves that
394        // same segment static so the state is still legible.
395        let track = if self.is_indeterminate && !ActiveTheme::reduce_motion(cx) {
396            let indeterminate_id = element_id::scoped(&self.id, "indeterminate");
397            track
398                .child(
399                    gpui::div()
400                        .relative()
401                        .h_full()
402                        .w(gpui::relative(0.4))
403                        .rounded(radius)
404                        .bg(progress_fill_color)
405                        .with_animation(
406                            indeterminate_id,
407                            gpui::Animation::new(std::time::Duration::from_millis(
408                                crate::anim::PROGRESS_BAR_INDETERMINATE_MS,
409                            ))
410                            .with_easing(crate::anim::progress_bar_indeterminate_ease())
411                            .repeat(),
412                            |el, delta| el.left(gpui::relative(delta * 1.8 - 0.4)),
413                        ),
414                )
415                .into_any_element()
416        } else if self.is_indeterminate {
417            track
418                .child(
419                    gpui::div()
420                        .h_full()
421                        .rounded(radius)
422                        .bg(progress_fill_color)
423                        .w(gpui::relative(0.4)),
424                )
425                .into_any_element()
426        } else {
427            let indicator = gpui::div().h_full().rounded(radius).bg(progress_fill_color);
428            track
429                .child(fill_motion.render(indicator))
430                .into_any_element()
431        };
432
433        el = el.child(track);
434        crate::util::apply_sx(el, &self.sx)
435    }
436}
437
438/// Normalises `value` into `0.0..=1.0` across the v3 `minValue`/`maxValue`
439/// range, guarding against an empty or inverted range.
440fn fraction_of(value: f32, min: f32, max: f32) -> f32 {
441    let span = max - min;
442    if span.abs() < f32::EPSILON {
443        return 0.0;
444    }
445    ((value - min) / span).clamp(0.0, 1.0)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::ProgressBarMotion;
451    use std::cell::Cell;
452    use std::rc::Rc;
453
454    fn close(left: f32, right: f32) -> bool {
455        (left - right).abs() < f32::EPSILON
456    }
457
458    #[test]
459    fn progress_bar_motion_reverses_from_current_width_and_snaps_without_motion() {
460        let width = Rc::new(Cell::new(0.25));
461        let mut motion = ProgressBarMotion {
462            target: 0.25,
463            generation: 0,
464            from: 0.25,
465            width: width.clone(),
466        };
467
468        assert!(motion.retarget(0.75, true));
469        assert_eq!(motion.generation, 1);
470        assert!(close(motion.from, 0.25));
471        assert!(close(width.get(), 0.25));
472
473        width.set(0.5);
474        assert!(motion.retarget(0.1, true));
475        assert_eq!(motion.generation, 2);
476        assert!(close(motion.from, 0.5));
477        assert!(close(width.get(), 0.5));
478
479        assert!(motion.retarget(0.9, false));
480        assert_eq!(motion.generation, 3);
481        assert!(close(motion.from, 0.9));
482        assert!(close(width.get(), 0.9));
483    }
484
485    #[test]
486    fn indeterminate_circle_rotates_track_and_fill_as_one_surface() {
487        let source = include_str!("progress.rs")
488            .rsplit("/// Circular progress ring")
489            .next()
490            .expect("the ProgressCircle implementation is always present");
491        assert!(source.contains("let progress_visual = gpui::div()"));
492        assert!(source.contains(".child(track).child(arc)"));
493        assert!(source.contains("progress_visual\n                .with_animation("));
494        assert!(source.contains("rotation.set(crate::anim::progress_circle_spin_turn(delta))"));
495    }
496
497    #[test]
498    fn determinate_circle_interpolates_the_retained_arc_fraction() {
499        let source = include_str!("progress.rs")
500            .rsplit("/// Circular progress ring")
501            .next()
502            .expect("the ProgressCircle implementation is always present");
503        assert!(source.contains("PROGRESS_CIRCLE_FILL_MS"));
504        assert!(source.contains("\"fill-fraction\""));
505        assert!(source.contains("let paint_fraction = fraction_motion.value()"));
506        assert!(source.contains("let fraction = paint_fraction.get()"));
507        assert!(source.contains("fraction_motion.animates(reduce_motion)"));
508    }
509
510    #[test]
511    fn progress_output_uses_shared_tabular_numeric_features() {
512        let source = include_str!("progress.rs")
513            .split("/// Circular progress ring")
514            .next()
515            .expect("the ProgressBar implementation is always present");
516        assert!(source.contains("tabular_font_features"));
517        assert!(source.contains("font_features(crate::util::tabular_font_features())"));
518    }
519
520    #[test]
521    fn indeterminate_bar_animation_is_instance_scoped() {
522        let source = include_str!("progress.rs");
523        assert!(
524            source.contains("element_id::scoped(&self.id, \"indeterminate\")"),
525            "each indeterminate bar needs an animation id under its own component id"
526        );
527        assert!(
528            !source.contains("\"progress-bar-indeterminate\""),
529            "a shared literal animation id lets sibling bars collide"
530        );
531    }
532}
533
534/// Circular progress ring (`ProgressCircle`).
535#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
536#[derive(IntoElement)]
537pub struct ProgressCircle {
538    value: f32,
539    min_value: f32,
540    max_value: f32,
541    color: Color,
542    size_px: Pixels,
543    is_indeterminate: bool,
544    show_value: bool,
545    /// `ProgressCircle.ValueLabel`'s render props: `percentage`, `valueText`,
546    /// and `isIndeterminate`.
547    value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
548    /// `formatOptions` — how the generated value label is written.
549    format: Option<herogpui_core::NumberFormat>,
550    id: Option<gpui::ElementId>,
551    /// The `sx` slot, refined over the root style at the end of render.
552    sx: Option<Box<gpui::StyleRefinement>>,
553}
554
555impl ProgressCircle {
556    /// Creates a progress circle.
557    pub fn new() -> Self {
558        Self {
559            value: 0.0,
560            min_value: 0.0,
561            max_value: 100.0,
562            color: Color::Accent,
563            size_px: px(28.),
564            is_indeterminate: false,
565            show_value: false,
566            value_content: None,
567            format: None,
568            id: None,
569            sx: None,
570        }
571    }
572
573    /// Names this ring so it can report `role="progressbar"`. Unnamed rings
574    /// produce no AccessKit node — a constant id would fold every instance
575    /// into one.
576    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
577        self.id = Some(id.into());
578        self
579    }
580
581    /// `ProgressCircle.ValueLabel`'s render function — handed `percentage`
582    /// (0-100), `valueText`, and `isIndeterminate`.
583    pub fn value_content(
584        mut self,
585        render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
586    ) -> Self {
587        self.value_content = Some(std::sync::Arc::new(render));
588        self
589    }
590
591    /// Sets the current value (v3 `value`).
592    pub fn value(mut self, v: f32) -> Self {
593        self.value = v;
594        self
595    }
596
597    /// Sets the minimum value (v3 `minValue`).
598    pub fn min_value(mut self, v: f32) -> Self {
599        self.min_value = v;
600        self
601    }
602
603    /// Sets the maximum value (v3 `maxValue`).
604    pub fn max_value(mut self, v: f32) -> Self {
605        self.max_value = v;
606        self
607    }
608
609    /// `isIndeterminate` — spins the arc instead of sweeping to a fraction.
610    pub fn is_indeterminate(mut self, v: bool) -> Self {
611        self.is_indeterminate = v;
612        self
613    }
614
615    /// Sets the color (v3 `color`).
616    pub fn color(mut self, c: Color) -> Self {
617        self.color = c;
618        self
619    }
620
621    /// `size` — the ring's diameter: 20 / 28 / 36px for `sm` / `md` / `lg`.
622    ///
623    /// v3 documents the three-step scale, not a pixel value.
624    pub fn size(mut self, s: Size) -> Self {
625        self.size_px = match s {
626            Size::Sm => px(20.),
627            Size::Md => px(28.),
628            Size::Lg => px(36.),
629        };
630        self
631    }
632
633    /// The one slot for caller-owned low-level styling: GPUI's styling methods
634    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
635    /// applied to the ring's root element after every value the size and the
636    /// active theme chose, so they win.
637    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
638        crate::util::refine_sx(&mut self.sx, style);
639        self
640    }
641
642    /// Sets whether the value label is shown.
643    pub fn show_value_label(mut self, v: bool) -> Self {
644        self.show_value = v;
645        self
646    }
647
648    /// `formatOptions` — v3 defaults to `{style: "percent"}`.
649    pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
650        self.format = Some(format);
651        self
652    }
653}
654
655impl Default for ProgressCircle {
656    fn default() -> Self {
657        Self::new()
658    }
659}
660
661impl RenderOnce for ProgressCircle {
662    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
663        // A value change moves the SVG stroke-dashoffset over 300ms. The
664        // canvas arc has no retained dash property, so keep the live fraction
665        // in the same keyed tween and let the paint callback read it. This
666        // preserves reversal from the frame actually on screen and lets
667        // reduced motion settle without scheduling a frame.
668        let motion_id = self
669            .id
670            .clone()
671            .unwrap_or_else(|| gpui::ElementId::from("progress-circle"));
672        let reduce_motion = ActiveTheme::reduce_motion(cx);
673        // Clamp once at entry, like the bar: a non-percent label formats the
674        // clamped value, and the guard keeps `f32::clamp` from panicking on
675        // the inverted range `fraction_of` tolerates.
676        let value = if self.min_value <= self.max_value {
677            self.value.clamp(self.min_value, self.max_value)
678        } else {
679            self.value
680        };
681        let fraction = if self.is_indeterminate {
682            // An indeterminate ring shows a fixed quarter arc.
683            0.25
684        } else {
685            fraction_of(self.value, self.min_value, self.max_value)
686        };
687        let mut fraction_motion =
688            crate::anim::Tween::keyed(&motion_id, "fill-fraction", fraction, window, cx);
689        if self.is_indeterminate {
690            fraction_motion.settle();
691        } else {
692            fraction_motion.snap_if_reduced(reduce_motion);
693        }
694        let animate_fraction = !self.is_indeterminate && fraction_motion.animates(reduce_motion);
695        let paint_fraction = fraction_motion.value();
696        let colors = cx.colors();
697        let arc_color = if self.color == Color::Default {
698            colors.default.foreground
699        } else {
700            cx.role(self.color).color
701        };
702        // v3 uses stroke-width 4 in a 36-unit viewBox.
703        let stroke_w = self.size_px / 9.;
704        let spins = self.is_indeterminate && !reduce_motion;
705        let rotation = std::rc::Rc::new(std::cell::Cell::new(0.0f32));
706        let paint_rotation = rotation.clone();
707
708        let arc = gpui::canvas(
709            move |bounds, _, _| bounds,
710            move |bounds, _, window, _| {
711                let fraction = paint_fraction.get();
712                if fraction <= 0.0 {
713                    return;
714                }
715                let mut builder = gpui::PathBuilder::stroke(stroke_w);
716                let center = bounds.center();
717                let radius = (bounds.size.width.min(bounds.size.height) / 2.) - stroke_w / 2.;
718                // CSS positive rotation is clockwise in screen coordinates;
719                // this path's mathematical angle increases counter-clockwise.
720                let start = std::f32::consts::FRAC_PI_2 - paint_rotation.get();
721                let sweep = std::f32::consts::TAU * fraction;
722                let steps = ((sweep / 0.05).ceil() as usize).max(2);
723                for i in 0..=steps {
724                    let a = start - sweep * i as f32 / steps as f32;
725                    let p = gpui::point(center.x + radius * a.cos(), center.y - radius * a.sin());
726                    if i == 0 {
727                        builder.move_to(p);
728                    } else {
729                        builder.line_to(p);
730                    }
731                }
732                if let Ok(path) = builder.build() {
733                    window.paint_path(path, arc_color);
734                }
735                // v3's SVG uses stroke-linecap="round". GPUI's public stroke
736                // builder uses butt caps, so complete the same geometry with
737                // a filled disc at each endpoint.
738                let cap_radius = stroke_w / 2.;
739                for angle in [start, start - sweep] {
740                    let cap_center = gpui::point(
741                        center.x + radius * angle.cos(),
742                        center.y - radius * angle.sin(),
743                    );
744                    crate::util::paint_disc(cap_center, cap_radius, arc_color, window);
745                }
746            },
747        )
748        .absolute()
749        .inset_0();
750
751        let spin_id = match &self.id {
752            Some(id) => element_id::scoped(id, "spin"),
753            None => gpui::ElementId::from("progress-circle-spin"),
754        };
755        let track = gpui::div()
756            .absolute()
757            .inset_0()
758            .rounded_full()
759            .border(stroke_w)
760            .border_color(colors.default.color);
761
762        // HeroUI animates the SVG track wrapper, so the track and the quarter
763        // fill arc rotate as one surface. Rotating only the arc leaves a
764        // stationary ring behind it and makes the indeterminate state visibly
765        // different from the pinned `progress-circle__track` animation.
766        let progress_visual = gpui::div().absolute().inset_0().child(track).child(arc);
767        let progress_visual = if spins {
768            progress_visual
769                .with_animation(
770                    spin_id,
771                    gpui::Animation::new(std::time::Duration::from_millis(
772                        crate::anim::PROGRESS_CIRCLE_SPIN_MS,
773                    ))
774                    .repeat(),
775                    move |visual, delta| {
776                        rotation.set(crate::anim::progress_circle_spin_turn(delta));
777                        visual
778                    },
779                )
780                .into_any_element()
781        } else if animate_fraction {
782            let from = fraction_motion.from();
783            let to = fraction_motion.target();
784            let value = fraction_motion.value();
785            progress_visual
786                .with_animation(
787                    element_id::indexed(&motion_id, "fill-fraction", fraction_motion.generation()),
788                    gpui::Animation::new(std::time::Duration::from_millis(
789                        crate::anim::PROGRESS_CIRCLE_FILL_MS,
790                    ))
791                    .with_easing(crate::anim::ease_out()),
792                    move |visual, delta| {
793                        value.set(from + (to - from) * delta);
794                        visual
795                    },
796                )
797                .into_any_element()
798        } else {
799            fraction_motion.settle();
800            progress_visual.into_any_element()
801        };
802
803        let el = gpui::div()
804            .relative()
805            .flex()
806            .items_center()
807            .justify_center()
808            .size(self.size_px)
809            // `.progress-circle__track` and `.progress-circle__fill-circle`
810            // share one animated SVG wrapper in v3. The canvas-backed port
811            // keeps the same stacking and rotates both layers together.
812            .child(progress_visual)
813            .when(self.show_value, |el| {
814                let format = self
815                    .format
816                    .clone()
817                    .unwrap_or_else(herogpui_core::NumberFormat::percent);
818                // The indeterminate quarter arc is geometry, not a value:
819                // React Aria generates no value label for indeterminate
820                // progress, so report an empty text and a 0% percentage,
821                // like the bar does. The arc keeps drawing regardless.
822                let (percentage, value_text) = if self.is_indeterminate {
823                    (0.0, String::new())
824                } else {
825                    let n = if format.style == herogpui_core::NumberStyle::Percent {
826                        fraction as f64
827                    } else {
828                        value as f64
829                    };
830                    (fraction * 100., format.format(n))
831                };
832                match &self.value_content {
833                    Some(render) => {
834                        el.child(render(percentage, &value_text, self.is_indeterminate))
835                    }
836                    None => el.child(
837                        gpui::div()
838                            .text_size(px(12.))
839                            .font_weight(gpui::FontWeight::SEMIBOLD)
840                            .text_color(colors.foreground)
841                            .child(value_text),
842                    ),
843                }
844            });
845        let el = crate::util::apply_sx(el, &self.sx);
846        match self.id {
847            Some(id) => {
848                let range = if self.is_indeterminate {
849                    crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
850                } else {
851                    crate::a11y::Range::new(
852                        self.min_value as f64,
853                        self.max_value as f64,
854                        self.value as f64,
855                    )
856                };
857                el.id(id)
858                    .a11y(crate::a11y::Role::ProgressIndicator)
859                    .a11y_range(&range)
860                    .into_any_element()
861            }
862            None => el.into_any_element(),
863        }
864    }
865}
866
867crate::util::impl_component_styled!(ProgressBar, ProgressCircle);