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#[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#[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 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 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 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 pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
141 self.provided_focus_handle = Some(focus_handle.clone());
142 self
143 }
144
145 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 .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}