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