Skip to main content

gpui_component/
radio.rs

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/// 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    /// Overrides how long the pointer must rest before the tooltip shows.
70    ///
71    /// Defaults to [`gpui_base::TooltipDefaults::show_delay`].
72    pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
73        self.tooltip.show_delay = Some(delay);
74        self
75    }
76
77    /// Set the label of the Radio element.
78    pub fn label(mut self, label: impl Into<Text>) -> Self {
79        self.label = Some(label.into());
80        self
81    }
82
83    /// Set the name a screen reader announces, when the visible label is not
84    /// it.
85    ///
86    /// A radio's name comes from its [`label`](Self::label) by default. Setting
87    /// this replaces the announced name without changing what is displayed.
88    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
89        self.accessibility_label = Some(label.into());
90        self
91    }
92
93    /// Set the checked state of the Radio element, default is `false`.
94    pub fn checked(mut self, checked: bool) -> Self {
95        self.checked = checked;
96        self
97    }
98
99    /// Set the disabled state of the Radio element, default is `false`.
100    pub fn disabled(mut self, disabled: bool) -> Self {
101        self.disabled = disabled;
102        self
103    }
104
105    /// Set the tab index for the Radio element, default is `0`.
106    pub fn tab_index(mut self, tab_index: isize) -> Self {
107        self.tab_index = tab_index;
108        self
109    }
110
111    /// Set the tab stop for the Radio element, default is `true`.
112    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
113        self.tab_stop = tab_stop;
114        self
115    }
116
117    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
118    pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
119        self.on_change(handler)
120    }
121
122    /// Handle a requested checked value from pointer or keyboard activation.
123    ///
124    /// This is a controlled value: the owner must write the requested value and
125    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
126    /// This and [`Self::on_click`] share one callback; chaining them replaces
127    /// the previous handler instead of calling both.
128    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                    // Center on the first 1.25em line, including when the label wraps.
237                    .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/// A Radio group element.
284#[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    /// Creates a radio group with vertical layout and no selected item.
297    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    /// Create a new Radio group with default Vertical layout.
310    pub fn vertical(id: impl Into<ElementId>) -> Self {
311        Self::new(id)
312    }
313
314    /// Create a new Radio group with Horizontal layout.
315    pub fn horizontal(id: impl Into<ElementId>) -> Self {
316        Self::new(id).layout(Axis::Horizontal)
317    }
318
319    /// Set the layout of the Radio group. Default is `Axis::Vertical`.
320    pub fn layout(mut self, layout: Axis) -> Self {
321        self.layout = layout;
322        self
323    }
324
325    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
326    pub fn on_click(self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
327        self.on_change(handler)
328    }
329
330    /// Handle a requested selected index from pointer or keyboard activation.
331    ///
332    /// This is a controlled value: the owner must write the requested value and
333    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
334    /// This and [`Self::on_click`] share one callback; chaining them replaces
335    /// the previous handler instead of calling both.
336    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    /// Set the selected index.
342    pub fn selected_index(mut self, index: Option<usize>) -> Self {
343        self.selected_index = index;
344        self
345    }
346
347    /// Set the disabled state.
348    pub fn disabled(mut self, disabled: bool) -> Self {
349        self.disabled = disabled;
350        self
351    }
352
353    /// Add a child Radio element.
354    pub fn child(mut self, child: impl Into<Radio>) -> Self {
355        self.radios.push(child.into());
356        self
357    }
358
359    /// Add multiple child Radio elements.
360    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}