From 962639140379248a1ad76237c89082a5e93fd6f5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 28 Oct 2026 19:58:01 +0700 Subject: [PATCH] add progress --- crates/core/src/lib.rs | 3 + crates/core/src/progress.rs | 101 ++++++++++++++++++++++++++++++++++ crates/core/tests/progress.rs | 36 ++++++++++++ examples/gallery/src/main.rs | 17 ++++++ 4 files changed, 157 insertions(+) create mode 100644 crates/core/src/progress.rs create mode 100644 crates/core/tests/progress.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 010bfb9..b68e690 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,3 +22,6 @@ pub mod link; #[cfg(feature = "slider")] pub mod slider; + +#[cfg(feature = "progress")] +pub mod progress; diff --git a/crates/core/src/progress.rs b/crates/core/src/progress.rs new file mode 100644 index 0000000..5f23904 --- /dev/null +++ b/crates/core/src/progress.rs @@ -0,0 +1,101 @@ +//! The progress bar: determinate fill or an indeterminate sweep. + +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, div, relative, +}; +use gpui_native_theme::ActiveTheme as _; + +/// A progress bar. With a `value` in `0..=1` it paints a determinate fill. +#[derive(IntoElement)] +pub struct Progress { + id: ElementId, + value: f32, + indeterminate: bool, + a11y_label: Option, +} + +impl Progress { + /// Creates a determinate progress bar at `value` (`0..=1`). + pub fn new(id: I, value: f32) -> Self + where + I: Into, + { + Self { + id: id.into(), + value, + indeterminate: false, + a11y_label: None, + } + } + + /// Sweeps instead of painting a fill, for work of unknown length. + pub fn indeterminate(mut self, indeterminate: bool) -> Self { + self.indeterminate = indeterminate; + self + } + + /// Sets the accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl RenderOnce for Progress { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let ramps = theme.ramps; + let radius = theme.radius; + let value = self.value.clamp(0.0, 1.0); + + div() + .id(self.id) + .w_full() + .role(Role::ProgressIndicator) + .when_some(self.a11y_label, |progress, label| { + progress.aria_label(label) + }) + .when(!self.indeterminate, |progress| { + progress + .aria_numeric_value(f64::from(value)) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(1.0) + }) + .child( + div() + .w_full() + .h_1p5() + .rounded(radius.full) + .bg(colors.border) + .child(if self.indeterminate { + div() + .h_full() + .w(relative(0.3)) + .rounded(radius.full) + .bg(ramps.primary.base) + .with_animation( + "indeterminate", + Animation::new(Duration::from_millis(1500)).repeat(), + |pill, delta| pill.ml(relative(delta * 0.7)), + ) + .into_any_element() + } else { + div() + .h_full() + .w(relative(value)) + .rounded(radius.full) + .bg(ramps.primary.base) + .into_any_element() + }), + ) + } +} diff --git a/crates/core/tests/progress.rs b/crates/core/tests/progress.rs new file mode 100644 index 0000000..37f5904 --- /dev/null +++ b/crates/core/tests/progress.rs @@ -0,0 +1,36 @@ +use gpui::{ParentElement as _, Render, Styled as _, TestAppContext, Window, div}; +use gpui_native_core::progress::Progress; +use gpui_native_theme::Theme; + +struct Harness; + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .child(Progress::new("test-progress", 0.6).accessibility_label("Upload")) + .child( + Progress::new("test-progress-indeterminate", 0.0) + .indeterminate(true) + .accessibility_label("Working"), + ) + } +} + +#[gpui::test] +fn determinate_and_indeterminate_bars_draw_headlessly(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (view, visual) = cx.add_window_view(|_, _| Harness); + let _ = view; + visual.run_until_parked(); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 6958f7e..2e189b6 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -8,6 +8,7 @@ use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::link::Link; +use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; @@ -392,6 +393,22 @@ impl Render for Gallery { .child(format!("volume: {}", self.volume)), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "progress · native refs — NSProgressIndicator, WinUI ProgressBar, Adwaita ProgressBar, Breeze", + colors, + )) + .child(Progress::new("progress-25", 0.25).accessibility_label("Quarter")) + .child(Progress::new("progress-60", 0.6).accessibility_label("Most")) + .child(Progress::new("progress-90", 0.9).accessibility_label("Nearly")) + .child( + Progress::new("progress-indeterminate", 0.0) + .indeterminate(true) + .accessibility_label("Working"), + ), + ) .child( div() .flex()