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