Skip to main content

gpui_component/button/
button.rs

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