From 3abf95216c8a5681921a1d0fb8fd3272850799ec Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Thu, 10 Apr 2025 12:11:58 -0600 Subject: [PATCH] Add progress bar component (#28518) - Adds the progress bar component Release Notes: - N/A --- Cargo.lock | 2 + crates/agent/Cargo.toml | 4 +- crates/agent/src/ui.rs | 2 + crates/agent/src/ui/user_spending.rs | 186 ++++++++++++++++++ .../src/component_preview.rs | 30 +-- crates/ui/src/components.rs | 2 + crates/ui/src/components/icon.rs | 2 +- .../ui/src/components/icon/decorated_icon.rs | 2 +- crates/ui/src/components/label/label.rs | 2 +- crates/ui/src/components/progress.rs | 2 + .../src/components/progress/progress_bar.rs | 159 +++++++++++++++ crates/ui/src/styles/typography.rs | 2 +- 12 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 crates/agent/src/ui/user_spending.rs create mode 100644 crates/ui/src/components/progress.rs create mode 100644 crates/ui/src/components/progress/progress_bar.rs diff --git a/Cargo.lock b/Cargo.lock index 2ac6054797..87fc4a5027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,7 @@ dependencies = [ "clock", "collections", "command_palette_hooks", + "component", "context_server", "convert_case 0.8.0", "db", @@ -85,6 +86,7 @@ dependencies = [ "language", "language_model", "language_model_selector", + "linkme", "log", "lsp", "markdown", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index deaee66d49..9031d2db1a 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -32,6 +32,7 @@ client.workspace = true clock.workspace = true collections.workspace = true command_palette_hooks.workspace = true +component.workspace = true context_server.workspace = true convert_case.workspace = true db.workspace = true @@ -51,6 +52,7 @@ itertools.workspace = true language.workspace = true language_model.workspace = true language_model_selector.workspace = true +linkme.workspace = true log.workspace = true lsp.workspace = true markdown.workspace = true @@ -85,9 +87,9 @@ ui.workspace = true ui_input.workspace = true util.workspace = true uuid.workspace = true +workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] buffer_diff = { workspace = true, features = ["test-support"] } diff --git a/crates/agent/src/ui.rs b/crates/agent/src/ui.rs index 40a84863af..d5b374208e 100644 --- a/crates/agent/src/ui.rs +++ b/crates/agent/src/ui.rs @@ -1,5 +1,7 @@ mod agent_notification; mod context_pill; +mod user_spending; pub use agent_notification::*; pub use context_pill::*; +// pub use user_spending::*; diff --git a/crates/agent/src/ui/user_spending.rs b/crates/agent/src/ui/user_spending.rs new file mode 100644 index 0000000000..59bcbc9671 --- /dev/null +++ b/crates/agent/src/ui/user_spending.rs @@ -0,0 +1,186 @@ +use gpui::{Entity, Render}; +use ui::{ProgressBar, prelude::*}; + +#[derive(RegisterComponent)] +pub struct UserSpending { + free_tier_current: u32, + free_tier_cap: u32, + over_tier_current: u32, + over_tier_cap: u32, + free_tier_progress: Entity, + over_tier_progress: Entity, +} + +impl UserSpending { + pub fn new( + free_tier_current: u32, + free_tier_cap: u32, + over_tier_current: u32, + over_tier_cap: u32, + cx: &mut App, + ) -> Self { + let free_tier_capped = free_tier_current == free_tier_cap; + let free_tier_near_capped = + free_tier_current as f32 / 100.0 >= free_tier_cap as f32 / 100.0 * 0.9; + let over_tier_capped = over_tier_current == over_tier_cap; + let over_tier_near_capped = + over_tier_current as f32 / 100.0 >= over_tier_cap as f32 / 100.0 * 0.9; + + let free_tier_progress = cx.new(|cx| { + ProgressBar::new( + "free_tier", + free_tier_current as f32, + free_tier_cap as f32, + cx, + ) + }); + let over_tier_progress = cx.new(|cx| { + ProgressBar::new( + "over_tier", + over_tier_current as f32, + over_tier_cap as f32, + cx, + ) + }); + + if free_tier_capped { + free_tier_progress.update(cx, |progress_bar, cx| { + progress_bar.fg_color(cx.theme().status().error); + }); + } else if free_tier_near_capped { + free_tier_progress.update(cx, |progress_bar, cx| { + progress_bar.fg_color(cx.theme().status().warning); + }); + } + + if over_tier_capped { + over_tier_progress.update(cx, |progress_bar, cx| { + progress_bar.fg_color(cx.theme().status().error); + }); + } else if over_tier_near_capped { + over_tier_progress.update(cx, |progress_bar, cx| { + progress_bar.fg_color(cx.theme().status().warning); + }); + } + + Self { + free_tier_current, + free_tier_cap, + over_tier_current, + over_tier_cap, + free_tier_progress, + over_tier_progress, + } + } +} + +impl Render for UserSpending { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let formatted_free_tier = format!( + "${} / ${}", + self.free_tier_current as f32 / 100.0, + self.free_tier_cap as f32 / 100.0 + ); + let formatted_over_tier = format!( + "${} / ${}", + self.over_tier_current as f32 / 100.0, + self.over_tier_cap as f32 / 100.0 + ); + + v_group() + .elevation_2(cx) + .py_1p5() + .px_2p5() + .w(px(360.)) + .child( + v_flex() + .child( + v_flex() + .p_1p5() + .gap_0p5() + .child( + h_flex() + .justify_between() + .child(Label::new("Free Tier Usage").size(LabelSize::Small)) + .child( + Label::new(formatted_free_tier) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(self.free_tier_progress.clone()), + ) + .child( + v_flex() + .p_1p5() + .gap_0p5() + .child( + h_flex() + .justify_between() + .child(Label::new("Current Spending").size(LabelSize::Small)) + .child( + Label::new(formatted_over_tier) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(self.over_tier_progress.clone()), + ), + ) + } +} + +impl Component for UserSpending { + fn scope() -> ComponentScope { + ComponentScope::None + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let new_user = cx.new(|cx| UserSpending::new(0, 2000, 0, 2000, cx)); + let free_capped = cx.new(|cx| UserSpending::new(2000, 2000, 0, 2000, cx)); + let free_near_capped = cx.new(|cx| UserSpending::new(1800, 2000, 0, 2000, cx)); + let over_near_capped = cx.new(|cx| UserSpending::new(2000, 2000, 1800, 2000, cx)); + let over_capped = cx.new(|cx| UserSpending::new(1000, 2000, 2000, 2000, cx)); + + Some( + v_flex() + .gap_6() + .p_4() + .children(vec![example_group(vec![ + single_example( + "New User", + div().size_full().child(new_user.clone()).into_any_element(), + ), + single_example( + "Free Tier Capped", + div() + .size_full() + .child(free_capped.clone()) + .into_any_element(), + ), + single_example( + "Free Tier Near Capped", + div() + .size_full() + .child(free_near_capped.clone()) + .into_any_element(), + ), + single_example( + "Over Tier Near Capped", + div() + .size_full() + .child(over_near_capped.clone()) + .into_any_element(), + ), + single_example( + "Over Tier Capped", + div() + .size_full() + .child(over_capped.clone()) + .into_any_element(), + ), + ])]) + .into_any_element(), + ) + } +} diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 45eda96caa..5109b8692d 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -187,22 +187,20 @@ impl ComponentPreview { let mut entries = Vec::new(); - let known_scopes = [ - ComponentScope::Layout, - ComponentScope::Input, - ComponentScope::Editor, - ComponentScope::Notification, - ComponentScope::Collaboration, - ComponentScope::VersionControl, - ComponentScope::None, - ]; - // Always show all components first entries.push(PreviewEntry::AllComponents); entries.push(PreviewEntry::Separator); - for scope in known_scopes.iter() { - if let Some(components) = scope_groups.remove(scope) { + let mut scopes: Vec<_> = scope_groups + .keys() + .filter(|scope| !matches!(**scope, ComponentScope::None)) + .cloned() + .collect(); + + scopes.sort_by_key(|s| s.to_string()); + + for scope in scopes { + if let Some(components) = scope_groups.remove(&scope) { if !components.is_empty() { entries.push(PreviewEntry::SectionHeader(scope.to_string().into())); let mut sorted_components = components; @@ -215,6 +213,7 @@ impl ComponentPreview { } } + // Add uncategorized components last if let Some(components) = scope_groups.get(&ComponentScope::None) { if !components.is_empty() { entries.push(PreviewEntry::Separator); @@ -272,7 +271,12 @@ impl ComponentPreview { .into_any_element() } PreviewEntry::Separator => ListItem::new(ix) - .child(h_flex().pt_3().child(Divider::horizontal_dashed())) + .child( + h_flex() + .occlude() + .pt_3() + .child(Divider::horizontal_dashed()), + ) .into_any_element(), } } diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index ca5f5d0237..7651a6e9a0 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -22,6 +22,7 @@ mod notification; mod numeric_stepper; mod popover; mod popover_menu; +mod progress; mod radio; mod right_click_menu; mod scrollbar; @@ -61,6 +62,7 @@ pub use notification::*; pub use numeric_stepper::*; pub use popover::*; pub use popover_menu::*; +pub use progress::*; pub use radio::*; pub use right_click_menu::*; pub use scrollbar::*; diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 7e353c82e9..bc0ec462e9 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -267,7 +267,7 @@ impl RenderOnce for IconWithIndicator { impl Component for Icon { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::Images } fn description() -> Option<&'static str> { diff --git a/crates/ui/src/components/icon/decorated_icon.rs b/crates/ui/src/components/icon/decorated_icon.rs index e3599caaff..82ca844c38 100644 --- a/crates/ui/src/components/icon/decorated_icon.rs +++ b/crates/ui/src/components/icon/decorated_icon.rs @@ -26,7 +26,7 @@ impl RenderOnce for DecoratedIcon { impl Component for DecoratedIcon { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::Images } fn description() -> Option<&'static str> { diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index e8a17aaebe..b278e6dab8 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -199,7 +199,7 @@ impl RenderOnce for Label { impl Component for Label { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::Typography } fn description() -> Option<&'static str> { diff --git a/crates/ui/src/components/progress.rs b/crates/ui/src/components/progress.rs new file mode 100644 index 0000000000..bfaf7f3dcf --- /dev/null +++ b/crates/ui/src/components/progress.rs @@ -0,0 +1,2 @@ +mod progress_bar; +pub use progress_bar::*; diff --git a/crates/ui/src/components/progress/progress_bar.rs b/crates/ui/src/components/progress/progress_bar.rs new file mode 100644 index 0000000000..ec9ee2d08f --- /dev/null +++ b/crates/ui/src/components/progress/progress_bar.rs @@ -0,0 +1,159 @@ +use documented::Documented; +use gpui::{Hsla, point}; + +use crate::components::Label; +use crate::prelude::*; + +/// A progress bar is a horizontal bar that communicates the status of a process. +/// +/// A progress bar should not be used to represent indeterminate progress. +#[derive(RegisterComponent, Documented)] +pub struct ProgressBar { + id: ElementId, + value: f32, + max_value: f32, + bg_color: Hsla, + fg_color: Hsla, +} + +impl ProgressBar { + /// Create a new progress bar with the given value and maximum value. + pub fn new( + id: impl Into, + value: f32, + max_value: f32, + cx: &mut Context, + ) -> Self { + Self { + id: id.into(), + value, + max_value, + bg_color: cx.theme().colors().background, + fg_color: cx.theme().status().info, + } + } + + /// Set the current value of the progress bar. + pub fn value(&mut self, value: f32) -> &mut Self { + self.value = value; + self + } + + /// Set the maximum value of the progress bar. + pub fn max_value(&mut self, max_value: f32) -> &mut Self { + self.max_value = max_value; + self + } + + /// Set the background color of the progress bar. + pub fn bg_color(&mut self, color: Hsla) -> &mut Self { + self.bg_color = color; + self + } + + /// Set the foreground color of the progress bar. + pub fn fg_color(&mut self, color: Hsla) -> &mut Self { + self.fg_color = color; + self + } +} + +impl Render for ProgressBar { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let fill_width = (self.value / self.max_value).clamp(0.02, 1.0); + + div() + .id(self.id.clone()) + .w_full() + .h(px(8.0)) + .rounded_full() + .py(px(2.0)) + .px(px(4.0)) + .bg(self.bg_color) + .shadow(smallvec::smallvec![gpui::BoxShadow { + color: gpui::black().opacity(0.08), + offset: point(px(0.), px(1.)), + blur_radius: px(0.), + spread_radius: px(0.), + }]) + .child( + div() + .h_full() + .rounded_full() + .bg(self.fg_color) + .w(relative(fill_width)), + ) + } +} + +impl Component for ProgressBar { + fn scope() -> ComponentScope { + ComponentScope::Status + } + + fn description() -> Option<&'static str> { + Some(Self::DOCS) + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let max_value = 180.0; + + let empty_progress_bar = cx.new(|cx| ProgressBar::new("empty", 0.0, max_value, cx)); + let partial_progress_bar = + cx.new(|cx| ProgressBar::new("partial", max_value * 0.35, max_value, cx)); + let filled_progress_bar = cx.new(|cx| ProgressBar::new("filled", max_value, max_value, cx)); + + 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(empty_progress_bar.clone()), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .justify_between() + .child(Label::new("38%")) + .child(Label::new("Partial")), + ) + .child(partial_progress_bar.clone()), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .justify_between() + .child(Label::new("100%")) + .child(Label::new("Complete")), + ) + .child(filled_progress_bar.clone()), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index a1a631deb2..79eb1b8cf7 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -235,7 +235,7 @@ impl Headline { impl Component for Headline { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::Typography } fn description() -> Option<&'static str> {