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