gpui_component/
radio.rs

1use std::rc::Rc;
2
3use crate::{
4    checkbox::checkbox_check_icon, h_flex, text::Text, v_flex, ActiveTheme, AxisExt,
5    FocusableExt as _, Sizable, Size, StyledExt,
6};
7use gpui::{
8    div, prelude::FluentBuilder, px, relative, rems, AnyElement, App, Axis, Div, ElementId,
9    InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
10    StatefulInteractiveElement, StyleRefinement, Styled, Window,
11};
12
13/// A Radio element.
14///
15/// This is not included the Radio group implementation, you can manage the group by yourself.
16#[derive(IntoElement)]
17pub struct Radio {
18    base: Div,
19    style: StyleRefinement,
20    id: ElementId,
21    label: Option<Text>,
22    children: Vec<AnyElement>,
23    checked: bool,
24    disabled: bool,
25    tab_stop: bool,
26    tab_index: isize,
27    size: Size,
28    on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
29}
30
31impl Radio {
32    /// Create a new Radio element with the given id.
33    pub fn new(id: impl Into<ElementId>) -> Self {
34        Self {
35            id: id.into(),
36            base: div(),
37            style: StyleRefinement::default(),
38            label: None,
39            children: Vec::new(),
40            checked: false,
41            disabled: false,
42            tab_index: 0,
43            tab_stop: true,
44            size: Size::default(),
45            on_click: None,
46        }
47    }
48
49    /// Set the label of the Radio element.
50    pub fn label(mut self, label: impl Into<Text>) -> Self {
51        self.label = Some(label.into());
52        self
53    }
54
55    /// Set the checked state of the Radio element, default is `false`.
56    pub fn checked(mut self, checked: bool) -> Self {
57        self.checked = checked;
58        self
59    }
60
61    /// Set the disabled state of the Radio element, default is `false`.
62    pub fn disabled(mut self, disabled: bool) -> Self {
63        self.disabled = disabled;
64        self
65    }
66
67    /// Set the tab index for the Radio element, default is `0`.
68    pub fn tab_index(mut self, tab_index: isize) -> Self {
69        self.tab_index = tab_index;
70        self
71    }
72
73    /// Set the tab stop for the Radio element, default is `true`.
74    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
75        self.tab_stop = tab_stop;
76        self
77    }
78
79    /// Add on_click handler when the Radio is clicked.
80    ///
81    /// The `&bool` parameter is the **new checked state**.
82    pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
83        self.on_click = Some(Rc::new(handler));
84        self
85    }
86
87    fn handle_click(
88        on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
89        checked: bool,
90        window: &mut Window,
91        cx: &mut App,
92    ) {
93        let new_checked = !checked;
94        if let Some(f) = on_click {
95            (f)(&new_checked, window, cx);
96        }
97    }
98}
99
100impl Sizable for Radio {
101    fn with_size(mut self, size: impl Into<Size>) -> Self {
102        self.size = size.into();
103        self
104    }
105}
106
107impl Styled for Radio {
108    fn style(&mut self) -> &mut gpui::StyleRefinement {
109        &mut self.style
110    }
111}
112
113impl InteractiveElement for Radio {
114    fn interactivity(&mut self) -> &mut gpui::Interactivity {
115        self.base.interactivity()
116    }
117}
118
119impl StatefulInteractiveElement for Radio {}
120
121impl ParentElement for Radio {
122    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
123        self.children.extend(elements);
124    }
125}
126
127impl RenderOnce for Radio {
128    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
129        let checked = self.checked;
130        let focus_handle = window
131            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
132            .read(cx)
133            .clone();
134        let is_focused = focus_handle.is_focused(window);
135        let disabled = self.disabled;
136
137        let (border_color, bg) = if checked {
138            (cx.theme().primary, cx.theme().primary)
139        } else {
140            (cx.theme().input, cx.theme().input.opacity(0.3))
141        };
142        let (border_color, bg) = if disabled {
143            (border_color.opacity(0.5), bg.opacity(0.5))
144        } else {
145            (border_color, bg)
146        };
147
148        // wrap a flex to patch for let Radio display inline
149        div().child(
150            self.base
151                .id(self.id.clone())
152                .when(!self.disabled, |this| {
153                    this.track_focus(
154                        &focus_handle
155                            .tab_stop(self.tab_stop)
156                            .tab_index(self.tab_index),
157                    )
158                })
159                .h_flex()
160                .gap_x_2()
161                .text_color(cx.theme().foreground)
162                .items_start()
163                .line_height(relative(1.))
164                .rounded(cx.theme().radius * 0.5)
165                .focus_ring(is_focused, px(2.), window, cx)
166                .map(|this| match self.size {
167                    Size::XSmall => this.text_xs(),
168                    Size::Small => this.text_sm(),
169                    Size::Medium => this.text_base(),
170                    Size::Large => this.text_lg(),
171                    _ => this,
172                })
173                .refine_style(&self.style)
174                .child(
175                    div()
176                        .relative()
177                        .map(|this| match self.size {
178                            Size::XSmall => this.size_3(),
179                            Size::Small => this.size_3p5(),
180                            Size::Medium => this.size_4(),
181                            Size::Large => this.size(rems(1.125)),
182                            _ => this.size_4(),
183                        })
184                        .flex_shrink_0()
185                        .rounded_full()
186                        .border_1()
187                        .border_color(border_color)
188                        .when(cx.theme().shadow && !disabled, |this| this.shadow_xs())
189                        .map(|this| match self.checked {
190                            false => this.bg(cx.theme().background),
191                            _ => this.bg(bg),
192                        })
193                        .child(checkbox_check_icon(
194                            self.id, self.size, checked, disabled, window, cx,
195                        )),
196                )
197                .when(!self.children.is_empty() || self.label.is_some(), |this| {
198                    this.child(
199                        v_flex()
200                            .w_full()
201                            .line_height(relative(1.2))
202                            .gap_1()
203                            .when_some(self.label, |this, label| {
204                                this.child(
205                                    div()
206                                        .size_full()
207                                        .overflow_hidden()
208                                        .line_height(relative(1.))
209                                        .when(self.disabled, |this| {
210                                            this.text_color(cx.theme().muted_foreground)
211                                        })
212                                        .child(label),
213                                )
214                            })
215                            .children(self.children),
216                    )
217                })
218                .on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
219                    // Avoid focus on mouse down.
220                    window.prevent_default();
221                })
222                .when(!self.disabled, |this| {
223                    this.on_click({
224                        let on_click = self.on_click.clone();
225                        move |_, window, cx| {
226                            window.prevent_default();
227                            Self::handle_click(&on_click, checked, window, cx);
228                        }
229                    })
230                }),
231        )
232    }
233}
234
235/// A Radio group element.
236#[derive(IntoElement)]
237pub struct RadioGroup {
238    id: ElementId,
239    style: StyleRefinement,
240    radios: Vec<Radio>,
241    layout: Axis,
242    selected_index: Option<usize>,
243    disabled: bool,
244    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
245}
246
247impl RadioGroup {
248    fn new(id: impl Into<ElementId>) -> Self {
249        Self {
250            id: id.into(),
251            style: StyleRefinement::default().flex_1(),
252            on_click: None,
253            layout: Axis::Vertical,
254            selected_index: None,
255            disabled: false,
256            radios: vec![],
257        }
258    }
259
260    /// Create a new Radio group with default Vertical layout.
261    pub fn vertical(id: impl Into<ElementId>) -> Self {
262        Self::new(id)
263    }
264
265    /// Create a new Radio group with Horizontal layout.
266    pub fn horizontal(id: impl Into<ElementId>) -> Self {
267        Self::new(id).layout(Axis::Horizontal)
268    }
269
270    /// Set the layout of the Radio group. Default is `Axis::Vertical`.
271    pub fn layout(mut self, layout: Axis) -> Self {
272        self.layout = layout;
273        self
274    }
275
276    // Add on_click handler when selected index changes.
277    //
278    // The `&usize` parameter is the selected index.
279    pub fn on_click(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
280        self.on_click = Some(Rc::new(handler));
281        self
282    }
283
284    /// Set the selected index.
285    pub fn selected_index(mut self, index: Option<usize>) -> Self {
286        self.selected_index = index;
287        self
288    }
289
290    /// Set the disabled state.
291    pub fn disabled(mut self, disabled: bool) -> Self {
292        self.disabled = disabled;
293        self
294    }
295
296    /// Add a child Radio element.
297    pub fn child(mut self, child: impl Into<Radio>) -> Self {
298        self.radios.push(child.into());
299        self
300    }
301
302    /// Add multiple child Radio elements.
303    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Radio>>) -> Self {
304        self.radios.extend(children.into_iter().map(Into::into));
305        self
306    }
307}
308
309impl Styled for RadioGroup {
310    fn style(&mut self) -> &mut StyleRefinement {
311        &mut self.style
312    }
313}
314
315impl From<&'static str> for Radio {
316    fn from(label: &'static str) -> Self {
317        Self::new(label).label(label)
318    }
319}
320
321impl From<SharedString> for Radio {
322    fn from(label: SharedString) -> Self {
323        Self::new(label.clone()).label(label)
324    }
325}
326
327impl From<String> for Radio {
328    fn from(label: String) -> Self {
329        Self::new(SharedString::from(label.clone())).label(SharedString::from(label))
330    }
331}
332
333impl RenderOnce for RadioGroup {
334    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
335        let on_click = self.on_click;
336        let disabled = self.disabled;
337        let selected_ix = self.selected_index;
338
339        let base = if self.layout.is_vertical() {
340            v_flex()
341        } else {
342            h_flex().w_full().flex_wrap()
343        };
344
345        let mut container = div().id(self.id);
346        *container.style() = self.style;
347
348        container.child(
349            base.gap_3()
350                .children(self.radios.into_iter().enumerate().map(|(ix, mut radio)| {
351                    let checked = selected_ix == Some(ix);
352
353                    radio.id = ix.into();
354                    radio.disabled(disabled).checked(checked).when_some(
355                        on_click.clone(),
356                        |this, on_click| {
357                            this.on_click(move |_, window, cx| {
358                                on_click(&ix, window, cx);
359                            })
360                        },
361                    )
362                })),
363        )
364    }
365}