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