1use std::rc::Rc;
2
3use gpui_kit::{
4 AnyElement, App, ClickEvent, ElementId, FocusHandle, Hsla, InteractiveElement, Interactivity,
5 IntoElement, ParentElement, Pixels, RenderOnce, Role, SharedString,
6 StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
7 base::{
8 self, Disableable, ElementExt as _, Interpolate, Placement, Selectable, StyledExt as _,
9 transition,
10 },
11 div,
12 prelude::FluentBuilder as _,
13 px, relative,
14};
15
16use crate::{
17 ActiveTheme as _, Icon, Spinner, Theme, ThemeTokens, TooltipHost,
18 theme::mix,
19 tooltip::{TooltipTrigger, TruncatedLabel},
20};
21
22type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
23type HoverHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ButtonVariant {
28 #[default]
31 Default,
32 Primary,
35 Destructive,
37 Outline,
39 Ghost,
41 Link,
44}
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum ButtonSize {
56 #[default]
58 Default,
59 Xs,
61 Sm,
63 Lg,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq)]
70pub(crate) struct ControlGeometry {
71 pub radius: Pixels,
72 pub height: Pixels,
73 pub padding: Pixels,
74 pub text_size: Pixels,
75}
76
77impl ButtonSize {
78 pub(crate) fn geometry(self, theme: &ThemeTokens) -> ControlGeometry {
79 let metrics = &theme.metrics;
80 ControlGeometry {
81 radius: if self == Self::Lg {
82 theme.radius_lg()
83 } else {
84 theme.radius_md()
85 },
86 height: match self {
87 Self::Xs => metrics.control_xs,
88 Self::Sm => metrics.control_sm,
89 Self::Default => metrics.control_md,
90 Self::Lg => metrics.control_lg,
91 },
92 padding: match self {
93 Self::Xs => metrics.control_padding_xs,
94 Self::Sm => metrics.control_padding_sm,
95 Self::Default => metrics.control_padding_md,
96 Self::Lg => metrics.control_padding_lg,
97 },
98 text_size: match self {
99 Self::Xs => theme.base.typography.xs.size,
100 Self::Sm | Self::Default => theme.text_control.size,
101 Self::Lg => theme.base.typography.sm.size,
102 },
103 }
104 }
105
106 fn is_xs(self) -> bool {
107 matches!(self, Self::Xs)
108 }
109
110 fn is_compact(self) -> bool {
112 matches!(self, Self::Xs | Self::Sm)
113 }
114}
115
116#[derive(IntoElement)]
143pub struct Button {
144 id: ElementId,
145 base: base::Button,
146 style: StyleRefinement,
147 variant: ButtonVariant,
148 size: ButtonSize,
149 label: Option<SharedString>,
150 icon: Option<Icon>,
151 trailing_icon: Option<Icon>,
152 children: Vec<AnyElement>,
153 disabled: bool,
154 loading: bool,
155 selected: bool,
156 open: bool,
157 toggled: Option<bool>,
158 accessibility_label: Option<SharedString>,
159 tooltip: Option<(SharedString, Option<Placement>)>,
160 truncation_tooltip: bool,
161 on_click: Option<ClickHandler>,
162 on_hover: Option<HoverHandler>,
163 focus_handle: Option<FocusHandle>,
164 tab_index: isize,
165 tab_stop: bool,
166 role: Option<Role>,
169 expanded: Option<bool>,
172}
173
174impl Button {
175 pub fn new(id: impl Into<ElementId>) -> Self {
177 let id = id.into();
178 Self {
179 base: base::Button::new(id.clone()),
180 id,
181 style: StyleRefinement::default(),
182 variant: ButtonVariant::default(),
183 size: ButtonSize::default(),
184 label: None,
185 icon: None,
186 trailing_icon: None,
187 children: Vec::new(),
188 disabled: false,
189 loading: false,
190 selected: false,
191 open: false,
192 toggled: None,
193 accessibility_label: None,
194 tooltip: None,
195 truncation_tooltip: true,
196 on_click: None,
197 on_hover: None,
198 focus_handle: None,
199 tab_index: 0,
200 tab_stop: true,
201 role: None,
202 expanded: None,
203 }
204 }
205
206 pub fn variant(mut self, variant: ButtonVariant) -> Self {
208 self.variant = variant;
209 self
210 }
211
212 pub fn primary(self) -> Self {
214 self.variant(ButtonVariant::Primary)
215 }
216
217 pub fn destructive(self) -> Self {
219 self.variant(ButtonVariant::Destructive)
220 }
221
222 pub fn outline(self) -> Self {
224 self.variant(ButtonVariant::Outline)
225 }
226
227 pub fn ghost(self) -> Self {
229 self.variant(ButtonVariant::Ghost)
230 }
231
232 pub fn link(self) -> Self {
234 self.variant(ButtonVariant::Link)
235 }
236
237 pub fn size(mut self, size: ButtonSize) -> Self {
239 self.size = size;
240 self
241 }
242
243 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
246 self.label = Some(label.into());
247 self
248 }
249
250 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
253 self.icon = Some(icon.into());
254 self
255 }
256
257 pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
259 self.trailing_icon = Some(icon.into());
260 self
261 }
262
263 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
266 self.accessibility_label = Some(label.into());
267 self
268 }
269
270 pub fn toggled(mut self, toggled: bool) -> Self {
274 self.toggled = Some(toggled);
275 self
276 }
277
278 pub fn loading(mut self, loading: bool) -> Self {
283 self.loading = loading;
284 self
285 }
286
287 pub fn is_loading(&self) -> bool {
289 self.loading
290 }
291
292 pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
296 self.tooltip = Some((text.into(), None));
297 self
298 }
299
300 pub fn truncation_tooltip(mut self, enabled: bool) -> Self {
303 self.truncation_tooltip = enabled;
304 self
305 }
306
307 pub fn tooltip_at(mut self, placement: Placement, text: impl Into<SharedString>) -> Self {
309 self.tooltip = Some((text.into(), Some(placement)));
310 self
311 }
312
313 pub fn on_click(
315 mut self,
316 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
317 ) -> Self {
318 self.on_click = Some(Rc::new(handler));
319 self
320 }
321
322 pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
324 self.on_hover = Some(Rc::new(handler));
325 self
326 }
327
328 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
331 self.focus_handle = Some(focus_handle.clone());
332 self
333 }
334
335 pub fn tab_index(mut self, tab_index: isize) -> Self {
337 self.tab_index = tab_index;
338 self
339 }
340
341 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
343 self.tab_stop = tab_stop;
344 self
345 }
346
347 pub(crate) fn with_role(mut self, role: Role) -> Self {
349 self.role = Some(role);
350 self
351 }
352
353 pub(crate) fn with_expanded(mut self, expanded: bool) -> Self {
356 self.expanded = Some(expanded);
357 self
358 }
359
360 pub fn is_icon_only(&self) -> bool {
363 self.icon.is_some() && self.label.is_none() && self.children.is_empty()
364 }
365
366 fn is_interactive(&self) -> bool {
369 !self.disabled && !self.loading
370 }
371}
372
373impl Disableable for Button {
374 fn disabled(mut self, disabled: bool) -> Self {
375 self.disabled = disabled;
376 self
377 }
378}
379
380impl Selectable for Button {
381 fn selected(mut self, selected: bool) -> Self {
382 self.selected = selected;
383 self
384 }
385
386 fn is_selected(&self) -> bool {
387 self.selected
388 }
389
390 fn open(mut self, open: bool) -> Self {
394 self.open = open;
395 self
396 }
397
398 fn is_open(&self) -> bool {
399 self.open
400 }
401}
402
403impl Styled for Button {
404 fn style(&mut self) -> &mut StyleRefinement {
405 &mut self.style
406 }
407}
408
409impl ParentElement for Button {
410 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
411 self.children.extend(elements);
412 }
413}
414
415impl InteractiveElement for Button {
416 fn interactivity(&mut self) -> &mut Interactivity {
417 self.base.interactivity()
418 }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq)]
424struct Surface {
425 background: Hsla,
426 foreground: Hsla,
427 border: Hsla,
428}
429
430impl Surface {
431 fn plain(background: Hsla, foreground: Hsla) -> Self {
432 Self {
433 background,
434 foreground,
435 border: gpui_kit::transparent_black(),
436 }
437 }
438}
439
440impl Interpolate for Surface {
444 fn interpolate(&self, target: &Self, progress: f32) -> Self {
445 let weigh = |from: f32, to: f32| from * (1. - progress) + to * progress;
449 let lerp = |from: Hsla, to: Hsla| Hsla {
450 h: weigh(from.h, to.h),
451 s: weigh(from.s, to.s),
452 l: weigh(from.l, to.l),
453 a: weigh(from.a, to.a),
454 };
455 Self {
456 background: lerp(self.background, target.background),
457 foreground: lerp(self.foreground, target.foreground),
458 border: lerp(self.border, target.border),
459 }
460 }
461}
462
463#[derive(Clone, Copy, Debug, PartialEq, Eq)]
465enum PointerState {
466 Rest,
467 Hovered,
468 Pressed,
469}
470
471#[derive(Clone, Copy, Debug, PartialEq)]
476struct StateWeights {
477 hovered: f32,
478 pressed: f32,
479 selected: f32,
480 disabled: f32,
481}
482
483impl StateWeights {
484 fn of(pointer: PointerState, selected: bool, disabled: bool) -> Self {
485 let weight = |on: bool| if on { 1. } else { 0. };
486 Self {
487 hovered: weight(pointer != PointerState::Rest),
488 pressed: weight(pointer == PointerState::Pressed),
489 selected: weight(selected),
490 disabled: weight(disabled),
491 }
492 }
493}
494
495impl Interpolate for StateWeights {
496 fn interpolate(&self, target: &Self, progress: f32) -> Self {
497 let lerp = |from: f32, to: f32| from + (to - from) * progress;
498 Self {
499 hovered: lerp(self.hovered, target.hovered),
500 pressed: lerp(self.pressed, target.pressed),
501 selected: lerp(self.selected, target.selected),
502 disabled: lerp(self.disabled, target.disabled),
503 }
504 }
505}
506
507impl ButtonVariant {
508 fn surface(
519 self,
520 state: PointerState,
521 selected: bool,
522 in_sidebar: bool,
523 theme: &ThemeTokens,
524 ) -> Surface {
525 use PointerState::*;
526 let dark = theme.is_dark();
527 let ink = theme.foreground();
528 let (surface, soft, selected_rest) = if in_sidebar {
529 (theme.sidebar, theme.sidebar_accent, theme.sidebar_selected)
530 } else {
531 (theme.background(), theme.secondary(), theme.selected)
532 };
533 let soft_hover = mix(soft, ink, 0.05);
535 let soft_pressed = mix(soft, ink, 0.10);
536 let selected_fill = match state {
537 Rest => selected_rest,
538 Hovered => mix(selected_rest, ink, 0.03),
539 Pressed => mix(selected_rest, ink, 0.06),
540 };
541 match self {
542 Self::Default => {
543 if selected {
544 return Surface::plain(selected_fill, ink);
545 }
546 let fill = match state {
547 Rest => soft,
548 Hovered => soft_hover,
549 Pressed => soft_pressed,
550 };
551 Surface::plain(fill, ink)
552 }
553 Self::Primary => {
554 let primary = theme.primary();
555 let fill = match state {
556 Rest => primary,
557 Hovered => mix(primary, surface, 0.09),
558 Pressed => mix(primary, surface, 0.18),
559 };
560 Surface::plain(fill, theme.primary_foreground())
561 }
562 Self::Destructive => {
563 let red = theme.destructive();
567 let fill = match state {
568 Rest => theme.destructive_tint,
569 Hovered => red.opacity(if dark { 0.3 } else { 0.16 }),
570 Pressed => red.opacity(if dark { 0.4 } else { 0.22 }),
571 };
572 Surface::plain(fill, red)
573 }
574 Self::Outline => {
575 let hover = soft;
576 let background = if selected {
577 selected_fill
578 } else {
579 match state {
580 Rest => hover.alpha(0.),
581 Hovered => hover,
582 Pressed => soft_hover,
583 }
584 };
585 Surface {
586 background,
587 foreground: ink,
588 border: theme.input(),
589 }
590 }
591 Self::Ghost => {
592 if selected {
596 return Surface::plain(selected_fill, ink);
597 }
598 match state {
599 Rest => Surface::plain(soft.alpha(0.), theme.muted_foreground()),
600 Hovered => Surface::plain(soft, ink),
601 Pressed => Surface::plain(soft_hover, ink),
602 }
603 }
604 Self::Link => Surface::plain(gpui_kit::transparent_black(), theme.link),
605 }
606 }
607
608 fn surface_at(self, weights: StateWeights, in_sidebar: bool, theme: &ThemeTokens) -> Surface {
611 use PointerState::*;
612 let at = |selected: bool| {
613 let surface = |state| self.surface(state, selected, in_sidebar, theme);
614 surface(Rest)
615 .interpolate(&surface(Hovered), weights.hovered)
616 .interpolate(&surface(Pressed), weights.pressed)
617 };
618 let enabled = at(false).interpolate(&at(true), weights.selected);
619 if weights.disabled == 0. {
620 return enabled;
621 }
622 enabled.interpolate(&self.disabled(enabled, theme), weights.disabled)
623 }
624
625 fn disabled(self, surface: Surface, theme: &ThemeTokens) -> Surface {
630 Surface {
631 background: surface.background.opacity(theme.disabled_opacity),
632 border: surface.border.opacity(theme.disabled_opacity),
633 foreground: match self {
634 ButtonVariant::Primary => surface.foreground,
635 _ => mix(
636 theme.foreground(),
637 theme.background(),
638 if theme.is_dark() { 0.52 } else { 0.64 },
639 ),
640 },
641 }
642 }
643}
644
645struct Look {
648 surface: Surface,
649 underline: bool,
650 ring: Hsla,
651 ring_spread: Pixels,
652 outline_focus_border: Hsla,
653 geometry: ControlGeometry,
654}
655
656fn icon_size_rem<E: Styled>(icon: E, size: ButtonSize) -> E {
658 if size.is_xs() {
659 icon.size_3()
660 } else if size.is_compact() {
661 icon.size_3p5()
662 } else {
663 icon.size_4()
664 }
665}
666
667impl RenderOnce for Button {
668 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
669 let settings = Theme::global(cx);
670 let fast = settings.motion.fast_transition();
671 let pointer_cursors = settings.pointer_cursors;
672
673 let interactive = self.is_interactive();
674 let icon_only = self.is_icon_only();
675 let variant = self.variant;
676 let size = self.size;
677 let disabled = self.disabled;
678 let shows_selected = self.selected || self.open;
679
680 let pointer = window.use_keyed_state(
684 ElementId::NamedChild(self.id.clone().into(), "pointer".into()),
685 cx,
686 |_, _| PointerState::Rest,
687 );
688 if !interactive && *pointer.read(cx) != PointerState::Rest {
689 pointer.update(cx, |state, _| *state = PointerState::Rest);
692 }
693 let touch = cx.theme().touch;
697 let pointer_state = match *pointer.read(cx) {
698 PointerState::Hovered if touch => PointerState::Rest,
699 state => state,
700 };
701
702 let in_sidebar = crate::sidebar::in_sidebar(cx);
703 let weights = transition(
704 ElementId::NamedChild(self.id.clone().into(), "state".into()),
705 StateWeights::of(pointer_state, shows_selected, disabled),
706 fast,
707 window,
708 cx,
709 );
710 let look =
711 {
712 let theme = cx.theme();
713 Look {
714 surface: variant.surface_at(weights, in_sidebar, theme),
715 underline: variant == ButtonVariant::Link
716 && (shows_selected || pointer_state != PointerState::Rest),
717 ring: match variant {
718 ButtonVariant::Destructive => theme
719 .destructive()
720 .opacity(if theme.is_dark() { 0.4 } else { 0.2 }),
721 _ => theme.focus_ring(),
722 },
723 ring_spread: theme.metrics.focus_ring,
724 outline_focus_border: theme.ring(),
725 geometry: size.geometry(theme),
726 }
727 };
728 let surface = look.surface;
729
730 let focus_handle = self.focus_handle.clone().unwrap_or_else(|| {
731 window
732 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
733 .read(cx)
734 .clone()
735 });
736 let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
737
738 let spinner = self.loading.then(|| {
739 icon_size_rem(
740 Spinner::new(ElementId::NamedChild(
741 self.id.clone().into(),
742 "spinner".into(),
743 )),
744 size,
745 )
746 });
747
748 let accessibility_label = self.accessibility_label.or_else(|| self.label.clone());
749 let on_click = self.on_click;
750 let on_hover = self.on_hover;
751 let pointer_for_hover = pointer.clone();
752 let pointer_for_down = pointer.clone();
753 let pointer_for_up = pointer.clone();
754 let pointer_for_up_out = pointer;
755 let label = self
756 .label
757 .map(|label| (label.clone(), TruncatedLabel::new(label).into_parts()));
758 let has_overlay = TooltipHost::overlay(window, cx).is_some();
759 let tooltip = match (self.tooltip, &label) {
760 (Some((text, placement)), _) => {
761 Some((TooltipTrigger::text(self.id.clone(), placement, text), None))
762 }
763 (None, Some((label, (_, check)))) if has_overlay && self.truncation_tooltip => Some((
764 TooltipTrigger::text(self.id.clone(), None, label.clone()),
765 Some(check.clone()),
766 )),
767 _ => None,
768 };
769 let (managed_tooltip, native_tooltip) = match tooltip {
772 Some((trigger, only_when_truncated)) if has_overlay => {
773 (Some((trigger, only_when_truncated)), None)
774 }
775 Some((trigger, _)) => (None, Some(trigger)),
776 None => (None, None),
777 };
778 let content = div()
779 .flex()
780 .items_center()
781 .justify_center()
782 .line_height(relative(1.25))
786 .min_w_0()
787 .whitespace_nowrap()
788 .map(|this| {
789 if size.is_xs() {
790 this.gap_1()
791 } else {
792 this.gap_1p5()
793 }
794 })
795 .map(|this| match spinner {
796 Some(spinner) => this.child(spinner),
797 None => this.when_some(self.icon, |this, icon| {
798 this.child(icon_size_rem(icon, size))
799 }),
800 })
801 .when_some(label, |this, (_, (text, _))| {
802 this.child(
803 div()
804 .min_w_0()
805 .overflow_hidden()
806 .whitespace_nowrap()
807 .text_ellipsis()
808 .when(look.underline && interactive, |this| {
809 this.text_decoration_1()
810 })
811 .child(text),
812 )
813 })
814 .children(self.children)
815 .when_some(self.trailing_icon, |this, icon| {
816 this.child(icon_size_rem(icon, size))
817 });
818
819 self.base
820 .flex_shrink_0()
821 .font_medium()
822 .text_size(look.geometry.text_size)
823 .rounded(look.geometry.radius)
824 .h(look.geometry.height)
825 .px(look.geometry.padding)
826 .when(icon_only, |this| this.px_0().w(look.geometry.height))
827 .bg(surface.background)
828 .text_color(surface.foreground)
829 .border_1()
833 .border_color(if focus_visible && variant == ButtonVariant::Outline {
834 look.outline_focus_border
835 } else {
836 surface.border
837 })
838 .when(focus_visible, |this| {
839 this.shadow(vec![gpui_kit::BoxShadow {
842 color: look.ring,
843 offset: gpui_kit::point(px(0.), px(0.)),
844 blur_radius: px(0.),
845 spread_radius: look.ring_spread,
846 inset: false,
847 }])
848 })
849 .map(|this| {
850 if interactive && (pointer_cursors || variant == ButtonVariant::Link) {
851 this.cursor_pointer()
852 } else {
853 this.cursor_default()
854 }
855 })
856 .refine_style(&self.style)
857 .role(self.role.unwrap_or(if variant == ButtonVariant::Link {
858 Role::Link
859 } else {
860 Role::Button
861 }))
862 .selected(shows_selected)
863 .disabled(disabled)
864 .when_some(accessibility_label, |this, label| {
865 this.accessibility_label(label)
866 })
867 .when_some(self.expanded, |this, expanded| this.aria_expanded(expanded))
868 .when_some(self.toggled, |this, toggled| {
869 this.aria_toggled(if toggled {
870 gpui_kit::accesskit::Toggled::True
871 } else {
872 gpui_kit::accesskit::Toggled::False
873 })
874 })
875 .track_focus(&focus_handle)
876 .tab_index(self.tab_index)
877 .tab_stop(self.tab_stop)
878 .child(content)
879 .when_some(managed_tooltip.as_ref(), |this, (trigger, _)| {
880 this.on_prepaint(trigger.track_bounds())
881 })
882 .when_some(native_tooltip, |this, trigger| {
883 this.tooltip(trigger.native())
884 })
885 .on_hover(move |hovered, window, cx| {
886 match &managed_tooltip {
887 Some((trigger, Some(check))) => check.hovered(trigger, *hovered, window, cx),
888 Some((trigger, None)) => trigger.hovered(*hovered, window, cx),
889 None => {}
890 }
891 if !interactive {
892 return;
893 }
894 pointer_for_hover.update(cx, |state, cx| {
895 let next = match (*hovered, *state) {
899 (false, PointerState::Pressed) => PointerState::Pressed,
900 (true, _) => PointerState::Hovered,
901 (false, _) => PointerState::Rest,
902 };
903 if *state != next {
904 *state = next;
905 cx.notify();
906 }
907 });
908 if let Some(on_hover) = &on_hover {
909 on_hover(hovered, window, cx);
910 }
911 })
912 .when(interactive, |this| {
913 this.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
914 window.prevent_default();
917 base::GlobalState::suppress_text_selection(cx);
918 TooltipTrigger::pressed(window, cx);
919 pointer_for_down.update(cx, |state, cx| {
920 *state = PointerState::Pressed;
921 cx.notify();
922 });
923 })
924 .on_mouse_up(gpui_kit::MouseButton::Left, move |_, _, cx| {
925 pointer_for_up.update(cx, |state, cx| {
926 if *state == PointerState::Pressed {
927 *state = PointerState::Hovered;
928 cx.notify();
929 }
930 });
931 })
932 .on_mouse_up_out(gpui_kit::MouseButton::Left, move |_, _, cx| {
933 pointer_for_up_out.update(cx, |state, cx| {
934 if *state != PointerState::Rest {
935 *state = PointerState::Rest;
936 cx.notify();
937 }
938 });
939 })
940 })
941 .when_some(on_click.filter(|_| interactive), |this, on_click| {
942 this.on_click(move |event, window, cx| on_click(event, window, cx))
943 })
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use crate::theme::{ThemeConfig, to_hex};
951 use gpui_kit::base::ThemeAppearance;
952
953 fn dark() -> ThemeTokens {
954 crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark)
955 }
956
957 #[test]
958 fn whole_weights_mix_to_the_surface_of_that_state() {
959 let theme = dark();
960 let ghost = ButtonVariant::Ghost;
961 for pointer in [
962 PointerState::Rest,
963 PointerState::Hovered,
964 PointerState::Pressed,
965 ] {
966 for selected in [false, true] {
967 let weights = StateWeights::of(pointer, selected, false);
968 assert_eq!(
969 ghost.surface_at(weights, true, &theme),
970 ghost.surface(pointer, selected, true, &theme),
971 "{pointer:?}, selected {selected}"
972 );
973 }
974 }
975 let selected = ghost.surface(PointerState::Rest, true, false, &theme);
976 assert_eq!(
977 ghost.surface_at(
978 StateWeights::of(PointerState::Rest, true, true),
979 false,
980 &theme
981 ),
982 ghost.disabled(selected, &theme),
983 );
984 }
985
986 #[test]
987 fn primary_reads_the_theme_tokens() {
988 let theme = dark();
989 let rest = ButtonVariant::Primary.surface(PointerState::Rest, false, false, &theme);
990 assert_eq!(to_hex(rest.background), "#dfdfdf");
991 assert_eq!(to_hex(rest.foreground), "#2d2d2d");
992 let hovered = ButtonVariant::Primary.surface(PointerState::Hovered, false, false, &theme);
993 assert!(hovered.background.l < rest.background.l);
994 }
995
996 #[test]
997 fn rest_surfaces_without_a_fill_share_the_hover_color_at_zero_alpha() {
998 let theme = dark();
999 for variant in [ButtonVariant::Outline, ButtonVariant::Ghost] {
1000 let rest = variant.surface(PointerState::Rest, false, false, &theme);
1001 let hovered = variant.surface(PointerState::Hovered, false, false, &theme);
1002 assert_eq!(rest.background.a, 0., "{variant:?} rest is clear");
1003 assert_eq!(
1004 rest.background.l, hovered.background.l,
1005 "{variant:?} only alpha moves"
1006 );
1007 let mid = rest.interpolate(&hovered, 0.5);
1008 assert_eq!(mid.background.l, hovered.background.l);
1009 assert!((mid.background.a - 0.5).abs() < 1e-6);
1010 }
1011 }
1012
1013 #[test]
1014 fn default_soft_fill_matches_the_reference() {
1015 let theme = dark();
1016 let rest = ButtonVariant::Default.surface(PointerState::Rest, false, false, &theme);
1017 assert_eq!(to_hex(rest.background), "#222222");
1018 assert_eq!(to_hex(rest.foreground), "#dfdfdf");
1019 let ghost = ButtonVariant::Ghost.surface(PointerState::Rest, false, false, &theme);
1020 assert_eq!(to_hex(ghost.foreground), "#969696");
1021 }
1022
1023 #[test]
1024 fn inside_a_sidebar_the_fills_are_the_sidebars() {
1025 let theme = dark();
1026 let hovered = ButtonVariant::Ghost.surface(PointerState::Hovered, false, true, &theme);
1027 assert_eq!(hovered.background, theme.sidebar_accent);
1028 let selected = ButtonVariant::Ghost.surface(PointerState::Rest, true, true, &theme);
1029 assert_eq!(selected.background, theme.sidebar_selected);
1030 assert!(hovered.background.l < selected.background.l);
1031 let selected_hovered =
1032 ButtonVariant::Ghost.surface(PointerState::Hovered, true, true, &theme);
1033 assert!(selected_hovered.background.l > selected.background.l);
1034 let window = ButtonVariant::Ghost.surface(PointerState::Hovered, false, false, &theme);
1035 assert_eq!(window.background, theme.secondary());
1036 }
1037}