Skip to main content

gpui_component/
radio.rs

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/// 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    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
110    pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
111        self.on_change(handler)
112    }
113
114    /// Handle a requested checked value from pointer or keyboard activation.
115    ///
116    /// This is a controlled value: the owner must write the requested value and
117    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
118    /// This and [`Self::on_click`] share one callback; chaining them replaces
119    /// the previous handler instead of calling both.
120    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                    // Center on the first 1.25em line, including when the label wraps.
229                    .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/// A Radio group element.
276#[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    /// Creates a radio group with vertical layout and no selected item.
289    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    /// Create a new Radio group with default Vertical layout.
302    pub fn vertical(id: impl Into<ElementId>) -> Self {
303        Self::new(id)
304    }
305
306    /// Create a new Radio group with Horizontal layout.
307    pub fn horizontal(id: impl Into<ElementId>) -> Self {
308        Self::new(id).layout(Axis::Horizontal)
309    }
310
311    /// Set the layout of the Radio group. Default is `Axis::Vertical`.
312    pub fn layout(mut self, layout: Axis) -> Self {
313        self.layout = layout;
314        self
315    }
316
317    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
318    pub fn on_click(self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
319        self.on_change(handler)
320    }
321
322    /// Handle a requested selected index from pointer or keyboard activation.
323    ///
324    /// This is a controlled value: the owner must write the requested value and
325    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
326    /// This and [`Self::on_click`] share one callback; chaining them replaces
327    /// the previous handler instead of calling both.
328    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    /// Set the selected index.
334    pub fn selected_index(mut self, index: Option<usize>) -> Self {
335        self.selected_index = index;
336        self
337    }
338
339    /// Set the disabled state.
340    pub fn disabled(mut self, disabled: bool) -> Self {
341        self.disabled = disabled;
342        self
343    }
344
345    /// Add a child Radio element.
346    pub fn child(mut self, child: impl Into<Radio>) -> Self {
347        self.radios.push(child.into());
348        self
349    }
350
351    /// Add multiple child Radio elements.
352    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}