Skip to main content

gpui_kit_material/components/
radio.rs

1//! M3 radio button over GPUI Base's radio behavior, and a radio group with
2//! roving keyboard focus.
3//!
4//! The radio is a 20dp ring with a 10dp dot inside a 40dp state layer and a
5//! 48dp target. Selecting scales the dot in on the fast spatial spring (its
6//! overshoot kept) while the ring's color follows it; reduced motion lands
7//! at once.
8use super::InteractionPreview;
9use super::button::ButtonFocusRing;
10use super::roving::{self, Orientation, Roving, index_handles};
11use crate::{
12    color::ColorRole,
13    motion,
14    theme::{blend, color},
15    tokens::{
16        state,
17        typography::{TypeRole, rem},
18    },
19};
20use gpui::{prelude::*, *};
21use std::rc::Rc;
22
23/// Radio geometry, in dp.
24const RING: f32 = 20.;
25const RING_STROKE: f32 = 2.;
26const DOT: f32 = 10.;
27const STATE_LAYER: f32 = 40.;
28const TARGET: f32 = 48.;
29
30/// The colors of a radio (and of a checkbox, which shares the selection
31/// control state policy) in one state.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub(crate) struct SelectionPaint {
34    /// The ring or outline (and the dot) at rest.
35    pub(crate) ink: Rgba,
36    /// The ring while hovered, focused or pressed.
37    pub(crate) ink_active: Rgba,
38    pub(crate) hover: Rgba,
39    pub(crate) focus: Rgba,
40    pub(crate) press: Rgba,
41}
42
43/// The M3 selection-control state policy: selected controls are `accent`
44/// with accent hover and focus layers but an on-surface press layer;
45/// unselected ones are on-surface-variant (on-surface while active) with
46/// on-surface hover and focus layers and an accent press layer. Disabled is
47/// on-surface at [`state::DISABLED_CONTENT`] with no layers. An error
48/// control passes the error role as `accent` and uses it for every layer and
49/// ink.
50pub(crate) fn selection_paint(
51    selected: bool,
52    enabled: bool,
53    accent: ColorRole,
54    error: bool,
55) -> SelectionPaint {
56    let clear = Rgba {
57        r: 0.,
58        g: 0.,
59        b: 0.,
60        a: 0.,
61    };
62    if !enabled {
63        let ink = state::disabled(color(ColorRole::ON_SURFACE));
64        return SelectionPaint {
65            ink,
66            ink_active: ink,
67            hover: clear,
68            focus: clear,
69            press: clear,
70        };
71    }
72    let accent = color(accent);
73    let on_surface = color(ColorRole::ON_SURFACE);
74    let (ink, ink_active, layer, press_layer) = if error {
75        (accent, accent, accent, accent)
76    } else if selected {
77        (accent, accent, accent, on_surface)
78    } else {
79        (
80            color(ColorRole::ON_SURFACE_VARIANT),
81            on_surface,
82            on_surface,
83            accent,
84        )
85    };
86    SelectionPaint {
87        ink,
88        ink_active,
89        hover: state::layer(clear, layer, state::HOVER),
90        focus: state::layer(clear, layer, state::FOCUS),
91        press: state::layer(clear, press_layer, state::PRESS),
92    }
93}
94
95/// The radio's ring and dot for an animated selection `amount` (0
96/// unselected, 1 selected; the spring may overshoot either end).
97fn radio_indicator(amount: f32, selected: bool, enabled: bool, active: bool) -> Div {
98    let amount = if amount.is_finite() { amount } else { 0. };
99    let rest = selection_paint(false, enabled, ColorRole::PRIMARY, false);
100    let chosen = selection_paint(true, enabled, ColorRole::PRIMARY, false);
101    let from = if active { rest.ink_active } else { rest.ink };
102    let tone = blend(from, chosen.ink, amount.clamp(0., 1.));
103    // The dot may overshoot, but never reaches the ring.
104    let dot = (DOT * amount).clamp(0., RING - 2. * RING_STROKE - 2.);
105    div()
106        .flex()
107        .flex_shrink_0()
108        .items_center()
109        .justify_center()
110        .size(rem(RING))
111        .rounded_full()
112        .border(rem(RING_STROKE))
113        .border_color(tone)
114        .when(selected || dot > 0., |ring| {
115            ring.child(
116                div()
117                    .when(cfg!(test), |dot| dot.debug_selector(|| "radio-dot".into()))
118                    .size(rem(dot))
119                    .rounded_full()
120                    .bg(tone),
121            )
122        })
123}
124
125/// The M3 radio of a choice: an outlined ring, filled with a dot when
126/// selected. A static indicator for controls that are themselves the radio
127/// (e.g. a choice tile).
128pub fn choice_radio(selected: bool, enabled: bool) -> Div {
129    radio_indicator(if selected { 1. } else { 0. }, selected, enabled, false)
130}
131
132/// [`choice_radio`] at an animated selection amount.
133pub(crate) fn animated_choice_radio(amount: f32, selected: bool, enabled: bool) -> Div {
134    radio_indicator(amount, selected, enabled, false)
135}
136
137type Select = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
138
139/// Controlled M3 radio button. It has no visible label unless
140/// [`MaterialRadio::visible_label`] is set; its label is always its
141/// accessible name.
142#[derive(gpui::IntoElement)]
143pub struct MaterialRadio {
144    id: ElementId,
145    label: SharedString,
146    visible_label: bool,
147    label_before: bool,
148    selected: bool,
149    disabled: bool,
150    preview: Option<InteractionPreview>,
151    focus: Option<FocusHandle>,
152    tab_stop: bool,
153    position: Option<(usize, usize)>,
154    on_select: Option<Select>,
155}
156
157impl MaterialRadio {
158    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
159        Self {
160            id: id.into(),
161            label: label.into(),
162            visible_label: false,
163            label_before: false,
164            selected: false,
165            disabled: false,
166            preview: None,
167            focus: None,
168            tab_stop: true,
169            position: None,
170            on_select: None,
171        }
172    }
173    pub fn selected(mut self, selected: bool) -> Self {
174        self.selected = selected;
175        self
176    }
177    pub fn disabled(mut self, disabled: bool) -> Self {
178        self.disabled = disabled;
179        self
180    }
181    /// Show the label after the radio, inside its target.
182    pub fn visible_label(mut self, visible: bool) -> Self {
183        self.visible_label = visible;
184        self
185    }
186    /// Put the visible label before the radio instead of after it, the
187    /// radio pushed to the row's far end (md3.io's "Labels before").
188    pub fn label_before(mut self, before: bool) -> Self {
189        self.label_before = before;
190        self
191    }
192    /// Render a static specimen in one forced interaction state. See
193    /// [`InteractionPreview`].
194    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
195        self.preview = Some(state);
196        self
197    }
198    /// Use a caller-owned focus handle, e.g. for roving focus in a group.
199    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
200        self.focus = Some(focus.clone());
201        self
202    }
203    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
204        self.tab_stop = tab_stop;
205        self
206    }
207    /// The radio's one-based position in its group of `size`.
208    pub fn position(mut self, position: usize, size: usize) -> Self {
209        self.position = Some((position, size));
210        self
211    }
212    /// Called with `true` when an unselected radio is chosen; a radio cannot
213    /// deselect itself.
214    pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
215        self.on_select = Some(Rc::new(f));
216        self
217    }
218}
219
220/// Hover and press styles resolve against this group.
221const RADIO_GROUP_STYLE: &str = "material-radio";
222
223impl RenderOnce for MaterialRadio {
224    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
225        let focus = self.focus.clone().unwrap_or_else(|| {
226            window
227                .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
228                .read(cx)
229                .clone()
230        });
231        let preview = self.preview;
232        let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
233        let enabled = !disabled;
234        let live = enabled && preview.is_none();
235        let forced = |state| enabled && preview == Some(state);
236        let keyboard_focus = forced(InteractionPreview::Focused)
237            || (live && focus.is_focused(window) && window.last_input_was_keyboard());
238        let amount = if preview.is_some() {
239            if self.selected { 1. } else { 0. }
240        } else {
241            motion::selection_value(
242                (self.id.clone(), "dot"),
243                if self.selected { 1. } else { 0. },
244                window,
245                cx,
246            )
247        };
248        let paint = selection_paint(self.selected, enabled, ColorRole::PRIMARY, false);
249        let active = keyboard_focus
250            || forced(InteractionPreview::Hovered)
251            || forced(InteractionPreview::Pressed);
252        let ring = radio_indicator(amount, self.selected, enabled, active).when(
253            live && !self.selected,
254            |ring| {
255                let ink = paint.ink_active;
256                ring.group_hover(RADIO_GROUP_STYLE, move |style| style.border_color(ink))
257            },
258        );
259        let layer = div()
260            .id("state-layer")
261            .flex()
262            .flex_shrink_0()
263            .items_center()
264            .justify_center()
265            .size(rem(STATE_LAYER))
266            .rounded_full()
267            .when(keyboard_focus, |layer| layer.bg(paint.focus))
268            .when(forced(InteractionPreview::Hovered), |layer| {
269                layer.bg(paint.hover)
270            })
271            .when(forced(InteractionPreview::Pressed), |layer| {
272                layer.bg(paint.press)
273            })
274            .when(live && !keyboard_focus, |layer| {
275                let (hover, press) = (paint.hover, paint.press);
276                layer
277                    .group_hover(RADIO_GROUP_STYLE, move |style| style.bg(hover))
278                    .group_active(RADIO_GROUP_STYLE, move |style| style.bg(press))
279            })
280            .child(ring);
281        let ringed = ButtonFocusRing::new(layer, focus.clone(), enabled, rem(STATE_LAYER / 2.))
282            .forced(forced(InteractionPreview::Focused));
283        let label_color = if enabled {
284            color(ColorRole::ON_SURFACE)
285        } else {
286            state::disabled(color(ColorRole::ON_SURFACE))
287        };
288        let scale = TypeRole::BodyLarge.scale();
289        let on_select = self.on_select;
290        let before = self.label_before && self.visible_label;
291        let (near, far) = (
292            (TARGET - STATE_LAYER) / 2.,
293            if self.visible_label {
294                8.
295            } else {
296                (TARGET - STATE_LAYER) / 2.
297            },
298        );
299        let mut radio = gpui_base::Radio::new(self.id)
300            .track_focus(&focus)
301            .accessibility_label(self.label.clone())
302            .checked(self.selected)
303            .disabled(disabled)
304            .tab_stop(self.tab_stop && preview.is_none());
305        if let Some((position, size)) = self.position {
306            radio = radio.set_position(position, size);
307        }
308        radio
309            .when_some(on_select, |radio, on_select| {
310                radio.on_change(move |value, _, window, cx| on_select(&value, window, cx))
311            })
312            .group(RADIO_GROUP_STYLE)
313            .flex()
314            .flex_shrink_0()
315            .items_center()
316            .h(rem(TARGET))
317            .min_w(rem(TARGET))
318            .map(|radio| {
319                if before {
320                    radio
321                        .flex_row_reverse()
322                        .justify_between()
323                        .gap(rem(16.))
324                        .pr(rem(near))
325                        .pl(rem(far))
326                } else {
327                    radio.pl(rem(near)).pr(rem(far))
328                }
329            })
330            .when(live, |radio| radio.cursor_pointer())
331            .child(ringed)
332            .when(self.visible_label, |radio| {
333                radio.child(
334                    div()
335                        .when(!before, |label| label.pl(rem(4.)))
336                        .whitespace_nowrap()
337                        .text_color(label_color)
338                        .text_size(scale.font_size)
339                        .line_height(scale.line_height)
340                        .child(self.label),
341                )
342            })
343    }
344}
345
346type GroupChange = Rc<dyn Fn(&usize, &mut Window, &mut App)>;
347
348struct GroupOption {
349    label: SharedString,
350    enabled: bool,
351}
352
353/// A labelled set of [`MaterialRadio`]s with one selection: Tab enters at
354/// the selected option; arrow keys, Home and End move focus among the
355/// available options and select them.
356#[derive(gpui::IntoElement)]
357pub struct MaterialRadioGroup {
358    id: ElementId,
359    label: SharedString,
360    axis: Axis,
361    options: Vec<GroupOption>,
362    selected: Option<usize>,
363    labels_before: bool,
364    disabled: bool,
365    on_change: Option<GroupChange>,
366}
367
368impl MaterialRadioGroup {
369    /// `label` names the group for accessibility.
370    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
371        Self {
372            id: id.into(),
373            label: label.into(),
374            axis: Axis::Vertical,
375            options: Vec::new(),
376            selected: None,
377            labels_before: false,
378            disabled: false,
379            on_change: None,
380        }
381    }
382    /// Lay the options out in a row instead of a column.
383    pub fn axis(mut self, axis: Axis) -> Self {
384        self.axis = axis;
385        self
386    }
387    /// Append an option with its visible label.
388    pub fn option(mut self, label: impl Into<SharedString>, enabled: bool) -> Self {
389        self.options.push(GroupOption {
390            label: label.into(),
391            enabled,
392        });
393        self
394    }
395    pub fn selected(mut self, selected: Option<usize>) -> Self {
396        self.selected = selected;
397        self
398    }
399    /// Put each label before its radio. See [`MaterialRadio::label_before`].
400    pub fn labels_before(mut self, before: bool) -> Self {
401        self.labels_before = before;
402        self
403    }
404    /// Disable every option: the group shows its selection but takes no
405    /// focus or input.
406    pub fn disabled(mut self, disabled: bool) -> Self {
407        self.disabled = disabled;
408        self
409    }
410    /// Called with the index of a newly chosen option.
411    pub fn on_change(mut self, f: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
412        self.on_change = Some(Rc::new(f));
413        self
414    }
415}
416
417impl RenderOnce for MaterialRadioGroup {
418    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
419        let count = self.options.len();
420        let focus = index_handles((self.id.clone(), "radio-focus"), count, window, cx);
421        let disabled = self.disabled;
422        let skip: Rc<[bool]> = self
423            .options
424            .iter()
425            .map(|option| disabled || !option.enabled)
426            .collect();
427        let selected = self.selected.filter(|&index| index < count);
428        let on_select = self.on_change.clone().map(|on_change| {
429            Rc::new(move |index: usize, window: &mut Window, cx: &mut App| {
430                on_change(&index, window, cx)
431            }) as roving::Select
432        });
433        let roving = Roving::new(focus.clone(), Orientation::Both)
434            .skip(skip)
435            .selected(selected)
436            .select_on_focus(on_select);
437        let entry = roving.tab_stop(window);
438        let radios = self.options.into_iter().enumerate().map(|(index, option)| {
439            let on_change = self.on_change.clone();
440            MaterialRadio::new(("radio", index), option.label)
441                .visible_label(true)
442                .label_before(self.labels_before)
443                .selected(selected == Some(index))
444                .disabled(disabled || !option.enabled)
445                .track_focus(&focus[index])
446                .tab_stop(entry == Some(index))
447                .position(index + 1, count)
448                .on_change(move |_, window, cx| {
449                    if let Some(on_change) = &on_change {
450                        on_change(&index, window, cx);
451                    }
452                })
453        });
454        let group = gpui_base::RadioGroup::new(self.id)
455            .axis(self.axis)
456            .aria_label(self.label)
457            .flex()
458            .map(|group| match self.axis {
459                Axis::Vertical => group.flex_col(),
460                Axis::Horizontal => group.flex_row().flex_wrap(),
461            })
462            .children(radios);
463        roving.install(group)
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use core::prelude::v1::test;
471
472    #[test]
473    fn radio_uses_the_m3_selection_state_policy() {
474        let c = color;
475        let on = selection_paint(true, true, ColorRole::PRIMARY, false);
476        assert_eq!(on.ink, c(ColorRole::PRIMARY));
477        let clear = Rgba {
478            r: 0.,
479            g: 0.,
480            b: 0.,
481            a: 0.,
482        };
483        assert_eq!(
484            on.hover,
485            state::layer(clear, c(ColorRole::PRIMARY), state::HOVER)
486        );
487        assert_eq!(
488            on.press,
489            state::layer(clear, c(ColorRole::ON_SURFACE), state::PRESS),
490            "a selected press layer is on-surface"
491        );
492        let off = selection_paint(false, true, ColorRole::PRIMARY, false);
493        assert_eq!(off.ink, c(ColorRole::ON_SURFACE_VARIANT));
494        assert_eq!(off.ink_active, c(ColorRole::ON_SURFACE));
495        assert_eq!(
496            off.press,
497            state::layer(clear, c(ColorRole::PRIMARY), state::PRESS),
498            "an unselected press layer is primary"
499        );
500        let disabled = selection_paint(true, false, ColorRole::PRIMARY, false);
501        assert_eq!(disabled.ink, state::disabled(c(ColorRole::ON_SURFACE)));
502        assert_eq!(disabled.hover.a, 0.);
503        let error = selection_paint(false, true, ColorRole::ERROR, true);
504        assert_eq!(error.ink, c(ColorRole::ERROR));
505        assert_eq!(
506            error.press,
507            state::layer(clear, c(ColorRole::ERROR), state::PRESS)
508        );
509        assert_eq!((RING, DOT, STATE_LAYER, TARGET), (20., 10., 40., 48.));
510    }
511
512    struct GroupHarness {
513        selected: Option<usize>,
514        changes: Vec<usize>,
515    }
516    impl Render for GroupHarness {
517        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
518            window.set_rem_size(px(14.));
519            div().w(px(400.)).child(
520                MaterialRadioGroup::new("fruit", "Fruit")
521                    .option("Apple", true)
522                    .option("Banana", false)
523                    .option("Cherry", true)
524                    .selected(self.selected)
525                    .on_change(cx.listener(|this, index: &usize, _, cx| {
526                        this.selected = Some(*index);
527                        this.changes.push(*index);
528                        cx.notify();
529                    })),
530            )
531        }
532    }
533
534    fn draw(cx: &mut VisualTestContext) {
535        cx.update(|window, cx| {
536            window.refresh();
537            window.draw(cx).clear(cx);
538        });
539    }
540
541    #[gpui::test]
542    fn a_radio_group_roves_with_arrow_keys(cx: &mut TestAppContext) {
543        cx.update(|cx| {
544            gpui_base::init(cx);
545            crate::components::init(cx);
546            cx.set_reduce_motion(true);
547        });
548        let (view, cx) = cx.add_window_view(|_, _| GroupHarness {
549            selected: Some(0),
550            changes: Vec::new(),
551        });
552        draw(cx);
553        cx.update(|window, cx| window.focus_next(cx));
554        let first = cx.update(|window, cx| window.focused(cx));
555        assert!(first.is_some());
556        cx.update(|window, cx| window.focus_next(cx));
557        assert_eq!(
558            cx.update(|window, cx| window.focused(cx)),
559            first,
560            "the group is one tab stop"
561        );
562        cx.simulate_keystrokes("down");
563        draw(cx);
564        view.read_with(cx, |h, _| assert_eq!(h.changes, [2]));
565        cx.simulate_keystrokes("down");
566        draw(cx);
567        view.read_with(cx, |h, _| assert_eq!(h.changes, [2, 0]));
568        cx.simulate_keystrokes("end");
569        draw(cx);
570        view.read_with(cx, |h, _| assert_eq!(h.selected, Some(2)));
571    }
572
573    struct DotHarness(bool);
574    impl Render for DotHarness {
575        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
576            window.set_rem_size(px(14.));
577            div().child(MaterialRadio::new("dot", "Dot").selected(self.0))
578        }
579    }
580
581    #[gpui::test]
582    fn the_dot_scales_in_and_lands_at_10dp(cx: &mut TestAppContext) {
583        cx.update(gpui_base::init);
584        let (view, cx) = cx.add_window_view(|_, _| DotHarness(false));
585        draw(cx);
586        assert!(cx.debug_bounds("radio-dot").is_none());
587        view.update(cx, |view, cx| {
588            view.0 = true;
589            cx.notify();
590        });
591        draw(cx);
592        cx.executor()
593            .advance_clock(std::time::Duration::from_millis(60));
594        draw(cx);
595        let growing = cx.debug_bounds("radio-dot").expect("the dot is growing");
596        assert!(growing.size.width < px(10.), "mid-animation: {growing:?}");
597        cx.executor()
598            .advance_clock(std::time::Duration::from_secs(1));
599        draw(cx);
600        assert_eq!(
601            cx.debug_bounds("radio-dot").unwrap().size,
602            size(px(10.), px(10.))
603        );
604    }
605
606    struct BeforeHarness(bool);
607    impl Render for BeforeHarness {
608        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
609            window.set_rem_size(px(14.));
610            div().flex().flex_col().w(px(240.)).child(
611                div().debug_selector(|| "before-radio".into()).child(
612                    MaterialRadio::new("before", "Express")
613                        .visible_label(true)
614                        .label_before(self.0)
615                        .selected(true),
616                ),
617            )
618        }
619    }
620
621    #[gpui::test]
622    fn a_label_before_puts_the_radio_at_the_far_end(cx: &mut TestAppContext) {
623        cx.update(|cx| {
624            gpui_base::init(cx);
625            cx.set_reduce_motion(true);
626        });
627        let (view, cx) = cx.add_window_view(|_, _| BeforeHarness(false));
628        draw(cx);
629        let row = cx.debug_bounds("before-radio").unwrap();
630        let dot = cx.debug_bounds("radio-dot").unwrap();
631        assert_eq!(
632            dot.center().x - row.left(),
633            px(24.),
634            "after: the radio leads"
635        );
636        view.update(cx, |view, cx| {
637            view.0 = true;
638            cx.notify();
639        });
640        draw(cx);
641        let dot = cx.debug_bounds("radio-dot").unwrap();
642        assert_eq!(
643            row.right() - dot.center().x,
644            px(24.),
645            "before: the radio trails"
646        );
647    }
648
649    struct ClickHarness {
650        selected: bool,
651        disabled: bool,
652    }
653    impl Render for ClickHarness {
654        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
655            window.set_rem_size(px(14.));
656            div().flex().items_start().child(
657                div().debug_selector(|| "click-radio".into()).child(
658                    MaterialRadio::new("click", "Click")
659                        .selected(self.selected)
660                        .disabled(self.disabled)
661                        .on_change(cx.listener(|this, _: &bool, _, cx| {
662                            this.selected = true;
663                            cx.notify();
664                        })),
665                ),
666            )
667        }
668    }
669
670    #[gpui::test]
671    fn clicking_selects_unless_disabled(cx: &mut TestAppContext) {
672        cx.update(|cx| {
673            gpui_base::init(cx);
674            cx.set_reduce_motion(true);
675        });
676        let (view, cx) = cx.add_window_view(|_, _| ClickHarness {
677            selected: false,
678            disabled: true,
679        });
680        draw(cx);
681        let target = cx.debug_bounds("click-radio").unwrap();
682        assert_eq!(target.size.height, px(48.));
683        cx.simulate_click(target.center(), Modifiers::default());
684        draw(cx);
685        view.read_with(cx, |h, _| assert!(!h.selected));
686        view.update(cx, |h, cx| {
687            h.disabled = false;
688            cx.notify();
689        });
690        draw(cx);
691        cx.simulate_click(target.center(), Modifiers::default());
692        draw(cx);
693        view.read_with(cx, |h, _| assert!(h.selected));
694    }
695}