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#[derive(IntoElement)]
30pub struct RadioMark {
31 checked: bool,
32 style: StyleRefinement,
33}
34
35impl RadioMark {
36 pub fn new(checked: bool) -> Self {
38 Self {
39 checked,
40 style: StyleRefinement::default(),
41 }
42 }
43
44 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
56fn 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#[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 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 pub fn checked(mut self, checked: bool) -> Self {
140 self.checked = checked;
141 self
142 }
143
144 pub fn is_checked(&self) -> bool {
146 self.checked
147 }
148
149 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
152 self.label = Some(label.into());
153 self
154 }
155
156 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
158 self.accessibility_label = Some(label.into());
159 self
160 }
161
162 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
174 self.tab_index = tab_index;
175 self
176 }
177
178 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#[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 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 pub fn horizontal(mut self) -> Self {
309 self.axis = Axis::Horizontal;
310 self
311 }
312
313 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
331fn step(group: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
335 if !group.contains_focused(window, cx) {
336 return;
337 }
338 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}