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