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#[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 .role(Role::RadioButton)
203 .aria_toggled(if checked {
204 Toggled::True
205 } else {
206 Toggled::False
207 })
208 .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}