Skip to main content

gpui_cn/
radio.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    AnyElement, App, Axis, ClickEvent, ElementId, FocusHandle, Hsla, InteractiveElement as _,
5    IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, ParentElement, Pixels, PlatformInput,
6    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
7    TestSupportExt as _, Window,
8    accesskit::Orientation,
9    base::{self, Disableable, StyledExt as _},
10    div, point,
11    prelude::FluentBuilder as _,
12    px,
13};
14
15use crate::{ActiveTheme as _, Theme, ThemeTokens};
16
17type ChangeHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
18
19/// The round indicator of a radio: a thin grey ring, or while checked a
20/// disc of the control accent with a white dot, as macOS paints it. The
21/// accent is the one a switch that is on uses. It paints only. A row that is already a
22/// button, such as a model in a list, draws it to show which one is on.
23///
24/// ```
25/// use gpui_cn::RadioMark;
26///
27/// let _ = RadioMark::new(true);
28/// ```
29#[derive(IntoElement)]
30pub struct RadioMark {
31    checked: bool,
32    style: StyleRefinement,
33}
34
35impl RadioMark {
36    /// An indicator, with its dot shown while `checked`.
37    pub fn new(checked: bool) -> Self {
38        Self {
39            checked,
40            style: StyleRefinement::default(),
41        }
42    }
43
44    /// Whether the dot shows.
45    pub fn is_checked(&self) -> bool {
46        self.checked
47    }
48}
49
50impl Styled for RadioMark {
51    fn style(&mut self) -> &mut StyleRefinement {
52        &mut self.style
53    }
54}
55
56/// The ring and the fill of an indicator: a chosen one is a disc of the
57/// control accent, the color a switch that is on takes for its track.
58fn colors(checked: bool, theme: &ThemeTokens) -> (Hsla, Option<Hsla>) {
59    if checked {
60        (theme.switch_track_on, Some(theme.switch_track_on))
61    } else {
62        (theme.field_border, None)
63    }
64}
65
66impl RenderOnce for RadioMark {
67    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
68        let theme = cx.theme();
69        let (size, dot) = (theme.metrics.radio_size, theme.metrics.radio_dot);
70        let radius = theme.radius_full();
71        let (ring, fill) = colors(self.checked, theme);
72        div()
73            .flex()
74            .flex_shrink_0()
75            .items_center()
76            .justify_center()
77            .size(size)
78            .rounded(radius)
79            .border_1()
80            .border_color(ring)
81            .when_some(fill, |this, fill| this.bg(fill))
82            .when(self.checked, |this| {
83                this.child(div().size(dot).rounded(radius).bg(theme.switch_thumb))
84            })
85            .refine_style(&self.style)
86    }
87}
88
89/// A shadcn-style radio on `gpui_base::Radio`: a [`RadioMark`] with an
90/// optional label beside it.
91///
92/// Base owns focus, Tab order, Enter and Space activation, the disabled
93/// contract, and the radio role with its selected state. A radio cannot
94/// uncheck itself, so activation reports only the request to check it. The
95/// checked value is the application's: a group of radios renders the one
96/// value it keeps. Wrap them in a [`RadioGroup`].
97///
98/// ```
99/// use gpui_cn::Radio;
100///
101/// let _ = Radio::new("small").checked(true).label("Small");
102/// ```
103///
104/// The id keys focus, so it must be stable across frames and unique under
105/// the nearest stateful ancestor.
106#[derive(IntoElement)]
107#[non_exhaustive]
108pub struct Radio {
109    id: ElementId,
110    style: StyleRefinement,
111    checked: bool,
112    disabled: bool,
113    label: Option<SharedString>,
114    accessibility_label: Option<SharedString>,
115    on_change: Option<ChangeHandler>,
116    tab_index: isize,
117    tab_stop: bool,
118    children: Vec<AnyElement>,
119}
120
121impl Radio {
122    /// An unchecked radio with a stable id.
123    pub fn new(id: impl Into<ElementId>) -> Self {
124        Self {
125            id: id.into(),
126            style: StyleRefinement::default(),
127            checked: false,
128            disabled: false,
129            label: None,
130            accessibility_label: None,
131            on_change: None,
132            tab_index: 0,
133            tab_stop: true,
134            children: Vec::new(),
135        }
136    }
137
138    /// Whether the radio is the chosen one.
139    pub fn checked(mut self, checked: bool) -> Self {
140        self.checked = checked;
141        self
142    }
143
144    /// Whether the radio is the chosen one.
145    pub fn is_checked(&self) -> bool {
146        self.checked
147    }
148
149    /// The text beside the indicator. It also names the radio for a screen
150    /// reader unless [`accessibility_label`](Self::accessibility_label) is set.
151    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
152        self.label = Some(label.into());
153        self
154    }
155
156    /// The name a screen reader announces, when it differs from the label.
157    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
158        self.accessibility_label = Some(label.into());
159        self
160    }
161
162    /// Called when the radio is activated while unchecked, by pointer,
163    /// Enter, or Space.
164    pub fn on_change(
165        mut self,
166        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
167    ) -> Self {
168        self.on_change = Some(Rc::new(handler));
169        self
170    }
171
172    /// The focus traversal index. The default is `0`.
173    pub fn tab_index(mut self, tab_index: isize) -> Self {
174        self.tab_index = tab_index;
175        self
176    }
177
178    /// Whether Tab reaches the radio. The default is `true`.
179    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
180        self.tab_stop = tab_stop;
181        self
182    }
183}
184
185impl Disableable for Radio {
186    fn disabled(mut self, disabled: bool) -> Self {
187        self.disabled = disabled;
188        self
189    }
190}
191
192impl Styled for Radio {
193    fn style(&mut self) -> &mut StyleRefinement {
194        &mut self.style
195    }
196}
197
198impl ParentElement for Radio {
199    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
200        self.children.extend(elements);
201    }
202}
203
204impl RenderOnce for Radio {
205    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
206        let pointer_cursors = Theme::global(cx).pointer_cursors;
207        let (ring, ring_spread, strength, gap, text): (_, Pixels, _, _, _) = {
208            let theme = cx.theme();
209            (
210                theme.focus_ring(),
211                theme.metrics.focus_ring,
212                theme.disabled_opacity,
213                theme.base.spacing.sm,
214                theme.text_control,
215            )
216        };
217        let disabled = self.disabled;
218        let checked = self.checked;
219        let focus_handle = window
220            .use_keyed_state(
221                ElementId::NamedChild(self.id.clone().into(), "focus".into()),
222                cx,
223                |_, cx| cx.focus_handle(),
224            )
225            .read(cx)
226            .clone();
227        let focus_visible = focus_handle.is_focused(window) && window.last_input_was_keyboard();
228        let name = self.accessibility_label.or_else(|| self.label.clone());
229        let on_change = self.on_change;
230        base::Radio::new(self.id)
231            .track_focus(&focus_handle)
232            .checked(checked)
233            .disabled(disabled)
234            .tab_index(self.tab_index)
235            .tab_stop(self.tab_stop)
236            .when_some(name, |this, name| this.accessibility_label(name))
237            .flex()
238            .items_center()
239            .gap(gap)
240            .text_size(text.size)
241            .line_height(text.line_height)
242            .when(disabled, |this| this.opacity(strength))
243            .map(|this| {
244                if !disabled && pointer_cursors {
245                    this.cursor_pointer()
246                } else {
247                    this.cursor_default()
248                }
249            })
250            .refine_style(&self.style)
251            .child(RadioMark::new(checked).when(focus_visible, |this| {
252                this.shadow(vec![gpui_kit::BoxShadow {
253                    color: ring,
254                    offset: point(px(0.), px(0.)),
255                    blur_radius: px(0.),
256                    spread_radius: ring_spread,
257                    inset: false,
258                }])
259            }))
260            .children(self.label)
261            .children(self.children)
262            .when_some(on_change.filter(|_| !disabled), |this, on_change| {
263                this.on_change(move |_, event, window, cx| on_change(event, window, cx))
264            })
265    }
266}
267
268/// The container of a set of [`Radio`] values: the radio-group role with
269/// its orientation (base's `RadioGroup` is only that, and an element that
270/// tests can find needs the id on the element itself), laid out as a column, or as a row for
271/// [`horizontal`](Self::horizontal).
272///
273/// The arrow keys move through the group, wrapping at the ends: the focus
274/// goes to the next radio that can take it and that radio is chosen, as
275/// Space would choose it. A radio that is disabled is skipped. For one tab
276/// stop in the group, give the radios that are not checked
277/// `.tab_stop(false)`.
278///
279/// ```
280/// use gpui_cn::{Radio, RadioGroup};
281/// use gpui_kit::ParentElement as _;
282///
283/// let _ = RadioGroup::new("size")
284///     .child(Radio::new("small").checked(true).label("Small"))
285///     .child(Radio::new("large").label("Large"));
286/// ```
287#[derive(IntoElement)]
288#[non_exhaustive]
289pub struct RadioGroup {
290    id: ElementId,
291    style: StyleRefinement,
292    axis: Axis,
293    children: Vec<AnyElement>,
294}
295
296impl RadioGroup {
297    /// A vertical group with a stable id.
298    pub fn new(id: impl Into<ElementId>) -> Self {
299        Self {
300            id: id.into(),
301            style: StyleRefinement::default(),
302            axis: Axis::Vertical,
303            children: Vec::new(),
304        }
305    }
306
307    /// Lays the radios out in a row.
308    pub fn horizontal(mut self) -> Self {
309        self.axis = Axis::Horizontal;
310        self
311    }
312
313    /// Which way the radios run.
314    pub fn axis(&self) -> Axis {
315        self.axis
316    }
317}
318
319impl Styled for RadioGroup {
320    fn style(&mut self) -> &mut StyleRefinement {
321        &mut self.style
322    }
323}
324
325impl ParentElement for RadioGroup {
326    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
327        self.children.extend(elements);
328    }
329}
330
331/// Moves the choice to the next (or previous) radio of the group that can
332/// take focus, wrapping at the ends, the way a native radio group does: the
333/// focus moves, and the radio that gets it is activated as Space would.
334fn step(group: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
335    if !group.contains_focused(window, cx) {
336        return;
337    }
338    // Past either end the tab order goes on around the window, and comes
339    // back into the group at its other end.
340    for _ in 0..64 {
341        if forward {
342            window.focus_next(cx);
343        } else {
344            window.focus_prev(cx);
345        }
346        if group.contains_focused(window, cx) {
347            break;
348        }
349    }
350    if !group.contains_focused(window, cx) {
351        return;
352    }
353    for event in [
354        PlatformInput::KeyDown(KeyDownEvent {
355            keystroke: Keystroke::parse("space").expect("a valid keystroke"),
356            is_held: false,
357            prefer_character_input: false,
358        }),
359        PlatformInput::KeyUp(KeyUpEvent {
360            keystroke: Keystroke::parse("space").expect("a valid keystroke"),
361        }),
362    ] {
363        window.dispatch_event(event, cx);
364    }
365}
366
367impl RenderOnce for RadioGroup {
368    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
369        let gap = cx.theme().base.spacing.md;
370        let group = window
371            .use_keyed_state(
372                ElementId::NamedChild(self.id.clone().into(), "focus".into()),
373                cx,
374                |_, cx| cx.focus_handle(),
375            )
376            .read(cx)
377            .clone();
378        let keys = group.clone();
379        div()
380            .id(self.id)
381            .track_focus(&group)
382            .on_key_down(move |event, window, cx| {
383                let forward = match event.keystroke.key.as_str() {
384                    "down" | "right" => true,
385                    "up" | "left" => false,
386                    _ => return,
387                };
388                if event.keystroke.modifiers.modified() {
389                    return;
390                }
391                cx.stop_propagation();
392                let keys = keys.clone();
393                window.defer(cx, move |window, cx| step(&keys, forward, window, cx));
394            })
395            .role(Role::RadioGroup)
396            .aria_orientation(match self.axis {
397                Axis::Horizontal => Orientation::Horizontal,
398                Axis::Vertical => Orientation::Vertical,
399            })
400            .test_support()
401            .flex()
402            .when(self.axis == Axis::Vertical, |this| this.flex_col())
403            .when(self.axis == Axis::Horizontal, |this| this.items_center())
404            .gap(gap)
405            .refine_style(&self.style)
406            .children(self.children)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn a_chosen_radio_fills_with_the_color_of_an_on_switch() {
416        use crate::theme::{ThemeConfig, to_hex};
417        use gpui_kit::base::ThemeAppearance;
418        for (config, appearance, hex) in [
419            (ThemeConfig::dark(), ThemeAppearance::Dark, "#539af8"),
420            (ThemeConfig::light(), ThemeAppearance::Light, "#339cff"),
421        ] {
422            let theme = crate::theme::test_tokens(&config, appearance);
423            let (ring, fill) = colors(true, &theme);
424            assert_eq!(fill.map(to_hex).as_deref(), Some(hex));
425            assert_eq!(to_hex(ring), hex);
426            assert_eq!(
427                fill,
428                Some(theme.switch_track_on),
429                "the switch's track token"
430            );
431            assert_eq!(colors(false, &theme), (theme.field_border, None));
432        }
433    }
434
435    #[test]
436    fn builders_read_back() {
437        assert!(Radio::new("r").checked(true).is_checked());
438        assert!(!Radio::new("r").is_checked());
439        assert!(RadioMark::new(true).is_checked());
440        assert_eq!(RadioGroup::new("g").axis(), Axis::Vertical);
441        assert_eq!(RadioGroup::new("g").horizontal().axis(), Axis::Horizontal);
442    }
443}