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.
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
50pub trait Buttons: ThemeExt {
51 /// A labeled button in one of the shipped styles. `fade` matters only for
52 /// [`ButtonStyle::Ghost`]: `Some` animates the hover wash per instance,
53 /// `None` is the plain ghost with the hover left to the caller.
54 fn button(
55 &self,
56 label: impl Into<SharedString>,
57 style: ButtonStyle,
58 fade: Option<Fade>,
59 ) -> Div {
60 let (button, _) = appearance(self.theme(), frame(), style, None, fade, true);
61 button.child(label.into())
62 }
63
64 /// A button that is only a glyph — SwiftUI's toolbar `Button` over an icon
65 /// `Label`. Square at [`Theme::BUTTON_HEIGHT`], so it stands the same
66 /// height as a [`Self::button`] beside it. `fade` reads as it does there.
67 ///
68 /// It builds the glyph rather than taking one: gpui reads an svg's colour
69 /// off that element's own style and paints **nothing** when it is unset, so
70 /// a colour set on this button would silently not reach it.
71 ///
72 /// An icon carries no accessible name — reach for
73 /// [`crate::tooltip`] on the way past.
74 fn icon_button(&self, icon: impl Into<Icon>, style: ButtonStyle, fade: Option<Fade>) -> Div {
75 let square = frame()
76 .px(px(0.0))
77 .w(px(Theme::BUTTON_HEIGHT))
78 .justify_center();
79 let (button, tint) = appearance(self.theme(), square, style, None, fade, true);
80 button.child(
81 crate::icons::icon(icon)
82 .size(px(ICON_GLYPH))
83 .text_color(tint),
84 )
85 }
86
87 /// SwiftUI's `ControlGroup`, and what a toolbar paints behind the items it
88 /// finds side by side: one shared background, its buttons inset in it. A
89 /// second cluster is a second call — the break between them is the spacing,
90 /// the way `ToolbarSpacer` puts it there.
91 ///
92 /// The track's radius is the item's plus the inset, so an ordinary
93 /// [`Self::button`] or [`Self::icon_button`] drops in already concentric.
94 ///
95 /// Items are left to stretch: that is what holds a glyph and a label to one
96 /// height when the type ladder moves under them. `self_start` because the
97 /// group must hug them — dropped into a `flex_col`, flexbox's default
98 /// `align-items: stretch` would otherwise blow it out to the column's full
99 /// width.
100 ///
101 /// Glass is chained, not baked: `.surface(theme, theme.popover_surface)`
102 /// turns the track into the capsule a macOS 26 toolbar floats.
103 fn control_group(&self) -> Div {
104 let theme = self.theme();
105 div()
106 .self_start()
107 .flex()
108 .flex_row()
109 .gap(px(GROUP_PAD))
110 .p(px(GROUP_PAD))
111 .rounded(px(Theme::button_radius() + GROUP_PAD))
112 .bg(theme.surface_raised)
113 .border_1()
114 .border_color(theme.border)
115 }
116
117 /// A quiet control: nothing at rest, a wash on hover. Stateful, so it
118 /// carries its own click and tooltip; padding and children are the
119 /// caller's, which is what lets a glyph sit before the text.
120 fn ghost(&self, id: impl Into<ElementId>) -> Stateful<Div> {
121 let tint = self.theme().element_hover;
122 div()
123 .id(id)
124 .flex()
125 .flex_row()
126 .items_center()
127 .rounded(px(Theme::control_radius()))
128 .cursor_pointer()
129 .hover(move |el| el.bg(tint))
130 }
131}
132
133impl Buttons for Theme {}
134
135/// Purpose is independent of emphasis: a destructive action may be quiet.
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum ButtonRole {
138 Cancel,
139 Destructive,
140}
141
142pub(super) fn appearance(
143 theme: &Theme,
144 frame: Div,
145 style: ButtonStyle,
146 role: Option<ButtonRole>,
147 fade: Option<Fade>,
148 interactive: bool,
149) -> (Div, gpui::Hsla) {
150 let destructive = role == Some(ButtonRole::Destructive) || style == ButtonStyle::Destructive;
151 if style == ButtonStyle::Ghost {
152 let rest = if destructive {
153 theme.danger_strong
154 } else {
155 theme.text_muted
156 };
157 let hot = if destructive {
158 theme.danger_strong
159 } else {
160 theme.text
161 };
162 if let Some(fade) = fade {
163 let tint = motion::hover_blend(&fade, rest, hot);
164 let mut button = frame.text_color(tint).bg(motion::hover_blend(
165 &fade,
166 theme.ink(0.0),
167 theme.element_hover,
168 ));
169 button
170 .interactivity()
171 .on_hover(motion::hover_listener(fade));
172 (button, tint)
173 } else {
174 (frame.text_color(rest), rest)
175 }
176 } else {
177 let (fill, tint) = if destructive {
178 (theme.danger_strong, gpui::white())
179 } else {
180 (theme.text, theme.on_solid)
181 };
182 (
183 frame
184 .bg(fill)
185 .font_weight(gpui::FontWeight::MEDIUM)
186 .text_color(tint)
187 .when(interactive, |button| button.hover(|s| s.opacity(0.9))),
188 tint,
189 )
190 }
191}