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! Screenshot 2026-02-13 at 10  59@2x 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(), ) } }