From ab3987eae31b11289ab6dace54bb14e34c98e206 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Fri, 13 Feb 2026 11:24:16 -0300
Subject: [PATCH] ui: Add circular progress component (#49100)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR adds a `CircularProgress` component, soon to be used in the
agent panel!
Release Notes:
- N/A
---
crates/feature_flags/src/flags.rs | 2 +-
crates/ui/src/components/progress.rs | 3 +
.../components/progress/circular_progress.rs | 215 ++++++++++++++++++
.../src/components/progress/progress_bar.rs | 93 ++++----
4 files changed, 260 insertions(+), 53 deletions(-)
create mode 100644 crates/ui/src/components/progress/circular_progress.rs
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index b94264879deb87b2880ef0d62ecf08489dfa8655..5a52ee1f7186c080702ac2c7589cd9d4e383e159 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -40,7 +40,7 @@ impl FeatureFlag for SubagentsFeatureFlag {
const NAME: &'static str = "subagents";
fn enabled_for_staff() -> bool {
- false
+ true
}
}
diff --git a/crates/ui/src/components/progress.rs b/crates/ui/src/components/progress.rs
index bfaf7f3dcf9d06cf1551dc46905523b46b07fcb7..1c0b102e95d724ccf4963cb94be64a7457ad0b0b 100644
--- a/crates/ui/src/components/progress.rs
+++ b/crates/ui/src/components/progress.rs
@@ -1,2 +1,5 @@
+mod circular_progress;
mod progress_bar;
+
+pub use circular_progress::*;
pub use progress_bar::*;
diff --git a/crates/ui/src/components/progress/circular_progress.rs b/crates/ui/src/components/progress/circular_progress.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ddd620282dc81836cefa79fc65f85bfb0bec7078
--- /dev/null
+++ b/crates/ui/src/components/progress/circular_progress.rs
@@ -0,0 +1,215 @@
+use documented::Documented;
+use gpui::{Hsla, PathBuilder, canvas, point};
+use std::f32::consts::PI;
+
+use crate::prelude::*;
+
+/// A circular progress indicator that displays progress as an arc growing clockwise from the top.
+#[derive(IntoElement, RegisterComponent, Documented)]
+pub struct CircularProgress {
+ value: f32,
+ max_value: f32,
+ size: Pixels,
+ bg_color: Hsla,
+ progress_color: Hsla,
+}
+
+impl CircularProgress {
+ pub fn new(value: f32, max_value: f32, size: Pixels, cx: &App) -> Self {
+ Self {
+ value,
+ max_value,
+ size,
+ bg_color: cx.theme().colors().border_variant,
+ progress_color: cx.theme().status().info,
+ }
+ }
+
+ /// Sets the current progress value.
+ pub fn value(mut self, value: f32) -> Self {
+ self.value = value;
+ self
+ }
+
+ /// Sets the maximum value for the progress indicator.
+ pub fn max_value(mut self, max_value: f32) -> Self {
+ self.max_value = max_value;
+ self
+ }
+
+ /// Sets the size (diameter) of the circular progress indicator.
+ pub fn size(mut self, size: Pixels) -> Self {
+ self.size = size;
+ self
+ }
+
+ /// Sets the background circle color.
+ pub fn bg_color(mut self, color: Hsla) -> Self {
+ self.bg_color = color;
+ self
+ }
+
+ /// Sets the progress arc color.
+ pub fn progress_color(mut self, color: Hsla) -> Self {
+ self.progress_color = color;
+ self
+ }
+}
+
+impl RenderOnce for CircularProgress {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let value = self.value;
+ let max_value = self.max_value;
+ let size = self.size;
+ let bg_color = self.bg_color;
+ let progress_color = self.progress_color;
+
+ canvas(
+ |_, _, _| {},
+ move |bounds, _, window, _cx| {
+ let current_value = value;
+
+ let center_x = bounds.origin.x + bounds.size.width / 2.0;
+ let center_y = bounds.origin.y + bounds.size.height / 2.0;
+
+ let stroke_width = px(4.0);
+ let radius = (size / 2.0) - stroke_width;
+
+ // Draw background circle (full 360 degrees)
+ let mut bg_builder = PathBuilder::stroke(stroke_width);
+
+ // Start at rightmost point
+ bg_builder.move_to(point(center_x + radius, center_y));
+
+ // Draw full circle using two 180-degree arcs
+ bg_builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ true,
+ point(center_x - radius, center_y),
+ );
+ bg_builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ true,
+ point(center_x + radius, center_y),
+ );
+ bg_builder.close();
+
+ if let Ok(path) = bg_builder.build() {
+ window.paint_path(path, bg_color);
+ }
+
+ // Draw progress arc if there's any progress
+ let progress = (current_value / max_value).clamp(0.0, 1.0);
+ if progress > 0.0 {
+ let mut progress_builder = PathBuilder::stroke(stroke_width);
+
+ // Handle 100% progress as a special case by drawing a full circle
+ if progress >= 0.999 {
+ // Start at rightmost point
+ progress_builder.move_to(point(center_x + radius, center_y));
+
+ // Draw full circle using two 180-degree arcs
+ progress_builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ true,
+ point(center_x - radius, center_y),
+ );
+ progress_builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ false,
+ true,
+ point(center_x + radius, center_y),
+ );
+ progress_builder.close();
+ } else {
+ // Start at 12 o'clock (top) position
+ let start_x = center_x;
+ let start_y = center_y - radius;
+ progress_builder.move_to(point(start_x, start_y));
+
+ // Calculate the end point of the arc based on progress
+ // Progress sweeps clockwise from -90° (top)
+ let angle = -PI / 2.0 + (progress * 2.0 * PI);
+ let end_x = center_x + radius * angle.cos();
+ let end_y = center_y + radius * angle.sin();
+
+ // Use large_arc flag when progress > 0.5 (more than 180 degrees)
+ let large_arc = progress > 0.5;
+
+ progress_builder.arc_to(
+ point(radius, radius),
+ px(0.),
+ large_arc,
+ true, // sweep clockwise
+ point(end_x, end_y),
+ );
+ }
+
+ if let Ok(path) = progress_builder.build() {
+ window.paint_path(path, progress_color);
+ }
+ }
+ },
+ )
+ .size(size)
+ }
+}
+
+impl Component for CircularProgress {
+ fn scope() -> ComponentScope {
+ ComponentScope::Status
+ }
+
+ fn description() -> Option<&'static str> {
+ Some(
+ "A circular progress indicator that displays progress as an arc growing clockwise from the top.",
+ )
+ }
+
+ fn preview(_window: &mut Window, cx: &mut App) -> Option {
+ let max_value = 100.0;
+ let container = || v_flex().items_center().gap_1();
+
+ Some(
+ example_group(vec![single_example(
+ "Examples",
+ h_flex()
+ .gap_6()
+ .child(
+ container()
+ .child(CircularProgress::new(0.0, max_value, px(48.0), cx))
+ .child(Label::new("0%").size(LabelSize::Small)),
+ )
+ .child(
+ container()
+ .child(CircularProgress::new(25.0, max_value, px(48.0), cx))
+ .child(Label::new("25%").size(LabelSize::Small)),
+ )
+ .child(
+ container()
+ .child(CircularProgress::new(50.0, max_value, px(48.0), cx))
+ .child(Label::new("50%").size(LabelSize::Small)),
+ )
+ .child(
+ container()
+ .child(CircularProgress::new(75.0, max_value, px(48.0), cx))
+ .child(Label::new("75%").size(LabelSize::Small)),
+ )
+ .child(
+ container()
+ .child(CircularProgress::new(100.0, max_value, px(48.0), cx))
+ .child(Label::new("100%").size(LabelSize::Small)),
+ )
+ .into_any_element(),
+ )])
+ .into_any_element(),
+ )
+ }
+}
diff --git a/crates/ui/src/components/progress/progress_bar.rs b/crates/ui/src/components/progress/progress_bar.rs
index 5cc5abd36d041bc03676410983020b94ac8d8809..b46b0523681e844189145a7ef2b9d1be571c1f35 100644
--- a/crates/ui/src/components/progress/progress_bar.rs
+++ b/crates/ui/src/components/progress/progress_bar.rs
@@ -67,9 +67,9 @@ impl RenderOnce for ProgressBar {
div()
.id(self.id.clone())
.w_full()
- .h(px(8.0))
+ .h_2()
+ .p_0p5()
.rounded_full()
- .p(px(2.0))
.bg(self.bg_color)
.shadow(vec![gpui::BoxShadow {
color: gpui::black().opacity(0.08),
@@ -99,58 +99,47 @@ impl Component for ProgressBar {
fn preview(_window: &mut Window, cx: &mut App) -> Option {
let max_value = 180.0;
+ let container = || v_flex().w_full().gap_1();
Some(
- div()
- .flex()
- .flex_col()
- .gap_4()
- .p_4()
- .w(px(240.0))
- .child(div().child("Progress Bar"))
- .child(
- div()
- .flex()
- .flex_col()
- .gap_2()
- .child(
- div()
- .flex()
- .justify_between()
- .child(Label::new("0%"))
- .child(Label::new("Empty")),
- )
- .child(ProgressBar::new("empty", 0.0, max_value, cx)),
- )
- .child(
- div()
- .flex()
- .flex_col()
- .gap_2()
- .child(
- div()
- .flex()
- .justify_between()
- .child(Label::new("38%"))
- .child(Label::new("Partial")),
- )
- .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)),
- )
- .child(
- div()
- .flex()
- .flex_col()
- .gap_2()
- .child(
- div()
- .flex()
- .justify_between()
- .child(Label::new("100%"))
- .child(Label::new("Complete")),
- )
- .child(ProgressBar::new("filled", max_value, max_value, cx)),
- )
- .into_any_element(),
+ example_group(vec![single_example(
+ "Examples",
+ v_flex()
+ .w_full()
+ .gap_2()
+ .child(
+ container()
+ .child(
+ h_flex()
+ .justify_between()
+ .child(Label::new("0%"))
+ .child(Label::new("Empty")),
+ )
+ .child(ProgressBar::new("empty", 0.0, max_value, cx)),
+ )
+ .child(
+ container()
+ .child(
+ h_flex()
+ .justify_between()
+ .child(Label::new("38%"))
+ .child(Label::new("Partial")),
+ )
+ .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)),
+ )
+ .child(
+ container()
+ .child(
+ h_flex()
+ .justify_between()
+ .child(Label::new("100%"))
+ .child(Label::new("Complete")),
+ )
+ .child(ProgressBar::new("filled", max_value, max_value, cx)),
+ )
+ .into_any_element(),
+ )])
+ .into_any_element(),
)
}
}