Skip to main content

ui/widgets/
buttons.rs

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