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#[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 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 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}