Skip to main content

egui_components/
radio.rs

1//! `Radio` — a single radio button. Pair several with the same backing value
2//! to form a group.
3//!
4//! Like egui's `radio_value`, the idiomatic use is via
5//! [`Radio::selectable`], which takes the current value and the value this
6//! button represents:
7//!
8//! ```ignore
9//! ui.add(sc::Radio::selectable(&mut self.choice, Choice::A, "Option A"));
10//! ui.add(sc::Radio::selectable(&mut self.choice, Choice::B, "Option B"));
11//! ```
12//!
13//! Or drive it manually with [`Radio::new`] (selected = bool) and handle
14//! `.clicked()` yourself.
15
16use egui::{pos2, vec2, FontId, Response, Sense, Stroke, Ui, Widget};
17use egui_components_theme::{mix, Theme};
18
19pub struct Radio {
20    selected: bool,
21    label: Option<String>,
22    disabled: bool,
23}
24
25impl Radio {
26    pub fn new(selected: bool, label: impl Into<String>) -> Self {
27        Self {
28            selected,
29            label: Some(label.into()),
30            disabled: false,
31        }
32    }
33    /// Radio with no text label.
34    pub fn bare(selected: bool) -> Self {
35        Self {
36            selected,
37            label: None,
38            disabled: false,
39        }
40    }
41    pub fn disabled(mut self, d: bool) -> Self {
42        self.disabled = d;
43        self
44    }
45
46    /// Convenience for radio groups: selects `value` into `current` on click.
47    /// Returns the [`Response`]; `.clicked()` is true when this option is
48    /// chosen.
49    pub fn selectable<'a, T: PartialEq>(
50        current: &'a mut T,
51        value: T,
52        label: impl Into<String>,
53    ) -> SelectableRadio<'a, T> {
54        SelectableRadio {
55            radio: Radio::new(*current == value, label),
56            current,
57            value,
58        }
59    }
60}
61
62impl Widget for Radio {
63    fn ui(self, ui: &mut Ui) -> Response {
64        let theme = Theme::get(ui.ctx());
65        let c = theme.colors;
66        let m = theme.metrics;
67        let size = m.checkbox_size;
68        let gap = 8.0;
69        let font = FontId::proportional(m.font_size_md);
70
71        let label_galley = self.label.as_ref().map(|t| {
72            ui.ctx()
73                .fonts_mut(|f| f.layout_no_wrap(t.clone(), font.clone(), c.foreground))
74        });
75        let label_w = label_galley.as_ref().map(|g| g.size().x + gap).unwrap_or(0.0);
76        let label_h = label_galley.as_ref().map(|g| g.size().y).unwrap_or(0.0);
77
78        let desired = vec2(size + label_w, size.max(label_h));
79        let sense = if self.disabled {
80            Sense::hover()
81        } else {
82            Sense::click()
83        };
84        let (rect, response) = ui.allocate_exact_size(desired, sense);
85
86        if ui.is_rect_visible(rect) {
87            let center = pos2(rect.left() + size * 0.5, rect.center().y);
88            let radius = size * 0.5;
89            let painter = ui.painter();
90
91            let (ring, dot) = if self.disabled {
92                (mix(c.input_border, c.background, 0.4), mix(c.primary_background, c.background, 0.4))
93            } else if self.selected {
94                (c.primary_background, c.primary_background)
95            } else {
96                (c.input_border, c.primary_background)
97            };
98
99            painter.circle(
100                center,
101                radius,
102                c.background,
103                Stroke::new(m.border_width + if self.selected { 0.5 } else { 0.0 }, ring),
104            );
105            if self.selected {
106                painter.circle_filled(center, radius * 0.5, dot);
107            }
108
109            if response.has_focus() {
110                painter.circle_stroke(center, radius + 2.5, theme.focus_ring());
111            }
112
113            if let Some(g) = label_galley {
114                let pos = pos2(rect.left() + size + gap, rect.center().y - g.size().y * 0.5);
115                let color = if self.disabled {
116                    c.muted_foreground
117                } else {
118                    c.foreground
119                };
120                painter.galley_with_override_text_color(pos, g, color);
121            }
122
123            if !self.disabled && response.hovered() {
124                ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
125            }
126        }
127
128        response
129    }
130}
131
132/// Returned by [`Radio::selectable`]; updates the backing value on click.
133pub struct SelectableRadio<'a, T: PartialEq> {
134    radio: Radio,
135    current: &'a mut T,
136    value: T,
137}
138
139impl<T: PartialEq> Widget for SelectableRadio<'_, T> {
140    fn ui(self, ui: &mut Ui) -> Response {
141        let mut response = self.radio.ui(ui);
142        if response.clicked() && *self.current != self.value {
143            *self.current = self.value;
144            response.mark_changed();
145        }
146        response
147    }
148}