Skip to main content

glassy_ui/
radio.rs

1use std::rc::Rc;
2
3use crate::motion::{Motion, StyledSlot};
4use crate::theme::ActiveTheme;
5use gpui::{
6    div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
7    RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9
10use crate::compat::{AccessibilityExt, Role};
11
12use crate::button::ButtonVariant;
13use crate::chrome::{box_shadow, button_chrome, focus_ring};
14
15type RadioClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
16type RadioChangeHandler = Rc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
17
18struct RadioGroupState {
19    selected: Option<SharedString>,
20    last_selected_prop: Option<SharedString>,
21}
22
23struct RadioFocusState {
24    focus_handle: FocusHandle,
25}
26
27/// 16×16 circle matching Paper `Glassy UI` → Radios.
28///
29/// Radios that share [`Radio::group`] keep one selection. Without a listener
30/// the group owns that selection; with [`Radio::on_change`] / [`Radio::on_click`],
31/// [`Radio::selected`] is the source of truth each render.
32#[derive(IntoElement)]
33pub struct Radio {
34    id: SharedString,
35    group: Option<SharedString>,
36    selected: bool,
37    disabled: bool,
38    label: Option<SharedString>,
39    style: StyleRefinement,
40    on_click: Option<RadioClickHandler>,
41    on_change: Option<RadioChangeHandler>,
42}
43
44impl Radio {
45    pub fn new(id: impl Into<SharedString>) -> Self {
46        Self {
47            id: id.into(),
48            group: None,
49            selected: false,
50            disabled: false,
51            label: None,
52            style: StyleRefinement::default(),
53            on_click: None,
54            on_change: None,
55        }
56    }
57
58    /// Radios that share a group keep one selection.
59    pub fn group(mut self, group: impl Into<SharedString>) -> Self {
60        self.group = Some(group.into());
61        self
62    }
63
64    pub fn selected(mut self, selected: bool) -> Self {
65        self.selected = selected;
66        self
67    }
68
69    pub fn disabled(mut self, disabled: bool) -> Self {
70        self.disabled = disabled;
71        self
72    }
73
74    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
75        self.label = Some(label.into());
76        self
77    }
78
79    pub fn on_click(
80        mut self,
81        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
82    ) -> Self {
83        self.on_click = Some(Rc::new(listener));
84        self
85    }
86
87    /// Selected radio id after a pointer or keyboard activation.
88    pub fn on_change(
89        mut self,
90        listener: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
91    ) -> Self {
92        self.on_change = Some(Rc::new(listener));
93        self
94    }
95}
96
97impl Styled for Radio {
98    fn style(&mut self) -> &mut StyleRefinement {
99        &mut self.style
100    }
101}
102
103impl RenderOnce for Radio {
104    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
105        let radio_id = self.id.clone();
106        let initially_selected = self.selected;
107        let seed = radio_id.clone();
108        let seed_prop = radio_id.clone();
109        let state_key = self.group.clone().unwrap_or_else(|| self.id.clone());
110        let state = window.use_keyed_state(state_key, cx, move |_, _| RadioGroupState {
111            selected: if initially_selected { Some(seed) } else { None },
112            last_selected_prop: if initially_selected {
113                Some(seed_prop)
114            } else {
115                None
116            },
117        });
118        let focus = window.use_keyed_state(
119            SharedString::from(format!("{}-focus", self.id)),
120            cx,
121            |_, cx| RadioFocusState {
122                focus_handle: cx.focus_handle(),
123            },
124        );
125        let controlled = self.on_change.is_some() || self.on_click.is_some();
126        if !controlled && self.selected {
127            let already = state
128                .read(cx)
129                .last_selected_prop
130                .as_ref()
131                .is_some_and(|id| id.as_ref() == self.id.as_ref());
132            if !already {
133                state.update(cx, |group, _| {
134                    group.selected = Some(self.id.clone());
135                    group.last_selected_prop = Some(self.id.clone());
136                });
137            }
138        }
139
140        let selected = if controlled {
141            self.selected
142        } else {
143            state
144                .read(cx)
145                .selected
146                .as_ref()
147                .is_some_and(|id| id.as_ref() == self.id.as_ref())
148        };
149        let theme = cx.theme();
150        let variant = if self.disabled {
151            ButtonVariant::Ghost
152        } else if selected {
153            ButtonVariant::Primary
154        } else {
155            ButtonVariant::Outline
156        };
157        let chrome = button_chrome(theme, variant);
158        let dot = if self.disabled && selected {
159            theme.muted_fg()
160        } else {
161            theme.on_solid
162        };
163
164        let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
165        if chrome.shadow_blur > 0.0 {
166            shadows.push(box_shadow(
167                0.,
168                chrome.shadow_y,
169                chrome.shadow,
170                chrome.shadow_blur,
171                0.,
172            ));
173        }
174
175        let interactive = !self.disabled;
176        let focus_handle = focus.read(cx).focus_handle.clone().tab_stop(interactive);
177        let focused = focus_handle.is_focused(window);
178        if focused {
179            shadows.push(focus_ring(theme));
180        }
181
182        let mark = div()
183            .flex()
184            .items_center()
185            .justify_center()
186            .size(px(16.))
187            .flex_shrink_0()
188            .rounded(px(8.))
189            .border_1()
190            .border_color(chrome.border)
191            .bg(chrome.bg)
192            .shadow(shadows)
193            .when(selected, |el| {
194                el.child(
195                    Motion::new()
196                        .id(format!("{}-dot", self.id))
197                        .selection_in()
198                        .child(div().size(px(6.)).flex_shrink_0().rounded(px(3.)).bg(dot)),
199                )
200            });
201
202        let label_color = if self.disabled {
203            theme.muted_fg()
204        } else {
205            theme.ink
206        };
207        let aria_label = self.label.clone();
208        let debug_selector = format!(
209            "{}-{}",
210            self.id,
211            if selected { "selected" } else { "unselected" }
212        );
213
214        let el = div()
215            .id(self.id.clone())
216            .debug_selector(move || debug_selector.clone())
217            .role(Role::RadioButton)
218            .aria_selected(selected)
219            .when_some(aria_label, |el, label| el.aria_label(label))
220            .track_focus(&focus_handle)
221            .tab_stop(interactive)
222            .flex()
223            .items_center()
224            .gap(px(8.))
225            .refine_style(&self.style)
226            .when(interactive, |el| el.cursor_pointer())
227            .when(!interactive, |el| el.cursor_default())
228            .child(mark)
229            .when_some(self.label, |el, label| {
230                el.child(
231                    div()
232                        .font_family(theme.font_family)
233                        .font_weight(FontWeight::NORMAL)
234                        .text_size(px(14.))
235                        .line_height(px(18.))
236                        .text_color(label_color)
237                        .child(label),
238                )
239            });
240
241        if interactive {
242            let on_click = self.on_click;
243            let on_change = self.on_change;
244            let activate = Rc::new(
245                move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
246                    if !controlled {
247                        state.update(cx, |group, cx| {
248                            group.selected = Some(radio_id.clone());
249                            cx.notify();
250                        });
251                    }
252                    if let Some(on_change) = &on_change {
253                        on_change(&radio_id, window, cx);
254                    }
255                    if let Some(on_click) = &on_click {
256                        on_click(event, window, cx);
257                    }
258                },
259            );
260            let keyboard = activate.clone();
261            let click_focus = focus_handle.clone();
262            el.on_key_down(move |event: &KeyDownEvent, window, cx| {
263                if event.keystroke.modifiers.modified() {
264                    return;
265                }
266                if matches!(event.keystroke.key.as_str(), "enter" | "space") {
267                    keyboard(&ClickEvent::default(), window, cx);
268                    cx.stop_propagation();
269                }
270            })
271            .on_click(move |event, window, cx| {
272                click_focus.focus(window);
273                activate(event, window, cx);
274            })
275        } else {
276            el
277        }
278    }
279}