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