Skip to main content

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}