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