diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 9af3f81..b0b39e4 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -11,6 +11,7 @@ use gpui_native_utils::focus_ring; use std::rc::Rc; use crate::common::{Sizable, h_flex}; +use crate::spinner::Spinner; /// The single activation handler, shared by pointer, Enter, and Space. type ClickHandler = Box; @@ -20,10 +21,10 @@ type TooltipBuilder = Rc AnyView>; /// Which visual treatment a button uses. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ButtonVariant { - /// Accent-filled; the platform's default action. + /// The platform's default action. #[default] Primary, - /// Neutral and bordered; the everyday companion to `Primary`. + /// The everyday companion to `Primary`. Secondary, /// Unpainted until hovered, for toolbars and dense UI. Ghost, @@ -40,6 +41,7 @@ pub struct Button { variant: ButtonVariant, size: Size, disabled: bool, + loading: bool, tab_stop: bool, debug_selector: Option String>>, a11y_label: Option, @@ -60,6 +62,7 @@ impl Button { variant: ButtonVariant::default(), size: Size::default(), disabled: false, + loading: false, tab_stop: true, debug_selector: None, a11y_label: None, @@ -119,6 +122,12 @@ impl Button { self } + /// Shows the spinner in place of the icon and suppresses activation. + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + /// Overrides the accessibility label, which defaults to the visible label. pub fn accessibility_label(mut self, label: L) -> Self where @@ -149,11 +158,11 @@ impl RenderOnce for Button { let theme = cx.theme(); let colors = theme.colors; let ramps = theme.ramps; + let borders = theme.borders; let metrics = theme.sizes.resolve(self.size); let on_click = self.on_click; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); - let icon_only = self.label.is_none() && self.icon.is_some(); let (background, foreground, hover, pressed) = match self.variant { ButtonVariant::Primary => ( @@ -183,35 +192,47 @@ impl RenderOnce for Button { }; h_flex() - .id(self.id) + .id(self.id.clone()) .when_some(self.debug_selector, |button, selector| { button.debug_selector(move || selector()) }) .role(Role::Button) .justify_center() - .when(icon_only, |button| button.size(metrics.control_height)) - .when(!icon_only, |button| { - button.h(metrics.control_height).px(metrics.padding_x) + .h(metrics.control_height) + .when(self.icon.is_some(), |button| { + button.px(metrics.dense_padding_x) }) + .when(self.icon.is_none(), |button| button.px(metrics.padding_x)) .line_height(relative(1.0)) - .gap_1p5() + .gap_2() .rounded(theme.radius.control) .text_size(metrics.text_size) - .when(self.variant == ButtonVariant::Secondary, |button| { - button.border_1().border_color(colors.border) - }) + .when( + borders.buttons && self.variant != ButtonVariant::Ghost, + |button| button.border_1().border_color(colors.border), + ) .when_some(background, |button, background| button.bg(background)) .text_color(foreground) .when_some(a11y_label, |button, label| button.aria_label(label)) - .when_some(self.icon, |button, role| { - button.child(icon(role).size(metrics.icon_size).color(foreground)) + .when(self.loading, |button| { + button.child( + Spinner::new((self.id.clone(), "spinner")) + .with_size(self.size) + .variant(crate::spinner::SpinnerVariant::Icon) + .color(foreground), + ) + }) + .when(!self.loading, |button| { + button.when_some(self.icon, |button, role| { + button.child(icon(role).size(metrics.icon_size).color(foreground)) + }) }) .when_some(self.label, |button, label| button.child(label)) .when(self.disabled, |button| button.opacity(0.5)) .when_some(self.tooltip, |button, tooltip| { button.tooltip(move |window, cx| tooltip(window, cx)) }) - .when(!self.disabled, |button| { + .when(!self.disabled && !self.loading, |button| { button .cursor_pointer() .focusable() diff --git a/crates/core/src/spinner.rs b/crates/core/src/spinner.rs index e9c130a..2a0bbee 100644 --- a/crates/core/src/spinner.rs +++ b/crates/core/src/spinner.rs @@ -41,6 +41,7 @@ pub struct Spinner { value: Option, variant: SpinnerVariant, size: Size, + color: Option, a11y_label: Option, } @@ -55,10 +56,17 @@ impl Spinner { value: None, variant: SpinnerVariant::default(), size: Size::default(), + color: None, a11y_label: None, } } + /// Overrides the arc color, for accent-filled surfaces. + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } + /// Sets the spinner's visual language. pub fn variant(mut self, variant: SpinnerVariant) -> Self { self.variant = variant; @@ -93,7 +101,7 @@ impl RenderOnce for Spinner { let side = theme.sizes.resolve(self.size).icon_size; let stroke = side * RING_STROKE; let track = theme.colors.border; - let accent = theme.ramps.primary.base; + let accent = self.color.unwrap_or(theme.ramps.primary.base); let value = self.value.map(|value| value.clamp(0.0, 1.0)); div() diff --git a/crates/core/tests/button.rs b/crates/core/tests/button.rs index df3c820..e8195c0 100644 --- a/crates/core/tests/button.rs +++ b/crates/core/tests/button.rs @@ -11,6 +11,7 @@ use gpui_native_theme::Theme; struct Harness { clicks: Rc>, disabled: bool, + loading: bool, } impl Render for Harness { @@ -20,14 +21,18 @@ impl Render for Harness { Button::new("test-button") .label("Save") .disabled(self.disabled) + .loading(self.loading) .on_click(move |_, _, _| *clicks.borrow_mut() += 1), ) } } -/// A maximized window whose button stretches across the top 28px, so +/// A maximized window whose button stretches across the top band, so /// `point(px(60.), px(14.))` always lands on it. -fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc>) { +fn button_window( + disabled: bool, + loading: bool, +) -> (VisualTestContext, AnyWindowHandle, Rc>) { let mut cx = TestAppContext::single(); cx.update(Theme::init); let clicks = Rc::new(RefCell::new(0)); @@ -35,6 +40,7 @@ fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc` beside `input_height` on `SystemTheme`, reuse the - ratio-scaling pattern: macOS 24 (measured), Windows 32, GNOME ≈34, KDE - ≈33. Fixes the smallness everywhere in one change. -2. **State `root_font_size` from Linux font settings** — GNOME - `font-name` (11pt ⇒ 14.67px), KDE `kdeglobals [General] font=` (10pt ⇒ - 13.33px); the parsing seams already exist for colors. -3. **Make the radius ladder platform-overridable** — stated overlay/sm - radius on `SystemTheme`: GNOME 15/6, Windows 8/4, KDE 5/4, macOS ≈10/4; - keep preset > system precedence. -4. **Per-source border and focus policy** — a theme-level token consumed by - components: secondary button and inputs drop borders on macOS/GNOME - (fill tints + 2px accent focus outline), keep 1px on Windows/KDE; - primary gains a border on Windows/KDE; menus/popovers shadow-only on - macOS/GNOME. -5. **State `titlebar_height` per platform** — GNOME 47, KDE ≈44; macOS - queried rather than a drifted floor (32 on current builds, 28 older); - Windows stays stated. -6. **Add a minimal spacing scale to `Theme`** driven by a per-platform base - unit (GNOME 6, KDE 4/6/8, others 4); route menu/popover paddings and menu - row min-heights through it — GNOME 32 (published), Windows ≈33 (padding - 4/5 + a 20px Body line + 2px item margins), KDE ≈29 (max(font, 20px - floors) + 1 + 2×4); the hardcoded `min_w_`/`max_w` surface values stay - put — they are platform-invariant. -7. **Calibrate switches and icons per platform** — macOS 54×24, Windows - 40×20 with 12px knob, KDE 36×18; icon 16 on macOS/Windows. - -## Source confidence notes - -Everything in the GNOME/Windows/KDE tables is quoted from the cited -stylesheets, sources, or Learn pages (published). KDE button/line-edit/title -heights are formulas from source constants (derived). Windows TextBox height -token and the focus-stroke pixel spec live in the OS `generic.xaml` and were -not retrievable — low confidence, commonly cited as 32. Apple publishes no -macOS control metrics; all macOS control numbers here are AppKit -measurements from 2026-11 on this machine and may shift across macOS -releases (titlebar already differs from older builds). diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 0c592cb..422f550 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -879,24 +879,75 @@ impl Render for ButtonDemo { button = button.tooltip(Tooltip::text("Removes the selected item")); } button - })) + })), + )) + .child(demo_section( + "icon + label", + colors, + h_flex() + .flex_wrap() + .gap_2() + .children(variants.map(|(name, variant)| { + let view = view.clone(); + let mut button = + Button::new(SharedString::from(format!("button-demo-icon-{name}"))) + .label(button_label(variant)) + .icon(IconRole::Folder) + .variant(variant) + .on_click(move |_, _, cx| { + view.update(cx, |demo, cx| { + demo.clicks += 1; + cx.notify(); + }); + }); + if variant == ButtonVariant::Destructive { + button = button.tooltip(Tooltip::text("Removes the selected item")); + } + button + })), + )) + .child(demo_section( + "icon only", + colors, + h_flex() + .flex_wrap() + .gap_2() + .children(variants.map(|(name, variant)| { + let view = view.clone(); + Button::new(SharedString::from(format!("button-demo-icon-only-{name}"))) + .icon(IconRole::Folder) + .variant(variant) + .accessibility_label("Open the selected file") + .tooltip(Tooltip::text("Opens the selected file")) + .on_click(move |_, _, cx| { + view.update(cx, |demo, cx| { + demo.clicks += 1; + cx.notify(); + }); + }) + })), + )) + .child(demo_section( + "states", + colors, + h_flex() + .flex_wrap() + .gap_2() .child( Button::new("button-demo-disabled") .label("Disabled") .disabled(true), ) .child( - Button::new("button-demo-icon") - .label("Open") - .icon(IconRole::Folder) - .tooltip(Tooltip::text("Opens the selected file")), + Button::new("button-demo-loading") + .label("Saving") + .loading(true), ) .child( - Button::new("button-demo-icon-only") + Button::new("button-demo-loading-icon-only") .icon(IconRole::Folder) - .variant(ButtonVariant::Ghost) - .accessibility_label("Open the selected file") - .tooltip(Tooltip::text("Opens the selected file")), + .accessibility_label("Saving the selected file") + .loading(true), ), )) .child(demo_section(