Skip to main content

agg_gui/widgets/
radio_group.rs

1//! `RadioGroup` — a set of mutually exclusive radio buttons.
2//!
3//! Each option label is rendered through a backbuffered [`Label`] child,
4//! so glyph rasterization is cached and only repeated when text or color changes.
5
6use std::cell::Cell;
7use std::rc::Rc;
8use std::sync::Arc;
9
10use crate::draw_ctx::DrawCtx;
11use crate::event::{Event, EventResult, Key, MouseButton};
12use crate::geometry::{Rect, Size};
13use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
14use crate::text::Font;
15use crate::widget::{paint_subtree, Widget};
16use crate::widgets::label::Label;
17
18const DOT_R: f64 = 7.0; // outer circle radius
19const GAP: f64 = 8.0;
20const ROW_H: f64 = 22.0;
21
22/// A group of mutually-exclusive radio options.
23///
24/// Each option is a `(label, value_string)` pair. `selected` is the index of
25/// the currently chosen option.
26pub struct RadioGroup {
27    bounds: Rect,
28    children: Vec<Box<dyn Widget>>, // always empty — label_widgets stored separately
29    base: WidgetBase,
30    options: Vec<String>,
31    selected: usize,
32    hovered: Option<usize>,
33    focused: bool,
34    font: Arc<Font>,
35    font_size: f64,
36    on_change: Option<Box<dyn FnMut(usize)>>,
37    /// One backbuffered Label per option.
38    label_widgets: Vec<Label>,
39    /// Optional external mirror of `selected` — same bidirectional-binding
40    /// pattern as `Slider::with_value_cell` / `ToggleSwitch::with_state_cell`.
41    selected_cell: Option<Rc<Cell<usize>>>,
42}
43
44impl RadioGroup {
45    pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
46        let font_size = 14.0;
47        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
48        let label_widgets = opts
49            .iter()
50            .map(|text| Label::new(text.as_str(), Arc::clone(&font)).with_font_size(font_size))
51            .collect();
52        Self {
53            bounds: Rect::default(),
54            children: Vec::new(),
55            base: WidgetBase::new(),
56            options: opts,
57            selected,
58            hovered: None,
59            focused: false,
60            font,
61            font_size,
62            on_change: None,
63            label_widgets,
64            selected_cell: None,
65        }
66    }
67
68    /// Bind this group's selection to an external `Rc<Cell<usize>>`.  The
69    /// cell is read each layout and written on every selection change, so
70    /// two RadioGroups sharing one cell stay in lock-step.
71    pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
72        let n = self.options.len();
73        let v = cell.get();
74        if n > 0 {
75            self.selected = v.min(n - 1);
76        }
77        self.selected_cell = Some(cell);
78        self
79    }
80
81    pub fn with_font_size(mut self, size: f64) -> Self {
82        self.font_size = size;
83        // Rebuild label widgets with new font size.
84        self.label_widgets = self
85            .options
86            .iter()
87            .map(|text| Label::new(text.as_str(), Arc::clone(&self.font)).with_font_size(size))
88            .collect();
89        self
90    }
91
92    pub fn with_margin(mut self, m: Insets) -> Self {
93        self.base.margin = m;
94        self
95    }
96    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
97        self.base.h_anchor = h;
98        self
99    }
100    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
101        self.base.v_anchor = v;
102        self
103    }
104    pub fn with_min_size(mut self, s: Size) -> Self {
105        self.base.min_size = s;
106        self
107    }
108    pub fn with_max_size(mut self, s: Size) -> Self {
109        self.base.max_size = s;
110        self
111    }
112
113    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
114        self.on_change = Some(Box::new(cb));
115        self
116    }
117
118    pub fn selected(&self) -> usize {
119        self.selected
120    }
121
122    pub fn set_selected(&mut self, idx: usize) {
123        if idx < self.options.len() {
124            self.selected = idx;
125            if let Some(cell) = &self.selected_cell {
126                cell.set(idx);
127            }
128        }
129    }
130
131    fn fire(&mut self) {
132        let idx = self.selected;
133        if let Some(cell) = &self.selected_cell {
134            cell.set(idx);
135        }
136        if let Some(cb) = self.on_change.as_mut() {
137            cb(idx);
138        }
139    }
140
141    /// Y coordinate (bottom-left) of the center of row `i` in Y-up space.
142    fn row_center_y(&self, i: usize, total_h: f64) -> f64 {
143        let n = self.options.len();
144        if n == 0 {
145            return total_h * 0.5;
146        }
147        // rows are stacked top-to-bottom, so row 0 is at the top.
148        // In Y-up, top row has the largest Y.
149        let row_top_y = total_h - (i as f64) * ROW_H;
150        row_top_y - ROW_H * 0.5
151    }
152
153    fn row_for_y(&self, pos_y: f64) -> Option<usize> {
154        let h = self.bounds.height;
155        for i in 0..self.options.len() {
156            let cy = self.row_center_y(i, h);
157            if pos_y >= cy - ROW_H * 0.5 && pos_y < cy + ROW_H * 0.5 {
158                return Some(i);
159            }
160        }
161        None
162    }
163}
164
165impl Widget for RadioGroup {
166    fn type_name(&self) -> &'static str {
167        "RadioGroup"
168    }
169    fn bounds(&self) -> Rect {
170        self.bounds
171    }
172    fn set_bounds(&mut self, b: Rect) {
173        self.bounds = b;
174    }
175    fn children(&self) -> &[Box<dyn Widget>] {
176        &self.children
177    }
178    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
179        &mut self.children
180    }
181
182    fn is_focusable(&self) -> bool {
183        true
184    }
185
186    fn margin(&self) -> Insets {
187        self.base.margin
188    }
189    fn h_anchor(&self) -> HAnchor {
190        self.base.h_anchor
191    }
192    fn v_anchor(&self) -> VAnchor {
193        self.base.v_anchor
194    }
195    fn min_size(&self) -> Size {
196        self.base.min_size
197    }
198    fn max_size(&self) -> Size {
199        self.base.max_size
200    }
201
202    fn layout(&mut self, available: Size) -> Size {
203        // Pick up external-cell writes every frame (e.g. the System
204        // window's typeface radio driving this demo's radio).
205        if let Some(cell) = &self.selected_cell {
206            let n = self.options.len();
207            if n > 0 {
208                let v = cell.get().min(n - 1);
209                self.selected = v;
210            }
211        }
212        let h = self.options.len() as f64 * ROW_H;
213        self.bounds = Rect::new(0.0, 0.0, available.width, h);
214        let label_avail_w = (available.width - DOT_R * 2.0 - GAP).max(0.0);
215        for lw in self.label_widgets.iter_mut() {
216            let s = lw.layout(Size::new(label_avail_w, ROW_H));
217            lw.set_bounds(Rect::new(0.0, 0.0, s.width, s.height));
218        }
219        Size::new(available.width, h)
220    }
221
222    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
223        let v = ctx.visuals();
224        let h = self.bounds.height;
225
226        // Focus outline around whole widget.
227        if self.focused {
228            ctx.set_stroke_color(v.accent_focus);
229            ctx.set_line_width(1.5);
230            ctx.begin_path();
231            ctx.rounded_rect(-2.0, -2.0, self.bounds.width + 4.0, h + 4.0, 4.0);
232            ctx.stroke();
233        }
234
235        for i in 0..self.options.len() {
236            let cy = self.row_center_y(i, h);
237            let checked = i == self.selected;
238            let hovered = self.hovered == Some(i);
239
240            // Outer circle.
241            let border = if checked {
242                v.accent
243            } else if hovered {
244                v.widget_bg_hovered
245            } else {
246                v.widget_stroke
247            };
248            let bg = if checked { v.accent } else { v.widget_bg };
249
250            ctx.set_fill_color(bg);
251            ctx.begin_path();
252            ctx.circle(DOT_R, cy, DOT_R);
253            ctx.fill();
254
255            ctx.set_stroke_color(border);
256            ctx.set_line_width(1.5);
257            ctx.begin_path();
258            ctx.circle(DOT_R, cy, DOT_R);
259            ctx.stroke();
260
261            // Inner dot when checked — always white since it's on the accent color background.
262            if checked {
263                ctx.set_fill_color(v.widget_bg);
264                ctx.begin_path();
265                ctx.circle(DOT_R, cy, DOT_R * 0.45);
266                ctx.fill();
267            }
268
269            // Label — rendered through backbuffered Label child.
270            self.label_widgets[i].set_color(v.text_color);
271
272            let lw = self.label_widgets[i].bounds().width;
273            let lh = self.label_widgets[i].bounds().height;
274            let lx = DOT_R * 2.0 + GAP;
275            let ly = cy - lh * 0.5;
276            self.label_widgets[i].set_bounds(Rect::new(lx, ly, lw, lh));
277
278            ctx.save();
279            ctx.translate(lx, ly);
280            paint_subtree(&mut self.label_widgets[i], ctx);
281            ctx.restore();
282        }
283    }
284
285    fn on_event(&mut self, event: &Event) -> EventResult {
286        match event {
287            Event::MouseMove { pos } => {
288                let was = self.hovered;
289                self.hovered = self.row_for_y(pos.y);
290                if was != self.hovered {
291                    crate::animation::request_draw();
292                    return EventResult::Consumed;
293                }
294                EventResult::Ignored
295            }
296            Event::MouseDown {
297                button: MouseButton::Left,
298                pos,
299                ..
300            } => {
301                if let Some(i) = self.row_for_y(pos.y) {
302                    let was = self.selected;
303                    self.selected = i;
304                    self.fire();
305                    if was != i {
306                        crate::animation::request_draw();
307                    }
308                    return EventResult::Consumed;
309                }
310                EventResult::Ignored
311            }
312            Event::KeyDown { key, .. } => {
313                let n = self.options.len();
314                let changed = match key {
315                    Key::ArrowUp | Key::ArrowLeft => {
316                        if self.selected > 0 {
317                            self.selected -= 1;
318                            true
319                        } else {
320                            false
321                        }
322                    }
323                    Key::ArrowDown | Key::ArrowRight => {
324                        if self.selected + 1 < n {
325                            self.selected += 1;
326                            true
327                        } else {
328                            false
329                        }
330                    }
331                    _ => false,
332                };
333                if changed {
334                    self.fire();
335                    crate::animation::request_draw();
336                    EventResult::Consumed
337                } else {
338                    EventResult::Ignored
339                }
340            }
341            Event::FocusGained => {
342                let was = self.focused;
343                self.focused = true;
344                if !was {
345                    crate::animation::request_draw();
346                    EventResult::Consumed
347                } else {
348                    EventResult::Ignored
349                }
350            }
351            Event::FocusLost => {
352                let was = self.focused;
353                self.focused = false;
354                if was {
355                    crate::animation::request_draw();
356                    EventResult::Consumed
357                } else {
358                    EventResult::Ignored
359                }
360            }
361            _ => EventResult::Ignored,
362        }
363    }
364}