Skip to main content

glassy_ui/
button.rs

1use std::rc::Rc;
2
3use crate::motion::StyledSlot;
4use gpui::{
5    div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
6    ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window,
7};
8
9use crate::compat::{AccessibilityExt, Role};
10
11use crate::theme::{ActiveTheme, Theme};
12
13use crate::chrome::{box_shadow, button_chrome, focus_ring};
14use crate::icon::{Icon, IconName};
15use crate::spinner::Spinner;
16use crate::tooltip::Tooltip;
17
18type ButtonClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
19
20struct ButtonState {
21    focus_handle: FocusHandle,
22}
23
24/// Visual treatment from the Paper Buttons page.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ButtonVariant {
27    #[default]
28    Primary,
29    Secondary,
30    Destructive,
31    Outline,
32    OutlineDestructive,
33    Ghost,
34}
35
36/// Height / type scale from Paper.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum ButtonSize {
39    Small,
40    #[default]
41    Medium,
42    Large,
43    Icon,
44}
45
46impl ButtonSize {
47    pub fn height(self) -> f32 {
48        match self {
49            Self::Small => 28.0,
50            Self::Medium | Self::Icon => 36.0,
51            Self::Large => 44.0,
52        }
53    }
54
55    pub fn pad_x(self) -> f32 {
56        match self {
57            Self::Small => 12.0,
58            Self::Medium => 16.0,
59            Self::Large => 20.0,
60            Self::Icon => 0.0,
61        }
62    }
63
64    pub fn icon_pad_x(self) -> f32 {
65        match self {
66            Self::Icon => 0.0,
67            _ => 14.0,
68        }
69    }
70
71    pub fn font_size(self) -> f32 {
72        match self {
73            Self::Small => 12.0,
74            Self::Medium | Self::Icon => 14.0,
75            Self::Large => 15.0,
76        }
77    }
78
79    pub fn line_height(self) -> f32 {
80        match self {
81            Self::Small => 16.0,
82            Self::Medium | Self::Large | Self::Icon => 18.0,
83        }
84    }
85}
86
87/// Clickable glass button matching Paper `Glassy UI` → Buttons.
88#[derive(IntoElement)]
89pub struct Button {
90    id: SharedString,
91    label: Option<SharedString>,
92    variant: ButtonVariant,
93    size: ButtonSize,
94    theme: Option<Theme>,
95    disabled: bool,
96    loading: bool,
97    muted: bool,
98    grouped: bool,
99    leading_icon: Option<IconName>,
100    trailing_icon: Option<IconName>,
101    tooltip: Option<Tooltip>,
102    focus_handle: Option<FocusHandle>,
103    style: StyleRefinement,
104    on_click: Option<ButtonClickHandler>,
105}
106
107impl Button {
108    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
109        Self {
110            id: id.into(),
111            label: Some(label.into()),
112            variant: ButtonVariant::Primary,
113            size: ButtonSize::Medium,
114            theme: None,
115            disabled: false,
116            loading: false,
117            muted: false,
118            grouped: false,
119            leading_icon: None,
120            trailing_icon: None,
121            tooltip: None,
122            focus_handle: None,
123            style: StyleRefinement::default(),
124            on_click: None,
125        }
126    }
127
128    pub fn icon_only(id: impl Into<SharedString>, icon: IconName) -> Self {
129        Self {
130            id: id.into(),
131            label: None,
132            variant: ButtonVariant::Primary,
133            size: ButtonSize::Icon,
134            theme: None,
135            disabled: false,
136            loading: false,
137            muted: false,
138            grouped: false,
139            leading_icon: Some(icon),
140            trailing_icon: None,
141            tooltip: None,
142            focus_handle: None,
143            style: StyleRefinement::default(),
144            on_click: None,
145        }
146    }
147
148    pub fn variant(mut self, variant: ButtonVariant) -> Self {
149        self.variant = variant;
150        self
151    }
152
153    pub fn size(mut self, size: ButtonSize) -> Self {
154        self.size = size;
155        self
156    }
157
158    /// Override the active app theme for this button only.
159    pub fn theme(mut self, theme: Theme) -> Self {
160        self.theme = Some(theme);
161        self
162    }
163
164    pub fn disabled(mut self, disabled: bool) -> Self {
165        self.disabled = disabled;
166        self
167    }
168
169    pub fn loading(mut self, loading: bool) -> Self {
170        self.loading = loading;
171        self
172    }
173
174    /// Ghost/skip treatment: same chrome, muted label.
175    pub fn muted(mut self, muted: bool) -> Self {
176        self.muted = muted;
177        self
178    }
179
180    /// Flush half of a [`ButtonGroup`].
181    pub fn grouped(mut self, grouped: bool) -> Self {
182        self.grouped = grouped;
183        self
184    }
185
186    pub fn leading_icon(mut self, icon: IconName) -> Self {
187        self.leading_icon = Some(icon);
188        self
189    }
190
191    pub fn trailing_icon(mut self, icon: IconName) -> Self {
192        self.trailing_icon = Some(icon);
193        self
194    }
195
196    pub fn tooltip(mut self, tooltip: Tooltip) -> Self {
197        self.tooltip = Some(tooltip);
198        self
199    }
200
201    pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self {
202        self.focus_handle = Some(focus_handle);
203        self
204    }
205
206    pub fn on_click(
207        mut self,
208        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
209    ) -> Self {
210        self.on_click = Some(Rc::new(listener));
211        self
212    }
213}
214
215impl Styled for Button {
216    fn style(&mut self) -> &mut StyleRefinement {
217        &mut self.style
218    }
219}
220
221impl RenderOnce for Button {
222    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
223        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| ButtonState {
224            focus_handle: cx.focus_handle(),
225        });
226        let theme = self.theme.unwrap_or_else(|| cx.theme());
227        let variant = if self.disabled {
228            ButtonVariant::Secondary
229        } else {
230            self.variant
231        };
232        let chrome = button_chrome(theme, variant);
233        let fg = if self.disabled || self.muted {
234            theme.muted_fg()
235        } else {
236            chrome.fg
237        };
238
239        let has_icon = self.loading || self.leading_icon.is_some() || self.trailing_icon.is_some();
240        let pad_x = if self.size == ButtonSize::Icon {
241            0.0
242        } else if has_icon {
243            self.size.icon_pad_x()
244        } else {
245            self.size.pad_x()
246        };
247
248        let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
249        if chrome.shadow_blur > 0.0 {
250            shadows.push(box_shadow(
251                0.,
252                chrome.shadow_y,
253                chrome.shadow,
254                chrome.shadow_blur,
255                0.,
256            ));
257        }
258
259        let icon_size = if self.loading { 14.0 } else { 16.0 };
260        let hover_bg = chrome.hover_bg;
261        let interactive = !self.disabled && !self.loading;
262        let focus_handle = self
263            .focus_handle
264            .unwrap_or_else(|| state.read(cx).focus_handle.clone())
265            .tab_stop(interactive);
266        let focused = focus_handle.is_focused(window);
267        let aria_label = self.label.clone();
268        let button_id = self.id.clone();
269        let debug_selector = self.id.to_string();
270
271        if focused {
272            shadows.push(focus_ring(theme));
273        }
274
275        let el = div()
276            .id(self.id)
277            .debug_selector(move || debug_selector.clone())
278            .role(Role::Button)
279            .when_some(aria_label, |el, label| el.aria_label(label))
280            .track_focus(&focus_handle)
281            .tab_stop(interactive)
282            .flex()
283            .items_center()
284            .justify_center()
285            .when(has_icon && self.size != ButtonSize::Icon, |el| {
286                el.gap(px(8.))
287            })
288            .when(self.grouped, |el| el.h_full())
289            .when(!self.grouped, |el| el.h(px(self.size.height())))
290            .when(self.size == ButtonSize::Icon, |el| {
291                el.w(px(36.)).flex_shrink_0()
292            })
293            .when(self.size != ButtonSize::Icon, |el| el.px(px(pad_x)))
294            .when(!self.grouped, |el| el.rounded(px(6.)))
295            .border_1()
296            .border_color(chrome.border)
297            .bg(chrome.bg)
298            .shadow(shadows)
299            .text_color(fg)
300            .font_family(theme.font_family)
301            .font_weight(FontWeight::MEDIUM)
302            .text_size(px(self.size.font_size()))
303            .line_height(px(self.size.line_height()))
304            .when(interactive, |el| {
305                el.cursor_pointer().hover(move |s| s.bg(hover_bg))
306            })
307            .when(!interactive, |el| el.cursor_default())
308            .refine_style(&self.style)
309            .when(self.loading, |el| {
310                el.child(Spinner::new().px(px(icon_size)).color(fg))
311            })
312            .when_some(self.leading_icon.filter(|_| !self.loading), |el, icon| {
313                el.child(Icon::new(icon).px(px(icon_size)).color(fg))
314            })
315            .when_some(self.label, |el, label| el.child(label))
316            .when_some(self.trailing_icon.filter(|_| !self.loading), |el, icon| {
317                el.child(Icon::new(icon).px(px(icon_size)).color(fg))
318            });
319
320        let el = if interactive {
321            if let Some(on_click) = self.on_click {
322                let keyboard_click = on_click.clone();
323                let click_focus = focus_handle.clone();
324                el.on_key_down(move |event: &KeyDownEvent, window, cx| {
325                    if event.keystroke.modifiers.modified() {
326                        return;
327                    }
328
329                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
330                        keyboard_click(&ClickEvent::default(), window, cx);
331                        window.refresh();
332                        cx.stop_propagation();
333                    }
334                })
335                .on_click(move |event, window, cx| {
336                    click_focus.focus(window);
337                    on_click(event, window, cx);
338                    window.refresh();
339                })
340            } else {
341                el
342            }
343        } else {
344            el
345        };
346
347        if let Some(tooltip) = self.tooltip {
348            tooltip.attach(button_id, el, window, cx).into_any_element()
349        } else {
350            el.into_any_element()
351        }
352    }
353}
354
355/// Clipped outline pill used for the Paper "Save draft | Publish" pair.
356#[derive(IntoElement)]
357pub struct ButtonGroup {
358    theme: Option<Theme>,
359    style: StyleRefinement,
360    children: Vec<gpui::AnyElement>,
361}
362
363impl ButtonGroup {
364    pub fn new() -> Self {
365        Self {
366            theme: None,
367            style: StyleRefinement::default(),
368            children: Vec::new(),
369        }
370    }
371
372    /// Override the active app theme for this group only.
373    pub fn theme(mut self, theme: Theme) -> Self {
374        self.theme = Some(theme);
375        self
376    }
377}
378
379impl Default for ButtonGroup {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385impl Styled for ButtonGroup {
386    fn style(&mut self) -> &mut StyleRefinement {
387        &mut self.style
388    }
389}
390
391impl ParentElement for ButtonGroup {
392    fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
393        self.children.extend(elements);
394    }
395}
396
397impl RenderOnce for ButtonGroup {
398    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
399        let theme = self.theme.unwrap_or_else(|| cx.theme());
400        let chrome = button_chrome(theme, ButtonVariant::Outline);
401        div()
402            .flex()
403            .items_center()
404            .h(px(36.))
405            .rounded(px(6.))
406            .overflow_hidden()
407            .border_1()
408            .border_color(chrome.border)
409            .bg(chrome.bg)
410            .shadow(vec![
411                box_shadow(0., 1., chrome.inset, 0., 0.),
412                box_shadow(0., chrome.shadow_y, chrome.shadow, chrome.shadow_blur, 0.),
413            ])
414            .refine_style(&self.style)
415            .children(self.children)
416    }
417}