Skip to main content

egui_material3/
radio.rs

1use crate::get_global_color;
2use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, FontId};
3
4/// Material Design radio button component.
5///
6/// Radio buttons allow users to select one option from a set.
7/// Use radio buttons when only one option may be selected.
8///
9/// # Example
10/// ```rust
11/// # egui::__run_test_ui(|ui| {
12/// let mut selected = Some(0);
13///
14/// ui.add(MaterialRadio::new(&mut selected, 0, "Option 1"));
15/// ui.add(MaterialRadio::new(&mut selected, 1, "Option 2"));
16/// ui.add(MaterialRadio::new(&mut selected, 2, "Option 3"));
17/// # });
18/// ```
19#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
20pub struct MaterialRadio<'a, T: PartialEq + Clone> {
21    /// Reference to the selected value
22    selected: &'a mut Option<T>,
23    /// Value this radio button represents
24    value: T,
25    /// Text label for the radio button
26    text: String,
27    /// Whether the radio button is enabled
28    enabled: bool,
29    /// Whether the radio can be toggled off when clicked while selected
30    toggleable: bool,
31    /// Custom fill color for the radio button
32    fill_color: Option<Color32>,
33    /// Custom overlay color for hover/press effects
34    overlay_color: Option<Color32>,
35    /// Custom background color
36    background_color: Option<Color32>,
37    /// Custom inner radius
38    inner_radius: Option<f32>,
39    /// Custom splash radius for ripple effect
40    splash_radius: Option<f32>,
41}
42
43/// Material Design radio button group component.
44///
45/// A convenience wrapper for managing multiple radio buttons as a group.
46/// Ensures only one option can be selected at a time.
47///
48/// # Example
49/// ```rust
50/// # egui::__run_test_ui(|ui| {
51/// let mut selected = Some(0);
52/// let mut group = MaterialRadioGroup::new(&mut selected)
53///     .option(0, "First Option")
54///     .option(1, "Second Option")
55///     .option(2, "Third Option");
56///
57/// ui.add(group);
58/// # });
59/// ```
60#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
61pub struct MaterialRadioGroup<'a, T: PartialEq + Clone> {
62    /// Reference to the selected value
63    selected: &'a mut Option<T>,
64    /// List of available options
65    options: Vec<RadioOption<T>>,
66    /// Whether the entire group is enabled
67    enabled: bool,
68    /// Whether radios can be toggled off
69    toggleable: bool,
70}
71
72/// Individual radio option data.
73pub struct RadioOption<T: PartialEq + Clone> {
74    /// Display text for the option
75    text: String,
76    /// Unique value identifying this option
77    value: T,
78}
79
80impl<'a, T: PartialEq + Clone> MaterialRadio<'a, T> {
81    /// Create a new radio button.
82    ///
83    /// # Arguments
84    /// * `selected` - Mutable reference to the currently selected value
85    /// * `value` - The value this radio button represents
86    /// * `text` - The text label to display
87    ///
88    /// # Example
89    /// ```rust
90    /// # egui::__run_test_ui(|ui| {
91    /// let mut selection = Some(1);
92    /// ui.add(MaterialRadio::new(&mut selection, 1, "My Option"));
93    /// # });
94    /// ```
95    pub fn new(selected: &'a mut Option<T>, value: T, text: impl Into<String>) -> Self {
96        Self {
97            selected,
98            value,
99            text: text.into(),
100            enabled: true,
101            toggleable: false,
102            fill_color: None,
103            overlay_color: None,
104            background_color: None,
105            inner_radius: None,
106            splash_radius: None,
107        }
108    }
109
110    /// Set whether the radio button is enabled.
111    ///
112    /// # Arguments
113    /// * `enabled` - Whether the radio button should respond to interactions
114    ///
115    /// # Example
116    /// ```rust
117    /// # egui::__run_test_ui(|ui| {
118    /// let mut selection = None;
119    /// ui.add(MaterialRadio::new(&mut selection, 0, "Disabled Option")
120    ///     .enabled(false));
121    /// # });
122    /// ```
123    pub fn enabled(mut self, enabled: bool) -> Self {
124        self.enabled = enabled;
125        self
126    }
127
128    /// Set whether the radio can be deselected by clicking when already selected.
129    ///
130    /// When true, clicking a selected radio button will deselect it.
131    pub fn toggleable(mut self, toggleable: bool) -> Self {
132        self.toggleable = toggleable;
133        self
134    }
135
136    /// Set custom fill color for the radio button.
137    pub fn fill_color(mut self, color: Color32) -> Self {
138        self.fill_color = Some(color);
139        self
140    }
141
142    /// Set custom overlay color for hover/press effects.
143    pub fn overlay_color(mut self, color: Color32) -> Self {
144        self.overlay_color = Some(color);
145        self
146    }
147
148    /// Set custom background color.
149    pub fn background_color(mut self, color: Color32) -> Self {
150        self.background_color = Some(color);
151        self
152    }
153
154    /// Set custom inner circle radius.
155    pub fn inner_radius(mut self, radius: f32) -> Self {
156        self.inner_radius = Some(radius);
157        self
158    }
159
160    /// Set custom splash radius for ripple effects.
161    pub fn splash_radius(mut self, radius: f32) -> Self {
162        self.splash_radius = Some(radius);
163        self
164    }
165}
166
167impl<'a, T: PartialEq + Clone> Widget for MaterialRadio<'a, T> {
168    fn ui(self, ui: &mut Ui) -> Response {
169        let desired_size = Vec2::new(ui.available_width().min(300.0), 24.0);
170
171        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
172
173        let is_selected = self.selected.as_ref().map_or(false, |s| s == &self.value);
174
175        if response.clicked() && self.enabled {
176            if self.toggleable && is_selected {
177                // Deselect if toggleable and already selected
178                *self.selected = None;
179            } else {
180                // Select this value
181                *self.selected = Some(self.value.clone());
182            }
183            response.mark_changed();
184        }
185
186        // Material Design colors
187        let primary_color = self.fill_color.unwrap_or_else(|| get_global_color("primary"));
188        let on_surface = get_global_color("onSurface");
189        let on_surface_variant = get_global_color("onSurfaceVariant");
190        let outline = get_global_color("outline");
191
192        let radio_size = 20.0;
193        let radio_rect = Rect::from_min_size(
194            Pos2::new(rect.min.x, rect.center().y - radio_size / 2.0),
195            Vec2::splat(radio_size),
196        );
197
198        let (border_color, fill_color, inner_color) = if !self.enabled {
199            let disabled_color = get_global_color("onSurfaceVariant").linear_multiply(0.38);
200            (disabled_color, Color32::TRANSPARENT, disabled_color)
201        } else if is_selected {
202            (primary_color, self.background_color.unwrap_or(Color32::TRANSPARENT), primary_color)
203        } else if response.hovered() {
204            let hover_overlay = self.overlay_color.unwrap_or_else(|| 
205                Color32::from_rgba_premultiplied(
206                    on_surface.r(),
207                    on_surface.g(),
208                    on_surface.b(),
209                    20,
210                )
211            );
212            (
213                outline,
214                hover_overlay,
215                on_surface_variant,
216            )
217        } else {
218            (outline, self.background_color.unwrap_or(Color32::TRANSPARENT), on_surface_variant)
219        };
220
221        // Draw hover background
222        if fill_color != Color32::TRANSPARENT {
223            ui.painter()
224                .circle_filled(radio_rect.center(), radio_size / 2.0 + 8.0, fill_color);
225        }
226
227        // Draw radio border
228        ui.painter().circle_stroke(
229            radio_rect.center(),
230            radio_size / 2.0,
231            Stroke::new(2.0, border_color),
232        );
233
234        // Draw selected inner circle
235        if is_selected {
236            let inner_radius = self.inner_radius.unwrap_or(radio_size / 4.0);
237            ui.painter()
238                .circle_filled(radio_rect.center(), inner_radius, inner_color);
239        }
240
241        // Draw label text
242        if !self.text.is_empty() {
243            let text_pos = Pos2::new(radio_rect.max.x + 8.0, rect.center().y);
244
245            let text_color = if self.enabled {
246                on_surface
247            } else {
248                get_global_color("onSurfaceVariant").linear_multiply(0.38)
249            };
250
251            ui.painter().text(
252                text_pos,
253                egui::Align2::LEFT_CENTER,
254                &self.text,
255                egui::FontId::default(),
256                text_color,
257            );
258        }
259
260        // Add ripple effect on hover
261        if response.hovered() && self.enabled {
262            let ripple_color = self.overlay_color.unwrap_or_else(|| {
263                if is_selected {
264                    Color32::from_rgba_premultiplied(
265                        primary_color.r(),
266                        primary_color.g(),
267                        primary_color.b(),
268                        20,
269                    )
270                } else {
271                    Color32::from_rgba_premultiplied(on_surface.r(), on_surface.g(), on_surface.b(), 20)
272                }
273            });
274
275            let ripple_radius = self.splash_radius.unwrap_or(radio_size / 2.0 + 12.0);
276            ui.painter()
277                .circle_filled(radio_rect.center(), ripple_radius, ripple_color);
278        }
279
280        response
281    }
282}
283
284impl<'a, T: PartialEq + Clone> MaterialRadioGroup<'a, T> {
285    /// Create a new radio button group.
286    ///
287    /// # Arguments
288    /// * `selected` - Mutable reference to the currently selected value
289    ///
290    /// # Example
291    /// ```rust
292    /// # egui::__run_test_ui(|ui| {
293    /// let mut selection = Some(1);
294    /// let group = MaterialRadioGroup::new(&mut selection);
295    /// # });
296    /// ```
297    pub fn new(selected: &'a mut Option<T>) -> Self {
298        Self {
299            selected,
300            options: Vec::new(),
301            enabled: true,
302            toggleable: false,
303        }
304    }
305
306    /// Add an option to the radio group.
307    ///
308    /// # Arguments
309    /// * `value` - The value this option represents
310    /// * `text` - The text label for this option
311    ///
312    /// # Example
313    /// ```rust
314    /// # egui::__run_test_ui(|ui| {
315    /// let mut selection = None;
316    /// let group = MaterialRadioGroup::new(&mut selection)
317    ///     .option(0, "First Choice")
318    ///     .option(1, "Second Choice");
319    /// # });
320    /// ```
321    pub fn option(mut self, value: T, text: impl Into<String>) -> Self {
322        self.options.push(RadioOption {
323            text: text.into(),
324            value,
325        });
326        self
327    }
328
329    /// Set whether the entire radio group is enabled.
330    ///
331    /// # Arguments
332    /// * `enabled` - Whether all radio buttons in the group should respond to interactions
333    ///
334    /// # Example
335    /// ```rust
336    /// # egui::__run_test_ui(|ui| {
337    /// let mut selection = None;
338    /// let group = MaterialRadioGroup::new(&mut selection)
339    ///     .option(0, "Option 1")
340    ///     .enabled(false); // Disable all options
341    /// # });
342    /// ```
343    pub fn enabled(mut self, enabled: bool) -> Self {
344        self.enabled = enabled;
345        self
346    }
347
348    /// Set whether radios in the group can be toggled off.
349    pub fn toggleable(mut self, toggleable: bool) -> Self {
350        self.toggleable = toggleable;
351        self
352    }
353}
354
355impl<'a, T: PartialEq + Clone> Widget for MaterialRadioGroup<'a, T> {
356    fn ui(self, ui: &mut Ui) -> Response {
357        let mut group_response = None;
358
359        ui.vertical(|ui| {
360            for option in self.options {
361                let radio = MaterialRadio::new(self.selected, option.value, option.text)
362                    .enabled(self.enabled)
363                    .toggleable(self.toggleable);
364
365                let response = ui.add(radio);
366
367                if group_response.is_none() {
368                    group_response = Some(response);
369                } else if let Some(ref mut group_resp) = group_response {
370                    *group_resp = group_resp.union(response);
371                }
372            }
373        });
374
375        group_response.unwrap_or_else(|| {
376            let (_rect, response) = ui.allocate_exact_size(Vec2::ZERO, Sense::hover());
377            response
378        })
379    }
380}
381
382/// Control affinity for RadioListTile - determines radio button position.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum ListTileControlAffinity {
385    /// Radio button appears before the title (leading edge)
386    Leading,
387    /// Radio button appears after the title (trailing edge)
388    Trailing,
389}
390
391/// Material Design radio list tile component.
392///
393/// Combines a radio button with list tile functionality, including title, subtitle,
394/// and secondary widgets. The entire tile is interactive.
395///
396/// # Example
397/// ```rust
398/// # egui::__run_test_ui(|ui| {
399/// let mut selected = Some(0);
400///
401/// ui.add(RadioListTile::new(&mut selected, 0)
402///     .title("First Option")
403///     .subtitle("Description of first option"));
404/// ui.add(RadioListTile::new(&mut selected, 1)
405///     .title("Second Option")
406///     .subtitle("Description of second option"));
407/// # });
408/// ```
409#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
410pub struct RadioListTile<'a, T: PartialEq + Clone> {
411    /// Reference to the selected value
412    selected: &'a mut Option<T>,
413    /// Value this radio button represents
414    value: T,
415    /// Primary title text
416    title: Option<String>,
417    /// Secondary subtitle text
418    subtitle: Option<String>,
419    /// Whether the radio list tile is enabled
420    enabled: bool,
421    /// Whether the radio can be toggled off
422    toggleable: bool,
423    /// Control affinity (radio position)
424    control_affinity: ListTileControlAffinity,
425    /// Whether to use dense/compact layout
426    dense: bool,
427    /// Custom fill color
428    fill_color: Option<Color32>,
429    /// Tile background color
430    tile_color: Option<Color32>,
431    /// Selected tile background color
432    selected_tile_color: Option<Color32>,
433}
434
435impl<'a, T: PartialEq + Clone> RadioListTile<'a, T> {
436    /// Create a new radio list tile.
437    ///
438    /// # Arguments
439    /// * `selected` - Mutable reference to the currently selected value
440    /// * `value` - The value this radio list tile represents
441    pub fn new(selected: &'a mut Option<T>, value: T) -> Self {
442        Self {
443            selected,
444            value,
445            title: None,
446            subtitle: None,
447            enabled: true,
448            toggleable: false,
449            control_affinity: ListTileControlAffinity::Leading,
450            dense: false,
451            fill_color: None,
452            tile_color: None,
453            selected_tile_color: None,
454        }
455    }
456
457    /// Set the primary title text.
458    pub fn title(mut self, title: impl Into<String>) -> Self {
459        self.title = Some(title.into());
460        self
461    }
462
463    /// Set the secondary subtitle text.
464    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
465        self.subtitle = Some(subtitle.into());
466        self
467    }
468
469    /// Set whether the radio list tile is enabled.
470    pub fn enabled(mut self, enabled: bool) -> Self {
471        self.enabled = enabled;
472        self
473    }
474
475    /// Set whether the radio can be toggled off when clicked while selected.
476    pub fn toggleable(mut self, toggleable: bool) -> Self {
477        self.toggleable = toggleable;
478        self
479    }
480
481    /// Set the control affinity (radio button position).
482    pub fn control_affinity(mut self, affinity: ListTileControlAffinity) -> Self {
483        self.control_affinity = affinity;
484        self
485    }
486
487    /// Set whether to use dense/compact layout.
488    pub fn dense(mut self, dense: bool) -> Self {
489        self.dense = dense;
490        self
491    }
492
493    /// Set custom fill color for the radio button.
494    pub fn fill_color(mut self, color: Color32) -> Self {
495        self.fill_color = Some(color);
496        self
497    }
498
499    /// Set tile background color.
500    pub fn tile_color(mut self, color: Color32) -> Self {
501        self.tile_color = Some(color);
502        self
503    }
504
505    /// Set selected tile background color.
506    pub fn selected_tile_color(mut self, color: Color32) -> Self {
507        self.selected_tile_color = Some(color);
508        self
509    }
510}
511
512impl<'a, T: PartialEq + Clone> Widget for RadioListTile<'a, T> {
513    fn ui(self, ui: &mut Ui) -> Response {
514        let is_selected = self.selected.as_ref().map_or(false, |s| s == &self.value);
515        
516        // Calculate dimensions
517        let height = if self.dense {
518            if self.subtitle.is_some() { 48.0 } else { 40.0 }
519        } else {
520            if self.subtitle.is_some() { 64.0 } else { 48.0 }
521        };
522        
523        let available_width = ui.available_width();
524        let desired_size = Vec2::new(available_width, height);
525        
526        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
527        
528        // Handle click
529        if response.clicked() && self.enabled {
530            if self.toggleable && is_selected {
531                *self.selected = None;
532            } else {
533                *self.selected = Some(self.value.clone());
534            }
535            response.mark_changed();
536        }
537        
538        // Determine colors
539        let on_surface = get_global_color("onSurface");
540        let on_surface_variant = get_global_color("onSurfaceVariant");
541        let surface_variant = get_global_color("surfaceVariant");
542        
543        // Background
544        let bg_color = if is_selected {
545            self.selected_tile_color.unwrap_or_else(|| 
546                surface_variant.linear_multiply(0.5)
547            )
548        } else if response.hovered() && self.enabled {
549            self.tile_color.unwrap_or_else(|| 
550                Color32::from_rgba_premultiplied(
551                    on_surface.r(),
552                    on_surface.g(),
553                    on_surface.b(),
554                    10,
555                )
556            )
557        } else {
558            self.tile_color.unwrap_or(Color32::TRANSPARENT)
559        };
560        
561        if bg_color != Color32::TRANSPARENT {
562            ui.painter().rect_filled(rect, 4.0, bg_color);
563        }
564        
565        // Radio button dimensions
566        let radio_size = 20.0;
567        let padding = 16.0;
568        let gap = 16.0;
569        
570        // Calculate positions based on control affinity
571        let (radio_x, text_x) = match self.control_affinity {
572            ListTileControlAffinity::Leading => {
573                let radio_x = rect.min.x + padding + radio_size / 2.0;
574                let text_x = radio_x + radio_size / 2.0 + gap;
575                (radio_x, text_x)
576            }
577            ListTileControlAffinity::Trailing => {
578                let radio_x = rect.max.x - padding - radio_size / 2.0;
579                let text_x = rect.min.x + padding;
580                (radio_x, text_x)
581            }
582        };
583        
584        let radio_center = Pos2::new(radio_x, rect.center().y);
585        
586        // Draw radio button
587        let primary_color = self.fill_color.unwrap_or_else(|| get_global_color("primary"));
588        let outline = get_global_color("outline");
589        
590        let (border_color, inner_color) = if !self.enabled {
591            let disabled_color = on_surface_variant.linear_multiply(0.38);
592            (disabled_color, disabled_color)
593        } else if is_selected {
594            (primary_color, primary_color)
595        } else {
596            (outline, outline)
597        };
598        
599        // Draw radio outer circle
600        ui.painter().circle_stroke(
601            radio_center,
602            radio_size / 2.0,
603            Stroke::new(2.0, border_color),
604        );
605        
606        // Draw selected inner circle
607        if is_selected {
608            ui.painter().circle_filled(radio_center, radio_size / 4.0, inner_color);
609        }
610        
611        // Draw text content
612        let text_color = if self.enabled {
613            on_surface
614        } else {
615            on_surface_variant.linear_multiply(0.38)
616        };
617        
618        let text_rect_width = match self.control_affinity {
619            ListTileControlAffinity::Leading => rect.max.x - text_x - padding,
620            ListTileControlAffinity::Trailing => radio_x - radio_size / 2.0 - gap - text_x,
621        };
622        
623        if let Some(title) = &self.title {
624            let title_y = if self.subtitle.is_some() {
625                rect.min.y + height * 0.35
626            } else {
627                rect.center().y
628            };
629            
630            let title_font = if self.dense {
631                FontId::proportional(14.0)
632            } else {
633                FontId::proportional(16.0)
634            };
635            
636            ui.painter().text(
637                Pos2::new(text_x, title_y),
638                egui::Align2::LEFT_CENTER,
639                title,
640                title_font,
641                text_color,
642            );
643        }
644        
645        if let Some(subtitle) = &self.subtitle {
646            let subtitle_y = rect.min.y + height * 0.65;
647            let subtitle_font = FontId::proportional(if self.dense { 12.0 } else { 14.0 });
648            
649            ui.painter().text(
650                Pos2::new(text_x, subtitle_y),
651                egui::Align2::LEFT_CENTER,
652                subtitle,
653                subtitle_font,
654                on_surface_variant,
655            );
656        }
657        
658        response
659    }
660}
661
662/// Convenience function to create a radio list tile.
663///
664/// Shorthand for `MaterialRadio::new()`.
665///
666/// # Arguments
667/// * `selected` - Mutable reference to the currently selected value
668/// * `value` - The value this radio button represents
669/// * `text` - The text label to display
670///
671/// # Example
672/// ```rust
673/// # egui::__run_test_ui(|ui| {
674/// let mut selection = Some(0);
675/// ui.add(radio(&mut selection, 0, "First Option"));
676/// ui.add(radio(&mut selection, 1, "Second Option"));
677/// # });
678/// ```
679pub fn radio<'a, T: PartialEq + Clone>(
680    selected: &'a mut Option<T>,
681    value: T,
682    text: impl Into<String>,
683) -> MaterialRadio<'a, T> {
684    MaterialRadio::new(selected, value, text)
685}
686
687/// Convenience function to create a radio button group.
688///
689/// Shorthand for `MaterialRadioGroup::new()`.
690///
691/// # Arguments
692/// * `selected` - Mutable reference to the currently selected value
693///
694/// # Example
695/// ```rust
696/// # egui::__run_test_ui(|ui| {
697/// let mut selection = None;
698/// ui.add(radio_group(&mut selection)
699///     .option(0, "Option A")
700///     .option(1, "Option B"));
701/// # });
702/// ```
703pub fn radio_group<'a, T: PartialEq + Clone>(selected: &'a mut Option<T>) -> MaterialRadioGroup<'a, T> {
704    MaterialRadioGroup::new(selected)
705}
706
707/// Convenience function to create a radio list tile.
708///
709/// Shorthand for `RadioListTile::new()`.
710///
711/// # Arguments
712/// * `selected` - Mutable reference to the currently selected value
713/// * `value` - The value this radio list tile represents
714///
715/// # Example
716/// ```rust
717/// # egui::__run_test_ui(|ui| {
718/// let mut selection = Some(0);
719/// ui.add(radio_list_tile(&mut selection, 0)
720///     .title("Option One")
721///     .subtitle("First choice"));
722/// # });
723/// ```
724pub fn radio_list_tile<'a, T: PartialEq + Clone>(
725    selected: &'a mut Option<T>,
726    value: T,
727) -> RadioListTile<'a, T> {
728    RadioListTile::new(selected, value)
729}