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