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