Skip to main content

gpuikit/elements/
radio_group.rs

1//! Radio group component for gpuikit
2
3use crate::theme::{ActiveTheme, ControlSize, Themeable};
4use crate::traits::control_sized::ControlSized;
5use crate::traits::disableable::Disableable;
6use crate::traits::orientable::{Orientable, Orientation};
7use gpui::{
8    Context, ElementId, EventEmitter, InteractiveElement, IntoElement, MouseButton, ParentElement,
9    Render, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
10};
11
12/// Event emitted when the radio group selection changes
13pub struct RadioGroupChanged<T: Clone> {
14    pub value: T,
15}
16
17/// A single radio option
18#[derive(Clone)]
19pub struct RadioOption<T: Clone> {
20    pub value: T,
21    pub label: SharedString,
22    pub disabled: bool,
23}
24
25impl<T: Clone> RadioOption<T> {
26    pub fn new(value: T, label: impl Into<SharedString>) -> Self {
27        Self {
28            value,
29            label: label.into(),
30            disabled: false,
31        }
32    }
33
34    pub fn disabled(mut self, disabled: bool) -> Self {
35        self.disabled = disabled;
36        self
37    }
38}
39
40/// A radio group component for selecting one option from a list
41pub struct RadioGroup<T: Clone + PartialEq + 'static> {
42    id: ElementId,
43    options: Vec<RadioOption<T>>,
44    selected: Option<T>,
45    disabled: bool,
46    orientation: Orientation,
47    size: ControlSize,
48}
49
50impl<T: Clone + PartialEq + 'static> EventEmitter<RadioGroupChanged<T>> for RadioGroup<T> {}
51
52impl<T: Clone + PartialEq + 'static> RadioGroup<T> {
53    pub fn new(id: impl Into<ElementId>, options: Vec<RadioOption<T>>) -> Self {
54        Self {
55            id: id.into(),
56            options,
57            selected: None,
58            disabled: false,
59            orientation: Orientation::default(),
60            size: ControlSize::default(),
61        }
62    }
63
64    pub fn selected(mut self, value: T) -> Self {
65        self.selected = Some(value);
66        self
67    }
68
69    pub fn selected_option(mut self, value: Option<T>) -> Self {
70        self.selected = value;
71        self
72    }
73
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    pub fn get_selected(&self) -> Option<&T> {
80        self.selected.as_ref()
81    }
82
83    pub fn set_selected(&mut self, value: T, cx: &mut Context<Self>) {
84        if self.selected.as_ref() != Some(&value) {
85            self.selected = Some(value.clone());
86            cx.emit(RadioGroupChanged { value });
87            cx.notify();
88        }
89    }
90
91    fn select_option(&mut self, index: usize, cx: &mut Context<Self>) {
92        if let Some(option) = self.options.get(index) {
93            if !option.disabled && !self.disabled {
94                self.set_selected(option.value.clone(), cx);
95            }
96        }
97    }
98}
99
100impl<T: Clone + PartialEq + 'static> Render for RadioGroup<T> {
101    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
102        let theme = cx.theme();
103        let metrics = theme.control(self.size);
104        let group_disabled = self.disabled;
105        let selected = self.selected.clone();
106        let orientation = self.orientation;
107
108        // The radio is the ink inside the rung, exactly as a checkbox's box
109        // is — the two are the same control with a different shape, and a
110        // radio that named `rems(1.0)` for itself lined up with a checkbox on
111        // no rung and moved with the scale on none either. The dot is half the
112        // ink, which is the ratio the old pair already had.
113        let radio_size = metrics.ink;
114        let dot_size = metrics.ink / 2.;
115
116        let container = if orientation == Orientation::Vertical {
117            div().flex().flex_col().gap(metrics.gap * 2.0)
118        } else {
119            div()
120                .flex()
121                .flex_row()
122                .gap(metrics.gap * 4.0)
123                .items_center()
124        };
125
126        container.id(self.id.clone()).children(
127            self.options
128                .iter()
129                .enumerate()
130                .map(|(index, option)| {
131                    let is_selected = selected.as_ref() == Some(&option.value);
132                    let is_disabled = group_disabled || option.disabled;
133                    let label = option.label.clone();
134
135                    let radio_bg = if is_disabled {
136                        theme.surface_tertiary()
137                    } else {
138                        theme.surface()
139                    };
140
141                    let radio_border = if is_disabled {
142                        theme.border_subtle()
143                    } else if is_selected {
144                        theme.accent()
145                    } else {
146                        theme.border()
147                    };
148
149                    let dot_color = if is_disabled {
150                        theme.fg_disabled()
151                    } else {
152                        theme.accent()
153                    };
154
155                    let text_color = if is_disabled {
156                        theme.fg_disabled()
157                    } else {
158                        theme.fg()
159                    };
160
161                    div()
162                        .id(ElementId::NamedInteger("radio-option".into(), index as u64))
163                        .flex()
164                        .flex_row()
165                        // The row is the rung, so a radio lines up with the
166                        // checkbox beside it; a label outside the control's
167                        // own box wants more room than the gap inside one.
168                        .h(metrics.height)
169                        .gap(metrics.gap * 2.0)
170                        .items_center()
171                        .when(!is_disabled, |this| {
172                            this.cursor_pointer()
173                                .on_mouse_down(MouseButton::Left, |_, window, _| {
174                                    window.prevent_default()
175                                })
176                                .on_click(cx.listener(move |this, _, _, cx| {
177                                    this.select_option(index, cx);
178                                }))
179                        })
180                        .when(is_disabled, |this| this.cursor_not_allowed().opacity(0.65))
181                        .child(
182                            div()
183                                .size(radio_size)
184                                .flex_none()
185                                .flex()
186                                .items_center()
187                                .justify_center()
188                                .bg(radio_bg)
189                                .border_1()
190                                .border_color(radio_border)
191                                .rounded_full()
192                                .when(!is_disabled, |this| {
193                                    this.hover(|style| {
194                                        style.border_color(if is_selected {
195                                            theme.accent()
196                                        } else {
197                                            theme.border_secondary()
198                                        })
199                                    })
200                                })
201                                .when(is_selected, |this| {
202                                    this.child(div().size(dot_size).bg(dot_color).rounded_full())
203                                }),
204                        )
205                        .child(
206                            div()
207                                .text_size(metrics.text_size)
208                                .line_height(metrics.line_height)
209                                .text_color(text_color)
210                                .child(label),
211                        )
212                })
213                .collect::<Vec<_>>(),
214        )
215    }
216}
217
218/// Convenience function to create a radio group
219pub fn radio_group<T: Clone + PartialEq + 'static>(
220    id: impl Into<ElementId>,
221    options: Vec<RadioOption<T>>,
222) -> RadioGroup<T> {
223    RadioGroup::new(id, options)
224}
225
226/// Convenience function to create a radio option
227pub fn radio_option<T: Clone>(value: T, label: impl Into<SharedString>) -> RadioOption<T> {
228    RadioOption::new(value, label)
229}
230
231impl<T: Clone + PartialEq + 'static> Disableable for RadioGroup<T> {
232    fn is_disabled(&self) -> bool {
233        self.disabled
234    }
235
236    fn disabled(mut self, disabled: bool) -> Self {
237        self.disabled = disabled;
238        self
239    }
240}
241
242impl<T: Clone> Disableable for RadioOption<T> {
243    fn is_disabled(&self) -> bool {
244        self.disabled
245    }
246
247    fn disabled(mut self, disabled: bool) -> Self {
248        self.disabled = disabled;
249        self
250    }
251}
252
253impl<T: Clone + PartialEq + 'static> Orientable for RadioGroup<T> {
254    fn orientation(mut self, orientation: Orientation) -> Self {
255        self.orientation = orientation;
256        self
257    }
258}
259
260impl<T: Clone + PartialEq + 'static> ControlSized for RadioGroup<T> {
261    fn control_size(mut self, size: ControlSize) -> Self {
262        self.size = size;
263        self
264    }
265}