Skip to main content

gpui_component/button/
button.rs

1use std::rc::Rc;
2
3use crate::ThemeStyled as _;
4use crate::{
5    ActiveTheme, Colorize as _, Disableable, Icon, Placement, RoleOverride, Selectable, Sizable,
6    Size, StyleSized, StyledExt,
7    button::ButtonIcon,
8    h_flex,
9    select::Caret,
10    tooltip::{ManagedTooltipExt as _, Tooltip},
11};
12use gpui::{
13    AnyElement, App, Background, ClickEvent, Corners, Edges, ElementId, Hsla, InteractiveElement,
14    Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Role, SharedString,
15    StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
16    prelude::FluentBuilder as _, relative, transparent_white,
17};
18
19#[derive(Default, Clone, Copy)]
20pub enum ButtonRounded {
21    None,
22    Small,
23    #[default]
24    Medium,
25    Large,
26    Size(Pixels),
27}
28
29impl From<Pixels> for ButtonRounded {
30    fn from(px: Pixels) -> Self {
31        ButtonRounded::Size(px)
32    }
33}
34
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub struct ButtonCustomVariant {
37    color: Hsla,
38    foreground: Hsla,
39    shadow: bool,
40    hover: Hsla,
41    active: Hsla,
42}
43
44pub trait ButtonVariants: Sized {
45    fn with_variant(self, variant: ButtonVariant) -> Self;
46
47    /// With the primary style for the Button.
48    fn primary(self) -> Self {
49        self.with_variant(ButtonVariant::Primary)
50    }
51
52    /// With the secondary style for the Button.
53    fn secondary(self) -> Self {
54        self.with_variant(ButtonVariant::Secondary)
55    }
56
57    /// With the danger style for the Button.
58    fn danger(self) -> Self {
59        self.with_variant(ButtonVariant::Danger)
60    }
61
62    /// With the warning style for the Button.
63    fn warning(self) -> Self {
64        self.with_variant(ButtonVariant::Warning)
65    }
66
67    /// With the success style for the Button.
68    fn success(self) -> Self {
69        self.with_variant(ButtonVariant::Success)
70    }
71
72    /// With the info style for the Button.
73    fn info(self) -> Self {
74        self.with_variant(ButtonVariant::Info)
75    }
76
77    /// With the ghost style for the Button.
78    fn ghost(self) -> Self {
79        self.with_variant(ButtonVariant::Ghost)
80    }
81
82    /// With the link style for the Button.
83    fn link(self) -> Self {
84        self.with_variant(ButtonVariant::Link)
85    }
86
87    /// With the text style for the Button, it will no padding look like a normal text.
88    fn text(self) -> Self {
89        self.with_variant(ButtonVariant::Text)
90    }
91
92    /// With the custom style for the Button.
93    fn custom(self, style: ButtonCustomVariant) -> Self {
94        self.with_variant(ButtonVariant::Custom(style))
95    }
96}
97
98impl ButtonCustomVariant {
99    pub fn new(cx: &App) -> Self {
100        Self {
101            color: cx.theme().transparent,
102            foreground: cx.theme().foreground,
103            hover: cx.theme().transparent,
104            active: cx.theme().transparent,
105            shadow: false,
106        }
107    }
108
109    /// Set background color, default is transparent.
110    pub fn color(mut self, color: Hsla) -> Self {
111        self.color = color;
112        self
113    }
114
115    /// Set foreground color, default is theme foreground.
116    pub fn foreground(mut self, color: Hsla) -> Self {
117        self.foreground = color;
118        self
119    }
120
121    /// Set hover background color, default is transparent.
122    pub fn hover(mut self, color: Hsla) -> Self {
123        self.hover = color;
124        self
125    }
126
127    /// Set active background color, default is transparent.
128    pub fn active(mut self, color: Hsla) -> Self {
129        self.active = color;
130        self
131    }
132
133    /// Set shadow, default is false.
134    pub fn shadow(mut self, shadow: bool) -> Self {
135        self.shadow = shadow;
136        self
137    }
138}
139
140/// The variant of the Button.
141#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
142pub enum ButtonVariant {
143    #[default]
144    Default,
145    Primary,
146    Secondary,
147    Danger,
148    Info,
149    Success,
150    Warning,
151    Ghost,
152    Link,
153    Text,
154    Custom(ButtonCustomVariant),
155}
156
157impl ButtonVariant {
158    #[inline]
159    pub fn is_link(&self) -> bool {
160        matches!(self, Self::Link)
161    }
162
163    #[inline]
164    pub fn is_text(&self) -> bool {
165        matches!(self, Self::Text)
166    }
167
168    #[inline]
169    pub fn is_ghost(&self) -> bool {
170        matches!(self, Self::Ghost)
171    }
172
173    #[inline]
174    fn no_padding(&self) -> bool {
175        self.is_link() || self.is_text()
176    }
177
178    #[inline]
179    fn is_default(&self) -> bool {
180        matches!(self, Self::Default)
181    }
182}
183
184/// A Button element.
185#[derive(IntoElement)]
186pub struct Button {
187    id: ElementId,
188    base: gpui_base::Button,
189    icon: Option<ButtonIcon>,
190    label: Option<SharedString>,
191    /// The announced name, when the visible content is not it.
192    accessibility_label: Option<SharedString>,
193    children: Vec<AnyElement>,
194    disabled: bool,
195    pub(crate) selected: bool,
196    toggled: Option<bool>,
197    role: RoleOverride,
198    variant: ButtonVariant,
199    rounded: ButtonRounded,
200    outline: bool,
201    border_corners: Corners<bool>,
202    border_edges: Edges<bool>,
203    dropdown_caret: bool,
204    hover_group: Option<SharedString>,
205    hover_group_held: bool,
206    size: Size,
207    content_style: StyleRefinement,
208    icon_size: Option<Size>,
209    compact: bool,
210    tooltip: Option<(
211        SharedString,
212        Option<(Rc<Box<dyn gpui::Action>>, Option<SharedString>)>,
213    )>,
214    tooltip_placement: Option<Placement>,
215    tooltip_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
216    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
217    on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
218    loading: bool,
219    loading_icon: Option<Icon>,
220    focus_ring_enabled: bool,
221
222    tab_index: isize,
223    tab_stop: bool,
224}
225
226impl From<Button> for AnyElement {
227    fn from(button: Button) -> Self {
228        button.into_any_element()
229    }
230}
231
232impl Button {
233    pub fn new(id: impl Into<ElementId>) -> Self {
234        let id = id.into();
235
236        Self {
237            id: id.clone(),
238            base: gpui_base::Button::new(id),
239            icon: None,
240            label: None,
241            accessibility_label: None,
242            children: Vec::new(),
243            disabled: false,
244            selected: false,
245            toggled: None,
246            role: RoleOverride::default(),
247            variant: ButtonVariant::default(),
248            rounded: ButtonRounded::Medium,
249            border_corners: Corners {
250                top_left: true,
251                top_right: true,
252                bottom_right: true,
253                bottom_left: true,
254            },
255            border_edges: Edges::all(true),
256            size: Size::Medium,
257            content_style: StyleRefinement::default(),
258            icon_size: None,
259            tooltip: None,
260            tooltip_placement: None,
261            tooltip_builder: None,
262            on_click: None,
263            focus_ring_enabled: true,
264            on_hover: None,
265            loading: false,
266            compact: false,
267            outline: false,
268            loading_icon: None,
269            dropdown_caret: false,
270            hover_group: None,
271            hover_group_held: false,
272            tab_index: 0,
273            tab_stop: true,
274        }
275    }
276
277    /// Uses a behavior primitive supplied by a compound Base control.
278    pub(crate) fn with_base(mut self, base: gpui_base::Button) -> Self {
279        self.base = base;
280        self
281    }
282
283    pub(crate) fn variant(&self) -> ButtonVariant {
284        self.variant
285    }
286
287    /// Presentation supplied by a styled compound control. Standalone buttons
288    /// retain their normal size-derived content style.
289    pub(crate) fn content_style(mut self, style: StyleRefinement, icon_size: Size) -> Self {
290        self.content_style = style;
291        self.icon_size = Some(icon_size);
292        self
293    }
294
295    pub(super) fn button_size(&self) -> Size {
296        self.size
297    }
298
299    pub(crate) fn is_disabled(&self) -> bool {
300        self.disabled
301    }
302
303    pub(crate) fn is_outline(&self) -> bool {
304        self.outline
305    }
306
307    /// Whether the button shows only its icon.
308    pub(crate) fn is_icon_only(&self) -> bool {
309        self.icon.is_some() && self.label.is_none() && self.children.is_empty()
310    }
311
312    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
313        self.role = role.into();
314        self
315    }
316
317    /// Set the outline style of the Button.
318    pub fn outline(mut self) -> Self {
319        self.outline = true;
320        self
321    }
322
323    /// Set the border radius of the Button.
324    pub fn rounded(mut self, rounded: impl Into<ButtonRounded>) -> Self {
325        self.rounded = rounded.into();
326        self
327    }
328
329    /// Set the border corners side of the Button.
330    pub(crate) fn border_corners(mut self, corners: impl Into<Corners<bool>>) -> Self {
331        self.border_corners = corners.into();
332        self
333    }
334
335    /// Set the border edges of the Button.
336    pub(crate) fn border_edges(mut self, edges: impl Into<Edges<bool>>) -> Self {
337        self.border_edges = edges.into();
338        self
339    }
340
341    /// Join a hover group: while any member is hovered, an idle member shows
342    /// its hover surface at half strength, so a composite such as a split
343    /// button reads as one control with the hovered part emphasized.
344    pub(crate) fn hover_group(mut self, group: impl Into<SharedString>) -> Self {
345        self.hover_group = Some(group.into());
346        self
347    }
348
349    /// Keep the hover group's idle surface up without a pointer, for as long as
350    /// the group is held engaged, such as while a sibling's menu is open.
351    pub(crate) fn hover_group_held(mut self, held: bool) -> Self {
352        self.hover_group_held = held;
353        self
354    }
355
356    /// Set label to the Button, if no label is set, the button will be in Icon Button mode.
357    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
358        self.label = Some(label.into());
359        self
360    }
361
362    /// Set the developer-assigned identifier exposed to accessibility clients.
363    pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
364        self.base = self.base.accessibility_id(id);
365        self
366    }
367
368    /// Set the name a screen reader announces, when the visible content is not
369    /// it.
370    ///
371    /// A button's name comes from its [`label`](Self::label) by default, which
372    /// is right for the ordinary case and wrong for two: an icon-only button has
373    /// no label to read, and a button whose content is a row of cells — a table
374    /// row that is also a control — would be read out cell by cell with no
375    /// statement of what pressing it does. Setting this replaces the announced
376    /// name without adding anything to the screen.
377    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
378        self.accessibility_label = Some(label.into());
379        self
380    }
381
382    /// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
383    pub fn icon(mut self, icon: impl Into<ButtonIcon>) -> Self {
384        self.icon = Some(icon.into());
385        self
386    }
387
388    /// Set the tooltip of the button.
389    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
390        self.tooltip = Some((tooltip.into(), None));
391        self
392    }
393
394    /// Prefer a side for the tooltip, falling back when it does not fit.
395    ///
396    /// Applies to [`Self::tooltip`] and [`Self::tooltip_with_action`].
397    /// Omitting placement keeps automatic positioning.
398    pub fn tooltip_placement(mut self, placement: Placement) -> Self {
399        self.tooltip_placement = Some(placement);
400        self
401    }
402
403    /// Set the tooltip of the button with action to show keybinding.
404    pub fn tooltip_with_action(
405        mut self,
406        tooltip: impl Into<SharedString>,
407        action: &dyn gpui::Action,
408        context: Option<&str>,
409    ) -> Self {
410        self.tooltip = Some((
411            tooltip.into(),
412            Some((
413                Rc::new(action.boxed_clone()),
414                context.map(|c| c.to_string().into()),
415            )),
416        ));
417        self
418    }
419
420    /// Set true to show the loading indicator.
421    pub fn loading(mut self, loading: bool) -> Self {
422        self.loading = loading;
423        self
424    }
425
426    /// Set the button to compact mode, then padding will be reduced.
427    pub fn compact(mut self) -> Self {
428        self.compact = true;
429        self
430    }
431
432    /// Add click handler.
433    pub fn on_click(
434        mut self,
435        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
436    ) -> Self {
437        self.on_click = Some(Rc::new(handler));
438        self
439    }
440
441    /// Add hover handler, the bool parameter indicates whether the mouse is hovering.
442    pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
443        self.on_hover = Some(Rc::new(handler));
444        self
445    }
446
447    /// Set the loading icon of the button, it will be used when loading is true.
448    ///
449    /// Default is a spinner icon.
450    pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
451        self.loading_icon = Some(icon.into());
452        self
453    }
454
455    /// Set the tab index of the button, it will be used to focus the button by tab key.
456    ///
457    /// Default is 0.
458    pub fn tab_index(mut self, tab_index: isize) -> Self {
459        self.tab_index = tab_index;
460        self
461    }
462
463    /// Set the tab stop of the button, if true, the button will be focusable by tab key.
464    ///
465    /// Default is true.
466    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
467        self.tab_stop = tab_stop;
468        self
469    }
470
471    /// Set to show a dropdown caret icon at the end of the button.
472    pub fn dropdown_caret(mut self, dropdown_caret: bool) -> Self {
473        self.dropdown_caret = dropdown_caret;
474        self
475    }
476
477    /// Expose this button as a toggle button to assistive technology, with
478    /// `toggled` as its pressed state.
479    ///
480    /// Only affects accessibility metadata. Use [`Selectable::selected`] for
481    /// the selected styling, and call this in addition when the button really
482    /// is a toggle, otherwise the button stays an ordinary push button.
483    pub fn toggled(mut self, toggled: bool) -> Self {
484        self.toggled = Some(toggled);
485        self
486    }
487
488    /// Whether the button responds to the pointer at all.
489    ///
490    /// A loading button is as inert as a disabled one, it just keeps looking
491    /// like itself instead of taking the disabled styling.
492    #[inline]
493    fn interactive(&self) -> bool {
494        !(self.disabled || self.loading)
495    }
496
497    #[inline]
498    fn hoverable(&self) -> bool {
499        self.interactive() && self.on_hover.is_some()
500    }
501}
502
503impl Disableable for Button {
504    fn disabled(mut self, disabled: bool) -> Self {
505        self.disabled = disabled;
506        self
507    }
508}
509
510impl crate::FocusableExt for Button {
511    fn focus_ring(mut self, enabled: bool) -> Self {
512        self.focus_ring_enabled = enabled;
513        self
514    }
515
516    fn is_focus_ring_enabled(&self) -> bool {
517        self.focus_ring_enabled
518    }
519}
520
521impl Selectable for Button {
522    fn selected(mut self, selected: bool) -> Self {
523        self.selected = selected;
524        self
525    }
526
527    fn is_selected(&self) -> bool {
528        self.selected
529    }
530}
531
532impl Sizable for Button {
533    fn with_size(mut self, size: impl Into<Size>) -> Self {
534        self.size = size.into();
535        self
536    }
537}
538
539impl ButtonVariants for Button {
540    fn with_variant(mut self, variant: ButtonVariant) -> Self {
541        self.variant = variant;
542        self
543    }
544}
545
546impl Styled for Button {
547    fn style(&mut self) -> &mut StyleRefinement {
548        self.base.style()
549    }
550}
551
552impl ParentElement for Button {
553    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
554        self.children.extend(elements)
555    }
556}
557
558impl InteractiveElement for Button {
559    fn interactivity(&mut self) -> &mut Interactivity {
560        self.base.interactivity()
561    }
562}
563
564impl RenderOnce for Button {
565    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
566        let style: ButtonVariant = self.variant;
567        let interactive = self.interactive();
568        let hoverable = self.hoverable();
569        let disabled = self.disabled;
570        let loading = self.loading;
571        let tooltip_placement = self.tooltip_placement;
572        let hover_group = self.hover_group;
573        let hover_group_held = self.hover_group_held;
574        let mut base = self.base;
575        let children = self.children;
576        let instance_style = base.style().clone();
577        let normal_style = style.normal(self.outline, cx);
578        let selected_style = style.selected(self.outline, cx);
579        let disabled_style = style.disabled(self.outline, cx);
580        let icon_size = self.icon_size.unwrap_or_else(|| match self.size {
581            Size::Size(v) => Size::Size(v * 0.75),
582            _ => self.size,
583        });
584        let has_content = self.icon.is_some() || self.label.is_some() || !children.is_empty();
585
586        let focus_handle = window
587            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
588            .read(cx)
589            .clone();
590        let is_focused = focus_handle.is_focused(window);
591
592        let rounding = match self.rounded {
593            ButtonRounded::Small => cx.theme().radius * 0.5,
594            ButtonRounded::Medium => cx.theme().radius,
595            ButtonRounded::Large => cx.theme().radius * 2.0,
596            ButtonRounded::Size(px) => px,
597            ButtonRounded::None => Pixels::ZERO,
598        };
599
600        let root = base
601            .cursor_default()
602            .flex()
603            .flex_shrink_0()
604            .items_center()
605            .justify_center()
606            .cursor_default()
607            .when(
608                interactive && (self.variant.is_link() || self.variant.is_text()),
609                |this| this.cursor_pointer(),
610            )
611            .when(
612                !disabled && cx.theme().shadow && normal_style.shadow,
613                |this| this.shadow_xs(),
614            )
615            .when(!style.no_padding(), |this| {
616                if self.label.is_none() && children.is_empty() {
617                    // Icon Button
618                    match self.size {
619                        Size::Size(px) => this.size(px),
620                        Size::XSmall => this.size_5(),
621                        Size::Small => this.size_6(),
622                        Size::Large | Size::Medium => this.size_8(),
623                    }
624                } else {
625                    // Normal Button
626                    match self.size {
627                        Size::Size(size) => this.px(size * 0.2),
628                        Size::XSmall => this.h_5().px_1().when(self.compact, |this| this.min_w_5()),
629                        Size::Small => this
630                            .h_6()
631                            .px_2()
632                            .when(self.compact, |this| this.min_w_6().px_1p5()),
633                        Size::Medium => this
634                            .h_8()
635                            .px_2p5()
636                            .when(self.compact, |this| this.min_w_8().px_2()),
637                        Size::Large => this
638                            .h_8()
639                            .px_3()
640                            .when(self.compact, |this| this.min_w_8().px_2()),
641                    }
642                }
643            })
644            .when(self.border_corners.top_left, |this| {
645                this.rounded_tl(rounding)
646            })
647            .when(self.border_corners.top_right, |this| {
648                this.rounded_tr(rounding)
649            })
650            .when(self.border_corners.bottom_left, |this| {
651                this.rounded_bl(rounding)
652            })
653            .when(self.border_corners.bottom_right, |this| {
654                this.rounded_br(rounding)
655            })
656            .when(self.variant.is_default() || self.outline, |this| {
657                this.when(self.border_edges.left, |this| this.border_l_1())
658                    .when(self.border_edges.right, |this| this.border_r_1())
659                    .when(self.border_edges.top, |this| this.border_t_1())
660                    .when(self.border_edges.bottom, |this| this.border_b_1())
661            })
662            .when(!self.disabled && !self.selected, |this| {
663                this.border_color(normal_style.border)
664                    .bg(normal_style.bg)
665                    .text_color(normal_style.fg)
666                    .when(normal_style.underline, |this| this.text_decoration_1())
667                    // A loading button keeps its normal colors, but must not react
668                    // to the pointer, it is not waiting for another click.
669                    .when(interactive, |this| {
670                        this.hover(|this| {
671                            let hover_style = style.hovered(self.outline, cx);
672                            this.bg(hover_style.bg)
673                                .border_color(hover_style.border)
674                                .text_color(hover_style.fg)
675                        })
676                        .active(|this| {
677                            let active_style = style.active(self.outline, cx);
678                            this.bg(active_style.bg)
679                                .border_color(active_style.border)
680                                .text_color(active_style.fg)
681                        })
682                        .when_some(hover_group, |this, group| {
683                            let idle_bg = style.hovered(self.outline, cx).bg.opacity(0.5);
684                            this.when(hover_group_held, |this| this.bg(idle_bg))
685                                .group_hover(group, |this| this.bg(idle_bg))
686                        })
687                    })
688            })
689            .line_height(relative(1.25))
690            .refine_style(&instance_style);
691
692        // The explicit name wins: it exists precisely for the cases where the
693        // visible content is not what a listener needs to hear.
694        let accessibility_label = self
695            .accessibility_label
696            .clone()
697            .or_else(|| self.label.clone());
698        let content = h_flex()
699            .id("label")
700            .size_full()
701            .min_w_0()
702            .overflow_hidden()
703            .whitespace_nowrap()
704            .items_center()
705            .justify_center()
706            .button_text_size(self.size)
707            .map(|this| match self.size {
708                Size::XSmall => this.gap_1(),
709                Size::Small => this.gap_1(),
710                _ => this.gap_2(),
711            })
712            .refine_style(&self.content_style)
713            .when_some(self.icon, |this, icon| {
714                this.child(
715                    icon.loading_icon(self.loading_icon)
716                        .loading(self.loading)
717                        .with_size(icon_size),
718                )
719            })
720            .when_some(self.label, |this, label| {
721                this.child(
722                    div()
723                        .min_w_0()
724                        .whitespace_nowrap()
725                        .text_ellipsis()
726                        .child(label),
727                )
728            })
729            .children(children)
730            .when(self.dropdown_caret, |this| {
731                this.when(has_content, |this| this.justify_between())
732                    .child(Caret::new(self.size).text_color(normal_style.fg.opacity(0.75)))
733            });
734        root.role(self.role.resolve(|| {
735            if self.variant.is_link() {
736                Role::Link
737            } else {
738                Role::Button
739            }
740        }))
741        .selected(self.selected)
742        .disabled(disabled)
743        // Base layers semantic states over the builder chain, so the caller's
744        // own style is replayed inside each state to keep it the closest layer.
745        .styles(|styles| {
746            styles
747                .selected(|style| {
748                    style
749                        .bg(selected_style.bg)
750                        .border_color(selected_style.border)
751                        .text_color(selected_style.fg)
752                        .refine_style(&instance_style)
753                })
754                .disabled(|style| {
755                    style
756                        .bg(disabled_style.bg)
757                        .text_color(disabled_style.fg)
758                        .border_color(disabled_style.border)
759                        .shadow_none()
760                        .refine_style(&instance_style)
761                })
762        })
763        .when_some(accessibility_label, |this, label| {
764            this.accessibility_label(label)
765        })
766        .when_some(self.toggled, |this, toggled| {
767            this.aria_toggled(if toggled {
768                gpui::accesskit::Toggled::True
769            } else {
770                gpui::accesskit::Toggled::False
771            })
772        })
773        .track_focus(&focus_handle)
774        .tab_index(self.tab_index)
775        .tab_stop(self.tab_stop)
776        .child(content)
777        // Fade the whole button while loading, so every variant is dimmed by
778        // the same amount. Fading `bg`, `border` and `fg` one by one instead
779        // only shows up on variants that have a background to begin with:
780        // `Ghost`, `Link` and `Text` are transparent, so an alpha on their
781        // background changes nothing.
782        .when(loading && !disabled, |this| this.opacity(0.8))
783        .when(!disabled, |this| {
784            this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
785                if loading {
786                    cx.stop_propagation();
787                    return;
788                }
789
790                // Avoid focus on mouse down.
791                window.prevent_default();
792
793                // Pressing a button must not start the window-level text selection.
794                crate::global_state::GlobalState::suppress_text_selection(cx);
795            })
796        })
797        .when_some(self.on_click, |this, on_click| {
798            this.on_click(move |event, window, cx| {
799                if loading {
800                    cx.stop_propagation();
801                    return;
802                }
803
804                on_click(event, window, cx);
805            })
806        })
807        .when(loading, |this| {
808            this.on_click(|_, _, cx| cx.stop_propagation())
809        })
810        .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
811            this.on_hover(move |hovered, window, cx| {
812                on_hover(hovered, window, cx);
813            })
814        })
815        .map(|this| {
816            if let Some(builder) = self.tooltip_builder {
817                this.managed_tooltip_with_placement(tooltip_placement, move |window, cx| {
818                    builder(window, cx)
819                })
820            } else if let Some((tooltip, action)) = self.tooltip {
821                this.managed_tooltip_with_placement(tooltip_placement, move |window, cx| {
822                    Tooltip::new(tooltip.clone())
823                        .when_some(action.clone(), |this, (action, context)| {
824                            this.action(
825                                action.boxed_clone().as_ref(),
826                                context.as_ref().map(|c| c.as_ref()),
827                            )
828                        })
829                        .build(window, cx)
830                })
831            } else {
832                this
833            }
834        })
835        .when(is_focused && self.focus_ring_enabled, |this| {
836            this.focus_ring_style(window, cx)
837        })
838    }
839}
840
841struct ButtonVariantStyle {
842    bg: Background,
843    border: Hsla,
844    fg: Hsla,
845    underline: bool,
846    shadow: bool,
847}
848
849#[derive(Clone, Copy)]
850enum ButtonStyleState {
851    Normal,
852    Hovered,
853    Active,
854}
855
856impl ButtonVariant {
857    fn outline_background(&self, state: ButtonStyleState, cx: &mut App) -> Background {
858        match (self, state) {
859            (Self::Default, ButtonStyleState::Normal) => cx.theme().input_background().into(),
860            (Self::Default, ButtonStyleState::Hovered) => cx
861                .theme()
862                .input
863                .mix_oklab(cx.theme().transparent, 0.5)
864                .into(),
865            (Self::Default, ButtonStyleState::Active) => cx
866                .theme()
867                .input
868                .mix_oklab(cx.theme().transparent, 0.7)
869                .into(),
870            (Self::Primary, ButtonStyleState::Normal) => {
871                cx.theme().tokens.primary.background.opacity(0.1)
872            }
873            (Self::Primary, ButtonStyleState::Hovered) => {
874                cx.theme().tokens.primary_hover.background.opacity(0.2)
875            }
876            (Self::Primary, ButtonStyleState::Active) => {
877                cx.theme().tokens.primary_active.background.opacity(0.4)
878            }
879            (Self::Secondary, ButtonStyleState::Normal) => {
880                cx.theme().tokens.secondary.background.opacity(0.1)
881            }
882            (Self::Secondary, ButtonStyleState::Hovered) => {
883                cx.theme().tokens.secondary_hover.background.opacity(0.2)
884            }
885            (Self::Secondary, ButtonStyleState::Active) => {
886                cx.theme().tokens.secondary_active.background.opacity(0.4)
887            }
888            (Self::Danger, ButtonStyleState::Normal) => {
889                cx.theme().tokens.danger.background.opacity(0.1)
890            }
891            (Self::Danger, ButtonStyleState::Hovered) => {
892                cx.theme().tokens.danger_hover.background.opacity(0.2)
893            }
894            (Self::Danger, ButtonStyleState::Active) => {
895                cx.theme().tokens.danger_active.background.opacity(0.4)
896            }
897            (Self::Warning, ButtonStyleState::Normal) => {
898                cx.theme().tokens.warning.background.opacity(0.1)
899            }
900            (Self::Warning, ButtonStyleState::Hovered) => {
901                cx.theme().tokens.warning_hover.background.opacity(0.2)
902            }
903            (Self::Warning, ButtonStyleState::Active) => {
904                cx.theme().tokens.warning_active.background.opacity(0.4)
905            }
906            (Self::Success, ButtonStyleState::Normal) => {
907                cx.theme().tokens.success.background.opacity(0.1)
908            }
909            (Self::Success, ButtonStyleState::Hovered) => {
910                cx.theme().tokens.success_hover.background.opacity(0.2)
911            }
912            (Self::Success, ButtonStyleState::Active) => {
913                cx.theme().tokens.success_active.background.opacity(0.4)
914            }
915            (Self::Info, ButtonStyleState::Normal) => {
916                cx.theme().tokens.info.background.opacity(0.1)
917            }
918            (Self::Info, ButtonStyleState::Hovered) => {
919                cx.theme().tokens.info_hover.background.opacity(0.2)
920            }
921            (Self::Info, ButtonStyleState::Active) => {
922                cx.theme().tokens.info_active.background.opacity(0.4)
923            }
924            (Self::Ghost | Self::Link | Self::Text, _) => cx.theme().transparent.into(),
925            (Self::Custom(colors), _) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(),
926        }
927    }
928
929    fn bg_color(&self, outline: bool, cx: &mut App) -> Background {
930        if outline {
931            return self.outline_background(ButtonStyleState::Normal, cx);
932        }
933
934        match self {
935            Self::Default => cx.theme().tokens.button.into(),
936            Self::Primary => cx.theme().tokens.button_primary.into(),
937            Self::Secondary => cx.theme().tokens.button_secondary.into(),
938            Self::Danger => cx.theme().tokens.button_danger.into(),
939            Self::Warning => cx.theme().tokens.button_warning.into(),
940            Self::Success => cx.theme().tokens.button_success.into(),
941            Self::Info => cx.theme().tokens.button_info.into(),
942            Self::Ghost | Self::Link | Self::Text => cx.theme().transparent.into(),
943            Self::Custom(colors) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(),
944        }
945    }
946
947    fn text_color(&self, outline: bool, cx: &mut App) -> Hsla {
948        match self {
949            Self::Default => cx.theme().button_foreground,
950            Self::Primary => {
951                if outline {
952                    cx.theme().primary
953                } else {
954                    cx.theme().button_primary_foreground
955                }
956            }
957            Self::Secondary => {
958                if outline {
959                    cx.theme().secondary_foreground
960                } else {
961                    cx.theme().button_secondary_foreground
962                }
963            }
964            Self::Ghost => cx.theme().secondary_foreground,
965            Self::Danger => {
966                if outline {
967                    cx.theme().danger
968                } else {
969                    cx.theme().button_danger_foreground
970                }
971            }
972            Self::Warning => {
973                if outline {
974                    cx.theme().warning
975                } else {
976                    cx.theme().button_warning_foreground
977                }
978            }
979            Self::Success => {
980                if outline {
981                    cx.theme().success
982                } else {
983                    cx.theme().button_success_foreground
984                }
985            }
986            Self::Info => {
987                if outline {
988                    cx.theme().info
989                } else {
990                    cx.theme().button_info_foreground
991                }
992            }
993            Self::Link => cx.theme().link,
994            Self::Text => cx.theme().foreground.opacity(0.9),
995            Self::Custom(colors) => colors.foreground,
996        }
997    }
998
999    fn border_color(&self, outline: bool, cx: &mut App) -> Hsla {
1000        match self {
1001            Self::Default => cx.theme().input,
1002            Self::Secondary => cx.theme().border,
1003            Self::Primary => cx.theme().primary,
1004            Self::Danger => {
1005                if outline {
1006                    cx.theme().danger.mix_oklab(transparent_white(), 0.4)
1007                } else {
1008                    cx.theme().button_danger
1009                }
1010            }
1011            Self::Info => {
1012                if outline {
1013                    cx.theme().info.mix_oklab(transparent_white(), 0.4)
1014                } else {
1015                    cx.theme().button_info
1016                }
1017            }
1018            Self::Warning => {
1019                if outline {
1020                    cx.theme().warning.mix_oklab(transparent_white(), 0.4)
1021                } else {
1022                    cx.theme().button_warning
1023                }
1024            }
1025            Self::Success => {
1026                if outline {
1027                    cx.theme().success.mix_oklab(transparent_white(), 0.4)
1028                } else {
1029                    cx.theme().button_success
1030                }
1031            }
1032            Self::Ghost | Self::Link | Self::Text => cx.theme().transparent,
1033            Self::Custom(colors) => {
1034                if outline {
1035                    colors.color.mix_oklab(transparent_white(), 0.4)
1036                } else {
1037                    colors.color
1038                }
1039            }
1040        }
1041    }
1042
1043    fn underline(&self, _: &App) -> bool {
1044        match self {
1045            Self::Link => true,
1046            _ => false,
1047        }
1048    }
1049
1050    fn shadow(&self, _outline: bool, _: &App) -> bool {
1051        match self {
1052            Self::Custom(c) => c.shadow,
1053            _ => false,
1054        }
1055    }
1056
1057    fn normal(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1058        let bg = self.bg_color(outline, cx);
1059        let border = self.border_color(outline, cx);
1060        let fg = self.text_color(outline, cx);
1061        let underline = self.underline(cx);
1062        let shadow = self.shadow(outline, cx);
1063
1064        ButtonVariantStyle {
1065            bg,
1066            border,
1067            fg,
1068            underline,
1069            shadow,
1070        }
1071    }
1072
1073    fn hovered(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1074        let bg: Background = match self {
1075            Self::Default => {
1076                if outline {
1077                    self.outline_background(ButtonStyleState::Hovered, cx)
1078                } else {
1079                    cx.theme().tokens.button_hover.into()
1080                }
1081            }
1082            Self::Primary => {
1083                if outline {
1084                    self.outline_background(ButtonStyleState::Hovered, cx)
1085                } else {
1086                    cx.theme().tokens.button_primary_hover.into()
1087                }
1088            }
1089            Self::Secondary => {
1090                if outline {
1091                    self.outline_background(ButtonStyleState::Hovered, cx)
1092                } else {
1093                    cx.theme().tokens.button_secondary_hover.into()
1094                }
1095            }
1096            Self::Danger => {
1097                if outline {
1098                    self.outline_background(ButtonStyleState::Hovered, cx)
1099                } else {
1100                    cx.theme().tokens.button_danger_hover.into()
1101                }
1102            }
1103            Self::Warning => {
1104                if outline {
1105                    self.outline_background(ButtonStyleState::Hovered, cx)
1106                } else {
1107                    cx.theme().tokens.button_warning_hover.into()
1108                }
1109            }
1110            Self::Success => {
1111                if outline {
1112                    self.outline_background(ButtonStyleState::Hovered, cx)
1113                } else {
1114                    cx.theme().tokens.button_success_hover.into()
1115                }
1116            }
1117            Self::Info => {
1118                if outline {
1119                    self.outline_background(ButtonStyleState::Hovered, cx)
1120                } else {
1121                    cx.theme().tokens.button_info_hover.into()
1122                }
1123            }
1124            Self::Custom(colors) => colors.hover.into(),
1125            Self::Ghost => {
1126                let accent: Background = cx.theme().tokens.accent.into();
1127                if cx.theme().mode.is_dark() {
1128                    accent.opacity(0.5)
1129                } else {
1130                    accent
1131                }
1132            }
1133            Self::Link => cx.theme().transparent.into(),
1134            Self::Text => cx.theme().transparent.into(),
1135        };
1136
1137        let border = self.border_color(outline, cx);
1138        let fg = match self {
1139            Self::Link => cx.theme().link_hover,
1140            Self::Text => cx.theme().foreground,
1141            Self::Ghost => cx.theme().accent_foreground,
1142            _ => self.text_color(outline, cx),
1143        };
1144
1145        let underline = self.underline(cx);
1146        let shadow = self.shadow(outline, cx);
1147
1148        ButtonVariantStyle {
1149            bg,
1150            border,
1151            fg,
1152            underline,
1153            shadow,
1154        }
1155    }
1156
1157    fn active(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1158        let bg = match self {
1159            Self::Default => {
1160                if outline {
1161                    self.outline_background(ButtonStyleState::Active, cx)
1162                } else {
1163                    cx.theme().tokens.button_active.into()
1164                }
1165            }
1166            Self::Primary => {
1167                if outline {
1168                    self.outline_background(ButtonStyleState::Active, cx)
1169                } else {
1170                    cx.theme().tokens.button_primary_active.into()
1171                }
1172            }
1173            Self::Secondary => {
1174                if outline {
1175                    self.outline_background(ButtonStyleState::Active, cx)
1176                } else {
1177                    cx.theme().tokens.button_secondary_active.into()
1178                }
1179            }
1180            Self::Ghost => cx.theme().tokens.button_active.into(),
1181            Self::Danger => {
1182                if outline {
1183                    self.outline_background(ButtonStyleState::Active, cx)
1184                } else {
1185                    cx.theme().tokens.button_danger_active.into()
1186                }
1187            }
1188            Self::Warning => {
1189                if outline {
1190                    self.outline_background(ButtonStyleState::Active, cx)
1191                } else {
1192                    cx.theme().tokens.button_warning_active.into()
1193                }
1194            }
1195            Self::Success => {
1196                if outline {
1197                    self.outline_background(ButtonStyleState::Active, cx)
1198                } else {
1199                    cx.theme().tokens.button_success_active.into()
1200                }
1201            }
1202            Self::Info => {
1203                if outline {
1204                    self.outline_background(ButtonStyleState::Active, cx)
1205                } else {
1206                    cx.theme().tokens.button_info_active.into()
1207                }
1208            }
1209            Self::Custom(colors) => colors.active.into(),
1210            Self::Link => cx.theme().transparent.into(),
1211            Self::Text => cx.theme().transparent.into(),
1212        };
1213        let border = self.border_color(outline, cx);
1214        let fg = match self {
1215            Self::Link => cx.theme().link_active,
1216            Self::Text => cx.theme().foreground.opacity(0.7),
1217            _ => self.text_color(outline, cx),
1218        };
1219        let underline = self.underline(cx);
1220        let shadow = self.shadow(outline, cx);
1221
1222        ButtonVariantStyle {
1223            bg,
1224            border,
1225            fg,
1226            underline,
1227            shadow,
1228        }
1229    }
1230
1231    fn selected(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1232        if outline {
1233            let active_style = self.active(outline, cx);
1234
1235            return ButtonVariantStyle {
1236                fg: self.text_color(outline, cx),
1237                ..active_style
1238            };
1239        }
1240
1241        let bg = match self {
1242            Self::Default => cx.theme().tokens.button_active.into(),
1243            Self::Primary => cx.theme().tokens.button_primary_active.into(),
1244            Self::Secondary => cx.theme().tokens.button_secondary_active.into(),
1245            Self::Ghost => cx.theme().tokens.secondary_active.into(),
1246            Self::Danger => cx.theme().tokens.button_danger_active.into(),
1247            Self::Warning => cx.theme().tokens.button_warning_active.into(),
1248            Self::Success => cx.theme().tokens.button_success_active.into(),
1249            Self::Info => cx.theme().tokens.button_info_active.into(),
1250            Self::Link => cx.theme().transparent.into(),
1251            Self::Text => cx.theme().transparent.into(),
1252            Self::Custom(colors) => colors.active.into(),
1253        };
1254
1255        let border = self.border_color(outline, cx);
1256        let fg = match self {
1257            Self::Link => cx.theme().link_active,
1258            Self::Text => cx.theme().foreground.opacity(0.7),
1259            _ => self.text_color(false, cx),
1260        };
1261        let underline = self.underline(cx);
1262        let shadow = self.shadow(outline, cx);
1263
1264        ButtonVariantStyle {
1265            bg,
1266            border,
1267            fg,
1268            underline,
1269            shadow,
1270        }
1271    }
1272
1273    fn disabled(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1274        let bg = match self {
1275            Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent.into(),
1276            Self::Primary => cx.theme().tokens.button_primary.background.opacity(0.15),
1277            Self::Danger => cx.theme().tokens.button_danger.background.opacity(0.15),
1278            Self::Warning => cx.theme().tokens.button_warning.background.opacity(0.15),
1279            Self::Success => cx.theme().tokens.button_success.background.opacity(0.15),
1280            Self::Info => cx.theme().tokens.button_info.background.opacity(0.15),
1281            Self::Secondary => cx.theme().tokens.button_secondary.background.opacity(1.5),
1282            Self::Custom(style) => style.color.opacity(0.15).into(),
1283        };
1284        let fg = cx.theme().muted_foreground.opacity(0.5);
1285        let (bg, border) = if outline {
1286            (
1287                self.outline_background(ButtonStyleState::Normal, cx)
1288                    .opacity(0.5),
1289                self.border_color(true, cx).opacity(0.5),
1290            )
1291        } else if let Self::Default = self {
1292            (
1293                cx.theme().input_background().opacity(0.5).into(),
1294                cx.theme().input.opacity(0.5),
1295            )
1296        } else {
1297            let border = match self {
1298                Self::Primary => cx.theme().button_primary.opacity(0.15),
1299                Self::Secondary => cx.theme().button_secondary.opacity(1.5),
1300                Self::Danger => cx.theme().button_danger.opacity(0.15),
1301                Self::Warning => cx.theme().button_warning.opacity(0.15),
1302                Self::Success => cx.theme().button_success.opacity(0.15),
1303                Self::Info => cx.theme().button_info.opacity(0.15),
1304                Self::Custom(style) => style.color.opacity(0.15),
1305                Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent,
1306            };
1307            (bg, border)
1308        };
1309
1310        let underline = self.underline(cx);
1311        let shadow = false;
1312
1313        ButtonVariantStyle {
1314            bg,
1315            border,
1316            fg,
1317            underline,
1318            shadow,
1319        }
1320    }
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326    use crate::IconName;
1327    use gpui::{linear_color_stop, linear_gradient, px};
1328
1329    /// A button's announced name is its label, unless it was given one — which
1330    /// is the case an icon-only button and a row-shaped button both need.
1331    #[test]
1332    fn an_explicit_accessibility_label_replaces_the_visible_one() {
1333        let plain = Button::new("save").label("Save");
1334        assert_eq!(plain.accessibility_label, None);
1335        assert_eq!(plain.label.as_deref(), Some("Save"));
1336
1337        let named = Button::new("row")
1338            .label("Save")
1339            .accessibility_label("Save the current document");
1340        assert_eq!(
1341            named.accessibility_label.as_deref(),
1342            Some("Save the current document"),
1343            "an explicit name must win over the visible label"
1344        );
1345        assert_eq!(
1346            named.label.as_deref(),
1347            Some("Save"),
1348            "and must not change what is drawn"
1349        );
1350    }
1351
1352    #[gpui::test]
1353    fn disabled_legacy_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) {
1354        use std::{cell::Cell, rc::Rc};
1355
1356        use gpui::{Context, Modifiers, Render, point};
1357
1358        struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1359
1360        impl Render for Harness {
1361            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1362                let button_clicks = self.0.clone();
1363                let parent_clicks = self.1.clone();
1364                div()
1365                    .id("disabled-button-parent")
1366                    .tab_group()
1367                    .size(px(100.))
1368                    .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1369                    .child(
1370                        Button::new("disabled-legacy")
1371                            .disabled(true)
1372                            .size_full()
1373                            .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)),
1374                    )
1375            }
1376        }
1377
1378        cx.update(crate::init);
1379        let button_clicks = Rc::new(Cell::new(0));
1380        let parent_clicks = Rc::new(Cell::new(0));
1381        let (_, cx) = cx.add_window_view({
1382            let button_clicks = button_clicks.clone();
1383            let parent_clicks = parent_clicks.clone();
1384            move |_, _| Harness(button_clicks, parent_clicks)
1385        });
1386        cx.update(|window, cx| window.draw(cx).clear(cx));
1387        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1388        cx.update(|window, cx| window.focus_next(cx));
1389        cx.simulate_keystrokes("enter space");
1390
1391        assert_eq!(button_clicks.get(), 0);
1392        assert_eq!(parent_clicks.get(), 0);
1393        cx.update(|window, cx| assert!(window.focused(cx).is_none()));
1394    }
1395
1396    #[gpui::test]
1397    fn enabled_button_delegates_pointer_enter_and_space_once(cx: &mut gpui::TestAppContext) {
1398        use std::{cell::Cell, rc::Rc};
1399
1400        use gpui::{
1401            ClickEvent, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, point,
1402        };
1403
1404        struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1405
1406        impl Render for Harness {
1407            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1408                let clicks = self.0.clone();
1409                let keyboard_clicks = self.1.clone();
1410                div().tab_group().size(px(100.)).child(
1411                    Button::new("enabled-legacy")
1412                        .size_full()
1413                        .on_click(move |event, _, _| {
1414                            clicks.set(clicks.get() + 1);
1415                            if matches!(event, ClickEvent::Keyboard(_)) {
1416                                keyboard_clicks.set(keyboard_clicks.get() + 1);
1417                            }
1418                        }),
1419                )
1420            }
1421        }
1422
1423        cx.update(crate::init);
1424        let clicks = Rc::new(Cell::new(0));
1425        let keyboard_clicks = Rc::new(Cell::new(0));
1426        let (_, cx) = cx.add_window_view({
1427            let clicks = clicks.clone();
1428            let keyboard_clicks = keyboard_clicks.clone();
1429            move |_, _| Harness(clicks, keyboard_clicks)
1430        });
1431        cx.update(|window, cx| window.draw(cx).clear(cx));
1432        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1433        cx.update(|window, cx| window.focus_next(cx));
1434        cx.update(|window, cx| {
1435            assert!(window.focused(cx).is_some());
1436            window.draw(cx).clear(cx);
1437        });
1438        for key in ["enter", "space"] {
1439            let keystroke = Keystroke::parse(key).unwrap();
1440            cx.simulate_event(KeyDownEvent {
1441                keystroke: keystroke.clone(),
1442                is_held: false,
1443                prefer_character_input: false,
1444            });
1445            cx.simulate_event(KeyUpEvent { keystroke });
1446        }
1447
1448        assert_eq!(clicks.get(), 3);
1449        assert_eq!(keyboard_clicks.get(), 2);
1450    }
1451
1452    #[gpui::test]
1453    fn loading_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) {
1454        use std::{cell::Cell, rc::Rc};
1455
1456        use gpui::{Context, Modifiers, Render, point};
1457
1458        struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1459
1460        impl Render for Harness {
1461            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1462                let button_clicks = self.0.clone();
1463                let parent_clicks = self.1.clone();
1464                div()
1465                    .id("loading-button-parent")
1466                    .tab_group()
1467                    .size(px(100.))
1468                    .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1469                    .child(
1470                        Button::new("loading-legacy")
1471                            .loading(true)
1472                            .size_full()
1473                            .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)),
1474                    )
1475            }
1476        }
1477
1478        cx.update(crate::init);
1479        let button_clicks = Rc::new(Cell::new(0));
1480        let parent_clicks = Rc::new(Cell::new(0));
1481        let (_, cx) = cx.add_window_view({
1482            let button_clicks = button_clicks.clone();
1483            let parent_clicks = parent_clicks.clone();
1484            move |_, _| Harness(button_clicks, parent_clicks)
1485        });
1486        cx.update(|window, cx| window.draw(cx).clear(cx));
1487        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1488        cx.update(|window, cx| window.focus_next(cx));
1489        cx.simulate_keystrokes("enter space");
1490
1491        assert_eq!(button_clicks.get(), 0);
1492        assert_eq!(parent_clicks.get(), 0);
1493        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
1494    }
1495
1496    #[gpui::test]
1497    fn loading_button_without_callback_still_blocks_parent_activation(
1498        cx: &mut gpui::TestAppContext,
1499    ) {
1500        use std::{cell::Cell, rc::Rc};
1501
1502        use gpui::{Context, Modifiers, Render, point};
1503
1504        struct Harness(Rc<Cell<usize>>);
1505
1506        impl Render for Harness {
1507            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1508                let parent_clicks = self.0.clone();
1509                div()
1510                    .id("loading-without-callback-parent")
1511                    .tab_group()
1512                    .size(px(100.))
1513                    .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1514                    .child(
1515                        Button::new("loading-without-callback")
1516                            .loading(true)
1517                            .size_full(),
1518                    )
1519            }
1520        }
1521
1522        cx.update(crate::init);
1523        let parent_clicks = Rc::new(Cell::new(0));
1524        let (_, cx) = cx.add_window_view({
1525            let parent_clicks = parent_clicks.clone();
1526            move |_, _| Harness(parent_clicks)
1527        });
1528        cx.update(|window, cx| window.draw(cx).clear(cx));
1529        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1530        cx.update(|window, cx| window.focus_next(cx));
1531        cx.simulate_keystrokes("enter space");
1532
1533        assert_eq!(parent_clicks.get(), 0);
1534        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
1535    }
1536
1537    #[gpui::test]
1538    fn base_activation_is_preserved_and_blocked_while_loading(cx: &mut gpui::TestAppContext) {
1539        use gpui::{Context, Render, point};
1540        use std::{cell::Cell, rc::Rc};
1541
1542        struct Harness {
1543            clicks: Rc<Cell<usize>>,
1544            loading: bool,
1545        }
1546        impl Render for Harness {
1547            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1548                let clicks = self.clicks.clone();
1549                div().tab_group().child(
1550                    Button::new("close")
1551                        .with_base(
1552                            gpui_base::Button::new("close")
1553                                .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
1554                        )
1555                        .loading(self.loading)
1556                        .size(px(100.)),
1557                )
1558            }
1559        }
1560
1561        cx.update(crate::init);
1562        let clicks = Rc::new(Cell::new(0));
1563        let (view, cx) = cx.add_window_view({
1564            let clicks = clicks.clone();
1565            move |_, _| Harness {
1566                clicks,
1567                loading: false,
1568            }
1569        });
1570        cx.update(|window, cx| window.draw(cx).clear(cx));
1571        cx.simulate_click(point(px(10.), px(10.)), Default::default());
1572        assert_eq!(clicks.get(), 1);
1573
1574        view.update(cx, |view, cx| {
1575            view.loading = true;
1576            cx.notify();
1577        });
1578        cx.update(|window, cx| window.draw(cx).clear(cx));
1579        cx.simulate_click(point(px(10.), px(10.)), Default::default());
1580        cx.update(|window, cx| window.focus_next(cx));
1581        cx.simulate_keystrokes("enter space");
1582        assert_eq!(clicks.get(), 1);
1583    }
1584
1585    #[gpui::test]
1586    fn test_button_builder(_cx: &mut gpui::TestAppContext) {
1587        let button = Button::new("complex-button")
1588            .label("Save Changes")
1589            .primary()
1590            .outline()
1591            .large()
1592            .tooltip("Click to save")
1593            .compact()
1594            .loading(false)
1595            .disabled(false)
1596            .selected(false)
1597            .tab_index(1)
1598            .tab_stop(true)
1599            .dropdown_caret(false)
1600            .rounded(ButtonRounded::Medium)
1601            .on_click(|_, _, _| {});
1602
1603        assert_eq!(button.label, Some("Save Changes".into()));
1604        assert_eq!(button.variant, ButtonVariant::Primary);
1605        assert!(button.outline);
1606        assert_eq!(button.size, Size::Large);
1607        assert!(button.tooltip.is_some());
1608        assert!(button.compact);
1609        assert!(!button.loading);
1610        assert!(!button.disabled);
1611        assert!(!button.selected);
1612        assert_eq!(button.toggled, None);
1613        assert_eq!(button.tab_index, 1);
1614        assert!(button.tab_stop);
1615        assert!(!button.dropdown_caret);
1616        assert!(matches!(button.rounded, ButtonRounded::Medium));
1617    }
1618
1619    #[test]
1620    fn selected_trigger_presentation_does_not_imply_toggled_accessibility() {
1621        let selected = Button::new("menu-trigger").selected(true);
1622        assert!(selected.selected);
1623        assert_eq!(selected.toggled, None);
1624
1625        let selected_toggle = Button::new("explicit-toggle").selected(true).toggled(true);
1626        assert!(selected_toggle.selected);
1627        assert_eq!(selected_toggle.toggled, Some(true));
1628    }
1629
1630    /// A loading button must be as inert as a disabled one. `interactive` is what
1631    /// gates the hover and active styling, the `cursor_pointer` of link buttons and
1632    /// the `mouse_down` handler, none of which depend on a listener being set.
1633    #[gpui::test]
1634    fn test_button_loading_is_not_interactive(_cx: &mut gpui::TestAppContext) {
1635        assert!(Button::new("test").interactive());
1636        assert!(!Button::new("test").loading(true).interactive());
1637        assert!(!Button::new("test").disabled(true).interactive());
1638        assert!(
1639            !Button::new("test")
1640                .loading(true)
1641                .disabled(true)
1642                .interactive()
1643        );
1644
1645        // Loading gates hovering even when an `on_hover` listener is set.
1646        let loading = Button::new("test").loading(true).on_hover(|_, _, _| {});
1647        assert!(!loading.hoverable());
1648    }
1649
1650    /// `selected` is styling only; the toggle state must be opted into, so that
1651    /// ordinary buttons are not announced as toggle buttons.
1652    #[gpui::test]
1653    fn test_button_variant_methods(_cx: &mut gpui::TestAppContext) {
1654        // Test variant check methods
1655        assert!(ButtonVariant::Link.is_link());
1656        assert!(ButtonVariant::Text.is_text());
1657        assert!(ButtonVariant::Ghost.is_ghost());
1658
1659        // Test no_padding logic
1660        assert!(ButtonVariant::Link.no_padding());
1661        assert!(ButtonVariant::Text.no_padding());
1662        assert!(!ButtonVariant::Ghost.no_padding());
1663    }
1664
1665    #[gpui::test]
1666    fn link_button_prepaints_its_child(cx: &mut gpui::TestAppContext) {
1667        use gpui::{Context, Render};
1668
1669        struct LinkButtonHarness;
1670
1671        impl Render for LinkButtonHarness {
1672            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1673                Button::new("link-button").link().child(
1674                    div()
1675                        .debug_selector(|| "link-button-child".into())
1676                        .child("Visible link button"),
1677                )
1678            }
1679        }
1680
1681        cx.update(crate::init);
1682        let (_, cx) = cx.add_window_view(|_, _| LinkButtonHarness);
1683        cx.update(|window, cx| window.draw(cx).clear(cx));
1684
1685        let bounds = cx
1686            .debug_bounds("link-button-child")
1687            .expect("link button child must participate in layout and prepaint");
1688        assert!(bounds.size.width > px(0.));
1689        assert!(bounds.size.height > px(0.));
1690    }
1691
1692    #[gpui::test]
1693    fn base_slot_prepaints_complete_button_content(cx: &mut gpui::TestAppContext) {
1694        use gpui::{Context, Render};
1695
1696        struct CompleteButtonHarness;
1697
1698        impl Render for CompleteButtonHarness {
1699            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1700                div()
1701                    .child(
1702                        Button::new("complete-button")
1703                            .icon(IconName::Plus)
1704                            .label("Create")
1705                            .child(
1706                                div()
1707                                    .debug_selector(|| "button-custom-content".into())
1708                                    .child("Details"),
1709                            )
1710                            .dropdown_caret(true),
1711                    )
1712                    .child(
1713                        Button::new("loading-button")
1714                            .icon(IconName::Plus)
1715                            .loading_icon(IconName::Loader)
1716                            .loading(true)
1717                            .label("Creating")
1718                            .dropdown_caret(true),
1719                    )
1720            }
1721        }
1722
1723        cx.update(crate::init);
1724        let (_, cx) = cx.add_window_view(|_, _| CompleteButtonHarness);
1725        cx.update(|window, cx| window.draw(cx).clear(cx));
1726
1727        let bounds = cx
1728            .debug_bounds("button-custom-content")
1729            .expect("application content must prepaint through the Base child seam");
1730        assert!(bounds.size.width > px(0.));
1731        assert!(bounds.size.height > px(0.));
1732    }
1733
1734    #[gpui::test]
1735    fn test_outline_selected_uses_outline_active_style(cx: &mut gpui::TestAppContext) {
1736        cx.update(crate::init);
1737        let window = cx.add_empty_window();
1738        window.update(|_, cx| {
1739            let variant = ButtonVariant::Danger;
1740            let active_style = variant.active(true, cx);
1741            let selected_style = variant.selected(true, cx);
1742
1743            assert_eq!(selected_style.bg, active_style.bg);
1744            assert_eq!(selected_style.border, active_style.border);
1745            assert_eq!(selected_style.fg, cx.theme().danger);
1746            assert_ne!(selected_style.bg, cx.theme().tokens.danger_active.into());
1747        });
1748    }
1749
1750    #[gpui::test]
1751    fn test_primary_button_uses_gradient_background_tokens(cx: &mut gpui::TestAppContext) {
1752        cx.update(crate::init);
1753        let window = cx.add_empty_window();
1754        window.update(|_, cx| {
1755            let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1756                "name": "Gradient",
1757                "mode": "light",
1758                "colors": {
1759                    "button.primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)",
1760                    "button.primary.hover.background": "linear-gradient(145deg, #4338CA, #0891B2)",
1761                    "button.primary.active.background": "linear-gradient(155deg, #3730A3, #0E7490)"
1762                }
1763            }))
1764            .unwrap();
1765            crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1766
1767            assert_eq!(
1768                ButtonVariant::Primary.normal(false, cx).bg,
1769                cx.theme().tokens.button_primary.into()
1770            );
1771            assert_eq!(
1772                ButtonVariant::Primary.hovered(false, cx).bg,
1773                cx.theme().tokens.button_primary_hover.into()
1774            );
1775            assert_eq!(
1776                ButtonVariant::Primary.active(false, cx).bg,
1777                cx.theme().tokens.button_primary_active.into()
1778            );
1779        });
1780    }
1781
1782    #[gpui::test]
1783    fn test_outline_primary_keeps_original_depth(cx: &mut gpui::TestAppContext) {
1784        cx.update(crate::init);
1785        let window = cx.add_empty_window();
1786        window.update(|_, cx| {
1787            let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1788                "name": "Outline Depth",
1789                "mode": "light",
1790                "colors": {
1791                    "primary.background": "linear-gradient(180deg, #111827, #020617)",
1792                    "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)",
1793                    "primary.active.background": "linear-gradient(180deg, #020617, #000000)"
1794                }
1795            }))
1796            .unwrap();
1797            crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1798
1799            assert_eq!(
1800                ButtonVariant::Primary.normal(true, cx).bg,
1801                cx.theme().tokens.primary.background.opacity(0.1)
1802            );
1803            assert_eq!(
1804                ButtonVariant::Primary.hovered(true, cx).bg,
1805                cx.theme().tokens.primary_hover.background.opacity(0.2)
1806            );
1807            assert_eq!(
1808                ButtonVariant::Primary.active(true, cx).bg,
1809                cx.theme().tokens.primary_active.background.opacity(0.4)
1810            );
1811        });
1812    }
1813
1814    #[gpui::test]
1815    fn test_outline_buttons_use_semantic_gradient_tokens(cx: &mut gpui::TestAppContext) {
1816        cx.update(crate::init);
1817        let window = cx.add_empty_window();
1818        window.update(|_, cx| {
1819            let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1820                "name": "Outline Gradient",
1821                "mode": "light",
1822                "colors": {
1823                    "primary.background": "linear-gradient(180deg, #111827, #020617)",
1824                    "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)",
1825                    "primary.active.background": "linear-gradient(180deg, #020617, #000000)",
1826                    "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)",
1827                    "button.primary.hover.background": "linear-gradient(180deg, #F9FAFB, #E5E7EB)",
1828                    "button.primary.active.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)",
1829                    "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
1830                    "danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
1831                    "danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
1832                    "button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
1833                    "button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
1834                    "button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)"
1835                }
1836            }))
1837            .unwrap();
1838            crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1839
1840            assert_eq!(
1841                ButtonVariant::Primary.normal(true, cx).bg,
1842                cx.theme().tokens.primary.background.opacity(0.1)
1843            );
1844            assert_eq!(
1845                ButtonVariant::Danger.normal(true, cx).bg,
1846                cx.theme().tokens.danger.background.opacity(0.1)
1847            );
1848            assert_eq!(
1849                ButtonVariant::Danger.hovered(true, cx).bg,
1850                cx.theme().tokens.danger_hover.background.opacity(0.2)
1851            );
1852            assert_eq!(
1853                ButtonVariant::Danger.active(true, cx).bg,
1854                cx.theme().tokens.danger_active.background.opacity(0.4)
1855            );
1856            assert_eq!(
1857                ButtonVariant::Primary.normal(false, cx).bg,
1858                cx.theme().tokens.button_primary.into()
1859            );
1860            assert_eq!(
1861                ButtonVariant::Danger.normal(false, cx).bg,
1862                linear_gradient(
1863                    180.,
1864                    linear_color_stop(crate::try_parse_color("#FEF2F2").unwrap(), 0.),
1865                    linear_color_stop(crate::try_parse_color("#FEE2E2").unwrap(), 1.)
1866                )
1867            );
1868        });
1869    }
1870
1871    #[gpui::test]
1872    fn test_disabled_outline_buttons_keep_semantic_backgrounds(cx: &mut gpui::TestAppContext) {
1873        cx.update(crate::init);
1874        let window = cx.add_empty_window();
1875        window.update(|_, cx| {
1876            let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1877                "name": "Disabled Outline Gradient",
1878                "mode": "light",
1879                "colors": {
1880                    "primary.background": "linear-gradient(180deg, #111827, #020617)",
1881                    "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)",
1882                    "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
1883                    "button.danger.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)"
1884                }
1885            }))
1886            .unwrap();
1887            crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1888
1889            assert_eq!(
1890                ButtonVariant::Primary.disabled(true, cx).bg,
1891                cx.theme()
1892                    .tokens
1893                    .primary
1894                    .background
1895                    .opacity(0.1)
1896                    .opacity(0.5)
1897            );
1898            assert_eq!(
1899                ButtonVariant::Danger.disabled(true, cx).bg,
1900                cx.theme()
1901                    .tokens
1902                    .danger
1903                    .background
1904                    .opacity(0.1)
1905                    .opacity(0.5)
1906            );
1907            assert_ne!(
1908                ButtonVariant::Danger.disabled(true, cx).bg,
1909                cx.theme().input_background().opacity(0.5).into()
1910            );
1911            assert_ne!(
1912                ButtonVariant::Danger.disabled(true, cx).bg,
1913                cx.theme().tokens.button_danger.background.opacity(0.15)
1914            );
1915        });
1916    }
1917}