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                            cx.stop_propagation();
219                            Self::handle_click(&on_click, checked, window, cx);
220                        }
221                    })
222                }),
223        )
224    }
225}
226
227/// A Radio group element.
228#[derive(IntoElement)]
229pub struct RadioGroup {
230    id: ElementId,
231    style: StyleRefinement,
232    radios: Vec<Radio>,
233    layout: Axis,
234    selected_index: Option<usize>,
235    disabled: bool,
236    on_change: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
237}
238
239impl RadioGroup {
240    fn new(id: impl Into<ElementId>) -> Self {
241        Self {
242            id: id.into(),
243            style: StyleRefinement::default().flex_1(),
244            on_change: None,
245            layout: Axis::Vertical,
246            selected_index: None,
247            disabled: false,
248            radios: vec![],
249        }
250    }
251
252    /// Create a new Radio group with default Vertical layout.
253    pub fn vertical(id: impl Into<ElementId>) -> Self {
254        Self::new(id)
255    }
256
257    /// Create a new Radio group with Horizontal layout.
258    pub fn horizontal(id: impl Into<ElementId>) -> Self {
259        Self::new(id).layout(Axis::Horizontal)
260    }
261
262    /// Set the layout of the Radio group. Default is `Axis::Vertical`.
263    pub fn layout(mut self, layout: Axis) -> Self {
264        self.layout = layout;
265        self
266    }
267
268    /// Listen to the change event.
269    pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
270        self.on_change = Some(Rc::new(handler));
271        self
272    }
273
274    /// Set the selected index.
275    pub fn selected_index(mut self, index: Option<usize>) -> Self {
276        self.selected_index = index;
277        self
278    }
279
280    /// Set the disabled state.
281    pub fn disabled(mut self, disabled: bool) -> Self {
282        self.disabled = disabled;
283        self
284    }
285
286    /// Add a child Radio element.
287    pub fn child(mut self, child: impl Into<Radio>) -> Self {
288        self.radios.push(child.into());
289        self
290    }
291
292    /// Add multiple child Radio elements.
293    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Radio>>) -> Self {
294        self.radios.extend(children.into_iter().map(Into::into));
295        self
296    }
297}
298
299impl Styled for RadioGroup {
300    fn style(&mut self) -> &mut StyleRefinement {
301        &mut self.style
302    }
303}
304
305impl From<&'static str> for Radio {
306    fn from(label: &'static str) -> Self {
307        Self::new(label).label(label)
308    }
309}
310
311impl From<SharedString> for Radio {
312    fn from(label: SharedString) -> Self {
313        Self::new(label.clone()).label(label)
314    }
315}
316
317impl From<String> for Radio {
318    fn from(label: String) -> Self {
319        Self::new(SharedString::from(label.clone())).label(SharedString::from(label))
320    }
321}
322
323impl RenderOnce for RadioGroup {
324    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
325        let on_change = self.on_change;
326        let disabled = self.disabled;
327        let selected_ix = self.selected_index;
328
329        let base = if self.layout.is_vertical() {
330            v_flex()
331        } else {
332            h_flex().w_full().flex_wrap()
333        };
334
335        let mut container = div().id(self.id);
336        *container.style() = self.style;
337
338        container.child(
339            base.gap_3()
340                .children(self.radios.into_iter().enumerate().map(|(ix, mut radio)| {
341                    let checked = selected_ix == Some(ix);
342
343                    radio.id = ix.into();
344                    radio.disabled(disabled).checked(checked).when_some(
345                        on_change.clone(),
346                        |this, on_change| {
347                            this.on_click(move |_, window, cx| {
348                                on_change(&ix, window, cx);
349                            })
350                        },
351                    )
352                })),
353        )
354    }
355}