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::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/// Left/right slack reserved so the circle's 1.5-px stroke (and its AA
22/// fringe) and the focus-ring outline stay INSIDE the widget's bounds.
23/// Without it, the parent container's `clip_children_rect` (which
24/// defaults to the widget's bounds rect) chops the leftmost stroke
25/// pixel off whenever the RadioGroup is placed flush against a
26/// container edge — see `paint::paint_subtree_direct_inner`.
27const LEFT_INSET: f64 = 2.0;
28
29/// A group of mutually-exclusive radio options.
30///
31/// Each option is a `(label, value_string)` pair. `selected` is the index of
32/// the currently chosen option.  Each option's text is held as a real
33/// `Label` child in `children` so the inspector tree mirrors the visible
34/// row structure (RadioGroup → Label × N) and the framework recurses
35/// into the labels naturally — RadioGroup's `paint()` only draws the
36/// dot circles.
37pub struct RadioGroup {
38    bounds: Rect,
39    /// One `Label` child per option, stored as `Box<dyn Widget>` so the
40    /// framework's tree walks (paint / hit-test / inspector) recurse into
41    /// them.  Mutated through `set_label_color` (Widget trait method) to
42    /// retint per frame without rebuilding.
43    children: Vec<Box<dyn Widget>>,
44    base: WidgetBase,
45    options: Vec<String>,
46    selected: usize,
47    hovered: Option<usize>,
48    focused: bool,
49    font: Arc<Font>,
50    font_size: f64,
51    on_change: Option<Box<dyn FnMut(usize)>>,
52    /// Optional external mirror of `selected` — same bidirectional-binding
53    /// pattern as `Slider::with_value_cell` / `ToggleSwitch::with_state_cell`.
54    selected_cell: Option<Rc<Cell<usize>>>,
55}
56
57impl RadioGroup {
58    pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
59        let font_size = 14.0;
60        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
61        let children: Vec<Box<dyn Widget>> = opts
62            .iter()
63            .map(|text| {
64                Box::new(Label::new(text.as_str(), Arc::clone(&font)).with_font_size(font_size))
65                    as Box<dyn Widget>
66            })
67            .collect();
68        Self {
69            bounds: Rect::default(),
70            children,
71            base: WidgetBase::new(),
72            options: opts,
73            selected,
74            hovered: None,
75            focused: false,
76            font,
77            font_size,
78            on_change: None,
79            selected_cell: None,
80        }
81    }
82
83    /// Bind this group's selection to an external `Rc<Cell<usize>>`.  The
84    /// cell is read each layout and written on every selection change, so
85    /// two RadioGroups sharing one cell stay in lock-step.
86    pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
87        let n = self.options.len();
88        let v = cell.get();
89        if n > 0 {
90            self.selected = v.min(n - 1);
91        }
92        self.selected_cell = Some(cell);
93        self
94    }
95
96    pub fn with_font_size(mut self, size: f64) -> Self {
97        self.font_size = size;
98        // Rebuild label children with new font size.
99        self.children = self
100            .options
101            .iter()
102            .map(|text| {
103                Box::new(Label::new(text.as_str(), Arc::clone(&self.font)).with_font_size(size))
104                    as Box<dyn Widget>
105            })
106            .collect();
107        self
108    }
109
110    pub fn with_margin(mut self, m: Insets) -> Self {
111        self.base.margin = m;
112        self
113    }
114    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
115        self.base.h_anchor = h;
116        self
117    }
118    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
119        self.base.v_anchor = v;
120        self
121    }
122    pub fn with_min_size(mut self, s: Size) -> Self {
123        self.base.min_size = s;
124        self
125    }
126    pub fn with_max_size(mut self, s: Size) -> Self {
127        self.base.max_size = s;
128        self
129    }
130
131    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
132        self.on_change = Some(Box::new(cb));
133        self
134    }
135
136    pub fn selected(&self) -> usize {
137        self.selected
138    }
139
140    pub fn set_selected(&mut self, idx: usize) {
141        if idx < self.options.len() {
142            self.selected = idx;
143            if let Some(cell) = &self.selected_cell {
144                cell.set(idx);
145            }
146        }
147    }
148
149    fn fire(&mut self) {
150        let idx = self.selected;
151        if let Some(cell) = &self.selected_cell {
152            cell.set(idx);
153        }
154        if let Some(cb) = self.on_change.as_mut() {
155            cb(idx);
156        }
157    }
158
159    /// Y coordinate (bottom-left) of the center of row `i` in Y-up space.
160    fn row_center_y(&self, i: usize, total_h: f64) -> f64 {
161        let n = self.options.len();
162        if n == 0 {
163            return total_h * 0.5;
164        }
165        // rows are stacked top-to-bottom, so row 0 is at the top.
166        // In Y-up, top row has the largest Y.
167        let row_top_y = total_h - (i as f64) * ROW_H;
168        row_top_y - ROW_H * 0.5
169    }
170
171    fn row_for_y(&self, pos_y: f64) -> Option<usize> {
172        let h = self.bounds.height;
173        for i in 0..self.options.len() {
174            let cy = self.row_center_y(i, h);
175            if pos_y >= cy - ROW_H * 0.5 && pos_y < cy + ROW_H * 0.5 {
176                return Some(i);
177            }
178        }
179        None
180    }
181}
182
183impl Widget for RadioGroup {
184    fn type_name(&self) -> &'static str {
185        "RadioGroup"
186    }
187    fn bounds(&self) -> Rect {
188        self.bounds
189    }
190    fn set_bounds(&mut self, b: Rect) {
191        self.bounds = b;
192    }
193    fn children(&self) -> &[Box<dyn Widget>] {
194        &self.children
195    }
196    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
197        &mut self.children
198    }
199
200    fn is_focusable(&self) -> bool {
201        true
202    }
203
204    fn margin(&self) -> Insets {
205        self.base.margin
206    }
207    fn widget_base(&self) -> Option<&WidgetBase> {
208        Some(&self.base)
209    }
210    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
211        Some(&mut self.base)
212    }
213    fn h_anchor(&self) -> HAnchor {
214        self.base.h_anchor
215    }
216    fn v_anchor(&self) -> VAnchor {
217        self.base.v_anchor
218    }
219    fn min_size(&self) -> Size {
220        self.base.min_size
221    }
222    fn max_size(&self) -> Size {
223        self.base.max_size
224    }
225
226    /// One [`ROW_H`]-tall row per option — matches the height [`layout`]
227    /// produces.  Without this override the trait default returns `0`, and
228    /// an ancestor `Window::with_tight_content_fit` would size the window
229    /// too short by the radio's full height.
230    fn measure_min_height(&self, _available_w: f64) -> f64 {
231        self.options.len() as f64 * ROW_H
232    }
233
234    fn layout(&mut self, available: Size) -> Size {
235        // Pick up external-cell writes every frame (e.g. the System
236        // window's typeface radio driving this demo's radio).
237        if let Some(cell) = &self.selected_cell {
238            let n = self.options.len();
239            if n > 0 {
240                let v = cell.get().min(n - 1);
241                self.selected = v;
242            }
243        }
244        let h = self.options.len() as f64 * ROW_H;
245        self.bounds = Rect::new(0.0, 0.0, available.width, h);
246        // `LEFT_INSET` shifts the circle inward; the label moves the
247        // same amount so the visual gap between dot and label is preserved.
248        let circle_extent = LEFT_INSET + DOT_R * 2.0;
249        let label_avail_w = (available.width - circle_extent - GAP).max(0.0);
250        let lx = circle_extent + GAP;
251        for (i, child) in self.children.iter_mut().enumerate() {
252            let s = child.layout(Size::new(label_avail_w, ROW_H));
253            // Position the label child in the row's vertical centre,
254            // offset right of the radio dot.  In Y-up the first row
255            // (i=0) sits at the TOP of the widget — see `row_center_y`.
256            let row_top_y = h - (i as f64) * ROW_H;
257            let cy = row_top_y - ROW_H * 0.5;
258            let ly = cy - s.height * 0.5;
259            child.set_bounds(Rect::new(lx, ly, s.width, s.height));
260        }
261        Size::new(available.width, h)
262    }
263
264    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
265        let v = ctx.visuals();
266        let h = self.bounds.height;
267
268        // Focus outline around whole widget — drawn JUST INSIDE bounds so
269        // the parent's clip_children_rect (defaults to widget bounds)
270        // doesn't chop the leftmost stroke pixel.
271        if self.focused {
272            ctx.set_stroke_color(v.accent_focus);
273            ctx.set_line_width(1.5);
274            ctx.begin_path();
275            ctx.rounded_rect(0.75, 0.75, self.bounds.width - 1.5, h - 1.5, 4.0);
276            ctx.stroke();
277        }
278
279        // Paint just the radio dot for each row — the row's text is a
280        // real `Label` child that the framework recurses into after this
281        // method returns (positioned by `layout`).  Setting label colour
282        // here through the `set_label_color` Widget-trait method keeps
283        // the foreground theme-aware without rebuilding the Label.
284        let text_color = v.text_color;
285        for i in 0..self.options.len() {
286            let cy = self.row_center_y(i, h);
287            let checked = i == self.selected;
288            let hovered = self.hovered == Some(i);
289
290            let border = if checked {
291                v.accent
292            } else if hovered {
293                v.widget_bg_hovered
294            } else {
295                v.widget_stroke
296            };
297            let bg = if checked { v.accent } else { v.widget_bg };
298
299            ctx.set_fill_color(bg);
300            ctx.begin_path();
301            ctx.circle(LEFT_INSET + DOT_R, cy, DOT_R);
302            ctx.fill();
303
304            ctx.set_stroke_color(border);
305            ctx.set_line_width(1.5);
306            ctx.begin_path();
307            ctx.circle(LEFT_INSET + DOT_R, cy, DOT_R);
308            ctx.stroke();
309
310            // Inner dot when checked — always widget_bg so it stays
311            // readable on the accent surface.
312            if checked {
313                ctx.set_fill_color(v.widget_bg);
314                ctx.begin_path();
315                ctx.circle(LEFT_INSET + DOT_R, cy, DOT_R * 0.45);
316                ctx.fill();
317            }
318
319            if let Some(child) = self.children.get_mut(i) {
320                child.set_label_color(text_color);
321            }
322        }
323    }
324
325    fn on_event(&mut self, event: &Event) -> EventResult {
326        match event {
327            Event::MouseMove { pos } => {
328                let was = self.hovered;
329                self.hovered = self.row_for_y(pos.y);
330                if was != self.hovered {
331                    crate::animation::request_draw();
332                    return EventResult::Consumed;
333                }
334                EventResult::Ignored
335            }
336            Event::MouseDown {
337                button: MouseButton::Left,
338                pos,
339                ..
340            } => {
341                if let Some(i) = self.row_for_y(pos.y) {
342                    let was = self.selected;
343                    self.selected = i;
344                    self.fire();
345                    if was != i {
346                        crate::animation::request_draw();
347                    }
348                    return EventResult::Consumed;
349                }
350                EventResult::Ignored
351            }
352            Event::KeyDown { key, .. } => {
353                let n = self.options.len();
354                let changed = match key {
355                    Key::ArrowUp | Key::ArrowLeft => {
356                        if self.selected > 0 {
357                            self.selected -= 1;
358                            true
359                        } else {
360                            false
361                        }
362                    }
363                    Key::ArrowDown | Key::ArrowRight => {
364                        if self.selected + 1 < n {
365                            self.selected += 1;
366                            true
367                        } else {
368                            false
369                        }
370                    }
371                    _ => false,
372                };
373                if changed {
374                    self.fire();
375                    crate::animation::request_draw();
376                    EventResult::Consumed
377                } else {
378                    EventResult::Ignored
379                }
380            }
381            Event::FocusGained => {
382                let was = self.focused;
383                self.focused = true;
384                if !was {
385                    crate::animation::request_draw();
386                    EventResult::Consumed
387                } else {
388                    EventResult::Ignored
389                }
390            }
391            Event::FocusLost => {
392                let was = self.focused;
393                self.focused = false;
394                if was {
395                    crate::animation::request_draw();
396                    EventResult::Consumed
397                } else {
398                    EventResult::Ignored
399                }
400            }
401            _ => EventResult::Ignored,
402        }
403    }
404}