Skip to main content

gpui_component/
radio.rs

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/// A Radio element.
16///
17/// This is not included the Radio group implementation, you can manage the group by yourself.
18#[derive(IntoElement)]
19pub struct Radio {
20    base: BaseRadio,
21    style: StyleRefinement,
22    id: ElementId,
23    label: Option<Text>,
24    /// The announced name, when the visible label is not it.
25    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    /// Create a new Radio element with the given id.
41    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    /// Set tooltip text for the radio.
64    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
65        self.tooltip.text = Some((tooltip.into(), None));
66        self
67    }
68
69    /// Set the label of the Radio element.
70    pub fn label(mut self, label: impl Into<Text>) -> Self {
71        self.label = Some(label.into());
72        self
73    }
74
75    /// Set the name a screen reader announces, when the visible label is not
76    /// it.
77    ///
78    /// A radio's name comes from its [`label`](Self::label) by default. Setting
79    /// this replaces the announced name without changing what is displayed.
80    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
81        self.accessibility_label = Some(label.into());
82        self
83    }
84
85    /// Set the checked state of the Radio element, default is `false`.
86    pub fn checked(mut self, checked: bool) -> Self {
87        self.checked = checked;
88        self
89    }
90
91    /// Set the disabled state of the Radio element, default is `false`.
92    pub fn disabled(mut self, disabled: bool) -> Self {
93        self.disabled = disabled;
94        self
95    }
96
97    /// Set the tab index for the Radio element, default is `0`.
98    pub fn tab_index(mut self, tab_index: isize) -> Self {
99        self.tab_index = tab_index;
100        self
101    }
102
103    /// Set the tab stop for the Radio element, default is `true`.
104    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
105        self.tab_stop = tab_stop;
106        self
107    }
108
109    /// Add on_click handler when the Radio is clicked.
110    ///
111    /// The `&bool` parameter is the **new checked state**.
112    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/// A Radio group element.
269#[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    /// Create a new Radio group with default Vertical layout.
294    pub fn vertical(id: impl Into<ElementId>) -> Self {
295        Self::new(id)
296    }
297
298    /// Create a new Radio group with Horizontal layout.
299    pub fn horizontal(id: impl Into<ElementId>) -> Self {
300        Self::new(id).layout(Axis::Horizontal)
301    }
302
303    /// Set the layout of the Radio group. Default is `Axis::Vertical`.
304    pub fn layout(mut self, layout: Axis) -> Self {
305        self.layout = layout;
306        self
307    }
308
309    // Add on_click handler when selected index changes.
310    //
311    // The `&usize` parameter is the selected index.
312    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    /// Set the selected index.
318    pub fn selected_index(mut self, index: Option<usize>) -> Self {
319        self.selected_index = index;
320        self
321    }
322
323    /// Set the disabled state.
324    pub fn disabled(mut self, disabled: bool) -> Self {
325        self.disabled = disabled;
326        self
327    }
328
329    /// Add a child Radio element.
330    pub fn child(mut self, child: impl Into<Radio>) -> Self {
331        self.radios.push(child.into());
332        self
333    }
334
335    /// Add multiple child Radio elements.
336    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}