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