1#[cfg(test)]
13mod active_tests;
14mod control;
15mod playback;
16pub use playback::{ButtonTransitionInputs, ButtonTransitionPlayback};
17mod effects;
18mod focus_ring;
19mod form;
20pub(crate) use focus_ring::ButtonFocusRing;
21pub use form::{FabSize, IconButtonWidth};
22pub(crate) use form::{Form, Palette, Roles, Segment, segment_metrics};
23mod hit_target;
24#[cfg(test)]
25mod loading_tests;
26#[cfg(test)]
27mod preview_tests;
28mod ripple;
29pub(crate) use ripple::ButtonRipple;
30mod ripple_clip;
31mod shadow;
32#[cfg(test)]
33mod toggle_tests;
34
35use super::InteractionPreview;
36use control::BaseControl;
37use effects::CssColor;
38
39use crate::{
40 color::ColorRole,
41 icons::Icon,
42 motion::{self, MotionToken},
43 tokens::{
44 state,
45 typography::{TypeRole, rem},
46 },
47};
48use gpui::{prelude::*, *};
49
50#[derive(Clone, Default)]
57pub struct ButtonProgressPlayback {
58 state: std::rc::Rc<std::cell::RefCell<ButtonProgressState>>,
59}
60
61#[derive(Default)]
62struct ButtonProgressState {
63 started: bool,
64 visible: bool,
65 generation: u64,
66 circular: super::progress::CircularProgressPlayback,
67 opacity: gpui_base::TransitionPlayback<f32>,
68}
69
70impl ButtonProgressPlayback {
71 pub fn sync(&self, progress: bool, loading: bool, disabled: bool, cx: &App) {
77 self.sample_inputs(progress, loading, disabled, cx);
78 }
79
80 pub fn observe_reduced_motion(&self, cx: &App) {
84 self.state.borrow().circular.observe_reduced_motion(cx);
85 }
86
87 fn sample_inputs(
91 &self,
92 progress: bool,
93 loading: bool,
94 disabled: bool,
95 cx: &App,
96 ) -> gpui_base::MotionValue<f32> {
97 let mut state = self.state.borrow_mut();
98 let visible = progress || loading;
99 if visible && !state.visible {
100 state.generation += 1;
101 state.opacity = gpui_base::TransitionPlayback::default();
102 }
103 state.started |= visible;
104 state.visible = visible;
105 if state.started {
106 state.circular.sample(cx);
107 }
108 if visible {
109 state.opacity.sample(
110 if disabled || loading { 0.7 } else { 1. },
111 playback::opacity_policy(),
112 cx,
113 )
114 } else {
115 gpui_base::MotionValue {
116 value: 1.,
117 status: gpui_base::MotionStatus::Idle,
118 }
119 }
120 }
121}
122
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub enum ButtonVariant {
125 #[default]
126 Filled,
127 Elevated,
128 Tonal,
129 Outlined,
130 Text,
131}
132impl ButtonVariant {
133 pub const ALL: [Self; 5] = [
134 Self::Filled,
135 Self::Elevated,
136 Self::Tonal,
137 Self::Outlined,
138 Self::Text,
139 ];
140 pub const fn name(self) -> &'static str {
141 match self {
142 Self::Filled => "filled",
143 Self::Elevated => "elevated",
144 Self::Tonal => "tonal",
145 Self::Outlined => "outlined",
146 Self::Text => "text",
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum ButtonSize {
153 ExtraSmall,
154 #[default]
155 Small,
156 Medium,
157 Large,
158 ExtraLarge,
159}
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub enum ButtonShape {
162 #[default]
163 Round,
164 Square,
165}
166
167impl ButtonShape {
168 pub const ALL: [Self; 2] = [Self::Round, Self::Square];
169 pub const fn name(self) -> &'static str {
170 match self {
171 Self::Round => "round",
172 Self::Square => "square",
173 }
174 }
175}
176
177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub enum ButtonIconPosition {
180 #[default]
181 Leading,
182 Trailing,
183}
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub enum ButtonDirection {
187 #[default]
188 Ltr,
189 Rtl,
190}
191
192#[derive(Clone, Copy, Debug)]
193struct Geometry {
194 height: f32,
195 padding: f32,
196 gap: f32,
197 outline: f32,
198 square: f32,
199 pressed: f32,
200 typography: TypeRole,
201}
202impl ButtonSize {
203 pub const ALL: [Self; 5] = [
204 Self::ExtraSmall,
205 Self::Small,
206 Self::Medium,
207 Self::Large,
208 Self::ExtraLarge,
209 ];
210 pub const fn name(self) -> &'static str {
211 match self {
212 Self::ExtraSmall => "xs",
213 Self::Small => "s",
214 Self::Medium => "m",
215 Self::Large => "l",
216 Self::ExtraLarge => "xl",
217 }
218 }
219 pub(crate) fn height(self) -> f32 {
221 self.geometry().height
222 }
223 fn icon_size(self) -> f32 {
224 match self {
225 Self::ExtraSmall | Self::Small => 20.,
226 Self::Medium => 24.,
227 Self::Large => 32.,
228 Self::ExtraLarge => 40.,
229 }
230 }
231 fn geometry(self) -> Geometry {
232 let (height, padding, gap, outline, square, pressed, typography) = match self {
233 Self::ExtraSmall => (32., 12., 4., 1., 12., 8., TypeRole::LabelLarge),
234 Self::Small => (40., 16., 8., 1., 12., 8., TypeRole::LabelLarge),
235 Self::Medium => (56., 24., 8., 1., 16., 12., TypeRole::TitleMedium),
236 Self::Large => (96., 48., 12., 2., 28., 16., TypeRole::HeadlineSmall),
237 Self::ExtraLarge => (136., 64., 16., 2., 28., 16., TypeRole::HeadlineLarge),
238 };
239 Geometry {
240 height,
241 padding,
242 gap,
243 outline,
244 square,
245 pressed,
246 typography,
247 }
248 }
249}
250
251fn horizontal_padding(size: ButtonSize) -> (f32, f32) {
256 let padding = size.geometry().padding;
257 (padding, padding)
258}
259
260#[cfg(test)]
261fn alpha(mut color: Rgba, opacity: f32) -> Rgba {
262 color.a *= opacity;
263 color
264}
265fn disabled_targets(outlined: bool) -> (CssColor, CssColor, CssColor) {
271 use ColorRole as C;
272 (
273 CssColor::alpha_role(C::ON_SURFACE, 0.10),
274 CssColor::alpha_role(C::ON_SURFACE, crate::tokens::state::DISABLED_CONTENT),
275 if outlined {
276 CssColor::role(C::OUTLINE_VARIANT)
277 } else {
278 CssColor::transparent()
279 },
280 )
281}
282fn color_targets(variant: ButtonVariant, disabled: bool) -> (CssColor, CssColor, CssColor) {
283 use ColorRole as C;
284 if disabled {
285 return disabled_targets(variant == ButtonVariant::Outlined);
286 }
287 let (bg, fg) = match variant {
288 ButtonVariant::Filled => (CssColor::role(C::PRIMARY), C::ON_PRIMARY),
289 ButtonVariant::Elevated => (CssColor::role(C::SURFACE_CONTAINER_LOW), C::PRIMARY),
290 ButtonVariant::Tonal => (
291 CssColor::role(C::SECONDARY_CONTAINER),
292 C::ON_SECONDARY_CONTAINER,
293 ),
294 ButtonVariant::Outlined => (CssColor::transparent(), C::ON_SURFACE_VARIANT),
295 ButtonVariant::Text => (CssColor::transparent(), C::PRIMARY),
296 };
297 (
298 bg,
299 CssColor::role(fg),
300 if variant == ButtonVariant::Outlined {
301 CssColor::role(C::OUTLINE_VARIANT)
302 } else {
303 CssColor::transparent()
304 },
305 )
306}
307fn resting_radius(size: ButtonSize, shape: ButtonShape, selected: bool) -> f32 {
308 let g = size.geometry();
309 if (shape == ButtonShape::Round) != selected {
310 g.height / 2.
311 } else {
312 g.square
313 }
314}
315
316fn toggle_color_targets(
317 variant: ButtonVariant,
318 disabled: bool,
319 toggle: bool,
320 selected: bool,
321) -> (CssColor, CssColor, CssColor) {
322 use ColorRole as C;
323 if !toggle {
324 return color_targets(variant, disabled);
325 }
326 if disabled {
327 return disabled_targets(variant == ButtonVariant::Outlined && !selected);
331 }
332 let roles = match (variant, selected) {
333 (ButtonVariant::Filled, false) => Some((C::SURFACE_CONTAINER, C::ON_SURFACE_VARIANT)),
334 (ButtonVariant::Filled | ButtonVariant::Elevated, true) => {
335 Some((C::PRIMARY, C::ON_PRIMARY))
336 }
337 (ButtonVariant::Tonal, true) => Some((C::SECONDARY, C::ON_SECONDARY)),
338 (ButtonVariant::Outlined, true) => Some((C::INVERSE_SURFACE, C::INVERSE_ON_SURFACE)),
339 _ => None,
340 };
341 match roles {
342 Some((bg, fg)) => (
343 CssColor::role(bg),
344 CssColor::role(fg),
345 CssColor::transparent(),
346 ),
347 None => color_targets(variant, false),
348 }
349}
350
351pub(crate) fn toggle_colors(variant: ButtonVariant, selected: bool) -> (Rgba, Rgba) {
355 let (bg, fg, _) = toggle_color_targets(variant, false, true, selected);
356 (bg.resolve(), fg.resolve())
357}
358
359#[cfg(test)]
360fn colors(variant: ButtonVariant, disabled: bool) -> (Rgba, Rgba, Rgba) {
361 let (bg, fg, border) = color_targets(variant, disabled);
362 (bg.resolve(), fg.resolve(), border.resolve())
363}
364
365struct PressState {
367 pointer: bool,
368 hovered: bool,
369 caller_hovered: bool,
370 native_hovered: bool,
371 outside_window: bool,
372 keyboard: bool,
373 focus: FocusHandle,
374 focus_out: Option<Subscription>,
375 _activation: Subscription,
376}
377fn clear_press(state: &mut PressState) {
378 state.pointer = false;
379 state.hovered = false;
380 state.outside_window = true;
381 state.keyboard = false;
382}
383fn press_state(
384 id: ElementId,
385 focus: &FocusHandle,
386 window: &mut Window,
387 cx: &mut App,
388) -> Entity<PressState> {
389 let state = window.use_keyed_state((id, "material-press"), cx, |window, cx| {
390 let activation =
391 cx.observe_window_activation(window, |state: &mut PressState, window, cx| {
392 if !window.is_window_active() {
393 clear_press(state);
394 cx.notify();
395 }
396 });
397 PressState {
398 pointer: false,
399 hovered: false,
400 caller_hovered: false,
401 native_hovered: false,
402 outside_window: false,
403 keyboard: false,
404 focus: focus.clone(),
405 focus_out: None,
406 _activation: activation,
407 }
408 });
409 if state.read(cx).focus_out.is_none() || state.read(cx).focus != *focus {
410 let weak = state.downgrade();
411 let subscription = window.on_focus_out(focus, cx, move |_, _, cx| {
412 let _ = weak.update(cx, |state, cx| {
413 state.keyboard = false;
414 cx.notify();
415 });
416 });
417 state.update(cx, |state, _| {
418 state.keyboard = false;
419 state.focus = focus.clone();
420 state.focus_out = Some(subscription);
421 });
422 }
423 state
424}
425fn observe_press(
426 mut base: BaseControl,
427 state: &Entity<PressState>,
428 focus: &FocusHandle,
429 disabled: bool,
430 focusable: bool,
431) -> BaseControl {
432 if disabled {
433 return base;
434 }
435 let down = state.clone();
436 base = base.on_mouse_down(MouseButton::Left, move |_, window, cx| {
437 if !window.default_prevented() {
438 down.update(cx, |state, cx| {
439 state.outside_window = false;
440 state.pointer = true;
441 state.hovered = !window.last_input_was_touch();
442 cx.notify();
443 });
444 }
445 });
446 let up = state.clone();
447 let out = state.clone();
448 base = base
449 .on_mouse_up(MouseButton::Left, move |_, window, cx| {
450 up.update(cx, |state, cx| {
451 state.pointer = false;
452 state.hovered = !window.last_input_was_touch();
453 cx.notify();
454 });
455 })
456 .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
457 out.update(cx, |state, cx| {
458 state.hovered = false;
459 state.pointer = false;
460 cx.notify();
461 });
462 });
463 if focusable {
464 let down = state.clone();
465 let up = state.clone();
466 let focus = focus.clone();
467 base = base
468 .on_key_down(move |event, window, cx| {
469 let stroke = &event.keystroke;
472 if !matches!(stroke.key.as_str(), "enter" | "space") {
473 return;
474 }
475 let pressed = !window.default_prevented()
476 && focus.is_focused(window)
477 && !stroke.modifiers.modified();
478 down.update(cx, |state, cx| {
479 if state.keyboard != pressed {
480 state.keyboard = pressed;
481 cx.notify();
482 }
483 });
484 })
485 .on_key_up(move |event, _, cx| {
486 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
487 up.update(cx, |state, cx| {
488 if state.keyboard {
489 state.keyboard = false;
490 cx.notify();
491 }
492 });
493 }
494 });
495 }
496 base
497}
498
499type HoverCallback = std::rc::Rc<dyn Fn(&bool, &mut Window, &mut App)>;
500
501#[derive(gpui::IntoElement)]
503pub struct MaterialButton {
504 #[cfg(test)]
505 progress_opacity_observer: Option<std::rc::Rc<std::cell::Cell<f32>>>,
506 #[cfg(test)]
507 radius_observer: Option<std::rc::Rc<std::cell::Cell<f32>>>,
508 #[cfg(test)]
509 colors_observer: Option<std::rc::Rc<std::cell::Cell<(Rgba, Rgba, Rgba)>>>,
510 hover_callback: Option<HoverCallback>,
511 caller_hover_mode: HoverListenerMode,
512 #[cfg(test)]
513 effects_observer: Option<std::rc::Rc<std::cell::RefCell<(f32, shadow::ShadowPair)>>>,
514 id: ElementId,
515 focus_handle: Option<FocusHandle>,
516 focusable: bool,
517 base: BaseControl,
518 children: Vec<AnyElement>,
519 icon: Option<Icon>,
520 icon_position: ButtonIconPosition,
521 direction: ButtonDirection,
522 variant: ButtonVariant,
523 size: ButtonSize,
524 shape: ButtonShape,
525 disabled: bool,
526 ripple: bool,
527 active_state: bool,
528 progress: Option<(bool, SharedString)>,
529 loading: Option<(bool, SharedString)>,
530 progress_playback: Option<ButtonProgressPlayback>,
531 transition_playback: Option<ButtonTransitionPlayback>,
532 preview: Option<InteractionPreview>,
533 form: Form,
534 palette: Option<Palette>,
535 open: bool,
536 press_probe: Option<std::rc::Rc<std::cell::Cell<bool>>>,
537 style: StyleRefinement,
538}
539impl MaterialButton {
540 pub fn new(id: impl Into<ElementId>) -> Self {
541 let id = id.into();
542 Self {
543 #[cfg(test)]
544 progress_opacity_observer: None,
545 #[cfg(test)]
546 radius_observer: None,
547 #[cfg(test)]
548 colors_observer: None,
549 hover_callback: None,
550 caller_hover_mode: HoverListenerMode::InputModalityAware,
551 #[cfg(test)]
552 effects_observer: None,
553 id: id.clone(),
554 focus_handle: None,
555 focusable: true,
556 base: BaseControl::new(id).block_mouse_except_scroll(),
557 children: vec![],
558 icon: None,
559 icon_position: ButtonIconPosition::Leading,
560 direction: ButtonDirection::Ltr,
561 variant: ButtonVariant::Filled,
562 size: ButtonSize::Small,
563 shape: ButtonShape::Round,
564 disabled: false,
565 ripple: true,
566 active_state: false,
567 progress: None,
568 loading: None,
569 progress_playback: None,
570 transition_playback: None,
571 preview: None,
572 form: Form::Label,
573 palette: None,
574 open: false,
575 press_probe: None,
576 style: StyleRefinement::default(),
577 }
578 }
579 pub(crate) fn form(mut self, form: Form) -> Self {
581 self.form = form;
582 self
583 }
584 pub(crate) fn palette(mut self, palette: Palette) -> Self {
586 self.palette = Some(palette);
587 self
588 }
589 pub(crate) fn press_probe(mut self, probe: std::rc::Rc<std::cell::Cell<bool>>) -> Self {
594 self.press_probe = Some(probe);
595 self
596 }
597 pub(crate) fn open(mut self, open: bool) -> Self {
598 self.open = open;
599 self
600 }
601 fn form_geometry(&self) -> form::FormGeometry {
602 let geometry = self.form.geometry(self.size, self.direction);
603 if self.open && self.form == Form::Segment(Segment::Trailing) {
604 let side = (geometry.padding.0 + geometry.padding.1) / 2.;
606 form::FormGeometry {
607 padding: (side, side),
608 ..geometry
609 }
610 } else {
611 geometry
612 }
613 }
614 fn color_targets(&self) -> (CssColor, CssColor, CssColor) {
615 match &self.palette {
616 Some(palette) => palette.targets(self.disabled, self.base.toggle, self.base.selected),
617 None => toggle_color_targets(
618 self.variant,
619 self.disabled,
620 self.base.toggle,
621 self.base.selected,
622 ),
623 }
624 }
625 pub fn toggle(mut self, enabled: bool) -> Self {
627 self.base.toggle = enabled;
628 self
629 }
630 pub fn selected(mut self, selected: bool) -> Self {
632 self.base.selected = selected;
633 self
634 }
635 pub fn toggle_on_click(mut self, enabled: bool) -> Self {
638 self.base.toggle_on_click = enabled;
639 self
640 }
641 pub fn on_change(
644 mut self,
645 callback: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
646 ) -> Self {
647 self.base.on_change = Some(std::rc::Rc::new(callback));
648 self
649 }
650 pub fn variant(mut self, variant: ButtonVariant) -> Self {
651 self.variant = variant;
652 self
653 }
654 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
656 self.icon = Some(icon.into());
657 self
658 }
659 pub fn icon_position(mut self, position: ButtonIconPosition) -> Self {
660 self.icon_position = position;
661 self
662 }
663 pub fn direction(mut self, direction: ButtonDirection) -> Self {
664 self.direction = direction;
665 self
666 }
667 pub fn button_size(mut self, size: ButtonSize) -> Self {
668 self.size = size;
669 self
670 }
671 pub fn shape(mut self, shape: ButtonShape) -> Self {
672 self.shape = shape;
673 self
674 }
675 pub fn disabled(mut self, disabled: bool) -> Self {
676 self.disabled = disabled;
677 self.base = self.base.disabled(disabled);
678 self
679 }
680 pub fn progress(mut self, visible: bool, label: impl Into<SharedString>) -> Self {
685 self.progress = Some((visible, label.into()));
686 self
687 }
688 pub fn loading(mut self, loading: bool, label: impl Into<SharedString>) -> Self {
697 self.loading = Some((loading, label.into()));
698 self
699 }
700 pub fn transition_playback(mut self, playback: ButtonTransitionPlayback) -> Self {
704 self.transition_playback = Some(playback);
705 self
706 }
707
708 pub fn progress_playback(mut self, playback: ButtonProgressPlayback) -> Self {
712 self.progress_playback = Some(playback);
713 self
714 }
715 pub fn active_state(mut self, active: bool) -> Self {
720 self.active_state = active;
721 self
722 }
723 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
730 self.preview = Some(state);
731 self
732 }
733 pub fn ripple(mut self, enabled: bool) -> Self {
735 self.ripple = enabled;
736 self
737 }
738 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
739 self.base = self.base.accessibility_label(label);
740 self
741 }
742 pub fn on_click(
743 mut self,
744 callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
745 ) -> Self {
746 self.base = self.base.on_click(callback);
747 self
748 }
749 pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
750 self.focus_handle = Some(handle.clone());
751 self.base = self.base.track_focus(handle);
752 self
753 }
754 pub fn tab_index(mut self, index: isize) -> Self {
755 self.base = self.base.tab_index(index);
756 self
757 }
758 pub fn tab_stop(mut self, stop: bool) -> Self {
759 self.base = self.base.tab_stop(stop);
760 self
761 }
762 pub fn focusable(mut self, focusable: bool) -> Self {
763 self.focusable = focusable;
764 self.base = self.base.focusable(focusable);
765 self
766 }
767}
768impl Styled for MaterialButton {
769 fn style(&mut self) -> &mut StyleRefinement {
770 &mut self.style
771 }
772}
773impl ParentElement for MaterialButton {
774 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
775 self.children.extend(elements);
776 }
777}
778impl InteractiveElement for MaterialButton {
779 fn interactivity(&mut self) -> &mut Interactivity {
780 self.base.interactivity()
781 }
782}
783impl StatefulInteractiveElement for MaterialButton {
784 fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self {
785 self.caller_hover_mode = mode;
786 self
787 }
788 fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
789 self.hover_callback = Some(std::rc::Rc::new(listener));
790 self
791 }
792}
793struct SampledColors {
795 bg: Rgba,
796 fg: Rgba,
797 border: Rgba,
798 disabled_icon: Rgba,
800}
801
802struct Interaction {
804 focus: FocusHandle,
805 pressed: Entity<PressState>,
806 active: bool,
807 hovered: bool,
808 focused: bool,
809}
810
811struct Effects {
813 opacity: f32,
814 shadows: shadow::ShadowPair,
815 radius: f32,
816}
817
818impl MaterialButton {
819 fn progress_visible(&self) -> bool {
820 self.progress.as_ref().is_some_and(|(visible, _)| *visible)
821 }
822
823 fn record_transition_inputs(&self, cx: &App) {
826 if let Some(playback) = &self.transition_playback {
827 playback.record_inputs(
828 ButtonTransitionInputs {
829 variant: self.variant,
830 size: self.size,
831 shape: self.shape,
832 toggle: self.base.toggle,
833 selected: self.base.selected,
834 disabled: self.disabled,
835 active: self.active_state,
836 ripple: self.ripple,
837 },
838 cx,
839 );
840 }
841 }
842
843 fn sample_progress(
847 &self,
848 loading: bool,
849 window: &mut Window,
850 cx: &mut App,
851 ) -> (Option<ButtonProgressPlayback>, f32) {
852 if self.loading.is_none() && self.progress.is_none() {
853 return (None, 1.);
854 }
855 let playback = self.progress_playback.clone().unwrap_or_else(|| {
856 window
857 .use_keyed_state((self.id.clone(), "progress-playback"), cx, |_, _| {
858 ButtonProgressPlayback::default()
859 })
860 .read(cx)
861 .clone()
862 });
863 let opacity = playback.sample_inputs(self.progress_visible(), loading, self.disabled, cx);
864 if matches!(
865 opacity.status,
866 gpui_base::MotionStatus::Running | gpui_base::MotionStatus::Delayed
867 ) {
868 window.request_animation_frame();
869 }
870 (Some(playback), opacity.value)
871 }
872
873 fn sample_colors(&self, window: &mut Window, cx: &mut App) -> SampledColors {
874 let (bg_target, fg_target, border_target) = self.color_targets();
875 let disabled_icon = fg_target.resolve();
876 let transitions = self.transition_playback.as_ref();
877 let bg = playback::token_sample(
878 transitions.map(|p| &p.background),
879 (self.id.clone(), "background-color"),
880 bg_target,
881 MotionToken::DefaultEffects,
882 window,
883 cx,
884 )
885 .resolve();
886 let fg = playback::token_sample(
887 transitions.map(|p| &p.foreground),
888 (self.id.clone(), "foreground-color"),
889 fg_target,
890 MotionToken::DefaultEffects,
891 window,
892 cx,
893 )
894 .resolve();
895 let border = playback::token_sample(
896 transitions.map(|p| &p.border),
897 (self.id.clone(), "border-color"),
898 border_target,
899 MotionToken::DefaultEffects,
900 window,
901 cx,
902 )
903 .resolve();
904 #[cfg(test)]
905 if let Some(observer) = &self.colors_observer {
906 observer.set((bg, fg, border));
907 }
908 SampledColors {
909 bg,
910 fg,
911 border,
912 disabled_icon,
913 }
914 }
915
916 fn resolve_interaction(&self, window: &mut Window, cx: &mut App) -> Interaction {
917 let focus = self.focus_handle.clone().unwrap_or_else(|| {
918 window
919 .use_keyed_state((self.id.clone(), "material-focus"), cx, |_, cx| {
920 cx.focus_handle()
921 })
922 .read(cx)
923 .clone()
924 });
925 let pressed = press_state(self.id.clone(), &focus, window, cx);
926 if self.disabled || !self.focusable || !focus.is_focused(window) {
927 pressed.update(cx, |state, _| {
928 if self.disabled {
929 state.pointer = false;
930 }
931 state.keyboard = false;
932 });
933 }
934 if let Some(preview) = self.preview {
935 return Interaction {
936 focus,
937 pressed,
938 active: self.active_state || preview.is_pressed(),
939 hovered: preview.is_hovered(),
940 focused: preview.is_focused(),
941 };
942 }
943 let press = pressed.read(cx);
944 let active = self.active_state || press.pointer || press.keyboard;
945 let hovered = press.hovered && !window.last_input_was_touch();
946 let focused =
947 self.focusable && focus.is_focused(window) && window.last_input_was_keyboard();
948 Interaction {
949 focus,
950 pressed,
951 active,
952 hovered,
953 focused,
954 }
955 }
956
957 fn sample_effects(
958 &self,
959 interaction: &Interaction,
960 window: &mut Window,
961 cx: &mut App,
962 ) -> Effects {
963 let Interaction {
964 active,
965 hovered,
966 focused,
967 ..
968 } = *interaction;
969 let transitions = self.transition_playback.as_ref();
970 let opacity = playback::opacity_sample(
971 transitions,
972 (self.id.clone(), "state-opacity"),
973 playback::state_opacity(
974 self.active_state,
975 self.disabled,
976 self.ripple && self.preview.is_none(),
979 active,
980 focused,
981 hovered,
982 ),
983 playback::opacity_policy(),
984 window,
985 cx,
986 );
987 let level = match &self.palette {
988 Some(palette) => palette.shadow_level(self.disabled, hovered, active),
989 None if matches!(self.form, Form::Icon(_)) => 0,
991 None => playback::shadow_level(
992 self.variant,
993 self.disabled,
994 self.base.toggle,
995 hovered,
996 active,
997 ),
998 };
999 let shadows = playback::token_sample(
1000 transitions.map(|p| &p.shadow),
1001 (self.id.clone(), "box-shadow"),
1002 shadow::ShadowPair::level(level),
1003 MotionToken::DefaultEffects,
1004 window,
1005 cx,
1006 );
1007 #[cfg(test)]
1008 if let Some(observer) = &self.effects_observer {
1009 *observer.borrow_mut() = (opacity, shadows.clone());
1010 }
1011 let target = match self.form {
1012 Form::Segment(segment) => {
1014 let metrics = segment_metrics(self.size);
1015 if self.open && segment == Segment::Trailing {
1016 self.size.geometry().height / 2.
1017 } else if active || hovered {
1018 metrics.inner_active
1019 } else {
1020 metrics.inner
1021 }
1022 }
1023 form if !form.sized_shape() => form.fixed_radius().unwrap_or(0.),
1024 _ if active => self.size.geometry().pressed,
1025 _ => resting_radius(
1026 self.size,
1027 self.shape,
1028 self.base.toggle && self.base.selected,
1029 ),
1030 };
1031 let radius = playback::token_sample(
1032 transitions.map(|p| &p.radius),
1033 (self.id.clone(), "pressed-radius"),
1034 target,
1035 MotionToken::FastSpatial,
1036 window,
1037 cx,
1038 );
1039 #[cfg(test)]
1040 if let Some(observer) = &self.radius_observer {
1041 observer.set(radius);
1042 }
1043 Effects {
1044 opacity,
1045 shadows,
1046 radius,
1047 }
1048 }
1049
1050 fn icon_slot_box(&self) -> Div {
1051 div()
1052 .flex()
1053 .items_center()
1054 .justify_center()
1055 .flex_shrink_0()
1056 .size(rem(self.form_geometry().icon))
1057 }
1058
1059 fn slots(
1062 &mut self,
1063 loading: bool,
1064 progress: Option<(&ButtonProgressPlayback, f32)>,
1065 disabled_icon: Rgba,
1066 cx: &App,
1067 ) -> (Option<Div>, Option<Div>) {
1068 if let Some((playback, opacity)) = progress {
1069 let label = if loading {
1070 self.loading.as_ref()
1071 } else {
1072 self.progress.as_ref()
1073 }
1074 .expect("visible progress has caller configuration")
1075 .1
1076 .clone();
1077 let indicator = super::progress::indeterminate_circular_progress_with_playback(
1079 (self.id.clone(), "button-progress-indicator"),
1080 label,
1081 size(rem(self.form_geometry().icon.min(24.)), rem(24.)),
1082 opacity,
1083 &playback.state.borrow().circular,
1084 cx,
1085 );
1086 let slot = self
1087 .icon_slot_box()
1088 .debug_selector(|| "material-button-progress-slot".into())
1089 .child(indicator);
1090 return (Some(slot), None);
1091 }
1092 let icon_size = self.form_geometry().icon;
1093 let slot = self.icon.take().map(|icon| {
1094 let icon = if self.disabled {
1095 icon.color(disabled_icon)
1096 } else {
1097 icon
1098 };
1099 self.icon_slot_box().child(icon.size(rem(icon_size)))
1100 });
1101 match self.icon_position {
1102 ButtonIconPosition::Leading => (slot, None),
1103 ButtonIconPosition::Trailing => (None, slot),
1104 }
1105 }
1106}
1107
1108fn pointer_observer(
1112 pressed: &Entity<PressState>,
1113 touch_enabled: bool,
1114 target: hit_target::SharedTarget,
1115) -> impl IntoElement {
1116 let weak = pressed.downgrade();
1117 canvas(
1118 move |bounds, window, _| {
1119 target
1120 .get()
1121 .unwrap_or_else(|| hit_target::ResolvedTarget::new(bounds, None, window))
1122 .insert(window)
1123 },
1124 move |_, hitbox, window, _| {
1125 let moving_hitbox = hitbox.clone();
1126 let touch = weak.clone();
1127 window.observe_touch_event(move |event, window, cx| match event.phase {
1128 TouchPhase::Started
1129 if touch_enabled && hitbox.is_hovered_at(event.position, window) =>
1130 {
1131 let _ = touch.update(cx, |state, cx| {
1132 state.pointer = true;
1133 state.hovered = false;
1134 state.outside_window = false;
1135 cx.notify();
1136 });
1137 }
1138 TouchPhase::Ended | TouchPhase::Cancelled => {
1139 let _ = touch.update(cx, |state, cx| {
1140 if state.pointer {
1141 state.pointer = false;
1142 state.hovered = false;
1143 cx.notify();
1144 }
1145 });
1146 }
1147 _ => {}
1148 });
1149 let moving = weak.clone();
1150 window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
1151 if phase == DispatchPhase::Capture {
1152 let _ = moving.update(cx, |state, cx| {
1153 if state.pointer || state.outside_window {
1154 state.outside_window = false;
1155 let hovered = moving_hitbox.is_hovered_at(event.position, window)
1156 && !window.last_input_was_touch();
1157 if state.hovered != hovered {
1158 state.hovered = hovered;
1159 cx.notify();
1160 }
1161 }
1162 });
1163 }
1164 });
1165 window.on_mouse_event(move |_: &MouseExitEvent, phase, _, cx| {
1166 if phase == DispatchPhase::Capture {
1167 let _ = weak.update(cx, |state, cx| {
1168 state.outside_window = true;
1172 if state.pointer || state.hovered {
1173 state.hovered = false;
1174 state.pointer = false;
1175 cx.notify();
1176 }
1177 });
1178 }
1179 });
1180 },
1181 )
1182 .absolute()
1183 .size_full()
1184}
1185
1186fn bridge_hover(
1189 base: BaseControl,
1190 pressed: &Entity<PressState>,
1191 callback: Option<HoverCallback>,
1192 caller_mode: HoverListenerMode,
1193 window: &mut Window,
1194 cx: &mut App,
1195) -> BaseControl {
1196 if let Some(callback) = callback.clone() {
1197 let caller_hovered = pressed.read(cx).native_hovered
1198 && !pressed.read(cx).outside_window
1199 && (!window.last_input_was_keyboard()
1200 || matches!(caller_mode, HoverListenerMode::InputModalityIndependent));
1201 if caller_hovered != pressed.read(cx).caller_hovered {
1202 pressed.update(cx, |state, _| state.caller_hovered = caller_hovered);
1203 let weak = pressed.downgrade();
1204 window.defer(cx, move |window, cx| {
1205 if let Some(state) = weak.upgrade()
1206 && state.read(cx).caller_hovered == caller_hovered
1207 {
1208 callback(&caller_hovered, window, cx);
1209 }
1210 });
1211 }
1212 }
1213 let hover_state = pressed.clone();
1214 base.hover_listener_mode(HoverListenerMode::InputModalityIndependent)
1215 .on_hover(move |hovered, window, cx| {
1216 let caller_hovered = *hovered
1217 && !hover_state.read(cx).outside_window
1218 && (!window.last_input_was_keyboard()
1219 || matches!(caller_mode, HoverListenerMode::InputModalityIndependent));
1220 let changed = hover_state.update(cx, |state, cx| {
1221 if !state.pointer {
1224 let hovered =
1225 *hovered && !state.outside_window && !window.last_input_was_touch();
1226 if state.hovered != hovered {
1227 state.hovered = hovered;
1228 cx.notify();
1229 }
1230 }
1231 state.native_hovered = *hovered;
1232 let changed = state.caller_hovered != caller_hovered;
1233 state.caller_hovered = caller_hovered;
1234 changed
1235 });
1236 if changed && let Some(callback) = &callback {
1237 callback(&caller_hovered, window, cx);
1238 }
1239 })
1240}
1241
1242fn ripple_ink(painted: &Style, fg: Rgba) -> (Rgba, bool) {
1246 let mut ink = painted.text.color.map(Rgba::from).unwrap_or(fg);
1247 ink.a *= painted.opacity.unwrap_or(1.);
1248 let visible = painted.visibility == Visibility::Visible;
1249 if !visible {
1250 ink.a = 0.;
1251 }
1252 (ink, visible)
1253}
1254
1255impl RenderOnce for MaterialButton {
1256 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1257 use gpui_base::StyledExt;
1258 let loading = self.loading.as_ref().is_some_and(|(visible, _)| *visible);
1259 let show_progress = loading || self.progress_visible();
1260 self.disabled |= loading;
1261 if let Some(preview) = self.preview {
1262 self.disabled |= preview.is_disabled();
1263 self.base = self.base.tab_stop(false);
1264 }
1265 self.record_transition_inputs(cx);
1266 self.base = self.base.disabled(self.disabled).busy(loading);
1268 let (progress_playback, progress_opacity) = self.sample_progress(loading, window, cx);
1269 #[cfg(test)]
1270 if let Some(observer) = &self.progress_opacity_observer {
1271 observer.set(progress_opacity);
1272 }
1273 let g = self.form_geometry();
1274 let ty = g.typography.scale();
1275 let colors = self.sample_colors(window, cx);
1276 let interaction = self.resolve_interaction(window, cx);
1277 if let Some(probe) = &self.press_probe
1278 && probe.get() != interaction.active
1279 {
1280 probe.set(interaction.active);
1281 window.request_animation_frame();
1282 }
1283 let effects = self.sample_effects(&interaction, window, cx);
1284 let (left_padding, right_padding) = g.padding;
1285 let progress = progress_playback
1286 .as_ref()
1287 .filter(|_| show_progress)
1288 .map(|playback| (playback, progress_opacity));
1289 let (leading, trailing) = self.slots(loading, progress, colors.disabled_icon, cx);
1290 let Interaction { focus, pressed, .. } = interaction;
1291 let expanded_target = matches!(self.size, ButtonSize::ExtraSmall | ButtonSize::Small)
1292 && matches!(self.form, Form::Label | Form::Icon(_) | Form::Segment(_));
1293 let target_geometry = hit_target::SharedTarget::default();
1297 let target_writer = target_geometry.clone();
1298 let base = self
1299 .base
1300 .track_focus(&focus)
1301 .hitbox_extension(move |bounds, style, window| {
1302 let extension = expanded_target
1303 .then(|| hit_target::extra_bounds(bounds, style, window))
1304 .flatten();
1305 target_writer.set(Some(hit_target::ResolvedTarget::new(
1306 bounds, extension, window,
1307 )));
1308 extension
1309 })
1310 .children(leading)
1311 .children(self.children)
1312 .children(trailing)
1313 .child(pointer_observer(
1314 &pressed,
1315 !self.disabled,
1316 target_geometry.clone(),
1317 ));
1318 let base = observe_press(base, &pressed, &focus, self.disabled, self.focusable);
1319 let base = bridge_hover(
1320 base,
1321 &pressed,
1322 self.hover_callback,
1323 self.caller_hover_mode,
1324 window,
1325 cx,
1326 );
1327 let SampledColors { bg, fg, border, .. } = colors;
1328 let corners = match self.form {
1330 Form::Segment(segment) => {
1331 let rem_size = window.rem_size();
1332 let outer = rem(g.height / 2.).to_pixels(rem_size);
1333 let inner = rem(effects.radius).to_pixels(rem_size);
1334 let leading_side =
1335 (segment == Segment::Leading) == (self.direction == ButtonDirection::Ltr);
1336 let (left, right) = if leading_side {
1337 (outer, inner)
1338 } else {
1339 (inner, outer)
1340 };
1341 Some(Corners {
1342 top_left: left,
1343 bottom_left: left,
1344 top_right: right,
1345 bottom_right: right,
1346 })
1347 }
1348 _ => None,
1349 };
1350 let mut base = base
1351 .flex()
1352 .flex_row()
1353 .when(self.direction == ButtonDirection::Rtl, |b| {
1354 b.flex_row_reverse()
1355 })
1356 .items_center()
1357 .justify_center()
1358 .flex_shrink_0()
1359 .min_w(rem(g.min_width))
1360 .when_some(g.width, |b, width| b.w(rem(width)))
1361 .h(rem(g.height))
1362 .pl(rem(left_padding))
1363 .pr(rem(right_padding))
1364 .gap(rem(g.gap))
1365 .map(|b| match corners {
1366 Some(corners) => b
1367 .rounded_tl(corners.top_left)
1368 .rounded_tr(corners.top_right)
1369 .rounded_br(corners.bottom_right)
1370 .rounded_bl(corners.bottom_left),
1371 None => b.rounded(rem(effects.radius)),
1372 })
1373 .bg(state::layer(bg, fg, effects.opacity))
1374 .text_color(fg)
1375 .font_family(ty.font_family)
1376 .text_size(ty.font_size)
1377 .line_height(ty.line_height)
1378 .font_weight(FontWeight(ty.font_weight as f32))
1379 .when(
1380 self.variant == ButtonVariant::Outlined && self.palette.is_none(),
1381 |b| b.border(rem(g.outline)).border_color(border),
1382 )
1383 .shadow(effects.shadows.into_shadows())
1384 .when(!self.disabled && self.preview.is_none(), |b| {
1385 b.cursor_pointer()
1386 })
1387 .refine_style(&self.style);
1388 let mut painted_style = Style::default();
1389 painted_style.refine(base.style());
1390 let (ripple_color, ripple_visible) = ripple_ink(&painted_style, fg);
1391 let base = ripple::ButtonRipple::new(
1392 base,
1393 self.id,
1394 self.ripple && self.preview.is_none(),
1395 !self.disabled && ripple_visible,
1396 painted_style.corner_radii,
1397 painted_style.border_widths,
1398 ripple_color,
1399 window,
1400 cx,
1401 )
1402 .hit_target(target_geometry);
1403 let forced_focus = self.preview == Some(InteractionPreview::Focused);
1404 let ring_enabled = !self.disabled && (self.focusable || forced_focus);
1405 match corners {
1406 Some(corners) => ButtonFocusRing::with_corners(base, focus, ring_enabled, corners),
1407 None => ButtonFocusRing::new(base, focus, ring_enabled, rem(effects.radius)),
1408 }
1409 .forced(forced_focus)
1410 }
1411}
1412pub fn material_button(id: impl Into<ElementId>, variant: ButtonVariant) -> MaterialButton {
1414 MaterialButton::new(id).variant(variant)
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::{ButtonSize, ButtonVariant, MaterialButton, alpha, colors};
1420 use crate::tokens::state::layer;
1421 use crate::{color::ColorRole, theme::color};
1422 use gpui::{
1423 Context, IntoElement, Modifiers, Render, TestAppContext, VisualTestContext, Window, div,
1424 prelude::*, px, rgba,
1425 };
1426
1427 type PaintedColors = (gpui::Rgba, gpui::Rgba, gpui::Rgba);
1428
1429 struct EffectsHarness {
1430 disabled: bool,
1431 ripple: bool,
1432 sample: std::rc::Rc<std::cell::RefCell<(f32, super::shadow::ShadowPair)>>,
1433 focus: gpui::FocusHandle,
1434 hovers: Vec<bool>,
1435 hover_mode: gpui::HoverListenerMode,
1436 activations: usize,
1437 }
1438 impl Render for EffectsHarness {
1439 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1440 window.set_rem_size(px(14.));
1441 crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
1442 let mut button = MaterialButton::new("effects")
1443 .ripple(self.ripple)
1444 .disabled(self.disabled)
1445 .track_focus(&self.focus)
1446 .debug_selector(|| "effects".into())
1447 .child("Effects")
1448 .on_click(cx.listener(|this, _, _, _| this.activations += 1));
1449 button = StatefulInteractiveElement::on_hover(
1450 button,
1451 cx.listener(|this, hovered, _, _| this.hovers.push(*hovered)),
1452 );
1453 button = StatefulInteractiveElement::hover_listener_mode(button, self.hover_mode);
1454 button.effects_observer = Some(self.sample.clone());
1455 div().p(px(30.)).child(button)
1456 }
1457 }
1458 #[gpui::test]
1459 fn default_ripple_suppresses_pressed_layer_but_retains_hover_and_keyboard_focus(
1460 cx: &mut TestAppContext,
1461 ) {
1462 use super::shadow::ShadowPair;
1463 use gpui::{MouseButton, MouseMoveEvent};
1464 use std::{cell::RefCell, rc::Rc, time::Duration};
1465 cx.update(gpui_base::init);
1466 assert!(MaterialButton::new("default").ripple);
1467 let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1468 let (_, cx) = cx.add_window_view({
1469 let sample = sample.clone();
1470 move |_, cx| EffectsHarness {
1471 disabled: false,
1472 ripple: true,
1473 sample,
1474 focus: cx.focus_handle(),
1475 hovers: vec![],
1476 hover_mode: gpui::HoverListenerMode::InputModalityAware,
1477 activations: 0,
1478 }
1479 });
1480 draw(cx);
1481 let center = cx.debug_bounds("effects").unwrap().center();
1482 cx.simulate_event(MouseMoveEvent {
1483 position: center,
1484 pressed_button: None,
1485 modifiers: Modifiers::default(),
1486 });
1487 cx.run_until_parked();
1488 draw(cx);
1489 cx.executor().advance_clock(Duration::from_millis(100));
1490 draw(cx);
1491 assert_eq!(sample.borrow().0, 0.08);
1492 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1493 draw(cx);
1494 cx.executor().advance_clock(Duration::from_millis(100));
1495 draw(cx);
1496 assert_eq!(
1497 sample.borrow().0,
1498 0.08,
1499 "a pointer wave coexists with hover, without adding the pressed state layer"
1500 );
1501 cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
1502 draw(cx);
1503 key(cx, "space", true);
1504 draw(cx);
1505 cx.executor().advance_clock(Duration::from_millis(100));
1506 draw(cx);
1507 assert_eq!(
1508 sample.borrow().0,
1509 0.1,
1510 "keyboard focus keeps its separate state layer"
1511 );
1512 key(cx, "space", false);
1513 }
1514 #[gpui::test]
1515 fn state_opacity_and_shadow_have_independent_event_clocks(cx: &mut TestAppContext) {
1516 use super::shadow::ShadowPair;
1517 use gpui::{MouseButton, MouseMoveEvent, point};
1518 use gpui_base::Interpolate;
1519 use std::{cell::RefCell, rc::Rc, time::Duration};
1520 cx.update(gpui_base::init);
1521 let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1522 let (view, cx) = cx.add_window_view({
1523 let sample = sample.clone();
1524 move |_, cx| EffectsHarness {
1525 ripple: false,
1526 disabled: false,
1527 sample,
1528 focus: cx.focus_handle(),
1529 hovers: vec![],
1530 hover_mode: gpui::HoverListenerMode::InputModalityAware,
1531 activations: 0,
1532 }
1533 });
1534 draw(cx);
1535 let center = cx.debug_bounds("effects").unwrap().center();
1536 cx.simulate_event(MouseMoveEvent {
1537 position: center,
1538 pressed_button: None,
1539 modifiers: Modifiers::default(),
1540 });
1541 draw(cx);
1542 cx.run_until_parked();
1543 draw(cx);
1544 assert_eq!(sample.borrow().0, 0.);
1545 cx.executor().advance_clock(Duration::from_millis(50));
1546 draw(cx);
1547 assert!((sample.borrow().0 - 0.0702267).abs() < 0.00001);
1549 cx.executor().advance_clock(Duration::from_millis(50));
1550 draw(cx);
1551 assert_eq!(sample.borrow().0, 0.08);
1552 let expected = ShadowPair::level(0).interpolate(
1553 &ShadowPair::level(1),
1554 crate::motion::MotionScheme::Expressive
1555 .resolve(crate::motion::MotionToken::DefaultEffects)
1556 .position(0.1),
1557 );
1558 assert_eq!(sample.borrow().1, expected);
1559 assert_ne!(sample.borrow().1, ShadowPair::level(1));
1560 cx.executor().advance_clock(Duration::from_millis(150));
1561 draw(cx);
1562 assert_eq!(sample.borrow().1, ShadowPair::level(1));
1563 view.update(cx, |view, cx| {
1566 view.disabled = true;
1567 cx.notify();
1568 });
1569 draw(cx);
1570 cx.executor().advance_clock(Duration::from_millis(250));
1571 draw(cx);
1572 assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1573 view.update(cx, |view, cx| {
1574 view.disabled = false;
1575 cx.notify();
1576 });
1577 draw(cx);
1578 cx.executor().advance_clock(Duration::from_millis(250));
1579 draw(cx);
1580 assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1581 cx.update(|_, cx| cx.set_reduce_motion(true));
1582 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1583 draw(cx);
1584 cx.simulate_mouse_move(center, MouseButton::Left, Modifiers::default());
1585 draw(cx);
1586 cx.executor().advance_clock(Duration::from_millis(50));
1587 draw(cx);
1588 assert!(
1589 (sample.borrow().0 - 0.0975567).abs() < 0.00001,
1590 "reduced motion preserves CSS state fade"
1591 );
1592 assert_ne!(
1593 sample.borrow().1,
1594 ShadowPair::level(0),
1595 "reduced motion preserves CSS shadow transition"
1596 );
1597 cx.executor().advance_clock(Duration::from_millis(50));
1598 draw(cx);
1599 assert_eq!(sample.borrow().0, 0.10);
1600 cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
1601 draw(cx);
1602 cx.executor().advance_clock(Duration::from_millis(250));
1603 draw(cx);
1604 assert_eq!(
1605 sample.borrow().0,
1606 0.08,
1607 "held move's GPUI hover=false must not lose hover on release"
1608 );
1609 assert_eq!(sample.borrow().1, ShadowPair::level(1));
1610 cx.update(|_, cx| {
1611 assert!(!view.read(cx).hovers.is_empty());
1612 assert_eq!(view.read(cx).activations, 1);
1613 });
1614 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1615 draw(cx);
1616 cx.simulate_mouse_up(
1617 point(px(900.), px(800.)),
1618 MouseButton::Left,
1619 Modifiers::default(),
1620 );
1621 draw(cx);
1622 cx.executor().advance_clock(Duration::from_millis(250));
1623 draw(cx);
1624 assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1625 key(cx, "a", true);
1627 draw(cx);
1628 cx.executor().advance_clock(Duration::from_millis(100));
1629 draw(cx);
1630 assert_eq!(sample.borrow().0, 0.10);
1631 view.update(cx, |view, cx| {
1632 view.disabled = true;
1633 cx.notify();
1634 });
1635 draw(cx);
1636 cx.executor().advance_clock(Duration::from_millis(250));
1637 draw(cx);
1638 assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1639 view.update(cx, |view, cx| {
1640 view.disabled = false;
1641 cx.notify();
1642 });
1643 cx.update(|window, cx| {
1644 window.blur(cx);
1645 window.activate_window();
1646 });
1647 cx.run_until_parked();
1648 cx.simulate_event(MouseMoveEvent {
1649 position: center,
1650 pressed_button: None,
1651 modifiers: Modifiers::default(),
1652 });
1653 draw(cx);
1654 cx.run_until_parked();
1655 draw(cx);
1656 cx.executor().advance_clock(Duration::from_millis(250));
1657 draw(cx);
1658 assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1659 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1660 draw(cx);
1661 cx.executor().advance_clock(Duration::from_millis(100));
1662 draw(cx);
1663 assert_eq!(sample.borrow().0, 0.10);
1664 cx.deactivate_window();
1665 draw(cx);
1666 cx.executor().advance_clock(Duration::from_millis(250));
1667 for _ in 0..3 {
1668 cx.run_until_parked();
1669 draw(cx);
1670 assert_eq!(
1671 *sample.borrow(),
1672 (0., ShadowPair::level(0)),
1673 "deactivation must not revive hover from the cached pointer position"
1674 );
1675 cx.update(|_, cx| assert_eq!(view.read(cx).hovers.last(), Some(&false)));
1676 }
1677 cx.simulate_mouse_up(
1678 point(px(900.), px(800.)),
1679 MouseButton::Left,
1680 Modifiers::default(),
1681 );
1682 cx.update(|window, _| window.activate_window());
1683 cx.run_until_parked();
1684 cx.simulate_event(MouseMoveEvent {
1685 position: center,
1686 pressed_button: None,
1687 modifiers: Modifiers::default(),
1688 });
1689 draw(cx);
1690 cx.run_until_parked();
1691 draw(cx);
1692 cx.executor().advance_clock(Duration::from_millis(250));
1693 draw(cx);
1694 assert_eq!(
1695 *sample.borrow(),
1696 (0.08, ShadowPair::level(1)),
1697 "a real pointer move after activation restores hover"
1698 );
1699 cx.update(|_, cx| assert_eq!(view.read(cx).hovers.last(), Some(&true)));
1700 }
1701
1702 #[gpui::test]
1703 fn keyboard_hover_modes_preserve_caller_events_and_material_elevation(cx: &mut TestAppContext) {
1704 use super::shadow::ShadowPair;
1705 use gpui::{HoverListenerMode, MouseMoveEvent, point};
1706 use std::{cell::RefCell, rc::Rc, time::Duration};
1707 cx.update(gpui_base::init);
1708 let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1709 let (view, cx) = cx.add_window_view({
1710 let sample = sample.clone();
1711 move |_, cx| EffectsHarness {
1712 ripple: false,
1713 disabled: false,
1714 sample,
1715 focus: cx.focus_handle(),
1716 hovers: vec![],
1717 hover_mode: HoverListenerMode::InputModalityAware,
1718 activations: 0,
1719 }
1720 });
1721 draw(cx);
1722 let center = cx.debug_bounds("effects").unwrap().center();
1723 cx.simulate_event(MouseMoveEvent {
1724 position: center,
1725 pressed_button: None,
1726 modifiers: Modifiers::default(),
1727 });
1728 draw(cx);
1729 cx.run_until_parked();
1730 draw(cx);
1731 cx.executor().advance_clock(Duration::from_millis(250));
1732 draw(cx);
1733 assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1734 cx.update(|window, cx| {
1735 let focus = view.read(cx).focus.clone();
1736 window.focus(&focus, cx);
1737 });
1738 key(cx, "a", true);
1739 draw(cx);
1740 cx.run_until_parked();
1741 draw(cx);
1742 cx.executor().advance_clock(Duration::from_millis(100));
1743 draw(cx);
1744 assert_eq!(*sample.borrow(), (0.10, ShadowPair::level(1)));
1745 cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false]));
1746 view.update(cx, |view, cx| {
1747 view.hover_mode = HoverListenerMode::InputModalityIndependent;
1748 cx.notify();
1749 });
1750 draw(cx);
1751 cx.run_until_parked();
1752 draw(cx);
1753 cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true]));
1754 key(cx, "b", true);
1755 draw(cx);
1756 cx.run_until_parked();
1757 cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true]));
1758 cx.simulate_event(gpui::TouchEvent {
1759 id: gpui::TouchId(1),
1760 phase: gpui::TouchPhase::Started,
1761 position: center,
1762 ..Default::default()
1763 });
1764 draw(cx);
1765 cx.executor().advance_clock(Duration::from_millis(250));
1766 draw(cx);
1767 assert_eq!(
1768 *sample.borrow(),
1769 (0.1, ShadowPair::level(0)),
1770 "without ripple, held touch uses its pressed layer and drops hover elevation"
1771 );
1772 cx.simulate_event(gpui::TouchEvent {
1773 id: gpui::TouchId(1),
1774 phase: gpui::TouchPhase::Ended,
1775 position: center,
1776 ..Default::default()
1777 });
1778 cx.update(|window, cx| window.blur(cx));
1779 cx.simulate_event(gpui::MouseExitEvent {
1780 position: point(px(1200.), px(1000.)),
1781 pressed_button: None,
1782 modifiers: Modifiers::default(),
1783 });
1784 draw(cx);
1785 cx.executor().advance_clock(Duration::from_millis(250));
1786 draw(cx);
1787 assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1788 cx.run_until_parked();
1789 draw(cx);
1790 cx.run_until_parked();
1791 draw(cx);
1792 assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1793 cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true, false]));
1794 }
1795
1796 struct ColorsHarness {
1797 variant: ButtonVariant,
1798 disabled: bool,
1799 palette: crate::color::PaletteId,
1800 sample: std::rc::Rc<std::cell::Cell<PaintedColors>>,
1801 }
1802
1803 impl Render for ColorsHarness {
1804 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1805 crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
1806 crate::theme::apply_scheme(
1807 crate::color::Scheme::from_palette(self.palette),
1808 window,
1809 cx,
1810 );
1811 let mut button = MaterialButton::new("colors-button")
1812 .variant(self.variant)
1813 .disabled(self.disabled)
1814 .child("Colors");
1815 button.colors_observer = Some(self.sample.clone());
1816 button
1817 }
1818 }
1819
1820 fn assert_colors_close(actual: PaintedColors, expected: PaintedColors) {
1821 for (actual, expected) in [
1822 (actual.0, expected.0),
1823 (actual.1, expected.1),
1824 (actual.2, expected.2),
1825 ] {
1826 for (actual, expected) in [
1827 (actual.r, expected.r),
1828 (actual.g, expected.g),
1829 (actual.b, expected.b),
1830 (actual.a, expected.a),
1831 ] {
1832 assert!(
1833 (actual - expected).abs() < 0.00001,
1834 "{actual} != {expected}"
1835 );
1836 }
1837 }
1838 }
1839
1840 #[gpui::test]
1841 fn rendered_button_colors_keep_state_timing_across_theme_changes(cx: &mut TestAppContext) {
1842 use crate::color::PaletteId;
1843 use std::{cell::Cell, rc::Rc, time::Duration};
1844 cx.update(gpui_base::init);
1845 let sample = Rc::new(Cell::new((rgba(0), rgba(0), rgba(0))));
1846 let (view, cx) = cx.add_window_view({
1847 let sample = sample.clone();
1848 move |_, _| ColorsHarness {
1849 variant: ButtonVariant::Filled,
1850 disabled: false,
1851 palette: PaletteId::Paper,
1852 sample,
1853 }
1854 });
1855 cx.update(|window, cx| {
1856 cx.set_reduce_motion(true);
1857 window.refresh();
1858 window.draw(cx).clear(cx);
1859 cx.set_reduce_motion(false);
1860 });
1861 let initial = sample.get();
1862 cx.update(|window, cx| {
1863 view.update(cx, |view, _| view.variant = ButtonVariant::Tonal);
1864 window.refresh();
1865 window.draw(cx).clear(cx);
1866 });
1867 assert_colors_close(sample.get(), initial);
1868 cx.executor().advance_clock(Duration::from_millis(125));
1869 draw(cx);
1870 let filled = colors(ButtonVariant::Filled, false);
1871 let tonal = colors(ButtonVariant::Tonal, false);
1872 let progress = crate::motion::MotionScheme::Expressive
1873 .resolve(crate::motion::MotionToken::DefaultEffects)
1874 .position(0.125);
1875 assert_colors_close(
1876 sample.get(),
1877 (
1878 crate::theme::blend(filled.0, tonal.0, progress),
1879 crate::theme::blend(filled.1, tonal.1, progress),
1880 tonal.2,
1881 ),
1882 );
1883 let midpoint = sample.get();
1884 cx.update(|window, cx| {
1885 view.update(cx, |view, _| view.palette = PaletteId::Ink);
1886 window.refresh();
1887 window.draw(cx).clear(cx);
1888 });
1889 assert_colors_close(sample.get(), midpoint);
1890 cx.executor().advance_clock(Duration::from_millis(125));
1891 draw(cx);
1892 assert_ne!(
1893 crate::theme::scheme(),
1894 crate::color::Scheme::from_palette(PaletteId::Paper)
1895 );
1896 assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1897
1898 cx.update(|window, cx| {
1899 view.update(cx, |view, _| view.disabled = true);
1900 window.refresh();
1901 window.draw(cx).clear(cx);
1902 });
1903 cx.executor().advance_clock(Duration::from_millis(50));
1904 draw(cx);
1905 let disabling = sample.get();
1906 assert!(disabling.0.a > 0.10 && disabling.0.a < 1.);
1907 assert!(disabling.1.a > 0.38 && disabling.1.a < 1.);
1908 cx.update(|window, cx| {
1909 view.update(cx, |view, _| view.disabled = false);
1910 window.refresh();
1911 window.draw(cx).clear(cx);
1912 });
1913 assert_colors_close(sample.get(), disabling);
1914 cx.executor().advance_clock(Duration::from_millis(250));
1915 draw(cx);
1916 assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1917 cx.update(|window, cx| {
1918 cx.set_reduce_motion(true);
1919 view.update(cx, |view, _| view.disabled = true);
1920 window.refresh();
1921 window.draw(cx).clear(cx);
1922 });
1923 assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1924 cx.executor().advance_clock(Duration::from_millis(250));
1925 draw(cx);
1926 assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, true));
1927 }
1928
1929 #[test]
1930 fn transparent_state_layers_preserve_content_rgb_and_opacity() {
1931 let content = rgba(0x6750a4ff);
1932 let hovered = layer(rgba(0), content, 0.08);
1933 for (a, b) in [
1934 (hovered.r, content.r),
1935 (hovered.g, content.g),
1936 (hovered.b, content.b),
1937 ] {
1938 assert!((a - b).abs() < 1e-6);
1939 }
1940 assert_eq!(hovered.a, 0.08);
1941 let filled = layer(rgba(0xffffffff), content, 0.10);
1942 assert_eq!(filled.a, 1.);
1943 assert!((filled.r - (1. * 0.9 + content.r * 0.1)).abs() < 0.00001);
1944 }
1945 fn figma_buttons() -> serde_json::Value {
1946 serde_json::from_str(include_str!("../../tests/fixtures/figma-button.json")).unwrap()
1947 }
1948
1949 pub(super) fn figma_role(variable: &str) -> (ColorRole, f32) {
1951 match variable {
1952 "Schemes/On Surface" => (ColorRole::ON_SURFACE, 1.),
1953 "Schemes/Outline Variant" => (ColorRole::OUTLINE_VARIANT, 1.),
1954 "State Layers/On Surface/Opacity-10" => (ColorRole::ON_SURFACE, 0.10),
1955 other => panic!("unexpected kit variable {other}"),
1956 }
1957 }
1958
1959 #[test]
1960 fn size_geometry_matches_the_figma_kit_buttons() {
1961 let fixture = figma_buttons();
1962 let px = |value: &serde_json::Value| value.as_f64().unwrap() as f32;
1963 for (size, name) in ButtonSize::ALL
1964 .into_iter()
1965 .zip(["XSmall", "Small", "Medium", "Large", "XLarge"])
1966 {
1967 let reference = &fixture["sizes"][name];
1968 let g = size.geometry();
1969 assert_eq!(g.height, px(&reference["container_height"]), "{name}");
1970 assert_eq!(size.icon_size(), px(&reference["icon_size"]), "{name}");
1971 assert_eq!(g.outline, px(&reference["outline_width"]), "{name}");
1972 assert_eq!(g.square, px(&reference["shape_square"]), "{name}");
1973 assert_eq!(g.pressed, px(&reference["shape_pressed"]), "{name}");
1974 assert_eq!(g.gap, px(&reference["icon_label_gap"]), "{name}");
1975 let padding = reference["padding"].as_array().unwrap();
1976 assert_eq!(padding[1], padding[3], "{name}");
1978 assert_eq!(
1979 super::horizontal_padding(size),
1980 (px(&padding[3]), px(&padding[1])),
1981 "{name}"
1982 );
1983 let style = reference["label_style"].as_str().unwrap();
1984 let role = g.typography.name();
1985 assert_eq!(
1986 style,
1987 format!("M3/{}", role.replacen('-', "/", 1)),
1988 "{name} label style"
1989 );
1990 }
1991 }
1992
1993 #[test]
1994 fn disabled_buttons_match_the_figma_kit() {
1995 let fixture = figma_buttons();
1996 let disabled = &fixture["disabled"];
1997 for variant in ButtonVariant::ALL {
1998 let (bg, fg, border) = colors(variant, true);
1999 let reference = &disabled[match variant {
2000 ButtonVariant::Filled => "filled",
2001 ButtonVariant::Elevated => "elevated",
2002 ButtonVariant::Tonal => "tonal",
2003 ButtonVariant::Outlined => "outlined",
2004 ButtonVariant::Text => "text",
2005 }];
2006 let (container, opacity) =
2007 figma_role(reference["container"]["variable"].as_str().unwrap());
2008 assert_eq!(
2009 bg,
2010 alpha(color(container), opacity),
2011 "{variant:?} container"
2012 );
2013 let (content, _) = figma_role(reference["content"]["variable"].as_str().unwrap());
2014 let content_opacity = reference["content"]["opacity"].as_f64().unwrap() as f32;
2015 assert_eq!(
2016 reference["content"]["icon_opacity"], reference["content"]["opacity"],
2017 "{variant:?}"
2018 );
2019 assert_eq!(fg, alpha(color(content), content_opacity), "{variant:?}");
2020 match reference["outline"].as_object() {
2021 Some(outline) => {
2022 let (role, opacity) = figma_role(outline["variable"].as_str().unwrap());
2023 assert_eq!(border, alpha(color(role), opacity), "{variant:?} outline");
2024 }
2025 None => assert_eq!(border.a, 0., "{variant:?} outline"),
2026 }
2027 }
2028 assert_eq!(
2029 colors(ButtonVariant::Text, false).1,
2030 color(ColorRole::PRIMARY)
2031 );
2032 assert_eq!(
2033 colors(ButtonVariant::Outlined, false).1,
2034 color(ColorRole::ON_SURFACE_VARIANT)
2035 );
2036 assert_eq!(
2037 colors(ButtonVariant::Outlined, false).2,
2038 color(ColorRole::OUTLINE_VARIANT)
2039 );
2040 }
2041
2042 struct Harness {
2043 disabled: bool,
2044 activations: usize,
2045 }
2046 impl Render for Harness {
2047 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2048 window.set_rem_size(px(14.));
2049 div().flex().flex_col().items_start().gap(px(10.)).children(
2050 ButtonSize::ALL.into_iter().map(|size| {
2051 MaterialButton::new(size.name())
2052 .button_size(size)
2053 .debug_selector(move || size.name().into())
2054 .disabled(self.disabled)
2055 .child("Go")
2056 .on_click(cx.listener(|this, _, _, cx| {
2057 this.activations += 1;
2058 cx.notify();
2059 }))
2060 }),
2061 )
2062 }
2063 }
2064 fn draw(cx: &mut VisualTestContext) {
2065 cx.update(|w, cx| {
2066 w.refresh();
2067 w.draw(cx).clear(cx);
2068 });
2069 }
2070 #[gpui::test]
2071 fn all_sizes_keep_visual_height_and_base_keyboard_disabled_behavior(cx: &mut TestAppContext) {
2072 cx.update(|cx| {
2073 gpui_base::init(cx);
2074 cx.set_reduce_motion(true);
2075 });
2076 let (view, cx) = cx.add_window_view(|_, _| Harness {
2077 disabled: false,
2078 activations: 0,
2079 });
2080 draw(cx);
2081 for (name, height) in [
2082 ("xs", 32.),
2083 ("s", 40.),
2084 ("m", 56.),
2085 ("l", 96.),
2086 ("xl", 136.),
2087 ] {
2088 let bounds = cx.debug_bounds(name).unwrap();
2089 assert_eq!(bounds.size.height, px(height));
2090 assert!(bounds.size.width >= px(58.));
2091 }
2092 let location = cx.debug_bounds("s").unwrap().center();
2093 cx.simulate_click(location, Modifiers::default());
2094 draw(cx);
2095 for key in ["enter", "space"] {
2096 let keystroke = cx.update(|_, cx| {
2097 gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap()
2098 });
2099 cx.simulate_event(gpui::KeyDownEvent {
2100 keystroke: keystroke.clone(),
2101 is_held: false,
2102 prefer_character_input: false,
2103 });
2104 cx.simulate_event(gpui::KeyUpEvent { keystroke });
2105 }
2106 draw(cx);
2107 cx.update(|_, cx| assert_eq!(view.read(cx).activations, 3));
2108 view.update(cx, |this, cx| {
2109 this.disabled = true;
2110 cx.notify();
2111 });
2112 draw(cx);
2113 cx.simulate_click(location, Modifiers::default());
2114 draw(cx);
2115 for key in ["enter", "space"] {
2116 let keystroke = cx.update(|_, cx| {
2117 gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap()
2118 });
2119 cx.simulate_event(gpui::KeyDownEvent {
2120 keystroke: keystroke.clone(),
2121 is_held: false,
2122 prefer_character_input: false,
2123 });
2124 cx.simulate_event(gpui::KeyUpEvent { keystroke });
2125 }
2126 draw(cx);
2127 cx.update(|_, cx| assert_eq!(view.read(cx).activations, 3));
2128 }
2129 struct MotionHarness {
2130 disabled: bool,
2131 shown: bool,
2132 sample: std::rc::Rc<std::cell::Cell<f32>>,
2133 focus: gpui::FocusHandle,
2134 other: gpui::FocusHandle,
2135 activations: usize,
2136 }
2137 impl Render for MotionHarness {
2138 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2139 window.set_rem_size(px(14.));
2140 crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
2141 let mut button = MaterialButton::new("motion-button")
2142 .track_focus(&self.focus)
2143 .button_size(ButtonSize::Large)
2144 .disabled(self.disabled)
2145 .child("Hold")
2146 .debug_selector(|| "motion-button".into())
2147 .on_click(cx.listener(|this, _, _, _| this.activations += 1));
2148 button.radius_observer = Some(self.sample.clone());
2149 div()
2150 .flex()
2151 .gap(px(30.))
2152 .children(self.shown.then_some(button))
2153 .child(
2154 gpui_base::Button::new("other")
2155 .track_focus(&self.other)
2156 .child("Other"),
2157 )
2158 }
2159 }
2160 fn key(cx: &mut VisualTestContext, name: &str, down: bool) {
2161 let keystroke =
2162 cx.update(|_, cx| gpui::Keystroke::parse_with_os(name, cx.platform_info().os).unwrap());
2163 if down {
2164 cx.simulate_event(gpui::KeyDownEvent {
2165 keystroke,
2166 is_held: false,
2167 prefer_character_input: false,
2168 });
2169 } else {
2170 cx.simulate_event(gpui::KeyUpEvent { keystroke });
2171 }
2172 }
2173 #[gpui::test]
2174 fn raw_touch_animates_held_shape_and_cancellation_preserves_base_activation(
2175 cx: &mut TestAppContext,
2176 ) {
2177 use gpui::{TouchEvent, TouchId, TouchPhase};
2178 use std::{cell::Cell, rc::Rc, time::Duration};
2179 cx.update(gpui_base::init);
2180 let sample = Rc::new(Cell::new(0.));
2181 let (view, cx) = cx.add_window_view({
2182 let sample = sample.clone();
2183 move |_, cx| MotionHarness {
2184 disabled: false,
2185 shown: true,
2186 sample,
2187 focus: cx.focus_handle(),
2188 other: cx.focus_handle(),
2189 activations: 0,
2190 }
2191 });
2192 draw(cx);
2193 let center = cx.debug_bounds("motion-button").unwrap().center();
2194 cx.simulate_event(TouchEvent {
2195 id: TouchId(1),
2196 phase: TouchPhase::Started,
2197 position: center,
2198 ..Default::default()
2199 });
2200 draw(cx);
2201 assert_eq!(sample.get(), 48.);
2202 cx.executor().advance_clock(Duration::from_millis(100));
2203 draw(cx);
2204 let expected = 48.
2205 + (16. - 48.)
2206 * crate::motion::MotionScheme::Expressive
2207 .resolve(crate::motion::MotionToken::FastSpatial)
2208 .position(0.1);
2209 assert!((sample.get() - expected).abs() < 0.001);
2210 cx.update(|_, cx| assert_eq!(view.read(cx).activations, 0));
2211 cx.simulate_event(TouchEvent {
2212 id: TouchId(1),
2213 phase: TouchPhase::Cancelled,
2214 position: center,
2215 ..Default::default()
2216 });
2217 draw(cx);
2218 cx.executor().advance_clock(Duration::from_millis(500));
2219 draw(cx);
2220 assert_eq!(sample.get(), 48.);
2221 cx.update(|_, cx| {
2222 assert_eq!(view.read(cx).activations, 0);
2223 cx.set_reduce_motion(true);
2224 });
2225 cx.simulate_event(TouchEvent {
2226 id: TouchId(2),
2227 phase: TouchPhase::Started,
2228 position: center,
2229 ..Default::default()
2230 });
2231 draw(cx);
2232 assert_eq!(sample.get(), 16.);
2233 cx.simulate_event(TouchEvent {
2234 id: TouchId(2),
2235 phase: TouchPhase::Ended,
2236 position: center,
2237 ..Default::default()
2238 });
2239 draw(cx);
2240 assert_eq!(sample.get(), 48.);
2241 cx.update(|_, cx| assert_eq!(view.read(cx).activations, 1));
2242 }
2243 #[gpui::test]
2244 fn press_radius_matches_reference_and_retargets_without_jumps(cx: &mut TestAppContext) {
2245 use gpui::{MouseButton, point};
2246 use std::{cell::Cell, rc::Rc, time::Duration};
2247 cx.update(gpui_base::init);
2248 let sample = Rc::new(Cell::new(0.));
2249 let (_, cx) = cx.add_window_view({
2250 let sample = sample.clone();
2251 move |_, cx| MotionHarness {
2252 disabled: false,
2253 shown: true,
2254 sample,
2255 focus: cx.focus_handle(),
2256 other: cx.focus_handle(),
2257 activations: 0,
2258 }
2259 });
2260 draw(cx);
2261 let center = cx.debug_bounds("motion-button").unwrap().center();
2262 assert_eq!(sample.get(), 48.);
2263 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2264 draw(cx);
2265 assert_eq!(sample.get(), 48.);
2266 cx.executor().advance_clock(Duration::from_millis(100));
2267 draw(cx);
2268 let expected = 48.
2269 + (16. - 48.)
2270 * crate::motion::MotionScheme::Expressive
2271 .resolve(crate::motion::MotionToken::FastSpatial)
2272 .position(0.1);
2273 assert!((sample.get() - expected).abs() < 0.001);
2274 let before = sample.get();
2275 cx.simulate_mouse_move(
2276 center + point(px(1.), px(1.)),
2277 MouseButton::Left,
2278 Modifiers::default(),
2279 );
2280 draw(cx);
2281 assert!(
2282 (sample.get() - before).abs() < 0.001,
2283 "held move must not release"
2284 );
2285 cx.simulate_mouse_up(
2286 point(px(900.), px(800.)),
2287 MouseButton::Left,
2288 Modifiers::default(),
2289 );
2290 draw(cx);
2291 assert!(
2292 (sample.get() - before).abs() < 0.001,
2293 "release must retarget continuously"
2294 );
2295 cx.executor().advance_clock(Duration::from_secs(1));
2296 draw(cx);
2297 assert_eq!(sample.get(), 48.);
2298 cx.update(|_, cx| cx.set_reduce_motion(true));
2299 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2300 draw(cx);
2301 assert_eq!(sample.get(), 16.);
2302 cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
2303 draw(cx);
2304 assert_eq!(sample.get(), 48.);
2305 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2306 draw(cx);
2307 cx.simulate_mouse_move(
2308 point(px(900.), px(800.)),
2309 MouseButton::Left,
2310 Modifiers::default(),
2311 );
2312 draw(cx);
2313 assert_eq!(sample.get(), 16.);
2314 cx.simulate_event(gpui::MouseExitEvent {
2315 position: point(px(1200.), px(1000.)),
2316 pressed_button: Some(MouseButton::Left),
2317 modifiers: Modifiers::default(),
2318 });
2319 draw(cx);
2320 assert_eq!(
2321 sample.get(),
2322 48.,
2323 "leaving window after moving outside must clear press"
2324 );
2325 }
2326 #[gpui::test]
2327 fn pressed_visuals_clear_on_blur_disable_and_remount(cx: &mut TestAppContext) {
2328 use gpui::MouseButton;
2329 use std::{cell::Cell, rc::Rc};
2330 cx.update(|cx| {
2331 gpui_base::init(cx);
2332 cx.set_reduce_motion(true);
2333 });
2334 let sample = Rc::new(Cell::new(0.));
2335 let (view, cx) = cx.add_window_view({
2336 let sample = sample.clone();
2337 move |_, cx| MotionHarness {
2338 disabled: false,
2339 shown: true,
2340 sample,
2341 focus: cx.focus_handle(),
2342 other: cx.focus_handle(),
2343 activations: 0,
2344 }
2345 });
2346 draw(cx);
2347 cx.update(|window, cx| {
2348 let handle = view.read(cx).focus.clone();
2349 window.focus(&handle, cx);
2350 });
2351 draw(cx);
2352 key(cx, "space", true);
2353 draw(cx);
2354 assert_eq!(sample.get(), 16.);
2355 key(cx, "a", false);
2356 draw(cx);
2357 assert_eq!(
2358 sample.get(),
2359 16.,
2360 "unrelated key release does not release Space"
2361 );
2362 for other in ["shift", "a", "shift-a"] {
2363 key(cx, other, true);
2364 draw(cx);
2365 assert_eq!(
2366 sample.get(),
2367 16.,
2368 "{other} pressed while holding Space keeps the pressed visual"
2369 );
2370 key(cx, other, false);
2371 }
2372 cx.update(|window, cx| {
2373 let handle = view.read(cx).other.clone();
2374 window.focus(&handle, cx);
2375 });
2376 draw(cx);
2377 assert_eq!(sample.get(), 48.);
2378 cx.update(|window, cx| {
2379 let handle = view.read(cx).focus.clone();
2380 window.focus(&handle, cx);
2381 });
2382 draw(cx);
2383 key(cx, "space", false);
2384 draw(cx);
2385 cx.update(|_, cx| assert_eq!(view.read(cx).activations, 0));
2386 key(cx, "space", true);
2387 draw(cx);
2388 view.update(cx, |this, cx| {
2389 this.disabled = true;
2390 cx.notify();
2391 });
2392 draw(cx);
2393 assert_eq!(sample.get(), 48.);
2394 key(cx, "space", false);
2395 view.update(cx, |this, cx| {
2396 this.disabled = false;
2397 cx.notify();
2398 });
2399 draw(cx);
2400 let center = cx.debug_bounds("motion-button").unwrap().center();
2401 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2402 draw(cx);
2403 assert_eq!(sample.get(), 16.);
2404 view.update(cx, |this, cx| {
2405 this.shown = false;
2406 cx.notify();
2407 });
2408 draw(cx);
2409 cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
2410 view.update(cx, |this, cx| {
2411 this.shown = true;
2412 cx.notify();
2413 });
2414 draw(cx);
2415 assert_eq!(sample.get(), 48.);
2416 cx.update(|window, _| window.activate_window());
2417 cx.run_until_parked();
2418 cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2419 draw(cx);
2420 assert_eq!(sample.get(), 16.);
2421 cx.deactivate_window();
2422 draw(cx);
2423 assert_eq!(sample.get(), 48., "window deactivation clears a held press");
2424 }
2425 struct IconHarness {
2426 size: ButtonSize,
2427 variant: ButtonVariant,
2428 position: super::ButtonIconPosition,
2429 direction: super::ButtonDirection,
2430 }
2431 impl Render for IconHarness {
2432 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2433 window.set_rem_size(px(14.));
2434 div().flex().items_start().child(
2435 MaterialButton::new("icon-button")
2436 .button_size(self.size)
2437 .variant(self.variant)
2438 .icon_position(self.position)
2439 .direction(self.direction)
2440 .icon(crate::icons::IconName::Check.icon().size(px(100.)))
2441 .debug_selector(|| "icon-button".into())
2442 .child(
2443 div()
2444 .w(px(40.))
2445 .h(px(10.))
2446 .debug_selector(|| "icon-label".into()),
2447 ),
2448 )
2449 }
2450 }
2451 #[gpui::test]
2452 fn icon_slots_and_logical_padding_match_all_sizes_variants_and_directions(
2453 cx: &mut TestAppContext,
2454 ) {
2455 use super::{ButtonDirection, ButtonIconPosition};
2456 cx.update(gpui_base::init);
2457 let (view, cx) = cx.add_window_view(|_, _| IconHarness {
2458 size: ButtonSize::Small,
2459 variant: ButtonVariant::Filled,
2460 position: ButtonIconPosition::Leading,
2461 direction: ButtonDirection::Ltr,
2462 });
2463 for (size, icon, gap, pad) in [
2464 (ButtonSize::ExtraSmall, 20., 4., 12.),
2465 (ButtonSize::Small, 20., 8., 16.),
2466 (ButtonSize::Medium, 24., 8., 24.),
2467 (ButtonSize::Large, 32., 12., 48.),
2468 (ButtonSize::ExtraLarge, 40., 16., 64.),
2469 ] {
2470 for variant in ButtonVariant::ALL {
2471 for direction in [ButtonDirection::Ltr, ButtonDirection::Rtl] {
2472 for position in [ButtonIconPosition::Leading, ButtonIconPosition::Trailing] {
2473 view.update(cx, |this, cx| {
2474 this.size = size;
2475 this.variant = variant;
2476 this.position = position;
2477 this.direction = direction;
2478 cx.notify();
2479 });
2480 draw(cx);
2481 let button = cx.debug_bounds("icon-button").unwrap();
2482 let label = cx.debug_bounds("icon-label").unwrap();
2483 let (icon_side, label_side) = (pad, pad);
2485 let border = if variant == ButtonVariant::Outlined {
2486 match size {
2487 ButtonSize::Large | ButtonSize::ExtraLarge => 2.,
2488 _ => 1.,
2489 }
2490 } else {
2491 0.
2492 };
2493 let expected = icon_side + label_side + icon + gap + 40. + 2. * border;
2494 assert!(
2495 (f32::from(button.size.width) - expected).abs() < 0.01,
2496 "{size:?} {variant:?} {position:?} {direction:?}"
2497 );
2498 let icon_left = (position == ButtonIconPosition::Leading)
2499 == (direction == ButtonDirection::Ltr);
2500 let expected_label = if icon_left {
2501 icon_side + icon + gap + border
2502 } else {
2503 label_side + border
2504 };
2505 assert!(
2506 (f32::from(label.left() - button.left()) - expected_label).abs() < 0.01,
2507 "label: {size:?} {variant:?} {position:?} {direction:?}"
2508 );
2509 }
2510 }
2511 }
2512 }
2513 }
2514}