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