Skip to main content

gpui_kit_material/components/
preview.rs

1//! Forced, static interaction states for specimen boards.
2//!
3//! Design-kit pages show every control in each of its interaction states side
4//! by side. `preview_state` on an interactive component replaces its live
5//! hover, focus and press tracking with one fixed [`InteractionPreview`] and
6//! paints exactly what that live state paints: the same state-layer opacity
7//! from [`crate::tokens::state`], the same keyboard focus ring and, for
8//! `Pressed`, the pressed shape with the press layer at rest (a ripple that has
9//! finished spreading covers the container at the press opacity).
10//!
11//! A previewed control is a specimen, not a control the person operates: it
12//! leaves the tab order and ignores pointer hover and press styling, so the
13//! forced state cannot be overridden by the pointer. Leaving `preview_state`
14//! unset keeps every live behavior unchanged.
15use crate::tokens::state;
16
17/// One interaction state a specimen renders without live input.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum InteractionPreview {
20    /// At rest: no state layer, no ring.
21    #[default]
22    Enabled,
23    /// The pointer is over the control.
24    Hovered,
25    /// The control has keyboard focus: focus layer (where the live control
26    /// draws one) and the focus ring.
27    Focused,
28    /// The control is held down.
29    Pressed,
30    /// The control cannot be used.
31    Disabled,
32}
33
34impl InteractionPreview {
35    /// Every state in design-kit column order.
36    pub const ALL: [Self; 5] = [
37        Self::Enabled,
38        Self::Hovered,
39        Self::Focused,
40        Self::Pressed,
41        Self::Disabled,
42    ];
43
44    /// The design kit's name for the state; galleries may use it as a
45    /// column label.
46    pub const fn name(self) -> &'static str {
47        match self {
48            Self::Enabled => "Enabled",
49            Self::Hovered => "Hovered",
50            Self::Focused => "Focused",
51            Self::Pressed => "Pressed",
52            Self::Disabled => "Disabled",
53        }
54    }
55
56    pub const fn is_disabled(self) -> bool {
57        matches!(self, Self::Disabled)
58    }
59    pub const fn is_hovered(self) -> bool {
60        matches!(self, Self::Hovered)
61    }
62    pub const fn is_focused(self) -> bool {
63        matches!(self, Self::Focused)
64    }
65    pub const fn is_pressed(self) -> bool {
66        matches!(self, Self::Pressed)
67    }
68
69    /// The M3 state-layer opacity for this state (0 at rest and disabled).
70    pub const fn layer_opacity(self) -> f32 {
71        match self {
72            Self::Hovered => state::HOVER,
73            Self::Focused => state::FOCUS,
74            Self::Pressed => state::PRESS,
75            Self::Enabled | Self::Disabled => 0.,
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::InteractionPreview::*;
83    use crate::tokens::state;
84
85    #[test]
86    fn states_use_the_state_layer_tokens_in_design_kit_order() {
87        assert_eq!(
88            super::InteractionPreview::ALL.map(|s| s.layer_opacity()),
89            [0., state::HOVER, state::FOCUS, state::PRESS, 0.]
90        );
91        assert_eq!(
92            super::InteractionPreview::ALL.map(|s| s.name()),
93            ["Enabled", "Hovered", "Focused", "Pressed", "Disabled"]
94        );
95        assert!(Disabled.is_disabled() && !Pressed.is_disabled());
96    }
97
98    use crate::components::focus::test_rings;
99    use crate::components::{
100        AssistChip, ChoiceTile, ConnectedButtonGroup, InteractionPreview, MaterialSwitch, TileScale,
101    };
102    use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px};
103
104    struct Specimens {
105        state: InteractionPreview,
106    }
107    impl Render for Specimens {
108        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
109            window.set_rem_size(px(14.));
110            let state = self.state;
111            div()
112                .flex()
113                .flex_col()
114                .gap(px(24.))
115                .p(px(24.))
116                .child(AssistChip::new("specimen-chip", "Chip").preview_state(state))
117                .child(
118                    div().debug_selector(|| "specimen-switch".into()).child(
119                        MaterialSwitch::new("specimen-switch", "Switch").preview_state(state),
120                    ),
121                )
122                .child(
123                    TileScale::Regular
124                        .button("specimen-tile", [true; 4])
125                        .preview_state(state)
126                        .child("Tile"),
127                )
128                .child(
129                    ChoiceTile::new("specimen-choice", 0, 1, "Choice", false)
130                        .preview_state(state)
131                        .render(window, cx),
132                )
133                .child(
134                    ConnectedButtonGroup::new("specimen-group", "Group")
135                        .options([("a", "A"), ("b", "B")])
136                        .selected("a")
137                        .preview_state("b", state),
138                )
139        }
140    }
141
142    fn draw(cx: &mut gpui::VisualTestContext) {
143        cx.update(|window, cx| {
144            window.refresh();
145            window.draw(cx).clear(cx);
146        });
147    }
148
149    #[gpui::test]
150    fn forced_focus_draws_the_ring_without_keyboard_input(cx: &mut TestAppContext) {
151        cx.update(gpui_base::init);
152        let (view, cx) = cx.add_window_view(|_, _| Specimens { state: Focused });
153        draw(cx);
154        for part in ["specimen-chip", "specimen-tile", "specimen-choice"] {
155            assert!(test_rings::painted(part).is_some(), "{part} shows its ring");
156        }
157        for state in [Enabled, Hovered, Pressed, Disabled] {
158            view.update(cx, |view, cx| {
159                view.state = state;
160                cx.notify();
161            });
162            draw(cx);
163            for part in ["specimen-chip", "specimen-tile", "specimen-choice"] {
164                assert!(test_rings::painted(part).is_none(), "{part}: {state:?}");
165            }
166        }
167    }
168
169    #[gpui::test]
170    fn specimens_are_not_tab_stops(cx: &mut TestAppContext) {
171        cx.update(gpui_base::init);
172        let (_, cx) = cx.add_window_view(|_, _| Specimens { state: Enabled });
173        draw(cx);
174        cx.update(|window, cx| window.focus_next(cx));
175        assert!(cx.update(|window, cx| window.focused(cx)).is_none());
176    }
177
178    #[gpui::test]
179    fn a_forced_press_grows_the_switch_handle(cx: &mut TestAppContext) {
180        cx.update(gpui_base::init);
181        let (view, cx) = cx.add_window_view(|_, _| Specimens { state: Pressed });
182        draw(cx);
183        let pressed = cx.debug_bounds("switch-handle").unwrap().size.width;
184        assert_eq!(pressed, px(28.));
185        view.update(cx, |view, cx| {
186            view.state = Hovered;
187            cx.notify();
188        });
189        draw(cx);
190        cx.executor()
191            .advance_clock(std::time::Duration::from_secs(1));
192        draw(cx);
193        assert_eq!(
194            cx.debug_bounds("switch-handle").unwrap().size.width,
195            px(16.)
196        );
197    }
198}