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::event::{Event, EventResult, Key, MouseButton};
11use crate::geometry::{Rect, Size};
12use crate::draw_ctx::DrawCtx;
13use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
14use crate::text::Font;
15use crate::widget::{Widget, paint_subtree};
16use crate::widgets::label::Label;
17
18const DOT_R: f64 = 8.0;   // outer circle radius
19const GAP: f64 = 8.0;
20const ROW_H: f64 = 28.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.iter().map(|text| {
49            Label::new(text.as_str(), Arc::clone(&font))
50                .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 { self.selected = v.min(n - 1); }
75        self.selected_cell = Some(cell);
76        self
77    }
78
79    pub fn with_font_size(mut self, size: f64) -> Self {
80        self.font_size = size;
81        // Rebuild label widgets with new font size.
82        self.label_widgets = self.options.iter().map(|text| {
83            Label::new(text.as_str(), Arc::clone(&self.font))
84                .with_font_size(size)
85        }).collect();
86        self
87    }
88
89    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
90    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
91    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
92    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
93    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
94
95    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
96        self.on_change = Some(Box::new(cb));
97        self
98    }
99
100    pub fn selected(&self) -> usize { self.selected }
101
102    pub fn set_selected(&mut self, idx: usize) {
103        if idx < self.options.len() {
104            self.selected = idx;
105            if let Some(cell) = &self.selected_cell { cell.set(idx); }
106        }
107    }
108
109    fn fire(&mut self) {
110        let idx = self.selected;
111        if let Some(cell) = &self.selected_cell { cell.set(idx); }
112        if let Some(cb) = self.on_change.as_mut() { cb(idx); }
113    }
114
115    /// Y coordinate (bottom-left) of the center of row `i` in Y-up space.
116    fn row_center_y(&self, i: usize, total_h: f64) -> f64 {
117        let n = self.options.len();
118        if n == 0 { return total_h * 0.5; }
119        // rows are stacked top-to-bottom, so row 0 is at the top.
120        // In Y-up, top row has the largest Y.
121        let row_top_y = total_h - (i as f64) * ROW_H;
122        row_top_y - ROW_H * 0.5
123    }
124
125    fn row_for_y(&self, pos_y: f64) -> Option<usize> {
126        let h = self.bounds.height;
127        for i in 0..self.options.len() {
128            let cy = self.row_center_y(i, h);
129            if pos_y >= cy - ROW_H * 0.5 && pos_y < cy + ROW_H * 0.5 {
130                return Some(i);
131            }
132        }
133        None
134    }
135}
136
137impl Widget for RadioGroup {
138    fn type_name(&self) -> &'static str { "RadioGroup" }
139    fn bounds(&self) -> Rect { self.bounds }
140    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
141    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
142    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
143
144    fn is_focusable(&self) -> bool { true }
145
146    fn margin(&self)   -> Insets  { self.base.margin }
147    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
148    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
149    fn min_size(&self) -> Size    { self.base.min_size }
150    fn max_size(&self) -> Size    { self.base.max_size }
151
152    fn layout(&mut self, available: Size) -> Size {
153        // Pick up external-cell writes every frame (e.g. the System
154        // window's typeface radio driving this demo's radio).
155        if let Some(cell) = &self.selected_cell {
156            let n = self.options.len();
157            if n > 0 {
158                let v = cell.get().min(n - 1);
159                self.selected = v;
160            }
161        }
162        let h = self.options.len() as f64 * ROW_H;
163        self.bounds = Rect::new(0.0, 0.0, available.width, h);
164        let label_avail_w = (available.width - DOT_R * 2.0 - GAP).max(0.0);
165        for lw in self.label_widgets.iter_mut() {
166            let s = lw.layout(Size::new(label_avail_w, ROW_H));
167            lw.set_bounds(Rect::new(0.0, 0.0, s.width, s.height));
168        }
169        Size::new(available.width, h)
170    }
171
172    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
173        let v = ctx.visuals();
174        let h = self.bounds.height;
175
176        // Focus outline around whole widget.
177        if self.focused {
178            ctx.set_stroke_color(v.accent_focus);
179            ctx.set_line_width(1.5);
180            ctx.begin_path();
181            ctx.rounded_rect(-2.0, -2.0, self.bounds.width + 4.0, h + 4.0, 4.0);
182            ctx.stroke();
183        }
184
185        for i in 0..self.options.len() {
186            let cy = self.row_center_y(i, h);
187            let checked = i == self.selected;
188            let hovered = self.hovered == Some(i);
189
190            // Outer circle.
191            let border = if checked { v.accent }
192                         else if hovered { v.widget_bg_hovered }
193                         else { v.widget_stroke };
194            let bg = if checked { v.accent } else { v.widget_bg };
195
196            ctx.set_fill_color(bg);
197            ctx.begin_path();
198            ctx.circle(DOT_R, cy, DOT_R);
199            ctx.fill();
200
201            ctx.set_stroke_color(border);
202            ctx.set_line_width(1.5);
203            ctx.begin_path();
204            ctx.circle(DOT_R, cy, DOT_R);
205            ctx.stroke();
206
207            // Inner dot when checked — always white since it's on the accent color background.
208            if checked {
209                ctx.set_fill_color(v.widget_bg);
210                ctx.begin_path();
211                ctx.circle(DOT_R, cy, DOT_R * 0.45);
212                ctx.fill();
213            }
214
215            // Label — rendered through backbuffered Label child.
216            self.label_widgets[i].set_color(v.text_color);
217
218            let lw = self.label_widgets[i].bounds().width;
219            let lh = self.label_widgets[i].bounds().height;
220            let lx = DOT_R * 2.0 + GAP;
221            let ly = cy - lh * 0.5;
222            self.label_widgets[i].set_bounds(Rect::new(lx, ly, lw, lh));
223
224            ctx.save();
225            ctx.translate(lx, ly);
226            paint_subtree(&mut self.label_widgets[i], ctx);
227            ctx.restore();
228        }
229    }
230
231    fn on_event(&mut self, event: &Event) -> EventResult {
232        match event {
233            Event::MouseMove { pos } => {
234                let was = self.hovered;
235                self.hovered = self.row_for_y(pos.y);
236                if was != self.hovered { crate::animation::request_tick(); }
237                EventResult::Ignored
238            }
239            Event::MouseDown { button: MouseButton::Left, pos, .. } => {
240                if let Some(i) = self.row_for_y(pos.y) {
241                    let was = self.selected;
242                    self.selected = i;
243                    self.fire();
244                    if was != i { crate::animation::request_tick(); }
245                    return EventResult::Consumed;
246                }
247                EventResult::Ignored
248            }
249            Event::KeyDown { key, .. } => {
250                let n = self.options.len();
251                let changed = match key {
252                    Key::ArrowUp | Key::ArrowLeft => {
253                        if self.selected > 0 { self.selected -= 1; true } else { false }
254                    }
255                    Key::ArrowDown | Key::ArrowRight => {
256                        if self.selected + 1 < n { self.selected += 1; true } else { false }
257                    }
258                    _ => false,
259                };
260                if changed {
261                    self.fire();
262                    crate::animation::request_tick();
263                    EventResult::Consumed
264                } else {
265                    EventResult::Ignored
266                }
267            }
268            Event::FocusGained => {
269                self.focused = true;
270                crate::animation::request_tick();
271                EventResult::Ignored
272            }
273            Event::FocusLost   => {
274                self.focused = false;
275                crate::animation::request_tick();
276                EventResult::Ignored
277            }
278            _ => EventResult::Ignored,
279        }
280    }
281}