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 description = self.tooltip.as_ref().map(|(tooltip, _)| tooltip.clone());
774 let content = h_flex()
775 .id("label")
776 .size_full()
777 .min_w_0()
778 .overflow_hidden()
779 .whitespace_nowrap()
780 .items_center()
781 .justify_center()
782 .button_text_size(self.size)
783 .map(|this| match self.size {
784 Size::XSmall => this.gap_1(),
785 Size::Small => this.gap_1(),
786 _ => this.gap_2(),
787 })
788 .refine_style(&self.content_style)
789 .when_some(self.icon, |this, icon| {
790 this.child(
791 icon.loading_icon(self.loading_icon)
792 .loading(self.loading)
793 .with_size(icon_size),
794 )
795 })
796 .when_some(self.label, |this, label| {
797 this.child(
798 div()
799 .min_w_0()
800 .whitespace_nowrap()
801 .text_ellipsis()
802 .child(label),
803 )
804 })
805 .children(children)
806 .when(self.dropdown_caret, |this| {
807 this.when(has_content, |this| this.justify_between())
808 .child(Caret::new(self.size).text_color(normal_style.fg.opacity(0.75)))
809 });
810 root.role(self.role.resolve(|| {
811 if self.variant.is_link() {
812 Role::Link
813 } else {
814 Role::Button
815 }
816 }))
817 .selected(selected)
818 .disabled(disabled)
819 .styles(|styles| {
822 styles
823 .selected(|style| {
824 style
825 .bg(selected_style.bg)
826 .border_color(selected_style.border)
827 .text_color(selected_style.fg)
828 .refine_style(&instance_style)
829 })
830 .disabled(|style| {
831 style
832 .bg(disabled_style.bg)
833 .text_color(disabled_style.fg)
834 .border_color(disabled_style.border)
835 .shadow_none()
836 .refine_style(&instance_style)
837 })
838 })
839 .when_some(accessibility_label, |this, label| {
840 this.accessibility_label(label)
841 })
842 .when_some(description, |this, description| {
843 this.aria_description(description)
844 })
845 .when_some(self.toggled, |this, toggled| {
846 this.aria_toggled(if toggled {
847 gpui::accesskit::Toggled::True
848 } else {
849 gpui::accesskit::Toggled::False
850 })
851 })
852 .track_focus(&focus_handle)
853 .tab_index(self.tab_index)
854 .tab_stop(self.tab_stop)
855 .child(content)
856 .when(loading && !disabled, |this| this.opacity(0.8))
862 .when(!disabled, |this| {
863 this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
864 if loading {
865 cx.stop_propagation();
866 return;
867 }
868
869 window.prevent_default();
871
872 crate::global_state::GlobalState::suppress_text_selection(cx);
874 })
875 })
876 .when_some(self.on_click, |this, on_click| {
877 this.on_click(move |event, window, cx| {
878 if loading {
879 cx.stop_propagation();
880 return;
881 }
882
883 on_click(event, window, cx);
884 })
885 })
886 .when(loading, |this| {
887 this.on_click(|_, _, cx| cx.stop_propagation())
888 })
889 .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
890 this.on_hover(move |hovered, window, cx| {
891 on_hover(hovered, window, cx);
892 })
893 })
894 .map(|this| {
895 if let Some(builder) = self.tooltip_builder {
896 this.managed_tooltip_with(
897 tooltip_placement,
898 tooltip_show_delay,
899 move |window, cx| builder(window, cx),
900 )
901 } else if let Some((tooltip, action)) = self.tooltip {
902 this.managed_tooltip_with(
903 tooltip_placement,
904 tooltip_show_delay,
905 move |window, cx| {
906 Tooltip::new(tooltip.clone())
907 .when_some(action.clone(), |this, (action, context)| {
908 this.action(
909 action.boxed_clone().as_ref(),
910 context.as_ref().map(|c| c.as_ref()),
911 )
912 })
913 .build(window, cx)
914 },
915 )
916 } else {
917 this
918 }
919 })
920 .when(is_focused && self.focus_ring_enabled, |this| {
921 focus_style(this, focus_line, window, cx)
922 })
923 }
924}
925
926struct ButtonVariantStyle {
927 bg: Background,
928 border: Hsla,
929 fg: Hsla,
930 underline: bool,
931 shadow: bool,
932}
933
934#[derive(Clone, Copy)]
935enum ButtonStyleState {
936 Normal,
937 Hovered,
938 Active,
939}
940
941impl ButtonVariant {
942 fn outline_background(&self, state: ButtonStyleState, cx: &mut App) -> Background {
943 match (self, state) {
944 (Self::Default, ButtonStyleState::Normal) => cx.theme().input_background().into(),
945 (Self::Default, ButtonStyleState::Hovered) => cx
946 .theme()
947 .input
948 .mix_oklab(cx.theme().transparent, 0.5)
949 .into(),
950 (Self::Default, ButtonStyleState::Active) => cx
951 .theme()
952 .input
953 .mix_oklab(cx.theme().transparent, 0.7)
954 .into(),
955 (Self::Primary, ButtonStyleState::Normal) => {
956 cx.theme().tokens.primary.background.opacity(0.1)
957 }
958 (Self::Primary, ButtonStyleState::Hovered) => {
959 cx.theme().tokens.primary_hover.background.opacity(0.2)
960 }
961 (Self::Primary, ButtonStyleState::Active) => {
962 cx.theme().tokens.primary_active.background.opacity(0.4)
963 }
964 (Self::Secondary, ButtonStyleState::Normal) => {
965 cx.theme().tokens.secondary.background.opacity(0.1)
966 }
967 (Self::Secondary, ButtonStyleState::Hovered) => {
968 cx.theme().tokens.secondary_hover.background.opacity(0.2)
969 }
970 (Self::Secondary, ButtonStyleState::Active) => {
971 cx.theme().tokens.secondary_active.background.opacity(0.4)
972 }
973 (Self::Danger, ButtonStyleState::Normal) => {
974 cx.theme().tokens.danger.background.opacity(0.1)
975 }
976 (Self::Danger, ButtonStyleState::Hovered) => {
977 cx.theme().tokens.danger_hover.background.opacity(0.2)
978 }
979 (Self::Danger, ButtonStyleState::Active) => {
980 cx.theme().tokens.danger_active.background.opacity(0.4)
981 }
982 (Self::Warning, ButtonStyleState::Normal) => {
983 cx.theme().tokens.warning.background.opacity(0.1)
984 }
985 (Self::Warning, ButtonStyleState::Hovered) => {
986 cx.theme().tokens.warning_hover.background.opacity(0.2)
987 }
988 (Self::Warning, ButtonStyleState::Active) => {
989 cx.theme().tokens.warning_active.background.opacity(0.4)
990 }
991 (Self::Success, ButtonStyleState::Normal) => {
992 cx.theme().tokens.success.background.opacity(0.1)
993 }
994 (Self::Success, ButtonStyleState::Hovered) => {
995 cx.theme().tokens.success_hover.background.opacity(0.2)
996 }
997 (Self::Success, ButtonStyleState::Active) => {
998 cx.theme().tokens.success_active.background.opacity(0.4)
999 }
1000 (Self::Info, ButtonStyleState::Normal) => {
1001 cx.theme().tokens.info.background.opacity(0.1)
1002 }
1003 (Self::Info, ButtonStyleState::Hovered) => {
1004 cx.theme().tokens.info_hover.background.opacity(0.2)
1005 }
1006 (Self::Info, ButtonStyleState::Active) => {
1007 cx.theme().tokens.info_active.background.opacity(0.4)
1008 }
1009 (Self::Ghost | Self::Link | Self::Text, _) => cx.theme().transparent.into(),
1010 (Self::Custom(colors), _) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(),
1011 }
1012 }
1013
1014 fn bg_color(&self, outline: bool, cx: &mut App) -> Background {
1015 if outline {
1016 return self.outline_background(ButtonStyleState::Normal, cx);
1017 }
1018
1019 match self {
1020 Self::Default => cx.theme().tokens.button.into(),
1021 Self::Primary => cx.theme().tokens.button_primary.into(),
1022 Self::Secondary => cx.theme().tokens.button_secondary.into(),
1023 Self::Danger => cx.theme().tokens.button_danger.into(),
1024 Self::Warning => cx.theme().tokens.button_warning.into(),
1025 Self::Success => cx.theme().tokens.button_success.into(),
1026 Self::Info => cx.theme().tokens.button_info.into(),
1027 Self::Ghost | Self::Link | Self::Text => cx.theme().transparent.into(),
1028 Self::Custom(colors) => colors.color.mix_oklab(cx.theme().transparent, 0.2).into(),
1029 }
1030 }
1031
1032 fn text_color(&self, outline: bool, cx: &mut App) -> Hsla {
1033 match self {
1034 Self::Default => cx.theme().button_foreground,
1035 Self::Primary => {
1036 if outline {
1037 cx.theme().primary
1038 } else {
1039 cx.theme().button_primary_foreground
1040 }
1041 }
1042 Self::Secondary => {
1043 if outline {
1044 cx.theme().secondary_foreground
1045 } else {
1046 cx.theme().button_secondary_foreground
1047 }
1048 }
1049 Self::Ghost => cx.theme().secondary_foreground,
1050 Self::Danger => {
1051 if outline {
1052 cx.theme().danger
1053 } else {
1054 cx.theme().button_danger_foreground
1055 }
1056 }
1057 Self::Warning => {
1058 if outline {
1059 cx.theme().warning
1060 } else {
1061 cx.theme().button_warning_foreground
1062 }
1063 }
1064 Self::Success => {
1065 if outline {
1066 cx.theme().success
1067 } else {
1068 cx.theme().button_success_foreground
1069 }
1070 }
1071 Self::Info => {
1072 if outline {
1073 cx.theme().info
1074 } else {
1075 cx.theme().button_info_foreground
1076 }
1077 }
1078 Self::Link => cx.theme().link,
1079 Self::Text => cx.theme().foreground.opacity(0.9),
1080 Self::Custom(colors) => colors.foreground,
1081 }
1082 }
1083
1084 fn border_color(&self, outline: bool, cx: &mut App) -> Hsla {
1085 match self {
1086 Self::Default => cx.theme().input,
1087 Self::Secondary => cx.theme().border,
1088 Self::Primary => cx.theme().primary,
1089 Self::Danger => {
1090 if outline {
1091 cx.theme().danger.mix_oklab(transparent_white(), 0.4)
1092 } else {
1093 cx.theme().button_danger
1094 }
1095 }
1096 Self::Info => {
1097 if outline {
1098 cx.theme().info.mix_oklab(transparent_white(), 0.4)
1099 } else {
1100 cx.theme().button_info
1101 }
1102 }
1103 Self::Warning => {
1104 if outline {
1105 cx.theme().warning.mix_oklab(transparent_white(), 0.4)
1106 } else {
1107 cx.theme().button_warning
1108 }
1109 }
1110 Self::Success => {
1111 if outline {
1112 cx.theme().success.mix_oklab(transparent_white(), 0.4)
1113 } else {
1114 cx.theme().button_success
1115 }
1116 }
1117 Self::Ghost | Self::Link | Self::Text => cx.theme().transparent,
1118 Self::Custom(colors) => {
1119 if outline {
1120 colors.color.mix_oklab(transparent_white(), 0.4)
1121 } else {
1122 colors.color
1123 }
1124 }
1125 }
1126 }
1127
1128 fn underline(&self, _: &App) -> bool {
1129 match self {
1130 Self::Link => true,
1131 _ => false,
1132 }
1133 }
1134
1135 fn shadow(&self, _outline: bool, _: &App) -> bool {
1136 match self {
1137 Self::Custom(c) => c.shadow,
1138 _ => false,
1139 }
1140 }
1141
1142 fn normal(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1143 let bg = self.bg_color(outline, cx);
1144 let border = self.border_color(outline, cx);
1145 let fg = self.text_color(outline, cx);
1146 let underline = self.underline(cx);
1147 let shadow = self.shadow(outline, cx);
1148
1149 ButtonVariantStyle {
1150 bg,
1151 border,
1152 fg,
1153 underline,
1154 shadow,
1155 }
1156 }
1157
1158 fn hovered(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1159 let bg: Background = match self {
1160 Self::Default => {
1161 if outline {
1162 self.outline_background(ButtonStyleState::Hovered, cx)
1163 } else {
1164 cx.theme().tokens.button_hover.into()
1165 }
1166 }
1167 Self::Primary => {
1168 if outline {
1169 self.outline_background(ButtonStyleState::Hovered, cx)
1170 } else {
1171 cx.theme().tokens.button_primary_hover.into()
1172 }
1173 }
1174 Self::Secondary => {
1175 if outline {
1176 self.outline_background(ButtonStyleState::Hovered, cx)
1177 } else {
1178 cx.theme().tokens.button_secondary_hover.into()
1179 }
1180 }
1181 Self::Danger => {
1182 if outline {
1183 self.outline_background(ButtonStyleState::Hovered, cx)
1184 } else {
1185 cx.theme().tokens.button_danger_hover.into()
1186 }
1187 }
1188 Self::Warning => {
1189 if outline {
1190 self.outline_background(ButtonStyleState::Hovered, cx)
1191 } else {
1192 cx.theme().tokens.button_warning_hover.into()
1193 }
1194 }
1195 Self::Success => {
1196 if outline {
1197 self.outline_background(ButtonStyleState::Hovered, cx)
1198 } else {
1199 cx.theme().tokens.button_success_hover.into()
1200 }
1201 }
1202 Self::Info => {
1203 if outline {
1204 self.outline_background(ButtonStyleState::Hovered, cx)
1205 } else {
1206 cx.theme().tokens.button_info_hover.into()
1207 }
1208 }
1209 Self::Custom(colors) => colors.hover.into(),
1210 Self::Ghost => {
1211 let accent: Background = cx.theme().tokens.accent.into();
1212 if cx.theme().mode.is_dark() {
1213 accent.opacity(0.5)
1214 } else {
1215 accent
1216 }
1217 }
1218 Self::Link => cx.theme().transparent.into(),
1219 Self::Text => cx.theme().transparent.into(),
1220 };
1221
1222 let border = self.border_color(outline, cx);
1223 let fg = match self {
1224 Self::Link => cx.theme().link_hover,
1225 Self::Text => cx.theme().foreground,
1226 Self::Ghost => cx.theme().accent_foreground,
1227 _ => self.text_color(outline, cx),
1228 };
1229
1230 let underline = self.underline(cx);
1231 let shadow = self.shadow(outline, cx);
1232
1233 ButtonVariantStyle {
1234 bg,
1235 border,
1236 fg,
1237 underline,
1238 shadow,
1239 }
1240 }
1241
1242 fn active(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1243 let bg = match self {
1244 Self::Default => {
1245 if outline {
1246 self.outline_background(ButtonStyleState::Active, cx)
1247 } else {
1248 cx.theme().tokens.button_active.into()
1249 }
1250 }
1251 Self::Primary => {
1252 if outline {
1253 self.outline_background(ButtonStyleState::Active, cx)
1254 } else {
1255 cx.theme().tokens.button_primary_active.into()
1256 }
1257 }
1258 Self::Secondary => {
1259 if outline {
1260 self.outline_background(ButtonStyleState::Active, cx)
1261 } else {
1262 cx.theme().tokens.button_secondary_active.into()
1263 }
1264 }
1265 Self::Ghost => cx.theme().tokens.button_active.into(),
1266 Self::Danger => {
1267 if outline {
1268 self.outline_background(ButtonStyleState::Active, cx)
1269 } else {
1270 cx.theme().tokens.button_danger_active.into()
1271 }
1272 }
1273 Self::Warning => {
1274 if outline {
1275 self.outline_background(ButtonStyleState::Active, cx)
1276 } else {
1277 cx.theme().tokens.button_warning_active.into()
1278 }
1279 }
1280 Self::Success => {
1281 if outline {
1282 self.outline_background(ButtonStyleState::Active, cx)
1283 } else {
1284 cx.theme().tokens.button_success_active.into()
1285 }
1286 }
1287 Self::Info => {
1288 if outline {
1289 self.outline_background(ButtonStyleState::Active, cx)
1290 } else {
1291 cx.theme().tokens.button_info_active.into()
1292 }
1293 }
1294 Self::Custom(colors) => colors.active.into(),
1295 Self::Link => cx.theme().transparent.into(),
1296 Self::Text => cx.theme().transparent.into(),
1297 };
1298 let border = self.border_color(outline, cx);
1299 let fg = match self {
1300 Self::Link => cx.theme().link_active,
1301 Self::Text => cx.theme().foreground.opacity(0.7),
1302 _ => self.text_color(outline, cx),
1303 };
1304 let underline = self.underline(cx);
1305 let shadow = self.shadow(outline, cx);
1306
1307 ButtonVariantStyle {
1308 bg,
1309 border,
1310 fg,
1311 underline,
1312 shadow,
1313 }
1314 }
1315
1316 fn selected(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1317 if outline {
1318 let active_style = self.active(outline, cx);
1319
1320 return ButtonVariantStyle {
1321 fg: self.text_color(outline, cx),
1322 ..active_style
1323 };
1324 }
1325
1326 let bg = match self {
1327 Self::Default => cx.theme().tokens.button_active.into(),
1328 Self::Primary => cx.theme().tokens.button_primary_active.into(),
1329 Self::Secondary => cx.theme().tokens.button_secondary_active.into(),
1330 Self::Ghost => cx.theme().tokens.secondary_active.into(),
1331 Self::Danger => cx.theme().tokens.button_danger_active.into(),
1332 Self::Warning => cx.theme().tokens.button_warning_active.into(),
1333 Self::Success => cx.theme().tokens.button_success_active.into(),
1334 Self::Info => cx.theme().tokens.button_info_active.into(),
1335 Self::Link => cx.theme().transparent.into(),
1336 Self::Text => cx.theme().transparent.into(),
1337 Self::Custom(colors) => colors.active.into(),
1338 };
1339
1340 let border = self.border_color(outline, cx);
1341 let fg = match self {
1342 Self::Link => cx.theme().link_active,
1343 Self::Text => cx.theme().foreground.opacity(0.7),
1344 _ => self.text_color(false, cx),
1345 };
1346 let underline = self.underline(cx);
1347 let shadow = self.shadow(outline, cx);
1348
1349 ButtonVariantStyle {
1350 bg,
1351 border,
1352 fg,
1353 underline,
1354 shadow,
1355 }
1356 }
1357
1358 fn disabled(&self, outline: bool, cx: &mut App) -> ButtonVariantStyle {
1359 let bg = match self {
1360 Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent.into(),
1361 Self::Primary => cx.theme().tokens.button_primary.background.opacity(0.15),
1362 Self::Danger => cx.theme().tokens.button_danger.background.opacity(0.15),
1363 Self::Warning => cx.theme().tokens.button_warning.background.opacity(0.15),
1364 Self::Success => cx.theme().tokens.button_success.background.opacity(0.15),
1365 Self::Info => cx.theme().tokens.button_info.background.opacity(0.15),
1366 Self::Secondary => cx.theme().tokens.button_secondary.background.opacity(1.5),
1367 Self::Custom(style) => style.color.opacity(0.15).into(),
1368 };
1369 let fg = cx.theme().muted_foreground.opacity(0.5);
1370 let (bg, border) = if outline {
1371 (
1372 self.outline_background(ButtonStyleState::Normal, cx)
1373 .opacity(0.5),
1374 self.border_color(true, cx).opacity(0.5),
1375 )
1376 } else if let Self::Default = self {
1377 (
1378 cx.theme().input_background().opacity(0.5).into(),
1379 cx.theme().input.opacity(0.5),
1380 )
1381 } else {
1382 let border = match self {
1383 Self::Primary => cx.theme().button_primary.opacity(0.15),
1384 Self::Secondary => cx.theme().button_secondary.opacity(1.5),
1385 Self::Danger => cx.theme().button_danger.opacity(0.15),
1386 Self::Warning => cx.theme().button_warning.opacity(0.15),
1387 Self::Success => cx.theme().button_success.opacity(0.15),
1388 Self::Info => cx.theme().button_info.opacity(0.15),
1389 Self::Custom(style) => style.color.opacity(0.15),
1390 Self::Default | Self::Link | Self::Ghost | Self::Text => cx.theme().transparent,
1391 };
1392 (bg, border)
1393 };
1394
1395 let underline = self.underline(cx);
1396 let shadow = false;
1397
1398 ButtonVariantStyle {
1399 bg,
1400 border,
1401 fg,
1402 underline,
1403 shadow,
1404 }
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411 use crate::IconName;
1412 use gpui::{linear_color_stop, linear_gradient, px};
1413
1414 #[test]
1417 fn an_explicit_accessibility_label_replaces_the_visible_one() {
1418 let plain = Button::new("save").label("Save");
1419 assert_eq!(plain.accessibility_label, None);
1420 assert_eq!(plain.label.as_deref(), Some("Save"));
1421
1422 let named = Button::new("row")
1423 .label("Save")
1424 .accessibility_label("Save the current document");
1425 assert_eq!(
1426 named.accessibility_label.as_deref(),
1427 Some("Save the current document"),
1428 "an explicit name must win over the visible label"
1429 );
1430 assert_eq!(
1431 named.label.as_deref(),
1432 Some("Save"),
1433 "and must not change what is drawn"
1434 );
1435 }
1436
1437 #[gpui::test]
1438 fn disabled_legacy_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) {
1439 use std::{cell::Cell, rc::Rc};
1440
1441 use gpui::{Context, Modifiers, Render, point};
1442
1443 struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1444
1445 impl Render for Harness {
1446 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1447 let button_clicks = self.0.clone();
1448 let parent_clicks = self.1.clone();
1449 div()
1450 .id("disabled-button-parent")
1451 .tab_group()
1452 .size(px(100.))
1453 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1454 .child(
1455 Button::new("disabled-legacy")
1456 .disabled(true)
1457 .size_full()
1458 .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)),
1459 )
1460 }
1461 }
1462
1463 cx.update(crate::init);
1464 let button_clicks = Rc::new(Cell::new(0));
1465 let parent_clicks = Rc::new(Cell::new(0));
1466 let (_, cx) = cx.add_window_view({
1467 let button_clicks = button_clicks.clone();
1468 let parent_clicks = parent_clicks.clone();
1469 move |_, _| Harness(button_clicks, parent_clicks)
1470 });
1471 cx.update(|window, cx| window.draw(cx).clear(cx));
1472 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1473 cx.update(|window, cx| window.focus_next(cx));
1474 cx.simulate_keystrokes("enter space");
1475
1476 assert_eq!(button_clicks.get(), 0);
1477 assert_eq!(parent_clicks.get(), 0);
1478 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
1479 }
1480
1481 #[gpui::test]
1482 fn enabled_button_delegates_pointer_enter_and_space_once(cx: &mut gpui::TestAppContext) {
1483 use std::{cell::Cell, rc::Rc};
1484
1485 use gpui::{
1486 ClickEvent, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, point,
1487 };
1488
1489 struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1490
1491 impl Render for Harness {
1492 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1493 let clicks = self.0.clone();
1494 let keyboard_clicks = self.1.clone();
1495 div().tab_group().size(px(100.)).child(
1496 Button::new("enabled-legacy")
1497 .size_full()
1498 .on_click(move |event, _, _| {
1499 clicks.set(clicks.get() + 1);
1500 if matches!(event, ClickEvent::Keyboard(_)) {
1501 keyboard_clicks.set(keyboard_clicks.get() + 1);
1502 }
1503 }),
1504 )
1505 }
1506 }
1507
1508 cx.update(crate::init);
1509 let clicks = Rc::new(Cell::new(0));
1510 let keyboard_clicks = Rc::new(Cell::new(0));
1511 let (_, cx) = cx.add_window_view({
1512 let clicks = clicks.clone();
1513 let keyboard_clicks = keyboard_clicks.clone();
1514 move |_, _| Harness(clicks, keyboard_clicks)
1515 });
1516 cx.update(|window, cx| window.draw(cx).clear(cx));
1517 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1518 cx.update(|window, cx| window.focus_next(cx));
1519 cx.update(|window, cx| {
1520 assert!(window.focused(cx).is_some());
1521 window.draw(cx).clear(cx);
1522 });
1523 for key in ["enter", "space"] {
1524 let keystroke = Keystroke::parse(key).unwrap();
1525 cx.simulate_event(KeyDownEvent {
1526 keystroke: keystroke.clone(),
1527 is_held: false,
1528 prefer_character_input: false,
1529 });
1530 cx.simulate_event(KeyUpEvent { keystroke });
1531 }
1532
1533 assert_eq!(clicks.get(), 3);
1534 assert_eq!(keyboard_clicks.get(), 2);
1535 }
1536
1537 #[cfg(feature = "test-support")]
1540 #[gpui::test]
1541 fn caller_owned_focus_handle_drives_focus_and_keyboard_activation(
1542 cx: &mut gpui::TestAppContext,
1543 ) {
1544 use std::{cell::Cell, rc::Rc};
1545
1546 use gpui::{Context, KeyDownEvent, KeyUpEvent, Keystroke, Render};
1547
1548 struct Harness {
1549 focus_handle: FocusHandle,
1550 clicks: Rc<Cell<usize>>,
1551 }
1552
1553 impl Render for Harness {
1554 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1555 let clicks = self.clicks.clone();
1556 div().size(px(100.)).child(
1557 Button::new("details")
1558 .label("Details")
1559 .track_focus(&self.focus_handle)
1560 .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
1561 )
1562 }
1563 }
1564
1565 cx.update(crate::init);
1566 let clicks = Rc::new(Cell::new(0));
1567 let (view, cx) = cx.add_window_view({
1568 let clicks = clicks.clone();
1569 move |_, cx| Harness {
1570 focus_handle: cx.focus_handle(),
1571 clicks,
1572 }
1573 });
1574 let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone());
1575 cx.update(|window, cx| {
1576 focus_handle.focus(window, cx);
1577 window.draw(cx).clear(cx);
1578 });
1579
1580 let button = cx.update(|window, _| {
1581 gpui_base::test_support::find(window, &[], &ElementId::from("details"))
1582 .expect("button is painted")
1583 });
1584 assert_eq!(button.focused(), Some(true));
1585
1586 let keystroke = Keystroke::parse("enter").unwrap();
1587 cx.simulate_event(KeyDownEvent {
1588 keystroke: keystroke.clone(),
1589 is_held: false,
1590 prefer_character_input: false,
1591 });
1592 cx.simulate_event(KeyUpEvent { keystroke });
1593 assert_eq!(clicks.get(), 1);
1594 }
1595
1596 #[gpui::test]
1597 fn loading_button_keeps_existing_pointer_blocking(cx: &mut gpui::TestAppContext) {
1598 use std::{cell::Cell, rc::Rc};
1599
1600 use gpui::{Context, Modifiers, Render, point};
1601
1602 struct Harness(Rc<Cell<usize>>, Rc<Cell<usize>>);
1603
1604 impl Render for Harness {
1605 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1606 let button_clicks = self.0.clone();
1607 let parent_clicks = self.1.clone();
1608 div()
1609 .id("loading-button-parent")
1610 .tab_group()
1611 .size(px(100.))
1612 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1613 .child(
1614 Button::new("loading-legacy")
1615 .loading(true)
1616 .size_full()
1617 .on_click(move |_, _, _| button_clicks.set(button_clicks.get() + 1)),
1618 )
1619 }
1620 }
1621
1622 cx.update(crate::init);
1623 let button_clicks = Rc::new(Cell::new(0));
1624 let parent_clicks = Rc::new(Cell::new(0));
1625 let (_, cx) = cx.add_window_view({
1626 let button_clicks = button_clicks.clone();
1627 let parent_clicks = parent_clicks.clone();
1628 move |_, _| Harness(button_clicks, parent_clicks)
1629 });
1630 cx.update(|window, cx| window.draw(cx).clear(cx));
1631 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1632 cx.update(|window, cx| window.focus_next(cx));
1633 cx.simulate_keystrokes("enter space");
1634
1635 assert_eq!(button_clicks.get(), 0);
1636 assert_eq!(parent_clicks.get(), 0);
1637 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
1638 }
1639
1640 #[gpui::test]
1641 fn loading_button_without_callback_still_blocks_parent_activation(
1642 cx: &mut gpui::TestAppContext,
1643 ) {
1644 use std::{cell::Cell, rc::Rc};
1645
1646 use gpui::{Context, Modifiers, Render, point};
1647
1648 struct Harness(Rc<Cell<usize>>);
1649
1650 impl Render for Harness {
1651 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1652 let parent_clicks = self.0.clone();
1653 div()
1654 .id("loading-without-callback-parent")
1655 .tab_group()
1656 .size(px(100.))
1657 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
1658 .child(
1659 Button::new("loading-without-callback")
1660 .loading(true)
1661 .size_full(),
1662 )
1663 }
1664 }
1665
1666 cx.update(crate::init);
1667 let parent_clicks = Rc::new(Cell::new(0));
1668 let (_, cx) = cx.add_window_view({
1669 let parent_clicks = parent_clicks.clone();
1670 move |_, _| Harness(parent_clicks)
1671 });
1672 cx.update(|window, cx| window.draw(cx).clear(cx));
1673 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
1674 cx.update(|window, cx| window.focus_next(cx));
1675 cx.simulate_keystrokes("enter space");
1676
1677 assert_eq!(parent_clicks.get(), 0);
1678 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
1679 }
1680
1681 #[gpui::test]
1682 fn base_activation_is_preserved_and_blocked_while_loading(cx: &mut gpui::TestAppContext) {
1683 use gpui::{Context, Render, point};
1684 use std::{cell::Cell, rc::Rc};
1685
1686 struct Harness {
1687 clicks: Rc<Cell<usize>>,
1688 loading: bool,
1689 }
1690 impl Render for Harness {
1691 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1692 let clicks = self.clicks.clone();
1693 div().tab_group().child(
1694 Button::new("close")
1695 .with_base(
1696 gpui_base::Button::new("close")
1697 .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
1698 )
1699 .loading(self.loading)
1700 .size(px(100.)),
1701 )
1702 }
1703 }
1704
1705 cx.update(crate::init);
1706 let clicks = Rc::new(Cell::new(0));
1707 let (view, cx) = cx.add_window_view({
1708 let clicks = clicks.clone();
1709 move |_, _| Harness {
1710 clicks,
1711 loading: false,
1712 }
1713 });
1714 cx.update(|window, cx| window.draw(cx).clear(cx));
1715 cx.simulate_click(point(px(10.), px(10.)), Default::default());
1716 assert_eq!(clicks.get(), 1);
1717
1718 view.update(cx, |view, cx| {
1719 view.loading = true;
1720 cx.notify();
1721 });
1722 cx.update(|window, cx| window.draw(cx).clear(cx));
1723 cx.simulate_click(point(px(10.), px(10.)), Default::default());
1724 cx.update(|window, cx| window.focus_next(cx));
1725 cx.simulate_keystrokes("enter space");
1726 assert_eq!(clicks.get(), 1);
1727 }
1728
1729 #[gpui::test]
1730 fn test_button_builder(_cx: &mut gpui::TestAppContext) {
1731 let button = Button::new("complex-button")
1732 .label("Save Changes")
1733 .primary()
1734 .outline()
1735 .large()
1736 .tooltip("Click to save")
1737 .tooltip_show_delay(Duration::from_millis(100))
1738 .compact()
1739 .loading(false)
1740 .disabled(false)
1741 .selected(false)
1742 .tab_index(1)
1743 .tab_stop(true)
1744 .dropdown_caret(false)
1745 .rounded(ButtonRounded::Medium)
1746 .on_click(|_, _, _| {});
1747
1748 assert_eq!(button.label, Some("Save Changes".into()));
1749 assert_eq!(button.variant, ButtonVariant::Primary);
1750 assert!(button.outline);
1751 assert_eq!(button.size, Size::Large);
1752 assert!(button.tooltip.is_some());
1753 assert_eq!(button.tooltip_show_delay, Some(Duration::from_millis(100)));
1754 assert!(button.compact);
1755 assert!(!button.loading);
1756 assert!(!button.disabled);
1757 assert!(!button.selected);
1758 assert_eq!(button.toggled, None);
1759 assert!(button.focus_handle.is_none());
1760 assert_eq!(button.tab_index, 1);
1761 assert!(button.tab_stop);
1762 assert!(!button.dropdown_caret);
1763 assert!(matches!(button.rounded, ButtonRounded::Medium));
1764 }
1765
1766 #[test]
1770 fn an_open_trigger_is_stored_apart_from_a_selected_one() {
1771 let open = Button::new("trigger").open(true);
1772 assert!(open.is_open());
1773 assert!(!open.is_selected());
1774 assert!(open.shows_selected_style());
1775
1776 let selected = Button::new("trigger").selected(true);
1777 assert!(selected.is_selected());
1778 assert!(!selected.is_open());
1779 assert!(selected.shows_selected_style());
1780
1781 assert!(!Button::new("trigger").shows_selected_style());
1782 }
1783
1784 #[test]
1785 fn selected_trigger_presentation_does_not_imply_toggled_accessibility() {
1786 let selected = Button::new("menu-trigger").selected(true);
1787 assert!(selected.selected);
1788 assert_eq!(selected.toggled, None);
1789
1790 let selected_toggle = Button::new("explicit-toggle").selected(true).toggled(true);
1791 assert!(selected_toggle.selected);
1792 assert_eq!(selected_toggle.toggled, Some(true));
1793 }
1794
1795 #[gpui::test]
1799 fn test_button_loading_is_not_interactive(_cx: &mut gpui::TestAppContext) {
1800 assert!(Button::new("test").interactive());
1801 assert!(!Button::new("test").loading(true).interactive());
1802 assert!(!Button::new("test").disabled(true).interactive());
1803 assert!(
1804 !Button::new("test")
1805 .loading(true)
1806 .disabled(true)
1807 .interactive()
1808 );
1809
1810 let loading = Button::new("test").loading(true).on_hover(|_, _, _| {});
1812 assert!(!loading.hoverable());
1813 }
1814
1815 #[gpui::test]
1818 fn test_button_variant_methods(_cx: &mut gpui::TestAppContext) {
1819 assert!(ButtonVariant::Link.is_link());
1821 assert!(ButtonVariant::Text.is_text());
1822 assert!(ButtonVariant::Ghost.is_ghost());
1823
1824 assert!(ButtonVariant::Link.no_padding());
1826 assert!(ButtonVariant::Text.no_padding());
1827 assert!(!ButtonVariant::Ghost.no_padding());
1828 }
1829
1830 #[gpui::test]
1831 fn link_button_prepaints_its_child(cx: &mut gpui::TestAppContext) {
1832 use gpui::{Context, Render};
1833
1834 struct LinkButtonHarness;
1835
1836 impl Render for LinkButtonHarness {
1837 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1838 Button::new("link-button").link().child(
1839 div()
1840 .debug_selector(|| "link-button-child".into())
1841 .child("Visible link button"),
1842 )
1843 }
1844 }
1845
1846 cx.update(crate::init);
1847 let (_, cx) = cx.add_window_view(|_, _| LinkButtonHarness);
1848 cx.update(|window, cx| window.draw(cx).clear(cx));
1849
1850 let bounds = cx
1851 .debug_bounds("link-button-child")
1852 .expect("link button child must participate in layout and prepaint");
1853 assert!(bounds.size.width > px(0.));
1854 assert!(bounds.size.height > px(0.));
1855 }
1856
1857 #[gpui::test]
1858 fn base_slot_prepaints_complete_button_content(cx: &mut gpui::TestAppContext) {
1859 use gpui::{Context, Render};
1860
1861 struct CompleteButtonHarness;
1862
1863 impl Render for CompleteButtonHarness {
1864 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1865 div()
1866 .child(
1867 Button::new("complete-button")
1868 .icon(IconName::Plus)
1869 .label("Create")
1870 .child(
1871 div()
1872 .debug_selector(|| "button-custom-content".into())
1873 .child("Details"),
1874 )
1875 .dropdown_caret(true),
1876 )
1877 .child(
1878 Button::new("loading-button")
1879 .icon(IconName::Plus)
1880 .loading_icon(IconName::Loader)
1881 .loading(true)
1882 .label("Creating")
1883 .dropdown_caret(true),
1884 )
1885 }
1886 }
1887
1888 cx.update(crate::init);
1889 let (_, cx) = cx.add_window_view(|_, _| CompleteButtonHarness);
1890 cx.update(|window, cx| window.draw(cx).clear(cx));
1891
1892 let bounds = cx
1893 .debug_bounds("button-custom-content")
1894 .expect("application content must prepaint through the Base child seam");
1895 assert!(bounds.size.width > px(0.));
1896 assert!(bounds.size.height > px(0.));
1897 }
1898
1899 #[gpui::test]
1900 fn test_outline_selected_uses_outline_active_style(cx: &mut gpui::TestAppContext) {
1901 cx.update(crate::init);
1902 let window = cx.add_empty_window();
1903 window.update(|_, cx| {
1904 let variant = ButtonVariant::Danger;
1905 let active_style = variant.active(true, cx);
1906 let selected_style = variant.selected(true, cx);
1907
1908 assert_eq!(selected_style.bg, active_style.bg);
1909 assert_eq!(selected_style.border, active_style.border);
1910 assert_eq!(selected_style.fg, cx.theme().danger);
1911 assert_ne!(selected_style.bg, cx.theme().tokens.danger_active.into());
1912 });
1913 }
1914
1915 #[gpui::test]
1916 fn test_primary_button_uses_gradient_background_tokens(cx: &mut gpui::TestAppContext) {
1917 cx.update(crate::init);
1918 let window = cx.add_empty_window();
1919 window.update(|_, cx| {
1920 let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1921 "name": "Gradient",
1922 "mode": "light",
1923 "colors": {
1924 "button.primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)",
1925 "button.primary.hover.background": "linear-gradient(145deg, #4338CA, #0891B2)",
1926 "button.primary.active.background": "linear-gradient(155deg, #3730A3, #0E7490)"
1927 }
1928 }))
1929 .unwrap();
1930 crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1931
1932 assert_eq!(
1933 ButtonVariant::Primary.normal(false, cx).bg,
1934 cx.theme().tokens.button_primary.into()
1935 );
1936 assert_eq!(
1937 ButtonVariant::Primary.hovered(false, cx).bg,
1938 cx.theme().tokens.button_primary_hover.into()
1939 );
1940 assert_eq!(
1941 ButtonVariant::Primary.active(false, cx).bg,
1942 cx.theme().tokens.button_primary_active.into()
1943 );
1944 });
1945 }
1946
1947 #[gpui::test]
1948 fn test_outline_primary_keeps_original_depth(cx: &mut gpui::TestAppContext) {
1949 cx.update(crate::init);
1950 let window = cx.add_empty_window();
1951 window.update(|_, cx| {
1952 let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1953 "name": "Outline Depth",
1954 "mode": "light",
1955 "colors": {
1956 "primary.background": "linear-gradient(180deg, #111827, #020617)",
1957 "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)",
1958 "primary.active.background": "linear-gradient(180deg, #020617, #000000)"
1959 }
1960 }))
1961 .unwrap();
1962 crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
1963
1964 assert_eq!(
1965 ButtonVariant::Primary.normal(true, cx).bg,
1966 cx.theme().tokens.primary.background.opacity(0.1)
1967 );
1968 assert_eq!(
1969 ButtonVariant::Primary.hovered(true, cx).bg,
1970 cx.theme().tokens.primary_hover.background.opacity(0.2)
1971 );
1972 assert_eq!(
1973 ButtonVariant::Primary.active(true, cx).bg,
1974 cx.theme().tokens.primary_active.background.opacity(0.4)
1975 );
1976 });
1977 }
1978
1979 #[gpui::test]
1980 fn test_outline_buttons_use_semantic_gradient_tokens(cx: &mut gpui::TestAppContext) {
1981 cx.update(crate::init);
1982 let window = cx.add_empty_window();
1983 window.update(|_, cx| {
1984 let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
1985 "name": "Outline Gradient",
1986 "mode": "light",
1987 "colors": {
1988 "primary.background": "linear-gradient(180deg, #111827, #020617)",
1989 "primary.hover.background": "linear-gradient(180deg, #1F2937, #111827)",
1990 "primary.active.background": "linear-gradient(180deg, #020617, #000000)",
1991 "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)",
1992 "button.primary.hover.background": "linear-gradient(180deg, #F9FAFB, #E5E7EB)",
1993 "button.primary.active.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)",
1994 "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
1995 "danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
1996 "danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
1997 "button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
1998 "button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
1999 "button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)"
2000 }
2001 }))
2002 .unwrap();
2003 crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
2004
2005 assert_eq!(
2006 ButtonVariant::Primary.normal(true, cx).bg,
2007 cx.theme().tokens.primary.background.opacity(0.1)
2008 );
2009 assert_eq!(
2010 ButtonVariant::Danger.normal(true, cx).bg,
2011 cx.theme().tokens.danger.background.opacity(0.1)
2012 );
2013 assert_eq!(
2014 ButtonVariant::Danger.hovered(true, cx).bg,
2015 cx.theme().tokens.danger_hover.background.opacity(0.2)
2016 );
2017 assert_eq!(
2018 ButtonVariant::Danger.active(true, cx).bg,
2019 cx.theme().tokens.danger_active.background.opacity(0.4)
2020 );
2021 assert_eq!(
2022 ButtonVariant::Primary.normal(false, cx).bg,
2023 cx.theme().tokens.button_primary.into()
2024 );
2025 assert_eq!(
2026 ButtonVariant::Danger.normal(false, cx).bg,
2027 linear_gradient(
2028 180.,
2029 linear_color_stop(crate::try_parse_color("#FEF2F2").unwrap(), 0.),
2030 linear_color_stop(crate::try_parse_color("#FEE2E2").unwrap(), 1.)
2031 )
2032 );
2033 });
2034 }
2035
2036 #[gpui::test]
2037 fn test_disabled_outline_buttons_keep_semantic_backgrounds(cx: &mut gpui::TestAppContext) {
2038 cx.update(crate::init);
2039 let window = cx.add_empty_window();
2040 window.update(|_, cx| {
2041 let config = serde_json::from_value::<crate::ThemeConfig>(serde_json::json!({
2042 "name": "Disabled Outline Gradient",
2043 "mode": "light",
2044 "colors": {
2045 "primary.background": "linear-gradient(180deg, #111827, #020617)",
2046 "button.primary.background": "linear-gradient(180deg, #FFFFFF, #E5E7EB)",
2047 "danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
2048 "button.danger.background": "linear-gradient(180deg, #E5E7EB, #D1D5DB)"
2049 }
2050 }))
2051 .unwrap();
2052 crate::Theme::global_mut(cx).apply_config(&std::rc::Rc::new(config));
2053
2054 assert_eq!(
2055 ButtonVariant::Primary.disabled(true, cx).bg,
2056 cx.theme()
2057 .tokens
2058 .primary
2059 .background
2060 .opacity(0.1)
2061 .opacity(0.5)
2062 );
2063 assert_eq!(
2064 ButtonVariant::Danger.disabled(true, cx).bg,
2065 cx.theme()
2066 .tokens
2067 .danger
2068 .background
2069 .opacity(0.1)
2070 .opacity(0.5)
2071 );
2072 assert_ne!(
2073 ButtonVariant::Danger.disabled(true, cx).bg,
2074 cx.theme().input_background().opacity(0.5).into()
2075 );
2076 assert_ne!(
2077 ButtonVariant::Danger.disabled(true, cx).bg,
2078 cx.theme().tokens.button_danger.background.opacity(0.15)
2079 );
2080 });
2081 }
2082}