Add progress bar component (#28518)

Nate Butler created

- Adds the progress bar component

Release Notes:

- N/A

Change summary

Cargo.lock                                        |   2 
crates/agent/Cargo.toml                           |   4 
crates/agent/src/ui.rs                            |   2 
crates/agent/src/ui/user_spending.rs              | 186 +++++++++++++++++
crates/component_preview/src/component_preview.rs |  30 +-
crates/ui/src/components.rs                       |   2 
crates/ui/src/components/icon.rs                  |   2 
crates/ui/src/components/icon/decorated_icon.rs   |   2 
crates/ui/src/components/label/label.rs           |   2 
crates/ui/src/components/progress.rs              |   2 
crates/ui/src/components/progress/progress_bar.rs | 159 ++++++++++++++
crates/ui/src/styles/typography.rs                |   2 
12 files changed, 377 insertions(+), 18 deletions(-)

Detailed changes

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",

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"] }

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::*;

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<ProgressBar>,
+    over_tier_progress: Entity<ProgressBar>,
+}
+
+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<Self>) -> 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<AnyElement> {
+        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(),
+        )
+    }
+}

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(),
         }
     }

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::*;

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> {

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> {

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> {

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<ElementId>,
+        value: f32,
+        max_value: f32,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>) -> 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<AnyElement> {
+        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(),
+        )
+    }
+}

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> {