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