Skip to main content

gpui_kit_material/components/
tile.rs

1//! Connected tiles: M3 lists whose items are separate surfaces with
2//! connected corners, as settings and option panels use them.
3use crate::color::ColorRole;
4use crate::icons::{Icon, IconName};
5use crate::theme::color;
6use crate::tokens::{layout::*, shape::*, state, typography::*};
7use gpui::{prelude::*, *};
8
9use super::focus::{RingPlacement, RingedButton};
10use super::text::accessible_text;
11
12/// One tile of a connected group: the surface with [`tile_corners`].
13pub fn tile(outer: OuterCorners) -> Div {
14    tile_corners(
15        div().w_full().min_w_0().bg(color(ColorRole::SURFACE)),
16        outer,
17    )
18}
19
20/// The corner radii [`tile_corners`] gives a tile with `outer` corners.
21pub(crate) fn outer_corner_radii(outer: OuterCorners) -> Corners<Pixels> {
22    let radius = |outside: bool| {
23        if outside {
24            TILE_RADIUS
25        } else {
26            TILE_INNER_RADIUS
27        }
28    };
29    Corners {
30        top_left: radius(outer[0]),
31        top_right: radius(outer[1]),
32        bottom_right: radius(outer[2]),
33        bottom_left: radius(outer[3]),
34    }
35}
36
37/// The tile at `index` of a vertical connected list of `count` tiles.
38pub fn list_tile(index: usize, count: usize) -> Div {
39    tile(list_corners(index, count))
40}
41
42/// The text scale of a tile's content. Both scales share one composition;
43/// settings read one step larger than denser option panels.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum TileScale {
46    /// Settings pages: body-large (16px) labels over 14px supporting text,
47    /// the M3 list item headline and supporting roles.
48    Regular,
49    /// Denser panels: 16px labels over 13px supporting text.
50    Compact,
51}
52
53impl TileScale {
54    pub fn label_size(self) -> Rems {
55        match self {
56            TileScale::Regular => TypeRole::BodyLarge.scale().font_size,
57            TileScale::Compact => rem(16.),
58        }
59    }
60
61    pub fn support_size(self) -> Rems {
62        match self {
63            TileScale::Regular => TEXT_SETTINGS_AUX,
64            TileScale::Compact => rem(13.),
65        }
66    }
67
68    /// The label's line box. Tiles that act are Buttons, whose text box is
69    /// exactly the font size; an explicit line height keeps wrapped and
70    /// clamped lines (and their descenders) inside it at every text scale.
71    pub fn label_line_height(self) -> Rems {
72        match self {
73            TileScale::Regular => rem(24.),
74            TileScale::Compact => rem(22.),
75        }
76    }
77
78    pub fn support_line_height(self) -> Rems {
79        match self {
80            TileScale::Regular => rem(20.),
81            TileScale::Compact => rem(18.),
82        }
83    }
84
85    /// A tile's main label, medium weight.
86    pub fn label(
87        self,
88        id: impl Into<gpui::ElementId>,
89        value: impl Into<gpui::SharedString>,
90    ) -> gpui::Stateful<gpui::Div> {
91        use gpui::prelude::*;
92        accessible_text(id, value)
93            .text_size(self.label_size())
94            .line_height(self.label_line_height())
95            .font_weight(gpui::FontWeight::MEDIUM)
96            .text_color(color(ColorRole::ON_SURFACE))
97    }
98
99    /// Supporting text under a tile's label, regular gray.
100    pub fn support(
101        self,
102        id: impl Into<gpui::ElementId>,
103        value: impl Into<gpui::SharedString>,
104    ) -> gpui::Stateful<gpui::Div> {
105        use gpui::prelude::*;
106        accessible_text(id, value)
107            .min_w_0()
108            .whitespace_normal()
109            .text_size(self.support_size())
110            .line_height(self.support_line_height())
111            .font_weight(gpui::FontWeight::NORMAL)
112            .text_color(color(ColorRole::ON_SURFACE_VARIANT))
113    }
114
115    /// One content tile of a connected group at this scale: the surface,
116    /// its corners, and the reading size settings content inherits.
117    pub fn surface(self, outer: OuterCorners) -> gpui::Div {
118        use gpui::prelude::*;
119        tile(outer)
120            .flex()
121            .flex_col()
122            .flex_shrink_0()
123            .when(self == TileScale::Regular, |tile| {
124                tile.text_size(TEXT_SETTINGS_BODY)
125            })
126    }
127
128    /// A whole tile that acts: the surface with M3 state layers, the tile's
129    /// corners and this scale's padding. Callers add one [`TileScale::row`]
130    /// holding the leading slot, text column and trailing items.
131    pub fn button(self, id: impl Into<ElementId>, outer: OuterCorners) -> RingedButton {
132        let (bg, ink) = (color(ColorRole::SURFACE), color(ColorRole::ON_SURFACE));
133        let scale = self;
134        // Connected tiles sit 2dp apart, so the focus ring uses the M3 inner
135        // offset: an outer ring would be covered by the next tile.
136        RingedButton::new(id, RingPlacement::Inner(outer_corner_radii(outer)))
137            .map(move |button| {
138                let button = button
139                    .w_full()
140                    .min_w_0()
141                    .flex()
142                    .items_center()
143                    .justify_start()
144                    .text_left()
145                    .whitespace_normal()
146                    .bg(bg)
147                    .text_color(ink);
148                let button = match scale {
149                    TileScale::Regular => button.min_h(rem(64.)).px(ITEM_INSET).py(rem(16.)),
150                    TileScale::Compact => button
151                        .min_h(rem(68.))
152                        .pl(ITEM_INSET)
153                        .pr(rem(16.))
154                        .py(rem(12.)),
155                };
156                tile_corners(button, outer)
157            })
158            .state_layers(state::hovered(bg, ink), state::pressed(bg, ink))
159    }
160
161    /// The gap between a tile's leading slot, text column and trailing items.
162    pub fn gap(self) -> Rems {
163        match self {
164            TileScale::Regular => rem(16.),
165            TileScale::Compact => rem(12.),
166        }
167    }
168
169    /// The content of a `button` tile: its leading slot, text column and
170    /// trailing items in one row at this scale's gap. The component's own
171    /// content row has a fixed 8px gap and does not wrap, so a tile's
172    /// children go in here to keep text on the rows' ruler and let long
173    /// labels wrap.
174    pub fn row(self) -> gpui::Div {
175        use gpui::prelude::*;
176        gpui_base::h_flex()
177            .flex_1()
178            .w_full()
179            .min_w_0()
180            .items_center()
181            .whitespace_normal()
182            .gap(self.gap())
183    }
184
185    /// A tile's text column: its label and, when present, the value or
186    /// explanation under it.
187    pub fn text(
188        self,
189        id: impl Into<gpui::ElementId>,
190        label: impl Into<gpui::SharedString>,
191        support: Option<gpui::SharedString>,
192    ) -> gpui::Div {
193        use gpui::prelude::*;
194        let id = id.into();
195        gpui_base::v_flex()
196            .flex_1()
197            .min_w_0()
198            .gap(rem(2.))
199            .child(self.label(id.clone(), label))
200            .when_some(support, |column, support| {
201                column.child(
202                    // A child id derived without formatting, so a render
203                    // allocates no string per tile.
204                    self.support(
205                        gpui::ElementId::NamedChild(std::sync::Arc::new(id), "support".into()),
206                        support,
207                    ),
208                )
209            })
210    }
211}
212
213pub use tile_group::TileGroup;
214mod tile_group {
215    use super::TileScale;
216    use crate::tokens::{layout::*, shape::*};
217    use gpui::{
218        AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
219    };
220    use gpui_base::v_flex;
221
222    /// Builds a tile's content once the group knows which corners are outside.
223    type TileBuilder = Box<dyn FnOnce(OuterCorners) -> AnyElement>;
224
225    enum TileMain {
226        /// Content on the plain surface; the group draws the tile.
227        Content(AnyElement),
228        /// A tile that draws itself (a button), given its corners.
229        Built(TileBuilder),
230        /// Two tiles side by side in one row of the group.
231        Pair(TileBuilder, TileBuilder),
232    }
233
234    struct GroupTile {
235        main: TileMain,
236        details: Vec<AnyElement>,
237    }
238
239    /// One section: a quiet title above a connected group of tiles, each
240    /// item on its own surface with 24px outer and 6px inner corners and 2px
241    /// between tiles, at the group's [`TileScale`].
242    #[derive(gpui::IntoElement)]
243    pub struct TileGroup {
244        id: SharedString,
245        title: SharedString,
246        scale: TileScale,
247        tiles: Vec<GroupTile>,
248    }
249
250    impl TileGroup {
251        pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
252            Self {
253                id: id.into(),
254                title: title.into(),
255                scale: TileScale::Regular,
256                tiles: Vec::new(),
257            }
258        }
259
260        /// The text scale of the group's content tiles.
261        pub fn scale(mut self, scale: TileScale) -> Self {
262            self.scale = scale;
263            self
264        }
265
266        /// A tile that draws its own surface (a button row), given its corners
267        /// as top-left, top-right, bottom-right, bottom-left (true = outside).
268        pub fn tile(mut self, build: impl FnOnce(OuterCorners) -> AnyElement + 'static) -> Self {
269            self.tiles.push(GroupTile {
270                main: TileMain::Built(Box::new(build)),
271                details: Vec::new(),
272            });
273            self
274        }
275
276        /// Two tiles sharing one row of the group.
277        pub fn pair(
278            mut self,
279            leading: impl FnOnce(OuterCorners) -> AnyElement + 'static,
280            trailing: impl FnOnce(OuterCorners) -> AnyElement + 'static,
281        ) -> Self {
282            self.tiles.push(GroupTile {
283                main: TileMain::Pair(Box::new(leading), Box::new(trailing)),
284                details: Vec::new(),
285            });
286            self
287        }
288
289        /// Content that belongs to the previous item (a disclosure, a summary
290        /// line): it stays inside that item's tile.
291        pub fn detail(mut self, element: impl IntoElement) -> Self {
292            match self.tiles.last_mut() {
293                Some(tile) => tile.details.push(element.into_any_element()),
294                None => self.tiles.push(GroupTile {
295                    main: TileMain::Content(element.into_any_element()),
296                    details: Vec::new(),
297                }),
298            }
299            self
300        }
301    }
302
303    impl ParentElement for TileGroup {
304        fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
305            self.tiles
306                .extend(elements.into_iter().map(|element| GroupTile {
307                    main: TileMain::Content(element),
308                    details: Vec::new(),
309                }));
310        }
311    }
312
313    impl RenderOnce for TileGroup {
314        fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
315            let count = self.tiles.len();
316            let scale = self.scale;
317            let title = super::super::section_title(self.id, self.title).px(ITEM_INSET);
318            let mut group = v_flex().w_full().min_w_0().gap(TILE_GAP);
319            for (index, tile) in self.tiles.into_iter().enumerate() {
320                let first = index == 0;
321                let last = index + 1 == count;
322                let outer = list_corners(index, count);
323                let element = match tile.main {
324                    TileMain::Content(content) => scale
325                        .surface(outer)
326                        .child(content)
327                        .children(tile.details)
328                        .into_any_element(),
329                    TileMain::Built(build) if tile.details.is_empty() => build(outer),
330                    // Details continue below the built tile inside the same
331                    // surface, so its own bottom corners stay inner.
332                    TileMain::Built(build) => scale
333                        .surface(outer)
334                        .child(build([outer[0], outer[1], false, false]))
335                        .children(tile.details)
336                        .into_any_element(),
337                    TileMain::Pair(leading, trailing) => v_flex()
338                        .w_full()
339                        .min_w_0()
340                        .child(
341                            div()
342                                .grid()
343                                .grid_cols(2)
344                                .w_full()
345                                .min_w_0()
346                                .gap(TILE_GAP)
347                                .child(leading([first, false, false, last]))
348                                .child(trailing([false, first, last, false])),
349                        )
350                        .children(tile.details)
351                        .into_any_element(),
352                };
353                group = group.child(element);
354            }
355            // The title's gap follows its text, so it is in rems; the tiles
356            // keep their dp ground between them.
357            v_flex()
358                .w_full()
359                .min_w_0()
360                .flex_shrink_0()
361                .gap(SECTION_TITLE_SPACE)
362                .child(title)
363                .child(group)
364        }
365    }
366}
367
368/// An explanation inside a tile: the information icon and supporting text.
369pub fn tile_info(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Div {
370    gpui_base::h_flex()
371        .w_full()
372        .min_w_0()
373        .items_start()
374        .gap(rem(10.))
375        .child(
376            div()
377                .flex()
378                .flex_shrink_0()
379                .h(rem(20.))
380                .items_center()
381                .child(
382                    IconName::Info
383                        .icon()
384                        .size(rem(18.))
385                        .color(color(ColorRole::ON_SURFACE_VARIANT)),
386                ),
387        )
388        .child(TileScale::Regular.support(id, text).flex_1())
389}
390
391/// The trailing chevron of a tile that opens something.
392pub fn tile_chevron() -> Icon {
393    IconName::ChevronRight
394        .icon()
395        .size(rem(20.))
396        .color(color(ColorRole::ON_SURFACE_VARIANT))
397}
398
399/// The chevron of an in-place disclosure; it turns over when open.
400pub fn disclosure_chevron(open: bool) -> Icon {
401    if open {
402        IconName::ChevronUp
403    } else {
404        IconName::ChevronDown
405    }
406    .icon()
407    .size(rem(20.))
408    .color(color(ColorRole::ON_SURFACE_VARIANT))
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::components::focus::test_rings;
415    use core::prelude::v1::test;
416    use gpui::{Context, Modifiers, TestAppContext, VisualTestContext};
417
418    struct Harness;
419    impl Render for Harness {
420        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
421            window.set_rem_size(px(14.));
422            div().p(px(20.)).w(px(300.)).child(
423                TileScale::Regular
424                    .button("ring-tile", [true, true, false, false])
425                    .debug_selector(|| "ring-tile".into())
426                    .on_click(|_, _, _| {})
427                    .child(TileScale::Regular.row().child("Tile")),
428            )
429        }
430    }
431
432    fn draw(cx: &mut VisualTestContext) {
433        cx.update(|window, cx| {
434            window.refresh();
435            window.draw(cx).clear(cx);
436        });
437    }
438
439    #[gpui::test]
440    fn tile_buttons_show_the_inner_ring_only_for_keyboard_focus(cx: &mut TestAppContext) {
441        cx.update(gpui_base::init);
442        let (_, cx) = cx.add_window_view(|_, _| Harness);
443        draw(cx);
444        let tile = cx.debug_bounds("ring-tile").unwrap();
445        cx.simulate_click(tile.center(), Modifiers::default());
446        draw(cx);
447        assert!(test_rings::painted("ring-tile").is_none());
448        let keystroke =
449            cx.update(|_, cx| gpui::Keystroke::parse_with_os("a", cx.platform_info().os).unwrap());
450        cx.simulate_event(gpui::KeyDownEvent {
451            keystroke,
452            is_held: false,
453            prefer_character_input: false,
454        });
455        draw(cx);
456        assert_eq!(test_rings::painted("ring-tile"), Some(tile.inset(px(3.))));
457    }
458
459    struct GroupHarness;
460    impl Render for GroupHarness {
461        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
462            window.set_rem_size(px(21.));
463            div().w(px(300.)).debug_selector(|| "group".into()).child(
464                TileGroup::new("group-title", "Title")
465                    .child(div().h(px(30.)).debug_selector(|| "first".into()))
466                    .child(div().h(px(30.)).debug_selector(|| "second".into())),
467            )
468        }
469    }
470
471    #[gpui::test]
472    fn the_title_gap_follows_the_text_scale_and_tiles_keep_their_ground(cx: &mut TestAppContext) {
473        let (_, cx) = cx.add_window_view(|_, _| GroupHarness);
474        draw(cx);
475        let group = cx.debug_bounds("group").unwrap();
476        let first = cx.debug_bounds("first").unwrap();
477        let second = cx.debug_bounds("second").unwrap();
478        // 150% text scale: a 30px title line, then the 15px title gap.
479        assert_eq!(first.top() - group.top(), px(30. + 15.));
480        assert_eq!(second.top() - first.bottom(), TILE_GAP);
481    }
482
483    #[test]
484    fn regular_tile_labels_use_body_large() {
485        assert_eq!(
486            TileScale::Regular.label_size(),
487            TypeRole::BodyLarge.scale().font_size
488        );
489        assert_eq!(TileScale::Regular.label_line_height(), rem(24.));
490        assert_eq!(TileScale::Regular.support_size(), TEXT_SETTINGS_AUX);
491    }
492
493    #[test]
494    fn disabled_and_previewed_tiles_are_inert() {
495        use crate::components::focus::shows_pointer;
496        assert!(shows_pointer(true, None));
497        assert!(
498            !shows_pointer(false, None),
499            "disabled: no pointer, no layers"
500        );
501        assert!(!shows_pointer(
502            false,
503            Some(crate::components::InteractionPreview::Disabled)
504        ));
505        assert!(!shows_pointer(
506            true,
507            Some(crate::components::InteractionPreview::Hovered)
508        ));
509    }
510
511    struct DisabledHarness;
512    impl Render for DisabledHarness {
513        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
514            div().p(px(20.)).w(px(300.)).child(
515                TileScale::Regular
516                    .button("disabled-tile", [true; 4])
517                    .disabled(true)
518                    .debug_selector(|| "disabled-tile".into())
519                    .child(TileScale::Regular.text("disabled-text", "Tile", Some("Off".into()))),
520            )
521        }
522    }
523
524    #[gpui::test]
525    fn a_disabled_tile_is_not_a_tab_stop(cx: &mut TestAppContext) {
526        cx.update(gpui_base::init);
527        let (_, cx) = cx.add_window_view(|_, _| DisabledHarness);
528        draw(cx);
529        assert!(cx.debug_bounds("disabled-tile").is_some());
530        cx.update(|window, cx| window.focus_next(cx));
531        assert!(cx.update(|window, cx| window.focused(cx)).is_none());
532    }
533
534    #[test]
535    fn ring_corners_match_the_tile_corners() {
536        let corners = outer_corner_radii([true, true, false, false]);
537        assert_eq!(corners.top_left, TILE_RADIUS);
538        assert_eq!(corners.top_right, TILE_RADIUS);
539        assert_eq!(corners.bottom_right, TILE_INNER_RADIUS);
540        assert_eq!(corners.bottom_left, TILE_INNER_RADIUS);
541    }
542}