Skip to main content

ui/widgets/
buttons.rs

1//! The button — one component, three shipped looks — in its labelled and
2//! glyph-only forms, the [`Buttons::control_group`] that gathers adjacent ones
3//! onto one shared background, and [`Buttons::ghost`], the open frame a quiet
4//! control paints around children of its own. A catalog trait like every widget
5//! group: `use ui::widgets::{ButtonStyle, Buttons};` →
6//! `theme.button("Save", ButtonStyle::Prominent, None)`.
7//!
8//! [`ButtonStyle`] is a closed enum, not free-form knobs: it selects between
9//! the looks that ship, while per-call overrides stay chain modifiers.
10
11use gpui::{Div, ElementId, SharedString, Stateful, div, prelude::*, px};
12use icons::Icon;
13use motion::{self, Fade};
14use theme::{ControlSize, Sizing, Theme, ThemeExt};
15
16/// The shipped looks (the reference `btnGhost` / `btnPrimary` /
17/// `btnDestructive`).
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum ButtonStyle {
20    /// Quiet text on a translucent wash.
21    Ghost,
22    /// The maximum-contrast plate — the primary action.
23    Prominent,
24    /// The muted red fill — carries the destructive semantics with the paint.
25    Destructive,
26}
27
28/// The glyph a [`Buttons::icon_button`] carries, at the size every other
29/// control in this crate paints one.
30const GLYPH: f32 = 14.0;
31
32/// What a [`Buttons::control_group`] insets its items by, and the gap between
33/// them — so the first sits as far from the track's edge as from its neighbour.
34const GROUP_PAD: f32 = 2.0;
35
36/// The frame every style shares — [`ControlSize::Regular`], which a caller
37/// moves with [`Sizing::control_size`].
38fn frame() -> Div {
39    div()
40        .control_size(ControlSize::Regular)
41        .flex()
42        .items_center()
43        .cursor_pointer()
44}
45
46pub trait Buttons: ThemeExt {
47    /// A labeled button in one of the shipped styles. `fade` matters only for
48    /// [`ButtonStyle::Ghost`]: `Some` animates the hover wash per instance,
49    /// `None` is the plain ghost with the hover left to the caller.
50    fn button(
51        &self,
52        label: impl Into<SharedString>,
53        style: ButtonStyle,
54        fade: Option<Fade>,
55    ) -> Div {
56        let (button, _) = appearance(self.theme(), frame(), style, None, fade, true);
57        button.child(label.into())
58    }
59
60    /// A button that is only a glyph — SwiftUI's toolbar `Button` over an icon
61    /// `Label`. Square at [`Theme::BUTTON_HEIGHT`], so it stands the same
62    /// height as a [`Self::button`] beside it. `fade` reads as it does there.
63    ///
64    /// It builds the glyph rather than taking one: gpui reads an svg's colour
65    /// off that element's own style and paints **nothing** when it is unset, so
66    /// a colour set on this button would silently not reach it.
67    ///
68    /// An icon carries no accessible name — reach for
69    /// [`crate::tooltip`] on the way past.
70    fn icon_button(&self, icon: impl Into<Icon>, style: ButtonStyle, fade: Option<Fade>) -> Div {
71        let square = frame()
72            .px(px(0.0))
73            .w(px(Theme::BUTTON_HEIGHT))
74            .justify_center();
75        let (button, tint) = appearance(self.theme(), square, style, None, fade, true);
76        button.child(crate::icons::icon(icon).size(px(GLYPH)).text_color(tint))
77    }
78
79    /// SwiftUI's `ControlGroup`, and what a toolbar paints behind the items it
80    /// finds side by side: one shared background, its buttons inset in it. A
81    /// second cluster is a second call — the break between them is the spacing,
82    /// the way `ToolbarSpacer` puts it there.
83    ///
84    /// The track's radius is the item's plus the inset, so an ordinary
85    /// [`Self::button`] or [`Self::icon_button`] drops in already concentric.
86    ///
87    /// Items are left to stretch: that is what holds a glyph and a label to one
88    /// height when the type ladder moves under them. `self_start` because the
89    /// group must hug them — dropped into a `flex_col`, flexbox's default
90    /// `align-items: stretch` would otherwise blow it out to the column's full
91    /// width.
92    ///
93    /// Glass is chained, not baked: `.surface(theme, theme.popover_surface)`
94    /// turns the track into the capsule a macOS 26 toolbar floats.
95    fn control_group(&self) -> Div {
96        let theme = self.theme();
97        div()
98            .self_start()
99            .flex()
100            .flex_row()
101            .gap(px(GROUP_PAD))
102            .p(px(GROUP_PAD))
103            .rounded(px(Theme::button_radius() + GROUP_PAD))
104            .bg(theme.surface_raised)
105            .border_1()
106            .border_color(theme.border)
107    }
108
109    /// A quiet control: nothing at rest, a wash on hover. Stateful, so it
110    /// carries its own click and tooltip; padding and children are the
111    /// caller's, which is what lets a glyph sit before the text.
112    fn ghost(&self, id: impl Into<ElementId>) -> Stateful<Div> {
113        let tint = self.theme().element_hover;
114        div()
115            .id(id)
116            .flex()
117            .flex_row()
118            .items_center()
119            .rounded(px(Theme::control_radius()))
120            .cursor_pointer()
121            .hover(move |el| el.bg(tint))
122    }
123}
124
125impl Buttons for Theme {}
126
127/// Purpose is independent of emphasis: a destructive action may be quiet.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ButtonRole {
130    Cancel,
131    Destructive,
132}
133
134pub(super) fn appearance(
135    theme: &Theme,
136    frame: Div,
137    style: ButtonStyle,
138    role: Option<ButtonRole>,
139    fade: Option<Fade>,
140    interactive: bool,
141) -> (Div, gpui::Hsla) {
142    let destructive = role == Some(ButtonRole::Destructive) || style == ButtonStyle::Destructive;
143    if style == ButtonStyle::Ghost {
144        let rest = if destructive {
145            theme.danger_strong
146        } else {
147            theme.text_muted
148        };
149        let hot = if destructive {
150            theme.danger_strong
151        } else {
152            theme.text
153        };
154        if let Some(fade) = fade {
155            let tint = motion::hover_blend(&fade, rest, hot);
156            let mut button = frame.text_color(tint).bg(motion::hover_blend(
157                &fade,
158                theme.ink(0.0),
159                theme.element_hover,
160            ));
161            button
162                .interactivity()
163                .on_hover(motion::hover_listener(fade));
164            (button, tint)
165        } else {
166            (frame.text_color(rest), rest)
167        }
168    } else {
169        let (fill, tint) = if destructive {
170            (theme.danger_strong, gpui::white())
171        } else {
172            (theme.text, theme.on_solid)
173        };
174        (
175            frame
176                .bg(fill)
177                .font_weight(gpui::FontWeight::MEDIUM)
178                .text_color(tint)
179                .when(interactive, |button| button.hover(|s| s.opacity(0.9))),
180            tint,
181        )
182    }
183}