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 _, TestSupportExt 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            .test_support()
203            .role(Role::RadioButton)
204            .aria_toggled(if checked {
205                Toggled::True
206            } else {
207                Toggled::False
208            })
209            // A radio is both "toggled" and "selected"; different assistive
210            // technology reads one or the other, so state both rather than
211            // making callers choose.
212            .aria_selected(checked)
213            .when_some(self.accessibility_label, |this, label| {
214                this.aria_label(label)
215            })
216            .when_some(self.position_in_set, |this, position| {
217                this.aria_position_in_set(position)
218            })
219            .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size))
220            .when(!disabled, |this| {
221                this.track_focus(
222                    &focus_handle
223                        .tab_index(self.tab_index)
224                        .tab_stop(self.tab_stop),
225                )
226            })
227            .when_some(
228                (!disabled && !checked).then_some(on_change).flatten(),
229                |this, on_change| {
230                    this.on_click(move |event, window, cx| {
231                        on_change(!checked, event, window, cx);
232                    })
233                },
234            )
235            .children(self.children)
236            .refine_style(&style)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::{
244        cell::Cell,
245        rc::Rc,
246        sync::{Arc, Mutex},
247    };
248
249    use gpui::{
250        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
251        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
252    };
253
254    #[test]
255    fn semantic_state_styles_are_available_to_applications() {
256        let _ = Radio::new("states").styles(|styles| {
257            styles
258                .checked(|style| style.opacity(0.8))
259                .disabled(|style| {
260                    style
261                        .opacity(0.5)
262                        .when(true, |style| style.border_1())
263                        .when_some(Some(0.4), |style, opacity| style.opacity(opacity))
264                        .when_none(&None::<f32>, |style| style.rounded_sm())
265                })
266        });
267    }
268
269    #[test]
270    fn semantic_root_styles_follow_radio_priority() {
271        let styled = |radio: Radio| {
272            radio.styles(|styles| {
273                styles
274                    .checked(|style| style.opacity(0.8))
275                    .disabled(|style| style.opacity(0.5))
276            })
277        };
278
279        assert_eq!(styled(Radio::new("normal")).resolved_style().opacity, None);
280        assert_eq!(
281            styled(Radio::new("checked").checked(true))
282                .resolved_style()
283                .opacity,
284            Some(0.8)
285        );
286        assert_eq!(
287            styled(Radio::new("checked-disabled").checked(true).disabled(true))
288                .resolved_style()
289                .opacity,
290            Some(0.5)
291        );
292        assert_eq!(
293            styled(
294                Radio::new("state-over-instance")
295                    .checked(true)
296                    .disabled(true)
297                    .opacity(0.9),
298            )
299            .resolved_style()
300            .opacity,
301            Some(0.5)
302        );
303    }
304
305    struct RadioHarness {
306        checked: bool,
307        disabled: bool,
308        changes: Rc<Cell<usize>>,
309        keyboard_changes: Rc<Cell<usize>>,
310    }
311
312    impl Render for RadioHarness {
313        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
314            let changes = self.changes.clone();
315            let keyboard_changes = self.keyboard_changes.clone();
316            Radio::new("radio")
317                .checked(self.checked)
318                .disabled(self.disabled)
319                .size(px(100.))
320                .on_change(move |checked, event, _, _| {
321                    assert!(checked);
322                    changes.set(changes.get() + 1);
323                    if matches!(event, ClickEvent::Keyboard(_)) {
324                        keyboard_changes.set(keyboard_changes.get() + 1);
325                    }
326                })
327        }
328    }
329
330    fn harness(
331        cx: &mut TestAppContext,
332        checked: bool,
333        disabled: bool,
334    ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
335        let changes = Rc::new(Cell::new(0));
336        let keyboard_changes = Rc::new(Cell::new(0));
337        let (_, cx) = cx.add_window_view({
338            let changes = changes.clone();
339            let keyboard_changes = keyboard_changes.clone();
340            move |_, _| RadioHarness {
341                checked,
342                disabled,
343                changes,
344                keyboard_changes,
345            }
346        });
347        cx.update(|window, cx| window.draw(cx).clear(cx));
348        (cx, changes, keyboard_changes)
349    }
350
351    #[gpui::test]
352    fn pointer_and_keyboard_activation_fire_once(cx: &mut TestAppContext) {
353        let (cx, changes, keyboard_changes) = harness(cx, false, false);
354        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
355        assert_eq!(changes.get(), 1);
356
357        changes.set(0);
358        cx.update(|window, cx| window.draw(cx).clear(cx));
359        for key in ["enter", "space"] {
360            let keystroke = Keystroke::parse(key).unwrap();
361            cx.simulate_event(KeyDownEvent {
362                keystroke: keystroke.clone(),
363                is_held: false,
364                prefer_character_input: false,
365            });
366            cx.simulate_event(KeyUpEvent { keystroke });
367        }
368        assert_eq!(changes.get(), 2);
369        assert_eq!(keyboard_changes.get(), 2);
370    }
371
372    #[gpui::test]
373    fn checked_and_disabled_radios_are_inert(cx: &mut TestAppContext) {
374        for (checked, disabled) in [(true, false), (false, true)] {
375            let (cx, changes, _) = harness(cx, checked, disabled);
376            cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
377            cx.simulate_keystrokes("enter space");
378            assert_eq!(changes.get(), 0);
379        }
380    }
381
382    #[gpui::test]
383    fn accessibility_exposes_role_state_and_action(cx: &mut TestAppContext) {
384        type Captured = Arc<Mutex<Option<accesskit::Node>>>;
385        struct Probe(Captured);
386        impl Render for Probe {
387            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
388                let captured = self.0.clone();
389                canvas(
390                    move |_, window, cx| {
391                        let mut node = accesskit::Node::new(Role::RadioButton);
392                        Radio::new("probe")
393                            .checked(true)
394                            .accessibility_label("Choice")
395                            .render(window, cx)
396                            .into_element()
397                            .write_a11y_info(&mut node);
398                        *captured.lock().unwrap() = Some(node);
399                    },
400                    |_, _, _, _| {},
401                )
402            }
403        }
404        let captured: Captured = Arc::new(Mutex::new(None));
405        let result = captured.clone();
406        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
407        cx.update(|window, cx| window.draw(cx).clear(cx));
408        let node = result.lock().unwrap().take().unwrap();
409        assert_eq!(node.role(), Role::RadioButton);
410        assert_eq!(node.label(), Some("Choice"));
411        assert_eq!(node.toggled(), Some(Toggled::True));
412        assert!(!node.supports_action(accesskit::Action::Click));
413    }
414}