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