gpui_kit/foundation/interaction.rs
1//! The two responses a control gives the pointer.
2//!
3//! Both are painted, never laid out: they move an element by a relative inset
4//! and change its shadow, so a control that is pressed or hovered occupies
5//! exactly the space it did before and nothing beside it shifts. Both are
6//! nothing at all under [`gpui::App::reduce_motion`].
7
8use gpui::{App, InteractiveElement, StatefulInteractiveElement, StyleRefinement, Styled, px};
9use gpui_kit_theme::{ActiveTheme, Elevation};
10
11/// The response a control gives while the pointer is held on it.
12pub trait Pressable: StatefulInteractiveElement + Styled + Sized {
13 /// Sinks the control by `motion.pressOffsetPx` while it is held.
14 ///
15 /// This is a downward shift, not a scale: the GPUI revision this library
16 /// pins offers a transform on `svg` alone, so there is no way to scale a
17 /// div and pretending otherwise would mean redrawing the control at a
18 /// different size, which is a layout change wearing a costume. A press is
19 /// therefore reported the way a physical key reports one — it goes down.
20 fn pressable(self, cx: &App) -> Self {
21 if cx.reduce_motion() {
22 return self;
23 }
24 let sink = px(cx.theme().motion.press_offset);
25 self.active(|style| style.top(sink))
26 }
27}
28
29impl<T: StatefulInteractiveElement + Styled + Sized> Pressable for T {}
30
31/// The response a surface gives while the pointer is over it.
32pub trait HoverLift: InteractiveElement + Styled + Sized {
33 /// Raises the surface by `motion.hoverLiftPx` onto the raised elevation
34 /// shadow while it is hovered.
35 ///
36 /// The shadow does the work and the pixel of travel only sells it, so a
37 /// theme that casts no shadow at all still gets a legible response.
38 fn hover_lift(self, cx: &App) -> Self {
39 self.hover_lift_with(cx, |style| style)
40 }
41
42 /// The same lift, folded into a hover style the caller already wanted.
43 ///
44 /// GPUI allows exactly one hover style per element, so a surface that
45 /// already washes its background on hover cannot ask for the lift
46 /// separately; it asks for both at once. `wash` is applied whether or not
47 /// motion is reduced, because a colour change is not motion.
48 fn hover_lift_with(
49 self,
50 cx: &App,
51 wash: impl FnOnce(StyleRefinement) -> StyleRefinement,
52 ) -> Self {
53 if cx.reduce_motion() {
54 return self.hover(wash);
55 }
56 let theme = cx.theme();
57 let lift = px(-theme.motion.hover_lift);
58 let shadow = theme.shadow(Elevation::Raised).to_vec();
59 self.hover(move |style| wash(style).top(lift).shadow(shadow.clone()))
60 }
61}
62
63impl<T: InteractiveElement + Styled + Sized> HoverLift for T {}
64
65#[cfg(test)]
66mod tests {
67 use gpui_kit_theme::Theme;
68
69 /// A response that travelled far enough to be measured would be a layout
70 /// change, and this library does not move a control because a pointer
71 /// arrived over it.
72 #[test]
73 fn the_pointer_responses_stay_within_a_hairline() {
74 for theme in [Theme::studio_dark(), Theme::studio_light()] {
75 assert!(theme.motion.press_offset > 0.0);
76 assert!(theme.motion.press_offset <= theme.borders.thick);
77 assert!(theme.motion.hover_lift > 0.0);
78 assert!(theme.motion.hover_lift <= theme.borders.thick);
79 }
80 }
81}