Skip to main content

armas_basic/components/
radio.rs

1//! Radio Button Components
2//!
3//! Radio buttons styled like shadcn/ui `RadioGroup`.
4//! For single selection from a group of options.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Ui;
10//! # fn example(ui: &mut Ui) {
11//! use armas_basic::components::{Radio, RadioGroup};
12//!
13//! // Single radio
14//! Radio::new().label("Option").show(ui, true);
15//!
16//! // Radio group
17//! let mut selected = Some("opt1".to_string());
18//! RadioGroup::new(&mut selected)
19//!     .label("Choose one")
20//!     .show(ui, |group| {
21//!         group.option("opt1", "First");
22//!         group.option("opt2", "Second");
23//!     });
24//! # }
25//! ```
26
27use crate::ext::ArmasContextExt;
28use crate::Theme;
29use egui::{Response, Sense, Stroke, Ui, Vec2};
30
31// shadcn RadioGroup constants
32const RADIO_SIZE: f32 = 16.0; // h-4 w-4 (default)
33const RADIO_SIZE_SM: f32 = 14.0; // Small variant
34const RADIO_SIZE_LG: f32 = 20.0; // Large variant
35const BORDER_WIDTH: f32 = 1.0; // border
36const INNER_CIRCLE_RATIO: f32 = 0.5; // Inner dot is 50% of outer
37                                     // Label font size resolved from theme.typography.base at show-time
38                                     // Description font size resolved from theme.typography.sm at show-time
39
40/// Radio button size
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum RadioSize {
43    /// Small radio button
44    Small,
45    /// Medium radio button (default)
46    Medium,
47    /// Large radio button
48    Large,
49}
50
51impl RadioSize {
52    const fn diameter(self) -> f32 {
53        match self {
54            Self::Small => RADIO_SIZE_SM,
55            Self::Medium => RADIO_SIZE,
56            Self::Large => RADIO_SIZE_LG,
57        }
58    }
59}
60
61/// Individual radio button
62pub struct Radio {
63    id: Option<egui::Id>,
64    size: RadioSize,
65    label: Option<String>,
66    description: Option<String>,
67    disabled: bool,
68}
69
70impl Radio {
71    /// Create a new radio button
72    #[must_use]
73    pub const fn new() -> Self {
74        Self {
75            id: None,
76            size: RadioSize::Medium,
77            label: None,
78            description: None,
79            disabled: false,
80        }
81    }
82
83    /// Set ID for state persistence
84    #[must_use]
85    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
86        self.id = Some(id.into());
87        self
88    }
89
90    /// Set the size
91    #[must_use]
92    pub const fn size(mut self, size: RadioSize) -> Self {
93        self.size = size;
94        self
95    }
96
97    /// Set a label
98    #[must_use]
99    pub fn label(mut self, label: impl Into<String>) -> Self {
100        self.label = Some(label.into());
101        self
102    }
103
104    /// Set a description
105    #[must_use]
106    pub fn description(mut self, description: impl Into<String>) -> Self {
107        self.description = Some(description.into());
108        self
109    }
110
111    /// Set disabled state
112    #[must_use]
113    pub const fn disabled(mut self, disabled: bool) -> Self {
114        self.disabled = disabled;
115        self
116    }
117
118    /// Show the radio button
119    pub fn show(self, ui: &mut Ui, selected: bool) -> RadioResponse {
120        let theme = ui.ctx().armas_theme();
121        let response = ui
122            .horizontal(|ui| {
123                // Radio control
124                let diameter = self.size.diameter();
125                let (rect, response) = ui.allocate_exact_size(
126                    Vec2::splat(diameter),
127                    if self.disabled {
128                        Sense::hover()
129                    } else {
130                        Sense::click()
131                    },
132                );
133
134                if ui.is_rect_visible(rect) {
135                    self.draw_radio(ui, rect, selected, &theme);
136                }
137
138                // Label and description
139                if self.label.is_some() || self.description.is_some() {
140                    ui.add_space(theme.spacing.sm);
141                    ui.vertical(|ui| {
142                        ui.spacing_mut().item_spacing.y = theme.spacing.xs;
143                        if let Some(label) = &self.label {
144                            let label_color = if self.disabled {
145                                theme.muted_foreground().linear_multiply(0.5)
146                            } else {
147                                theme.foreground()
148                            };
149
150                            ui.label(
151                                egui::RichText::new(label)
152                                    .size(theme.typography.base)
153                                    .color(label_color),
154                            );
155                        }
156
157                        if let Some(description) = &self.description {
158                            ui.label(
159                                egui::RichText::new(description)
160                                    .size(theme.typography.sm)
161                                    .color(theme.muted_foreground()),
162                            );
163                        }
164                    });
165                }
166
167                response
168            })
169            .inner;
170
171        RadioResponse { response, selected }
172    }
173
174    /// Draw the radio button circle (shadcn style)
175    fn draw_radio(&self, ui: &mut Ui, rect: egui::Rect, selected: bool, theme: &Theme) {
176        let painter = ui.painter();
177        let center = rect.center();
178        let radius = rect.width() / 2.0;
179
180        // Outer circle border (shadcn uses primary color when selected)
181        let border_color = if self.disabled {
182            theme.muted_foreground().gamma_multiply(0.5)
183        } else {
184            theme.primary() // shadcn uses primary for both selected and unselected
185        };
186
187        painter.circle_stroke(center, radius, Stroke::new(BORDER_WIDTH, border_color));
188
189        // Inner filled circle when selected (shadcn indicator)
190        if selected {
191            let inner_radius = radius * INNER_CIRCLE_RATIO;
192            let fill_color = if self.disabled {
193                theme.muted_foreground().gamma_multiply(0.5)
194            } else {
195                theme.primary()
196            };
197
198            painter.circle_filled(center, inner_radius, fill_color);
199        }
200    }
201}
202
203impl Default for Radio {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Response from radio button interaction
210pub struct RadioResponse {
211    /// The underlying egui response
212    pub response: Response,
213    /// Whether this radio is currently selected
214    pub selected: bool,
215}
216
217// ============================================================================
218// RADIO GROUP - CLOSURE-BASED API
219// ============================================================================
220
221/// Builder for configuring individual radio options
222pub struct RadioOptionBuilder {
223    label: String,
224    description: Option<String>,
225}
226
227impl RadioOptionBuilder {
228    fn new(_value: String, label: String) -> Self {
229        Self {
230            label,
231            description: None,
232        }
233    }
234
235    /// Set option description
236    #[must_use]
237    pub fn description(mut self, description: impl Into<String>) -> Self {
238        self.description = Some(description.into());
239        self
240    }
241}
242
243/// Builder for adding radio options to the group
244pub struct RadioGroupBuilder<'a> {
245    selected_value: &'a mut Option<String>,
246    ui: &'a mut Ui,
247    changed: &'a mut bool,
248    disabled: bool,
249}
250
251impl RadioGroupBuilder<'_> {
252    /// Add a radio option to the group
253    pub fn option(&mut self, value: &str, label: &str) -> RadioOptionBuilder {
254        let builder = RadioOptionBuilder::new(value.to_string(), label.to_string());
255
256        // Check if this option is currently selected
257        let is_selected = self.selected_value.as_ref().is_some_and(|v| v == value);
258
259        // Create radio and show it
260        let mut radio = Radio::new().label(&builder.label).disabled(self.disabled);
261
262        if let Some(desc) = &builder.description {
263            radio = radio.description(desc);
264        }
265
266        let response = radio.show(self.ui, is_selected);
267
268        // Update selection if clicked and not already selected
269        if response.response.clicked() && !is_selected {
270            *self.selected_value = Some(value.to_string());
271            *self.changed = true;
272        }
273
274        builder
275    }
276}
277
278/// Response from radio group
279pub struct RadioGroupResponse {
280    /// The UI response
281    pub response: Response,
282    /// Whether the selection changed
283    pub changed: bool,
284    /// The currently selected value (if any)
285    pub selected: Option<String>,
286}
287
288/// Radio group for single selection from multiple options
289///
290/// # Example
291///
292/// ```ignore
293/// let mut selected = Some("option1".to_string());
294/// let response = RadioGroup::new(&mut selected)
295///     .label("Choose one")
296///     .show(ui, |group| {
297///         group.option("option1", "First Option");
298///         group.option("option2", "Second Option")
299///             .description("This is the second option");
300///         group.option("option3", "Third Option");
301///     });
302///
303/// if response.changed {
304///     println!("Selected: {:?}", response.selected);
305/// }
306/// ```
307pub struct RadioGroup<'a> {
308    selected_value: &'a mut Option<String>,
309    label: Option<String>,
310    disabled: bool,
311}
312
313impl<'a> RadioGroup<'a> {
314    /// Create a new radio group
315    ///
316    /// # Arguments
317    /// * `selected_value` - Mutable reference to the currently selected value
318    pub const fn new(selected_value: &'a mut Option<String>) -> Self {
319        Self {
320            selected_value,
321            label: None,
322            disabled: false,
323        }
324    }
325
326    /// Set a label for the group
327    #[must_use]
328    pub fn label(mut self, label: impl Into<String>) -> Self {
329        self.label = Some(label.into());
330        self
331    }
332
333    /// Set disabled state for all radios in the group
334    #[must_use]
335    pub const fn disabled(mut self, disabled: bool) -> Self {
336        self.disabled = disabled;
337        self
338    }
339
340    /// Show the radio group with closure-based API
341    pub fn show<R>(
342        self,
343        ui: &mut Ui,
344        content: impl FnOnce(&mut RadioGroupBuilder) -> R,
345    ) -> RadioGroupResponse {
346        let theme = ui.ctx().armas_theme();
347        let mut changed = false;
348
349        let inner_response = ui.vertical(|ui| {
350            ui.spacing_mut().item_spacing.y = theme.spacing.sm;
351
352            // Group label
353            if let Some(label) = &self.label {
354                ui.label(
355                    egui::RichText::new(label)
356                        .size(theme.typography.base)
357                        .strong()
358                        .color(theme.foreground()),
359                );
360                ui.add_space(theme.spacing.xs);
361            }
362
363            // Build radio options from closure
364            let mut builder = RadioGroupBuilder {
365                selected_value: self.selected_value,
366                ui,
367                changed: &mut changed,
368                disabled: self.disabled,
369            };
370            content(&mut builder);
371        });
372
373        RadioGroupResponse {
374            response: inner_response.response,
375            changed,
376            selected: self.selected_value.clone(),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_radio_creation() {
387        let radio = Radio::new();
388        assert_eq!(radio.size, RadioSize::Medium);
389        assert!(!radio.disabled);
390    }
391
392    #[test]
393    fn test_radio_builder() {
394        let radio = Radio::new()
395            .size(RadioSize::Large)
396            .label("Select me")
397            .disabled(true);
398
399        assert_eq!(radio.size, RadioSize::Large);
400        assert_eq!(radio.label, Some("Select me".to_string()));
401        assert!(radio.disabled);
402    }
403
404    #[test]
405    fn test_radio_size_dimensions() {
406        assert_eq!(RadioSize::Small.diameter(), RADIO_SIZE_SM);
407        assert_eq!(RadioSize::Medium.diameter(), RADIO_SIZE);
408        assert_eq!(RadioSize::Large.diameter(), RADIO_SIZE_LG);
409    }
410}