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, Typeset as _};
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.
30pub const ICON_GLYPH: f32 = 14.0;
31
32/// The padding that sets an [`Buttons::icon_button`]'s glyph [`Theme::EDGE`]
33/// from its container's edge.
34pub const ICON_EDGE_PAD: f32 = Theme::EDGE - (Theme::BUTTON_HEIGHT - ICON_GLYPH) / 2.0;
35
36/// What a [`Buttons::control_group`] insets its items by, and the gap between
37/// them — so the first sits as far from the track's edge as from its neighbour.
38const GROUP_PAD: f32 = 2.0;
39
40/// The frame every style shares — [`ControlSize::Regular`], which a caller
41/// moves with [`Sizing::control_size`].
42fn frame() -> Div {
43    div()
44        .control_size(ControlSize::Regular)
45        .flex()
46        .items_center()
47        .cursor_pointer()
48}
49
50/// [`frame`] squared to a glyph: [`Buttons::icon_button`]'s shape.
51fn square() -> Div {
52    frame()
53        .px(px(0.0))
54        .w(px(Theme::BUTTON_HEIGHT))
55        .justify_center()
56}
57
58pub trait Buttons: ThemeExt {
59    /// A labeled button in one of the shipped styles. `fade` matters only for
60    /// [`ButtonStyle::Ghost`]: `Some` animates the hover wash per instance,
61    /// `None` is the plain ghost with the hover left to the caller.
62    fn button(
63        &self,
64        label: impl Into<SharedString>,
65        style: ButtonStyle,
66        fade: Option<Fade>,
67    ) -> Div {
68        let (button, _) = appearance(self.theme(), frame(), style, None, fade, true);
69        button.child(label.into())
70    }
71
72    /// A button that is only a glyph — SwiftUI's toolbar `Button` over an icon
73    /// `Label`. Square at [`Theme::BUTTON_HEIGHT`], so it stands the same
74    /// height as a [`Self::button`] beside it. `fade` reads as it does there.
75    ///
76    /// It builds the glyph rather than taking one: gpui reads an svg's colour
77    /// off that element's own style and paints **nothing** when it is unset, so
78    /// a colour set on this button would silently not reach it.
79    ///
80    /// An icon carries no accessible name — reach for
81    /// [`crate::tooltip`] on the way past.
82    fn icon_button(&self, icon: impl Into<Icon>, style: ButtonStyle, fade: Option<Fade>) -> Div {
83        let (button, tint) = appearance(self.theme(), square(), style, None, fade, true);
84        button.child(
85            crate::icons::icon(icon)
86                .size(px(ICON_GLYPH))
87                .text_color(tint),
88        )
89    }
90
91    /// [`Self::icon_button`]'s square with its glyph in `tint`, and no hover:
92    /// neither the glyph nor the fill moves under the pointer.
93    fn tinted_icon_button(&self, icon: impl Into<Icon>, tint: gpui::Hsla) -> Div {
94        square().text_color(tint).child(
95            crate::icons::icon(icon)
96                .size(px(ICON_GLYPH))
97                .text_color(tint),
98        )
99    }
100
101    /// SwiftUI's `ControlGroup`, and what a toolbar paints behind the items it
102    /// finds side by side: one shared background, its buttons inset in it. A
103    /// second cluster is a second call — the break between them is the spacing,
104    /// the way `ToolbarSpacer` puts it there.
105    ///
106    /// The track's radius is the item's plus the inset, so an ordinary
107    /// [`Self::button`] or [`Self::icon_button`] drops in already concentric.
108    ///
109    /// Items are left to stretch: that is what holds a glyph and a label to one
110    /// height when the type ladder moves under them. `self_start` because the
111    /// group must hug them — dropped into a `flex_col`, flexbox's default
112    /// `align-items: stretch` would otherwise blow it out to the column's full
113    /// width.
114    ///
115    /// Glass is chained, not baked: `.surface(theme, theme.popover_surface)`
116    /// turns the track into the capsule a macOS 26 toolbar floats.
117    fn control_group(&self) -> Div {
118        let theme = self.theme();
119        div()
120            .self_start()
121            .flex()
122            .flex_row()
123            .gap(px(GROUP_PAD))
124            .p(px(GROUP_PAD))
125            .rounded(px(Theme::button_radius() + GROUP_PAD))
126            .bg(theme.surface_raised)
127            .border_1()
128            .border_color(theme.border)
129    }
130
131    /// `[icon] Label | ▾`: a press on the label side, a chevron beside it, in
132    /// one bordered frame at `size`. The halves come back open for the
133    /// caller's handlers; [`SplitButton::build`] puts them together.
134    ///
135    /// `icon` is the caller's, sized by the caller: it may be an image.
136    fn split_button(
137        &self,
138        main: impl Into<ElementId>,
139        more: impl Into<ElementId>,
140        icon: impl IntoElement,
141        label: impl Into<SharedString>,
142        size: ControlSize,
143    ) -> SplitButton {
144        let theme = self.theme();
145        let pad = size.pad_x();
146        let main = div()
147            .id(main)
148            .flex()
149            .items_center()
150            .gap(px(pad / 2.0 + 1.0))
151            .pl(px(pad / 2.0 + 1.0))
152            .pr(px(pad))
153            .cursor_pointer()
154            .hover(|el| el.bg(theme.element_hover))
155            .child(icon)
156            .child(label.into());
157        let more = div()
158            .id(more)
159            .flex()
160            .items_center()
161            .px(px(pad / 2.0))
162            .border_l_1()
163            .border_color(theme.border)
164            .cursor_pointer()
165            .hover(|el| el.bg(theme.element_hover))
166            .child(
167                crate::icons::icon(crate::icons::arrows::ChevronDown)
168                    .size(px(12.0))
169                    .text_color(theme.text_muted),
170            );
171        SplitButton {
172            main,
173            more,
174            size,
175            border: theme.border,
176            fill: theme.surface_raised,
177            text: theme.text,
178        }
179    }
180
181    /// A quiet control: nothing at rest, a wash on hover. Stateful, so it
182    /// carries its own click and tooltip; padding and children are the
183    /// caller's, which is what lets a glyph sit before the text.
184    fn ghost(&self, id: impl Into<ElementId>) -> Stateful<Div> {
185        let tint = self.theme().element_hover;
186        div()
187            .id(id)
188            .flex()
189            .flex_row()
190            .items_center()
191            .rounded(px(Theme::control_radius()))
192            .cursor_pointer()
193            .hover(move |el| el.bg(tint))
194    }
195}
196
197impl Buttons for Theme {}
198
199/// The halves of a [`Buttons::split_button`], for handlers before
200/// [`Self::build`].
201pub struct SplitButton {
202    pub main: Stateful<Div>,
203    pub more: Stateful<Div>,
204    size: ControlSize,
205    border: gpui::Hsla,
206    fill: gpui::Hsla,
207    text: gpui::Hsla,
208}
209
210impl SplitButton {
211    /// The control, with `menu` hung off it when there is one.
212    pub fn build(self, menu: Option<gpui::AnyElement>) -> Div {
213        let radius = px(self.size.radius());
214        self.frame()
215            .child(self.main.rounded_l(radius))
216            .child(self.more.rounded_r(radius))
217            .children(menu)
218    }
219
220    /// The label side alone, in the same frame: no chevron and no menu.
221    pub fn build_alone(self) -> Div {
222        let radius = px(self.size.radius());
223        self.frame().child(self.main.rounded(radius))
224    }
225
226    fn frame(&self) -> Div {
227        div()
228            .relative()
229            .flex_none()
230            .flex()
231            .items_stretch()
232            .h(px(self.size.height()))
233            .rounded(px(self.size.radius()))
234            .border_1()
235            .border_color(self.border)
236            .bg(self.fill)
237            .text_style(self.size.text())
238            .text_color(self.text)
239    }
240}
241
242/// Purpose is independent of emphasis: a destructive action may be quiet.
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244pub enum ButtonRole {
245    Cancel,
246    Destructive,
247}
248
249pub(super) fn appearance(
250    theme: &Theme,
251    frame: Div,
252    style: ButtonStyle,
253    role: Option<ButtonRole>,
254    fade: Option<Fade>,
255    interactive: bool,
256) -> (Div, gpui::Hsla) {
257    let destructive = role == Some(ButtonRole::Destructive) || style == ButtonStyle::Destructive;
258    if style == ButtonStyle::Ghost {
259        let rest = if destructive {
260            theme.danger_strong
261        } else {
262            theme.text_muted
263        };
264        let hot = if destructive {
265            theme.danger_strong
266        } else {
267            theme.text
268        };
269        if let Some(fade) = fade {
270            let tint = motion::hover_blend(&fade, rest, hot);
271            let mut button = frame.text_color(tint).bg(motion::hover_blend(
272                &fade,
273                theme.ink(0.0),
274                theme.element_hover,
275            ));
276            button
277                .interactivity()
278                .on_hover(motion::hover_listener(fade));
279            (button, tint)
280        } else {
281            (frame.text_color(rest), rest)
282        }
283    } else {
284        let (fill, tint) = if destructive {
285            (theme.danger_strong, gpui::white())
286        } else {
287            (theme.text, theme.on_solid)
288        };
289        (
290            frame
291                .bg(fill)
292                .font_weight(gpui::FontWeight::MEDIUM)
293                .text_color(tint)
294                .when(interactive, |button| button.hover(|s| s.opacity(0.9))),
295            tint,
296        )
297    }
298}