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