Skip to main content

gpui_base/
radio.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5    IntoElement, ParentElement, Refineable as _, RenderOnce, Role, SharedString, Stateful,
6    StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div,
7    prelude::FluentBuilder as _,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _};
12
13type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
14
15/// An unstyled radio control that owns activation, focus, and accessibility behavior.
16///
17/// The application owns its indicator, label, layout, colors, and state styling.
18/// Selection is controlled through [`Radio::checked`]; activating an unchecked
19/// radio requests `true` through [`Radio::on_change`].
20#[derive(IntoElement)]
21pub struct Radio {
22    id: ElementId,
23    base: Stateful<Div>,
24    style: StyleRefinement,
25    semantic_styles: RadioStyles,
26    checked: bool,
27    disabled: bool,
28    children: SmallVec<[AnyElement; 2]>,
29    on_change: Option<ChangeHandler>,
30    accessibility_label: Option<SharedString>,
31    tab_index: isize,
32    tab_stop: bool,
33    provided_focus_handle: Option<FocusHandle>,
34    position_in_set: Option<usize>,
35    size_of_set: Option<usize>,
36}
37
38/// Semantic root styles supported by [`Radio`].
39#[derive(Default)]
40pub struct RadioStyles {
41    checked: StyleRefinement,
42    disabled: StyleRefinement,
43}
44
45impl RadioStyles {
46    pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
47        self.checked
48            .refine(&build(StateStyle::default()).into_refinement());
49        self
50    }
51
52    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
53        self.disabled
54            .refine(&build(StateStyle::default()).into_refinement());
55        self
56    }
57}
58
59impl Radio {
60    pub fn new(id: impl Into<ElementId>) -> Self {
61        let id = id.into();
62        Self {
63            base: div().id(id.clone()),
64            id,
65            style: StyleRefinement::default(),
66            semantic_styles: RadioStyles::default(),
67            checked: false,
68            disabled: false,
69            children: SmallVec::new(),
70            on_change: None,
71            accessibility_label: None,
72            tab_index: 0,
73            tab_stop: true,
74            provided_focus_handle: None,
75            position_in_set: None,
76            size_of_set: None,
77        }
78    }
79
80    /// Updates the element identity used when the radio is rendered.
81    ///
82    /// A group that assigns positional ids after construction needs this so
83    /// each radio keeps a distinct element identity.
84    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
85        let id = id.into();
86        self.base.interactivity().element_id = Some(id.clone());
87        self.id = id;
88        self
89    }
90
91    pub fn checked(mut self, checked: bool) -> Self {
92        self.checked = checked;
93        self
94    }
95
96    pub fn disabled(mut self, disabled: bool) -> Self {
97        self.disabled = disabled;
98        self
99    }
100
101    /// Configures application-owned styles for the radio's semantic states.
102    pub fn styles(mut self, build: impl FnOnce(RadioStyles) -> RadioStyles) -> Self {
103        self.semantic_styles = build(self.semantic_styles);
104        self
105    }
106
107    fn resolved_style(&self) -> StyleRefinement {
108        crate::state_style::resolve_style(
109            &self.style,
110            [
111                self.checked.then_some(&self.semantic_styles.checked),
112                self.disabled.then_some(&self.semantic_styles.disabled),
113            ]
114            .into_iter()
115            .flatten(),
116        )
117    }
118
119    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
120        self.accessibility_label = Some(label.into());
121        self
122    }
123
124    /// Handles a requested selection change.
125    ///
126    /// The callback receives `true`. Activating an already checked radio is a
127    /// no-op because a radio cannot deselect itself.
128    pub fn on_change(
129        mut self,
130        handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
131    ) -> Self {
132        self.on_change = Some(Rc::new(handler));
133        self
134    }
135
136    /// Uses a caller-owned focus handle instead of creating keyed state.
137    ///
138    /// A styled radio needs this to draw its own focus ring from the same
139    /// handle the primitive tracks.
140    pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
141        self.provided_focus_handle = Some(focus_handle.clone());
142        self
143    }
144
145    /// Sets this radio's one-based position and its group's total size, so
146    /// assistive technology can announce "option 2 of 5".
147    pub fn set_position(mut self, position: usize, size: usize) -> Self {
148        self.position_in_set = Some(position);
149        self.size_of_set = Some(size);
150        self
151    }
152
153    pub fn tab_index(mut self, tab_index: isize) -> Self {
154        self.tab_index = tab_index;
155        self
156    }
157
158    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
159        self.tab_stop = tab_stop;
160        self
161    }
162
163    fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
164        self.provided_focus_handle.clone().unwrap_or_else(|| {
165            window
166                .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
167                .read(cx)
168                .clone()
169        })
170    }
171}
172
173impl Styled for Radio {
174    fn style(&mut self) -> &mut StyleRefinement {
175        &mut self.style
176    }
177}
178
179impl ParentElement for Radio {
180    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
181        self.children.extend(elements);
182    }
183}
184
185impl InteractiveElement for Radio {
186    fn interactivity(&mut self) -> &mut Interactivity {
187        self.base.interactivity()
188    }
189}
190
191impl StatefulInteractiveElement for Radio {}
192
193impl RenderOnce for Radio {
194    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
195        let focus_handle = self.focus_handle(window, cx);
196        let disabled = self.disabled;
197        let checked = self.checked;
198        let style = self.resolved_style();
199        let on_change = self.on_change;
200
201        self.base
202            .role(Role::RadioButton)
203            .aria_toggled(if checked {
204                Toggled::True
205            } else {
206                Toggled::False
207            })
208            // A radio is both "toggled" and "selected"; different assistive
209            // technology reads one or the other, so state both rather than
210            // making callers choose.
211            .aria_selected(checked)
212            .when_some(self.accessibility_label, |this, label| {
213                this.aria_label(label)
214            })
215            .when_some(self.position_in_set, |this, position| {
216                this.aria_position_in_set(position)
217            })
218            .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size))
219            .when(!disabled, |this| {
220                this.track_focus(
221                    &focus_handle
222                        .tab_index(self.tab_index)
223                        .tab_stop(self.tab_stop),
224                )
225            })
226            .when_some(
227                (!disabled && !checked).then_some(on_change).flatten(),
228                |this, on_change| {
229                    this.on_click(move |event, window, cx| {
230                        on_change(!checked, event, window, cx);
231                    })
232                },
233            )
234            .children(self.children)
235            .refine_style(&style)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::{
243        cell::Cell,
244        rc::Rc,
245        sync::{Arc, Mutex},
246    };
247
248    use gpui::{
249        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
250        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
251    };
252
253    #[test]
254    fn semantic_state_styles_are_available_to_applications() {
255        let _ = Radio::new("states").styles(|styles| {
256            styles
257                .checked(|style| style.opacity(0.8))
258                .disabled(|style| {
259                    style
260                        .opacity(0.5)
261                        .when(true, |style| style.border_1())
262                        .when_some(Some(0.4), |style, opacity| style.opacity(opacity))
263                        .when_none(&None::<f32>, |style| style.rounded_sm())
264                })
265        });
266    }
267
268    #[test]
269    fn semantic_root_styles_follow_radio_priority() {
270        let styled = |radio: Radio| {
271            radio.styles(|styles| {
272                styles
273                    .checked(|style| style.opacity(0.8))
274                    .disabled(|style| style.opacity(0.5))
275            })
276        };
277
278        assert_eq!(styled(Radio::new("normal")).resolved_style().opacity, None);
279        assert_eq!(
280            styled(Radio::new("checked").checked(true))
281                .resolved_style()
282                .opacity,
283            Some(0.8)
284        );
285        assert_eq!(
286            styled(Radio::new("checked-disabled").checked(true).disabled(true))
287                .resolved_style()
288                .opacity,
289            Some(0.5)
290        );
291        assert_eq!(
292            styled(
293                Radio::new("state-over-instance")
294                    .checked(true)
295                    .disabled(true)
296                    .opacity(0.9),
297            )
298            .resolved_style()
299            .opacity,
300            Some(0.5)
301        );
302    }
303
304    struct RadioHarness {
305        checked: bool,
306        disabled: bool,
307        changes: Rc<Cell<usize>>,
308        keyboard_changes: Rc<Cell<usize>>,
309    }
310
311    impl Render for RadioHarness {
312        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
313            let changes = self.changes.clone();
314            let keyboard_changes = self.keyboard_changes.clone();
315            Radio::new("radio")
316                .checked(self.checked)
317                .disabled(self.disabled)
318                .size(px(100.))
319                .on_change(move |checked, event, _, _| {
320                    assert!(checked);
321                    changes.set(changes.get() + 1);
322                    if matches!(event, ClickEvent::Keyboard(_)) {
323                        keyboard_changes.set(keyboard_changes.get() + 1);
324                    }
325                })
326        }
327    }
328
329    fn harness(
330        cx: &mut TestAppContext,
331        checked: bool,
332        disabled: bool,
333    ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
334        let changes = Rc::new(Cell::new(0));
335        let keyboard_changes = Rc::new(Cell::new(0));
336        let (_, cx) = cx.add_window_view({
337            let changes = changes.clone();
338            let keyboard_changes = keyboard_changes.clone();
339            move |_, _| RadioHarness {
340                checked,
341                disabled,
342                changes,
343                keyboard_changes,
344            }
345        });
346        cx.update(|window, cx| window.draw(cx).clear(cx));
347        (cx, changes, keyboard_changes)
348    }
349
350    #[gpui::test]
351    fn pointer_and_keyboard_activation_fire_once(cx: &mut TestAppContext) {
352        let (cx, changes, keyboard_changes) = harness(cx, false, false);
353        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
354        assert_eq!(changes.get(), 1);
355
356        changes.set(0);
357        cx.update(|window, cx| window.draw(cx).clear(cx));
358        for key in ["enter", "space"] {
359            let keystroke = Keystroke::parse(key).unwrap();
360            cx.simulate_event(KeyDownEvent {
361                keystroke: keystroke.clone(),
362                is_held: false,
363                prefer_character_input: false,
364            });
365            cx.simulate_event(KeyUpEvent { keystroke });
366        }
367        assert_eq!(changes.get(), 2);
368        assert_eq!(keyboard_changes.get(), 2);
369    }
370
371    #[gpui::test]
372    fn checked_and_disabled_radios_are_inert(cx: &mut TestAppContext) {
373        for (checked, disabled) in [(true, false), (false, true)] {
374            let (cx, changes, _) = harness(cx, checked, disabled);
375            cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
376            cx.simulate_keystrokes("enter space");
377            assert_eq!(changes.get(), 0);
378        }
379    }
380
381    #[gpui::test]
382    fn accessibility_exposes_role_state_and_action(cx: &mut TestAppContext) {
383        type Captured = Arc<Mutex<Option<accesskit::Node>>>;
384        struct Probe(Captured);
385        impl Render for Probe {
386            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
387                let captured = self.0.clone();
388                canvas(
389                    move |_, window, cx| {
390                        let mut node = accesskit::Node::new(Role::RadioButton);
391                        Radio::new("probe")
392                            .checked(true)
393                            .accessibility_label("Choice")
394                            .render(window, cx)
395                            .into_element()
396                            .write_a11y_info(&mut node);
397                        *captured.lock().unwrap() = Some(node);
398                    },
399                    |_, _, _, _| {},
400                )
401            }
402        }
403        let captured: Captured = Arc::new(Mutex::new(None));
404        let result = captured.clone();
405        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
406        cx.update(|window, cx| window.draw(cx).clear(cx));
407        let node = result.lock().unwrap().take().unwrap();
408        assert_eq!(node.role(), Role::RadioButton);
409        assert_eq!(node.label(), Some("Choice"));
410        assert_eq!(node.toggled(), Some(Toggled::True));
411        assert!(!node.supports_action(accesskit::Action::Click));
412    }
413}