1use std::rc::Rc;
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 label(mut self, label: impl Into<Text>) -> Self {
71 self.label = Some(label.into());
72 self
73 }
74
75 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
81 self.accessibility_label = Some(label.into());
82 self
83 }
84
85 pub fn checked(mut self, checked: bool) -> Self {
87 self.checked = checked;
88 self
89 }
90
91 pub fn disabled(mut self, disabled: bool) -> Self {
93 self.disabled = disabled;
94 self
95 }
96
97 pub fn tab_index(mut self, tab_index: isize) -> Self {
99 self.tab_index = tab_index;
100 self
101 }
102
103 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
105 self.tab_stop = tab_stop;
106 self
107 }
108
109 pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
111 self.on_change(handler)
112 }
113
114 pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
121 self.on_click = Some(Rc::new(handler));
122 self
123 }
124}
125
126impl Sizable for Radio {
127 fn with_size(mut self, size: impl Into<Size>) -> Self {
128 self.size = size.into();
129 self
130 }
131}
132
133impl crate::FocusableExt for Radio {
134 fn focus_ring(mut self, enabled: bool) -> Self {
135 self.focus_ring_enabled = enabled;
136 self
137 }
138
139 fn is_focus_ring_enabled(&self) -> bool {
140 self.focus_ring_enabled
141 }
142}
143
144impl Styled for Radio {
145 fn style(&mut self) -> &mut gpui::StyleRefinement {
146 &mut self.style
147 }
148}
149
150impl InteractiveElement for Radio {
151 fn interactivity(&mut self) -> &mut gpui::Interactivity {
152 self.base.interactivity()
153 }
154}
155
156impl StatefulInteractiveElement for Radio {}
157
158impl ParentElement for Radio {
159 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
160 self.children.extend(elements);
161 }
162}
163
164impl RenderOnce for Radio {
165 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
166 let checked = self.checked;
167 let has_content = self.label.is_some() || !self.children.is_empty();
168 let indicator_size = rems(match self.size {
169 Size::XSmall => 0.75,
170 Size::Small => 0.875,
171 Size::Large => 1.125,
172 _ => 1.,
173 });
174 let focus_handle = window
175 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
176 .read(cx)
177 .clone();
178 let is_focused = focus_handle.is_focused(window);
179 let disabled = self.disabled;
180 let accessibility_label = self
181 .accessibility_label
182 .clone()
183 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
184
185 let (border_color, bg) = if checked {
186 (cx.theme().primary, cx.theme().primary)
187 } else {
188 (cx.theme().input, cx.theme().input.opacity(0.5))
189 };
190 let (border_color, bg) = if disabled {
191 (border_color.opacity(0.5), bg.opacity(0.5))
192 } else {
193 (border_color, bg)
194 };
195
196 self.base
197 .id(self.id.clone())
198 .checked(self.checked)
199 .disabled(self.disabled)
200 .track_focus(&focus_handle)
201 .tab_stop(self.tab_stop)
202 .tab_index(self.tab_index)
203 .when_some(accessibility_label, |this, label| {
204 this.accessibility_label(label)
205 })
206 .when_some(
207 self.position_in_set.zip(self.size_of_set),
208 |this, (position, size)| this.set_position(position, size),
209 )
210 .h_flex()
211 .gap_x_2()
212 .text_color(cx.theme().foreground)
213 .items_start()
214 .line_height(relative(1.))
215 .rounded(cx.theme().radius * 0.5)
216 .when(is_focused && self.focus_ring_enabled, |this| {
217 this.focus_ring_style(window, cx)
218 })
219 .map(|this| match self.size {
220 Size::Size(_) => this,
221 size => this.input_text_size(size),
222 })
223 .refine_style(&self.style)
224 .child(
225 div()
226 .relative()
227 .size(indicator_size)
228 .when(has_content, |this| this.mt(indicator_size * 0.125))
230 .flex_shrink_0()
231 .rounded_full_style(cx)
232 .border_1()
233 .border_color(border_color)
234 .map(|this| match self.checked {
235 false => this.bg(cx.theme().input_background()),
236 true if disabled => this.bg(bg),
237 true => this.bg(cx.theme().tokens.primary),
238 })
239 .child(checkbox_check_icon(
240 self.id, self.size, checked, disabled, window, cx,
241 )),
242 )
243 .when(!self.children.is_empty() || self.label.is_some(), |this| {
244 this.child(
245 v_flex()
246 .w_full()
247 .line_height(relative(1.25))
248 .gap_1()
249 .when_some(self.label, |this, label| {
250 this.child(
251 div()
252 .size_full()
253 .when(self.disabled, |this| {
254 this.text_color(cx.theme().muted_foreground)
255 })
256 .child(label),
257 )
258 })
259 .children(self.children),
260 )
261 })
262 .on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
263 window.prevent_default()
264 })
265 .when_some(self.on_click.clone(), |this, on_click| {
266 this.on_change(move |next, _, window, cx| {
267 window.prevent_default();
268 on_click(&next, window, cx);
269 })
270 })
271 .map(|this| self.tooltip.apply(this))
272 }
273}
274
275#[derive(IntoElement)]
277pub struct RadioGroup {
278 id: ElementId,
279 style: StyleRefinement,
280 radios: Vec<Radio>,
281 layout: Axis,
282 selected_index: Option<usize>,
283 disabled: bool,
284 on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
285}
286
287impl RadioGroup {
288 pub fn new(id: impl Into<ElementId>) -> Self {
290 Self {
291 id: id.into(),
292 style: StyleRefinement::default().flex_1(),
293 on_click: None,
294 layout: Axis::Vertical,
295 selected_index: None,
296 disabled: false,
297 radios: vec![],
298 }
299 }
300
301 pub fn vertical(id: impl Into<ElementId>) -> Self {
303 Self::new(id)
304 }
305
306 pub fn horizontal(id: impl Into<ElementId>) -> Self {
308 Self::new(id).layout(Axis::Horizontal)
309 }
310
311 pub fn layout(mut self, layout: Axis) -> Self {
313 self.layout = layout;
314 self
315 }
316
317 pub fn on_click(self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
319 self.on_change(handler)
320 }
321
322 pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
329 self.on_click = Some(Rc::new(handler));
330 self
331 }
332
333 pub fn selected_index(mut self, index: Option<usize>) -> Self {
335 self.selected_index = index;
336 self
337 }
338
339 pub fn disabled(mut self, disabled: bool) -> Self {
341 self.disabled = disabled;
342 self
343 }
344
345 pub fn child(mut self, child: impl Into<Radio>) -> Self {
347 self.radios.push(child.into());
348 self
349 }
350
351 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Radio>>) -> Self {
353 self.radios.extend(children.into_iter().map(Into::into));
354 self
355 }
356}
357
358impl Styled for RadioGroup {
359 fn style(&mut self) -> &mut StyleRefinement {
360 &mut self.style
361 }
362}
363
364impl From<&'static str> for Radio {
365 fn from(label: &'static str) -> Self {
366 Self::new(label).label(label)
367 }
368}
369
370impl From<SharedString> for Radio {
371 fn from(label: SharedString) -> Self {
372 Self::new(label.clone()).label(label)
373 }
374}
375
376impl From<String> for Radio {
377 fn from(label: String) -> Self {
378 Self::new(SharedString::from(label.clone())).label(SharedString::from(label))
379 }
380}
381
382impl RenderOnce for RadioGroup {
383 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
384 let on_click = self.on_click;
385 let disabled = self.disabled;
386 let selected_ix = self.selected_index;
387
388 let base = if self.layout.is_vertical() {
389 v_flex()
390 } else {
391 h_flex().w_full().flex_wrap()
392 };
393
394 let total = self.radios.len();
395 BaseRadioGroup::new(self.id)
396 .axis(self.layout)
397 .refine_style(&self.style)
398 .child(
399 base.gap_3()
400 .children(self.radios.into_iter().enumerate().map(|(ix, mut radio)| {
401 let checked = selected_ix == Some(ix);
402
403 radio.id = ix.into();
404 radio.position_in_set = Some(ix + 1);
405 radio.size_of_set = Some(total);
406 let item_disabled = radio.disabled;
407 radio
408 .disabled(disabled || item_disabled)
409 .checked(checked)
410 .when_some(on_click.clone(), |this, on_click| {
411 this.on_click(move |_, window, cx| on_click(&ix, window, cx))
412 })
413 })),
414 )
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn an_explicit_accessibility_label_replaces_the_visible_one() {
424 let plain = Radio::new("automatic").label("Automatic");
425 assert_eq!(plain.accessibility_label, None);
426 assert!(matches!(
427 &plain.label,
428 Some(Text::String(label)) if label.as_ref() == "Automatic"
429 ));
430
431 let named = Radio::new("automatic")
432 .label("Automatic")
433 .accessibility_label("Choose automatic mode");
434 assert_eq!(
435 named.accessibility_label.as_deref(),
436 Some("Choose automatic mode"),
437 "an explicit name must win over the visible label"
438 );
439 assert!(
440 matches!(
441 &named.label,
442 Some(Text::String(label)) if label.as_ref() == "Automatic"
443 ),
444 "and must not change what is drawn"
445 );
446 }
447}