Skip to main content

gpui_kit_material/components/
choice.rs

1//! Single choice among tiles: a choice tile with tonal selection and the
2//! shared M3 radio indicator, and category tints.
3use crate::color::ColorRole;
4use crate::icons::Icon;
5use crate::theme::{blend, color};
6use crate::tokens::{layout::*, shape::*, state, typography::*};
7use gpui::{prelude::*, *};
8
9pub use super::radio::choice_radio;
10
11pub use choice_tile::{ChoiceGroup, ChoiceTile};
12mod choice_tile {
13    use super::*;
14    use crate::components::InteractionPreview;
15    use crate::components::focus::{RingPlacement, RingedButton};
16    use crate::components::radio::animated_choice_radio;
17    use crate::components::roving::{Orientation, Roving, Select, index_handles};
18    use crate::components::tile::outer_corner_radii;
19    use gpui_base::v_flex;
20    use std::rc::Rc;
21
22    /// The resolved colors of a choice tile for one frame.
23    #[derive(Clone, Copy, Debug, PartialEq)]
24    pub(super) struct TilePaint {
25        pub(super) background: Rgba,
26        /// The content color, which also tints the state layers.
27        pub(super) ink: Rgba,
28        /// The label color: the ink, at disabled emphasis when unavailable.
29        pub(super) label: Rgba,
30        pub(super) hover: Rgba,
31        pub(super) press: Rgba,
32    }
33
34    /// `amount` is the animated selection (0 unselected, 1 selected).
35    pub(super) fn tile_paint(amount: f32, selected: bool, enabled: bool) -> TilePaint {
36        let background = blend(
37            color(ColorRole::SURFACE),
38            color(ColorRole::SECONDARY_CONTAINER),
39            amount,
40        );
41        let ink = color(if selected {
42            ColorRole::ON_SECONDARY_CONTAINER
43        } else {
44            ColorRole::ON_SURFACE
45        });
46        TilePaint {
47            background,
48            ink,
49            label: if enabled { ink } else { state::disabled(ink) },
50            hover: state::hovered(background, ink),
51            press: state::pressed(background, ink),
52        }
53    }
54
55    /// One choice in a connected group of tiles: tonal
56    /// selection on the secondary container, a leading icon, the label with
57    /// optional supporting text, a trailing status chip and a radio. An
58    /// unavailable choice keeps its status chip at full strength.
59    ///
60    /// It is exposed as a radio button. Put the choices of one question in a
61    /// [`ChoiceGroup`], which adds the radio group, roving focus and arrow
62    /// keys.
63    pub struct ChoiceTile {
64        id: ElementId,
65        index: usize,
66        count: usize,
67        outer: OuterCorners,
68        icon: Option<AnyElement>,
69        tinted: Option<(Icon, Tint, Pixels)>,
70        title: SharedString,
71        description: Option<SharedString>,
72        status: Option<AnyElement>,
73        selected: bool,
74        enabled: bool,
75        preview: Option<InteractionPreview>,
76    }
77
78    impl ChoiceTile {
79        /// The choice at `index` of a vertical group of `count`.
80        pub fn new(
81            id: impl Into<ElementId>,
82            index: usize,
83            count: usize,
84            title: impl Into<SharedString>,
85            selected: bool,
86        ) -> Self {
87            Self {
88                id: id.into(),
89                index,
90                count,
91                outer: list_corners(index, count),
92                icon: None,
93                tinted: None,
94                title: title.into(),
95                description: None,
96                status: None,
97                selected,
98                enabled: true,
99                preview: None,
100            }
101        }
102
103        /// Which corners are outside the group, when the tile is not simply
104        /// one of a vertical list (e.g. choices hanging below another tile).
105        pub fn corners(mut self, outer: OuterCorners) -> Self {
106            self.outer = outer;
107            self
108        }
109
110        pub fn icon(mut self, icon: impl IntoElement) -> Self {
111            self.icon = Some(icon.into_any_element());
112            self
113        }
114
115        /// A category icon in its tinted circle. While the tile is selected
116        /// the circle turns to the surface, so it stays distinct from the
117        /// secondary container behind it.
118        pub fn tinted_icon(mut self, icon: Icon, tint: Tint, size: Pixels) -> Self {
119            self.tinted = Some((icon, tint, size));
120            self
121        }
122
123        pub fn description(mut self, description: impl Into<SharedString>) -> Self {
124            let description = description.into();
125            self.description = (!description.is_empty()).then_some(description);
126            self
127        }
128
129        pub fn status(mut self, status: Option<impl IntoElement>) -> Self {
130            self.status = status.map(IntoElement::into_any_element);
131            self
132        }
133
134        /// Render a static specimen in one forced interaction state, painting
135        /// what the live state paints. See [`InteractionPreview`].
136        pub fn preview_state(mut self, state: InteractionPreview) -> Self {
137            self.preview = Some(state);
138            self
139        }
140
141        pub fn enabled(mut self, enabled: bool) -> Self {
142            self.enabled = enabled;
143            self
144        }
145
146        /// Resolve the tile for this frame. A plain method rather than
147        /// `RenderOnce`, so callers can still chain `on_click` and styles on
148        /// the returned button. Outside a [`ChoiceGroup`] the tile has no
149        /// arrow-key navigation.
150        pub fn render(self, window: &mut Window, cx: &mut App) -> RingedButton {
151            let id = self.id.clone();
152            let selected = self.selected;
153            let preview = self.preview;
154            let enabled = self.enabled && !preview.is_some_and(|p| p.is_disabled());
155            let amount = crate::motion::selection_value(
156                (id.clone(), "selection"),
157                if selected { 1. } else { 0. },
158                window,
159                cx,
160            );
161            let paint = tile_paint(amount, selected, enabled);
162            let icon = self.icon.or_else(|| {
163                self.tinted.map(|(icon, tint, size)| {
164                    tinted_icon(icon, tint, size)
165                        .bg(blend(tint.container(), color(ColorRole::SURFACE), amount))
166                        .into_any_element()
167                })
168            });
169            let title = self.title;
170            let (outer, index, count) = (self.outer, self.index, self.count);
171            let description = self.description;
172            let status = self.status;
173            // Connected tiles sit 2dp apart, so the ring uses the M3 inner
174            // offset.
175            RingedButton::new(id, RingPlacement::Inner(outer_corner_radii(outer)))
176                .map(move |button| {
177                    tile_corners(
178                        button
179                            .selected(selected)
180                            .role(Role::RadioButton)
181                            .aria_toggled(if selected {
182                                gpui::accesskit::Toggled::True
183                            } else {
184                                gpui::accesskit::Toggled::False
185                            })
186                            .aria_selected(selected)
187                            .aria_position_in_set(index + 1)
188                            .aria_size_of_set(count)
189                            .accessibility_label(title.clone())
190                            .w_full()
191                            .min_w_0()
192                            .h_auto()
193                            .min_h(rem(64.))
194                            .justify_start()
195                            .gap(rem(16.))
196                            .pl(ITEM_INSET)
197                            .pr(rem(20.))
198                            .py(rem(12.)),
199                        outer,
200                    )
201                    .bg(paint.background)
202                    .text_color(paint.label)
203                    .when(enabled && preview.is_none(), |button| {
204                        button.cursor_pointer()
205                    })
206                    .children(icon)
207                    .child(
208                        v_flex()
209                            .flex_1()
210                            .min_w_0()
211                            .gap(rem(2.))
212                            .child(
213                                div()
214                                    .min_w_0()
215                                    .whitespace_normal()
216                                    .text_size(TEXT_SETTINGS_BODY)
217                                    .line_height(rem(22.))
218                                    .font_weight(FontWeight::MEDIUM)
219                                    .child(title),
220                            )
221                            .when_some(description, |column, description| {
222                                column.child(
223                                    div()
224                                        .min_w_0()
225                                        .whitespace_normal()
226                                        .text_size(TEXT_SETTINGS_AUX)
227                                        .line_height(rem(20.))
228                                        .text_color(if selected {
229                                            Rgba {
230                                                a: paint.label.a * 0.8,
231                                                ..paint.label
232                                            }
233                                        } else if enabled {
234                                            color(ColorRole::ON_SURFACE_VARIANT)
235                                        } else {
236                                            state::disabled(color(ColorRole::ON_SURFACE_VARIANT))
237                                        })
238                                        .child(description),
239                                )
240                            }),
241                    )
242                    .children(status)
243                    .child(animated_choice_radio(amount, selected, enabled))
244                })
245                .state_layers(paint.hover, paint.press)
246                .disabled(!enabled)
247                .when_some(preview, |tile, preview| tile.preview_state(preview))
248        }
249    }
250
251    type GroupChange = Rc<dyn Fn(&usize, &mut Window, &mut App)>;
252
253    /// One single-choice question as connected [`ChoiceTile`]s under a section
254    /// title: a radio group with roving focus. Tab enters at the selected
255    /// choice; arrow keys, Home and End move focus among the available
256    /// choices and select them, as a radio group does.
257    #[derive(gpui::IntoElement)]
258    pub struct ChoiceGroup {
259        id: ElementId,
260        title: SharedString,
261        tiles: Vec<ChoiceTile>,
262        on_change: Option<GroupChange>,
263    }
264
265    impl ChoiceGroup {
266        /// `title` is both the section title and the radio group's name.
267        pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
268            Self {
269                id: id.into(),
270                title: title.into(),
271                tiles: Vec::new(),
272                on_change: None,
273            }
274        }
275
276        /// Append a choice. Its index and count come from the group.
277        pub fn tile(mut self, tile: ChoiceTile) -> Self {
278            self.tiles.push(tile);
279            self
280        }
281
282        /// Called with the index of a newly chosen tile.
283        pub fn on_change(mut self, f: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
284            self.on_change = Some(Rc::new(f));
285            self
286        }
287    }
288
289    impl RenderOnce for ChoiceGroup {
290        fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
291            let count = self.tiles.len();
292            let focus = index_handles((self.id.clone(), "choice-focus"), count, window, cx);
293            let skip: Rc<[bool]> = self.tiles.iter().map(|tile| !tile.enabled).collect();
294            let selected = self.tiles.iter().position(|tile| tile.selected);
295            let on_select = self.on_change.clone().map(|on_change| {
296                Rc::new(move |index: usize, window: &mut Window, cx: &mut App| {
297                    on_change(&index, window, cx)
298                }) as Select
299            });
300            let roving = Roving::new(focus.clone(), Orientation::Both)
301                .skip(skip)
302                .selected(selected)
303                .select_on_focus(on_select);
304            let entry = roving.tab_stop(window);
305            // The title's id is scoped by the group's own id below, so a
306            // static name is unique without formatting one per frame.
307            let mut group = super::super::TileGroup::new("choice-group-title", self.title.clone());
308            for (index, tile) in self.tiles.into_iter().enumerate() {
309                let chosen = tile.selected;
310                let tile = ChoiceTile {
311                    index,
312                    count,
313                    outer: list_corners(index, count),
314                    ..tile
315                };
316                let handle = focus[index].clone();
317                let on_change = self.on_change.clone();
318                let button = tile
319                    .render(window, cx)
320                    .track_focus(&handle)
321                    .tab_stop(entry == Some(index))
322                    .on_click(move |_, window, cx| {
323                        handle.focus(window, cx);
324                        if !chosen && let Some(on_change) = &on_change {
325                            on_change(&index, window, cx);
326                        }
327                    });
328                group = group.tile(move |_| button.into_any_element());
329            }
330            roving
331                .install(
332                    div()
333                        .id(self.id)
334                        .role(Role::RadioGroup)
335                        .aria_label(self.title)
336                        .w_full()
337                        .min_w_0(),
338                )
339                .child(group)
340        }
341    }
342}
343
344/// A distinct color family for a category icon: a tonal container role and
345/// the content role that pairs with it. The names are the families' usual
346/// hues; the exact colors follow the current scheme, so every palette keeps
347/// its own ground and contrast.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum Tint {
350    /// Error container.
351    Orange,
352    /// Primary container.
353    Blue,
354    /// Secondary container.
355    Teal,
356    /// Warning container.
357    Amber,
358    /// Success container.
359    Green,
360    /// Tertiary fixed.
361    Pink,
362    /// Tertiary container.
363    Purple,
364}
365
366impl Tint {
367    pub const ALL: [Tint; 7] = [
368        Tint::Orange,
369        Tint::Blue,
370        Tint::Teal,
371        Tint::Amber,
372        Tint::Green,
373        Tint::Pink,
374        Tint::Purple,
375    ];
376
377    /// The container role and the content role on it.
378    pub const fn roles(self) -> (ColorRole, ColorRole) {
379        match self {
380            Tint::Orange => (ColorRole::ERROR_CONTAINER, ColorRole::ON_ERROR_CONTAINER),
381            Tint::Blue => (
382                ColorRole::PRIMARY_CONTAINER,
383                ColorRole::ON_PRIMARY_CONTAINER,
384            ),
385            Tint::Teal => (
386                ColorRole::SECONDARY_CONTAINER,
387                ColorRole::ON_SECONDARY_CONTAINER,
388            ),
389            Tint::Amber => (ColorRole::WARNING_BG, ColorRole::WARNING),
390            Tint::Green => (ColorRole::SUCCESS_BG, ColorRole::SUCCESS),
391            Tint::Pink => (ColorRole::TERTIARY_FIXED, ColorRole::ON_TERTIARY_FIXED),
392            Tint::Purple => (
393                ColorRole::TERTIARY_CONTAINER,
394                ColorRole::ON_TERTIARY_CONTAINER,
395            ),
396        }
397    }
398
399    pub fn container(self) -> Rgba {
400        color(self.roles().0)
401    }
402
403    pub fn content(self) -> Rgba {
404        color(self.roles().1)
405    }
406}
407
408/// A category or resource icon in a round tonal container. Give the size in
409/// rems where the circle shares a ruler with text, so both follow the text
410/// scale.
411pub fn tinted_icon(icon: impl Into<Icon>, tint: Tint, size: impl Into<AbsoluteLength>) -> Div {
412    let size = size.into();
413    let glyph = match size {
414        AbsoluteLength::Pixels(size) => AbsoluteLength::Pixels(size * 0.5),
415        AbsoluteLength::Rems(size) => AbsoluteLength::Rems(rems(size.0 * 0.5)),
416    };
417    div()
418        .flex()
419        .flex_shrink_0()
420        .items_center()
421        .justify_center()
422        .size(size)
423        .rounded_full()
424        .bg(tint.container())
425        .text_color(tint.content())
426        .child(icon.into().size(glyph))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::choice_tile::tile_paint;
432    use super::*;
433    use crate::components::focus::test_rings;
434    use core::prelude::v1::test;
435    use gpui::{Context, Modifiers, TestAppContext, VisualTestContext};
436
437    #[test]
438    fn selection_paints_the_secondary_container_and_keeps_its_state_layers() {
439        let rest = tile_paint(0., false, true);
440        assert_eq!(rest.background, color(ColorRole::SURFACE));
441        assert_eq!(rest.ink, color(ColorRole::ON_SURFACE));
442        let chosen = tile_paint(1., true, true);
443        assert_eq!(chosen.background, color(ColorRole::SECONDARY_CONTAINER));
444        assert_eq!(chosen.ink, color(ColorRole::ON_SECONDARY_CONTAINER));
445        for paint in [rest, chosen] {
446            assert_eq!(paint.hover, state::hovered(paint.background, paint.ink));
447            assert_eq!(paint.press, state::pressed(paint.background, paint.ink));
448            assert_eq!(paint.label, paint.ink);
449        }
450        let unavailable = tile_paint(0., false, false);
451        assert_eq!(unavailable.label, state::disabled(unavailable.ink));
452    }
453
454    struct GroupHarness {
455        selected: usize,
456        changes: Vec<usize>,
457    }
458
459    impl Render for GroupHarness {
460        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
461            window.set_rem_size(px(14.));
462            let mut group = ChoiceGroup::new("engines", "Engine").on_change(cx.listener(
463                |this, index: &usize, _, cx| {
464                    this.selected = *index;
465                    this.changes.push(*index);
466                    cx.notify();
467                },
468            ));
469            for (index, title) in ["Local", "Cloud", "Remote"].into_iter().enumerate() {
470                group = group.tile(
471                    ChoiceTile::new(("engine", index), 0, 0, title, self.selected == index)
472                        .enabled(index != 1),
473                );
474            }
475            div().w(px(400.)).child(group)
476        }
477    }
478
479    fn draw(cx: &mut VisualTestContext) {
480        cx.update(|window, cx| {
481            window.refresh();
482            window.draw(cx).clear(cx);
483        });
484    }
485
486    #[gpui::test]
487    fn a_choice_group_is_a_radio_group_with_arrow_keys(cx: &mut TestAppContext) {
488        cx.update(|cx| {
489            gpui_base::init(cx);
490            crate::components::init(cx);
491            cx.set_reduce_motion(true);
492        });
493        let (view, cx) = cx.add_window_view(|_, _| GroupHarness {
494            selected: 0,
495            changes: Vec::new(),
496        });
497        draw(cx);
498        // The selected choice is the group's only tab stop.
499        cx.update(|window, cx| window.focus_next(cx));
500        let first = cx.update(|window, cx| window.focused(cx));
501        assert!(first.is_some());
502        cx.update(|window, cx| window.focus_next(cx));
503        assert_eq!(cx.update(|window, cx| window.focused(cx)), first);
504        cx.simulate_keystrokes("a");
505        draw(cx);
506        assert!(
507            test_rings::painted("engine").is_some(),
508            "keyboard focus shows the ring"
509        );
510        // Down skips the unavailable choice and selects the next one.
511        cx.simulate_keystrokes("down");
512        draw(cx);
513        view.read_with(cx, |h, _| {
514            assert_eq!(h.selected, 2);
515            assert_eq!(h.changes, [2]);
516        });
517        // And wraps back to the first.
518        cx.simulate_keystrokes("down");
519        draw(cx);
520        view.read_with(cx, |h, _| assert_eq!(h.changes, [2, 0]));
521    }
522
523    struct TileHarness;
524    impl Render for TileHarness {
525        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
526            window.set_rem_size(px(14.));
527            div().w(px(400.)).child(
528                ChoiceTile::new("pointer-tile", 0, 1, "Only", false)
529                    .render(window, cx)
530                    .debug_selector(|| "pointer-tile".into())
531                    .on_click(|_, _, _| {}),
532            )
533        }
534    }
535
536    #[gpui::test]
537    fn pointer_focus_shows_no_ring(cx: &mut TestAppContext) {
538        cx.update(gpui_base::init);
539        let (_, cx) = cx.add_window_view(|_, _| TileHarness);
540        draw(cx);
541        let tile = cx.debug_bounds("pointer-tile").unwrap();
542        cx.simulate_click(tile.center(), Modifiers::default());
543        draw(cx);
544        assert!(test_rings::painted("pointer-tile").is_none());
545    }
546
547    #[test]
548    fn tints_are_distinct_semantic_role_pairs() {
549        let mut containers = Tint::ALL.map(|tint| tint.roles().0 as usize).to_vec();
550        containers.sort_unstable();
551        containers.dedup();
552        assert_eq!(containers.len(), Tint::ALL.len(), "one container per tint");
553        for tint in Tint::ALL {
554            let (container, content) = tint.roles();
555            assert_ne!(container, content);
556            assert_eq!(tint.container(), color(container));
557            assert_eq!(tint.content(), color(content));
558        }
559    }
560}