1use std::{rc::Rc, time::Duration};
2
3use crate::{
4 ActiveTheme, AxisExt, Sizable, Size, StyledExt, checkbox::checkbox_check_icon, h_flex,
5 text::Text, tooltip::ComponentTooltip, v_flex,
6};
7use crate::{StyleSized as _, ThemeStyled as _};
8use gpui::{
9 AnyElement, App, Axis, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
10 SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
11 prelude::FluentBuilder, relative, rems,
12};
13use gpui_base::{Radio as BaseRadio, RadioGroup as BaseRadioGroup};
14
15#[derive(IntoElement)]
19pub struct Radio {
20 base: BaseRadio,
21 style: StyleRefinement,
22 id: ElementId,
23 label: Option<Text>,
24 accessibility_label: Option<SharedString>,
26 children: Vec<AnyElement>,
27 checked: bool,
28 disabled: bool,
29 tab_stop: bool,
30 tab_index: isize,
31 size: Size,
32 on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
33 tooltip: ComponentTooltip,
34 position_in_set: Option<usize>,
35 size_of_set: Option<usize>,
36 focus_ring_enabled: bool,
37}
38
39impl Radio {
40 pub fn new(id: impl Into<ElementId>) -> Self {
42 let id = id.into();
43 Self {
44 base: BaseRadio::new(id.clone()),
45 id,
46 style: StyleRefinement::default(),
47 label: None,
48 accessibility_label: None,
49 children: Vec::new(),
50 checked: false,
51 disabled: false,
52 tab_index: 0,
53 tab_stop: true,
54 size: Size::default(),
55 on_click: None,
56 tooltip: ComponentTooltip::default(),
57 position_in_set: None,
58 size_of_set: None,
59 focus_ring_enabled: true,
60 }
61 }
62
63 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
65 self.tooltip.text = Some((tooltip.into(), None));
66 self
67 }
68
69 pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
73 self.tooltip.show_delay = Some(delay);
74 self
75 }
76
77 pub fn label(mut self, label: impl Into<Text>) -> Self {
79 self.label = Some(label.into());
80 self
81 }
82
83 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
89 self.accessibility_label = Some(label.into());
90 self
91 }
92
93 pub fn checked(mut self, checked: bool) -> Self {
95 self.checked = checked;
96 self
97 }
98
99 pub fn disabled(mut self, disabled: bool) -> Self {
101 self.disabled = disabled;
102 self
103 }
104
105 pub fn tab_index(mut self, tab_index: isize) -> Self {
107 self.tab_index = tab_index;
108 self
109 }
110
111 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
113 self.tab_stop = tab_stop;
114 self
115 }
116
117 pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
119 self.on_change(handler)
120 }
121
122 pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
129 self.on_click = Some(Rc::new(handler));
130 self
131 }
132}
133
134impl Sizable for Radio {
135 fn with_size(mut self, size: impl Into<Size>) -> Self {
136 self.size = size.into();
137 self
138 }
139}
140
141impl crate::FocusableExt for Radio {
142 fn focus_ring(mut self, enabled: bool) -> Self {
143 self.focus_ring_enabled = enabled;
144 self
145 }
146
147 fn is_focus_ring_enabled(&self) -> bool {
148 self.focus_ring_enabled
149 }
150}
151
152impl Styled for Radio {
153 fn style(&mut self) -> &mut gpui::StyleRefinement {
154 &mut self.style
155 }
156}
157
158impl InteractiveElement for Radio {
159 fn interactivity(&mut self) -> &mut gpui::Interactivity {
160 self.base.interactivity()
161 }
162}
163
164impl StatefulInteractiveElement for Radio {}
165
166impl ParentElement for Radio {
167 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
168 self.children.extend(elements);
169 }
170}
171
172impl RenderOnce for Radio {
173 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
174 let checked = self.checked;
175 let has_content = self.label.is_some() || !self.children.is_empty();
176 let indicator_size = rems(match self.size {
177 Size::XSmall => 0.75,
178 Size::Small => 0.875,
179 Size::Large => 1.125,
180 _ => 1.,
181 });
182 let focus_handle = window
183 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
184 .read(cx)
185 .clone();
186 let is_focused = focus_handle.is_focused(window);
187 let disabled = self.disabled;
188 let accessibility_label = self
189 .accessibility_label
190 .clone()
191 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
192
193 let (border_color, bg) = if checked {
194 (cx.theme().primary, cx.theme().primary)
195 } else {
196 (cx.theme().input, cx.theme().input.opacity(0.5))
197 };
198 let (border_color, bg) = if disabled {
199 (border_color.opacity(0.5), bg.opacity(0.5))
200 } else {
201 (border_color, bg)
202 };
203
204 self.base
205 .id(self.id.clone())
206 .checked(self.checked)
207 .disabled(self.disabled)
208 .track_focus(&focus_handle)
209 .tab_stop(self.tab_stop)
210 .tab_index(self.tab_index)
211 .when_some(accessibility_label, |this, label| {
212 this.accessibility_label(label)
213 })
214 .when_some(
215 self.position_in_set.zip(self.size_of_set),
216 |this, (position, size)| this.set_position(position, size),
217 )
218 .h_flex()
219 .gap_x_2()
220 .text_color(cx.theme().foreground)
221 .items_start()
222 .line_height(relative(1.))
223 .rounded(cx.theme().radius * 0.5)
224 .when(is_focused && self.focus_ring_enabled, |this| {
225 this.focus_ring_style(window, cx)
226 })
227 .map(|this| match self.size {
228 Size::Size(_) => this,
229 size => this.input_text_size(size),
230 })
231 .refine_style(&self.style)
232 .child(
233 div()
234 .relative()
235 .size(indicator_size)
236 .when(has_content, |this| this.mt(indicator_size * 0.125))
238 .flex_shrink_0()
239 .rounded_full_style(cx)
240 .border_1()
241 .border_color(border_color)
242 .map(|this| match self.checked {
243 false => this.bg(cx.theme().input_background()),
244 true if disabled => this.bg(bg),
245 true => this.bg(cx.theme().tokens.primary),
246 })
247 .child(checkbox_check_icon(
248 self.id, self.size, checked, disabled, window, cx,
249 )),
250 )
251 .when(!self.children.is_empty() || self.label.is_some(), |this| {
252 this.child(
253 v_flex()
254 .w_full()
255 .line_height(relative(1.25))
256 .gap_1()
257 .when_some(self.label, |this, label| {
258 this.child(
259 div()
260 .size_full()
261 .when(self.disabled, |this| {
262 this.text_color(cx.theme().muted_foreground)
263 })
264 .child(label),
265 )
266 })
267 .children(self.children),
268 )
269 })
270 .on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
271 window.prevent_default()
272 })
273 .when_some(self.on_click.clone(), |this, on_click| {
274 this.on_change(move |next, _, window, cx| {
275 window.prevent_default();
276 on_click(&next, window, cx);
277 })
278 })
279 .map(|this| self.tooltip.apply(this))
280 }
281}
282
283#[derive(IntoElement)]
285pub struct RadioGroup {
286 id: ElementId,
287 style: StyleRefinement,
288 radios: Vec<Radio>,
289 layout: Axis,
290 selected_index: Option<usize>,
291 disabled: bool,
292 on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
293}
294
295impl RadioGroup {
296 pub fn new(id: impl Into<ElementId>) -> Self {
298 Self {
299 id: id.into(),
300 style: StyleRefinement::default().flex_1(),
301 on_click: None,
302 layout: Axis::Vertical,
303 selected_index: None,
304 disabled: false,
305 radios: vec![],
306 }
307 }
308
309 pub fn vertical(id: impl Into<ElementId>) -> Self {
311 Self::new(id)
312 }
313
314 pub fn horizontal(id: impl Into<ElementId>) -> Self {
316 Self::new(id).layout(Axis::Horizontal)
317 }
318
319 pub fn layout(mut self, layout: Axis) -> Self {
321 self.layout = layout;
322 self
323 }
324
325 pub fn on_click(self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
327 self.on_change(handler)
328 }
329
330 pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
337 self.on_click = Some(Rc::new(handler));
338 self
339 }
340
341 pub fn selected_index(mut self, index: Option<usize>) -> Self {
343 self.selected_index = index;
344 self
345 }
346
347 pub fn disabled(mut self, disabled: bool) -> Self {
349 self.disabled = disabled;
350 self
351 }
352
353 pub fn child(mut self, child: impl Into<Radio>) -> Self {
355 self.radios.push(child.into());
356 self
357 }
358
359 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Radio>>) -> Self {
361 self.radios.extend(children.into_iter().map(Into::into));
362 self
363 }
364}
365
366impl Styled for RadioGroup {
367 fn style(&mut self) -> &mut StyleRefinement {
368 &mut self.style
369 }
370}
371
372impl From<&'static str> for Radio {
373 fn from(label: &'static str) -> Self {
374 Self::new(label).label(label)
375 }
376}
377
378impl From<SharedString> for Radio {
379 fn from(label: SharedString) -> Self {
380 Self::new(label.clone()).label(label)
381 }
382}
383
384impl From<String> for Radio {
385 fn from(label: String) -> Self {
386 Self::new(SharedString::from(label.clone())).label(SharedString::from(label))
387 }
388}
389
390impl RenderOnce for RadioGroup {
391 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
392 let on_click = self.on_click;
393 let disabled = self.disabled;
394 let selected_ix = self.selected_index;
395
396 let base = if self.layout.is_vertical() {
397 v_flex()
398 } else {
399 h_flex().w_full().flex_wrap()
400 };
401
402 let total = self.radios.len();
403 BaseRadioGroup::new(self.id)
404 .axis(self.layout)
405 .refine_style(&self.style)
406 .child(
407 base.gap_3()
408 .children(self.radios.into_iter().enumerate().map(|(ix, mut radio)| {
409 let checked = selected_ix == Some(ix);
410
411 radio.id = ix.into();
412 radio.position_in_set = Some(ix + 1);
413 radio.size_of_set = Some(total);
414 let item_disabled = radio.disabled;
415 radio
416 .disabled(disabled || item_disabled)
417 .checked(checked)
418 .when_some(on_click.clone(), |this, on_click| {
419 this.on_click(move |_, window, cx| on_click(&ix, window, cx))
420 })
421 })),
422 )
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn an_explicit_accessibility_label_replaces_the_visible_one() {
432 let plain = Radio::new("automatic").label("Automatic");
433 assert_eq!(plain.accessibility_label, None);
434 assert!(matches!(
435 &plain.label,
436 Some(Text::String(label)) if label.as_ref() == "Automatic"
437 ));
438
439 let named = Radio::new("automatic")
440 .label("Automatic")
441 .accessibility_label("Choose automatic mode");
442 assert_eq!(
443 named.accessibility_label.as_deref(),
444 Some("Choose automatic mode"),
445 "an explicit name must win over the visible label"
446 );
447 assert!(
448 matches!(
449 &named.label,
450 Some(Text::String(label)) if label.as_ref() == "Automatic"
451 ),
452 "and must not change what is drawn"
453 );
454 }
455}