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::{measure_advance, 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/// Horizontal spacing between wrapped items in horizontal-wrap mode.
22const HWRAP_SPACING: f64 = 6.0;
23/// Left/right slack reserved so the circle's 1.5-px stroke (and its AA
24/// fringe) and the focus-ring outline stay INSIDE the widget's bounds.
25/// Without it, the parent container's `clip_children_rect` (which
26/// defaults to the widget's bounds rect) chops the leftmost stroke
27/// pixel off whenever the RadioGroup is placed flush against a
28/// container edge — see `paint::paint_subtree_direct_inner`.
29const LEFT_INSET: f64 = 2.0;
30
31/// A group of mutually-exclusive radio options.
32///
33/// Each option is a `(label, value_string)` pair. `selected` is the index of
34/// the currently chosen option.  Each option's text is held as a real
35/// `Label` child in `children` so the inspector tree mirrors the visible
36/// row structure (RadioGroup → Label × N) and the framework recurses
37/// into the labels naturally — RadioGroup's `paint()` only draws the
38/// dot circles.
39pub struct RadioGroup {
40    bounds: Rect,
41    /// One `Label` child per option, stored as `Box<dyn Widget>` so the
42    /// framework's tree walks (paint / hit-test / inspector) recurse into
43    /// them.  Mutated through `set_label_color` (Widget trait method) to
44    /// retint per frame without rebuilding.
45    children: Vec<Box<dyn Widget>>,
46    base: WidgetBase,
47    options: Vec<String>,
48    selected: usize,
49    hovered: Option<usize>,
50    focused: bool,
51    font: Arc<Font>,
52    font_size: f64,
53    on_change: Option<Box<dyn FnMut(usize)>>,
54    /// Optional external mirror of `selected` — same bidirectional-binding
55    /// pattern as `Slider::with_value_cell` / `ToggleSwitch::with_state_cell`.
56    selected_cell: Option<Rc<Cell<usize>>>,
57    /// When `true`, options flow left-to-right and wrap to new lines instead
58    /// of stacking vertically. Backs egui's "64 radio buttons" wrapped row.
59    horizontal: bool,
60    /// Per-option layout computed during a horizontal-wrap `layout` pass:
61    /// `(dot_cx, dot_cy, label_x, has_label, hit_box)` in widget-local
62    /// coordinates (x-left / y-up). Empty in vertical mode, where
63    /// `row_center_y`/`row_for_y` are used instead.
64    hwrap_items: Vec<(f64, f64, f64, bool, Rect)>,
65}
66
67impl RadioGroup {
68    pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
69        let font_size = 14.0;
70        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
71        let children: Vec<Box<dyn Widget>> = opts
72            .iter()
73            .map(|text| {
74                Box::new(Label::new(text.as_str(), Arc::clone(&font)).with_font_size(font_size))
75                    as Box<dyn Widget>
76            })
77            .collect();
78        Self {
79            bounds: Rect::default(),
80            children,
81            base: WidgetBase::new(),
82            options: opts,
83            selected,
84            hovered: None,
85            focused: false,
86            font,
87            font_size,
88            on_change: None,
89            selected_cell: None,
90            horizontal: false,
91            hwrap_items: Vec::new(),
92        }
93    }
94
95    /// Lay the options out left-to-right, wrapping to new lines when they run
96    /// out of horizontal room (egui's `horizontal_wrapped` radio row). Options
97    /// with empty labels render as bare dots, which is how egui's 64-radio demo
98    /// is built.
99    pub fn with_horizontal_wrap(mut self, on: bool) -> Self {
100        self.horizontal = on;
101        self
102    }
103
104    /// Bind this group's selection to an external `Rc<Cell<usize>>`.  The
105    /// cell is read each layout and written on every selection change, so
106    /// two RadioGroups sharing one cell stay in lock-step.
107    pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
108        let n = self.options.len();
109        let v = cell.get();
110        if n > 0 {
111            self.selected = v.min(n - 1);
112        }
113        self.selected_cell = Some(cell);
114        self
115    }
116
117    pub fn with_font_size(mut self, size: f64) -> Self {
118        self.font_size = size;
119        // Rebuild label children with new font size.
120        self.children = self
121            .options
122            .iter()
123            .map(|text| {
124                Box::new(Label::new(text.as_str(), Arc::clone(&self.font)).with_font_size(size))
125                    as Box<dyn Widget>
126            })
127            .collect();
128        self
129    }
130
131    pub fn with_margin(mut self, m: Insets) -> Self {
132        self.base.margin = m;
133        self
134    }
135    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
136        self.base.h_anchor = h;
137        self
138    }
139    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
140        self.base.v_anchor = v;
141        self
142    }
143    pub fn with_min_size(mut self, s: Size) -> Self {
144        self.base.min_size = s;
145        self
146    }
147    pub fn with_max_size(mut self, s: Size) -> Self {
148        self.base.max_size = s;
149        self
150    }
151
152    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
153        self.on_change = Some(Box::new(cb));
154        self
155    }
156
157    pub fn selected(&self) -> usize {
158        self.selected
159    }
160
161    pub fn set_selected(&mut self, idx: usize) {
162        if idx < self.options.len() {
163            self.selected = idx;
164            if let Some(cell) = &self.selected_cell {
165                cell.set(idx);
166            }
167        }
168    }
169
170    fn fire(&mut self) {
171        let idx = self.selected;
172        if let Some(cell) = &self.selected_cell {
173            cell.set(idx);
174        }
175        if let Some(cb) = self.on_change.as_mut() {
176            cb(idx);
177        }
178    }
179
180    /// Y coordinate (bottom-left) of the center of row `i` in Y-up space.
181    fn row_center_y(&self, i: usize, total_h: f64) -> f64 {
182        let n = self.options.len();
183        if n == 0 {
184            return total_h * 0.5;
185        }
186        // rows are stacked top-to-bottom, so row 0 is at the top.
187        // In Y-up, top row has the largest Y.
188        let row_top_y = total_h - (i as f64) * ROW_H;
189        row_top_y - ROW_H * 0.5
190    }
191
192    fn row_for_y(&self, pos_y: f64) -> Option<usize> {
193        let h = self.bounds.height;
194        for i in 0..self.options.len() {
195            let cy = self.row_center_y(i, h);
196            if pos_y >= cy - ROW_H * 0.5 && pos_y < cy + ROW_H * 0.5 {
197                return Some(i);
198            }
199        }
200        None
201    }
202
203    /// Compute horizontal-wrap geometry for the given available width.
204    ///
205    /// Returns one `(dot_cx, dot_cy, label_x, has_label, hit_box)` per option
206    /// plus the total height. Coordinates are widget-local (x grows right,
207    /// y grows up, so the first wrapped line sits at the top). Pure w.r.t.
208    /// widget state — used by both `layout` and `measure_min_height`.
209    fn compute_hwrap(&self, available_w: f64) -> (Vec<(f64, f64, f64, bool, Rect)>, f64) {
210        let n = self.options.len();
211        if n == 0 {
212            return (Vec::new(), 0.0);
213        }
214        let dot_extent = LEFT_INSET + DOT_R * 2.0;
215        // First pass (top-down rows): assign each option a row and x offset.
216        let mut placed: Vec<(usize, f64, f64, f64, bool)> = Vec::with_capacity(n);
217        let mut x = 0.0_f64;
218        let mut row = 0usize;
219        for opt in &self.options {
220            let has_label = !opt.is_empty();
221            let label_w = if has_label {
222                measure_advance(&self.font, opt, self.font_size)
223            } else {
224                0.0
225            };
226            let item_w = dot_extent + if has_label { GAP + label_w } else { 0.0 };
227            if x > 0.0 && x + item_w > available_w {
228                row += 1;
229                x = 0.0;
230            }
231            placed.push((row, x, item_w, label_w, has_label));
232            x += item_w + HWRAP_SPACING;
233        }
234        let rows = row + 1;
235        let h = rows as f64 * ROW_H;
236        // Second pass: convert row index to Y-up centre and build hit boxes.
237        let items = placed
238            .into_iter()
239            .map(|(r, x_left, item_w, _label_w, has_label)| {
240                let cy = h - (r as f64) * ROW_H - ROW_H * 0.5;
241                let dot_cx = LEFT_INSET + DOT_R + x_left;
242                let label_x = x_left + dot_extent + GAP;
243                let hit = Rect::new(x_left, cy - ROW_H * 0.5, item_w, ROW_H);
244                (dot_cx, cy, label_x, has_label, hit)
245            })
246            .collect();
247        (items, h)
248    }
249
250    /// Locate the option whose clickable box contains `pos` (horizontal mode).
251    fn hwrap_item_at(&self, pos_x: f64, pos_y: f64) -> Option<usize> {
252        self.hwrap_items.iter().position(|(_, _, _, _, hit)| {
253            pos_x >= hit.x
254                && pos_x < hit.x + hit.width
255                && pos_y >= hit.y
256                && pos_y < hit.y + hit.height
257        })
258    }
259
260    /// Horizontal-wrap layout pass: caches item geometry, positions the label
261    /// children beside their dots, and sizes the widget to the wrapped height.
262    fn layout_horizontal(&mut self, available: Size) -> Size {
263        let (items, h) = self.compute_hwrap(available.width);
264        self.bounds = Rect::new(0.0, 0.0, available.width, h);
265        for (i, child) in self.children.iter_mut().enumerate() {
266            let Some(&(_dot_cx, cy, label_x, has_label, _hit)) = items.get(i) else {
267                continue;
268            };
269            if !has_label {
270                // Bare dot: give the empty label a zero-width slot at the dot.
271                child.set_bounds(Rect::new(label_x, cy, 0.0, 0.0));
272                let _ = child.layout(Size::new(0.0, ROW_H));
273                continue;
274            }
275            let s = child.layout(Size::new((available.width - label_x).max(0.0), ROW_H));
276            let ly = cy - s.height * 0.5;
277            child.set_bounds(Rect::new(label_x, ly, s.width, s.height));
278        }
279        self.hwrap_items = items;
280        Size::new(available.width, h)
281    }
282}
283
284impl Widget for RadioGroup {
285    fn type_name(&self) -> &'static str {
286        "RadioGroup"
287    }
288    fn bounds(&self) -> Rect {
289        self.bounds
290    }
291    fn set_bounds(&mut self, b: Rect) {
292        self.bounds = b;
293    }
294    fn children(&self) -> &[Box<dyn Widget>] {
295        &self.children
296    }
297    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
298        &mut self.children
299    }
300
301    fn is_focusable(&self) -> bool {
302        true
303    }
304
305    fn margin(&self) -> Insets {
306        self.base.margin
307    }
308    fn widget_base(&self) -> Option<&WidgetBase> {
309        Some(&self.base)
310    }
311    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
312        Some(&mut self.base)
313    }
314    fn h_anchor(&self) -> HAnchor {
315        self.base.h_anchor
316    }
317    fn v_anchor(&self) -> VAnchor {
318        self.base.v_anchor
319    }
320    fn min_size(&self) -> Size {
321        self.base.min_size
322    }
323    fn max_size(&self) -> Size {
324        self.base.max_size
325    }
326
327    /// One [`ROW_H`]-tall row per option — matches the height [`layout`]
328    /// produces.  Without this override the trait default returns `0`, and
329    /// an ancestor `Window::with_tight_content_fit` would size the window
330    /// too short by the radio's full height.
331    fn measure_min_height(&self, available_w: f64) -> f64 {
332        if self.horizontal {
333            let (_, h) = self.compute_hwrap(available_w);
334            return h;
335        }
336        self.options.len() as f64 * ROW_H
337    }
338
339    fn layout(&mut self, available: Size) -> Size {
340        // Pick up external-cell writes every frame (e.g. the System
341        // window's typeface radio driving this demo's radio).
342        if let Some(cell) = &self.selected_cell {
343            let n = self.options.len();
344            if n > 0 {
345                let v = cell.get().min(n - 1);
346                self.selected = v;
347            }
348        }
349        if self.horizontal {
350            return self.layout_horizontal(available);
351        }
352        let h = self.options.len() as f64 * ROW_H;
353        self.bounds = Rect::new(0.0, 0.0, available.width, h);
354        // `LEFT_INSET` shifts the circle inward; the label moves the
355        // same amount so the visual gap between dot and label is preserved.
356        let circle_extent = LEFT_INSET + DOT_R * 2.0;
357        let label_avail_w = (available.width - circle_extent - GAP).max(0.0);
358        let lx = circle_extent + GAP;
359        for (i, child) in self.children.iter_mut().enumerate() {
360            let s = child.layout(Size::new(label_avail_w, ROW_H));
361            // Position the label child in the row's vertical centre,
362            // offset right of the radio dot.  In Y-up the first row
363            // (i=0) sits at the TOP of the widget — see `row_center_y`.
364            let row_top_y = h - (i as f64) * ROW_H;
365            let cy = row_top_y - ROW_H * 0.5;
366            let ly = cy - s.height * 0.5;
367            child.set_bounds(Rect::new(lx, ly, s.width, s.height));
368        }
369        Size::new(available.width, h)
370    }
371
372    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
373        let v = ctx.visuals();
374        let h = self.bounds.height;
375
376        // Focus outline around whole widget — drawn JUST INSIDE bounds so
377        // the parent's clip_children_rect (defaults to widget bounds)
378        // doesn't chop the leftmost stroke pixel.
379        if self.focused {
380            ctx.set_stroke_color(v.accent_focus);
381            ctx.set_line_width(1.5);
382            ctx.begin_path();
383            ctx.rounded_rect(0.75, 0.75, self.bounds.width - 1.5, h - 1.5, 4.0);
384            ctx.stroke();
385        }
386
387        // Paint just the radio dot for each row — the row's text is a
388        // real `Label` child that the framework recurses into after this
389        // method returns (positioned by `layout`).  Setting label colour
390        // here through the `set_label_color` Widget-trait method keeps
391        // the foreground theme-aware without rebuilding the Label.
392        let text_color = v.text_color;
393        for i in 0..self.options.len() {
394            // Dot centre differs by layout mode: vertical rows use the fixed
395            // left column; horizontal-wrap uses the cached per-item centre.
396            let (dot_cx, cy) = if self.horizontal {
397                match self.hwrap_items.get(i) {
398                    Some(&(cx, cy, _, _, _)) => (cx, cy),
399                    None => continue,
400                }
401            } else {
402                (LEFT_INSET + DOT_R, self.row_center_y(i, h))
403            };
404            let checked = i == self.selected;
405            let hovered = self.hovered == Some(i);
406
407            let border = if checked {
408                v.accent
409            } else if hovered {
410                v.widget_bg_hovered
411            } else {
412                v.widget_stroke
413            };
414            let bg = if checked { v.accent } else { v.widget_bg };
415
416            ctx.set_fill_color(bg);
417            ctx.begin_path();
418            ctx.circle(dot_cx, cy, DOT_R);
419            ctx.fill();
420
421            ctx.set_stroke_color(border);
422            ctx.set_line_width(1.5);
423            ctx.begin_path();
424            ctx.circle(dot_cx, cy, DOT_R);
425            ctx.stroke();
426
427            // Inner dot when checked — always widget_bg so it stays
428            // readable on the accent surface.
429            if checked {
430                ctx.set_fill_color(v.widget_bg);
431                ctx.begin_path();
432                ctx.circle(dot_cx, cy, DOT_R * 0.45);
433                ctx.fill();
434            }
435
436            if let Some(child) = self.children.get_mut(i) {
437                child.set_label_color(text_color);
438            }
439        }
440    }
441
442    fn on_event(&mut self, event: &Event) -> EventResult {
443        match event {
444            Event::MouseMove { pos } => {
445                let was = self.hovered;
446                self.hovered = if self.horizontal {
447                    self.hwrap_item_at(pos.x, pos.y)
448                } else {
449                    self.row_for_y(pos.y)
450                };
451                if was != self.hovered {
452                    crate::animation::request_draw();
453                    return EventResult::Consumed;
454                }
455                EventResult::Ignored
456            }
457            Event::MouseDown {
458                button: MouseButton::Left,
459                pos,
460                ..
461            } => {
462                let hit = if self.horizontal {
463                    self.hwrap_item_at(pos.x, pos.y)
464                } else {
465                    self.row_for_y(pos.y)
466                };
467                if let Some(i) = hit {
468                    let was = self.selected;
469                    self.selected = i;
470                    self.fire();
471                    if was != i {
472                        crate::animation::request_draw();
473                    }
474                    return EventResult::Consumed;
475                }
476                EventResult::Ignored
477            }
478            Event::KeyDown { key, .. } => {
479                let n = self.options.len();
480                let changed = match key {
481                    Key::ArrowUp | Key::ArrowLeft => {
482                        if self.selected > 0 {
483                            self.selected -= 1;
484                            true
485                        } else {
486                            false
487                        }
488                    }
489                    Key::ArrowDown | Key::ArrowRight => {
490                        if self.selected + 1 < n {
491                            self.selected += 1;
492                            true
493                        } else {
494                            false
495                        }
496                    }
497                    _ => false,
498                };
499                if changed {
500                    self.fire();
501                    crate::animation::request_draw();
502                    EventResult::Consumed
503                } else {
504                    EventResult::Ignored
505                }
506            }
507            Event::FocusGained => {
508                let was = self.focused;
509                self.focused = true;
510                if !was {
511                    crate::animation::request_draw();
512                    EventResult::Consumed
513                } else {
514                    EventResult::Ignored
515                }
516            }
517            Event::FocusLost => {
518                let was = self.focused;
519                self.focused = false;
520                if was {
521                    crate::animation::request_draw();
522                    EventResult::Consumed
523                } else {
524                    EventResult::Ignored
525                }
526            }
527            _ => EventResult::Ignored,
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::color::Color;
536    use crate::draw_ctx::{FillRule, GlPaint, LinearGradientPaint, RadialGradientPaint};
537    use crate::event::Modifiers;
538    use crate::geometry::Point;
539    use crate::text::TextMetrics;
540    use crate::theme::{current_visuals, set_visuals, Visuals};
541    use agg_rust::comp_op::CompOp;
542    use agg_rust::math_stroke::{LineCap, LineJoin};
543    use agg_rust::trans_affine::TransAffine;
544
545    const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
546
547    fn test_font() -> Arc<Font> {
548        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
549    }
550
551    /// Records the fill colour of every filled *circle* so a test can assert
552    /// how many radio dots are painted with the accent (selected) surface.
553    /// A circle is "filled" when `fill()` is called while the most recent
554    /// path primitive was `circle()`; we snapshot the current fill colour at
555    /// that moment. RadioGroup draws exactly one accent-filled outer circle —
556    /// the selected dot — plus per-dot strokes and a `widget_bg` inner dot.
557    struct CircleFillRecorder {
558        transform: TransAffine,
559        stack: Vec<TransAffine>,
560        fill_color: Color,
561        last_was_circle: bool,
562        filled_circles: Vec<Color>,
563    }
564
565    impl CircleFillRecorder {
566        fn new() -> Self {
567            Self {
568                transform: TransAffine::new(),
569                stack: Vec::new(),
570                fill_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
571                last_was_circle: false,
572                filled_circles: Vec::new(),
573            }
574        }
575    }
576
577    impl DrawCtx for CircleFillRecorder {
578        fn set_fill_color(&mut self, color: Color) {
579            self.fill_color = color;
580        }
581        fn set_stroke_color(&mut self, _color: Color) {}
582        fn set_fill_linear_gradient(&mut self, _gradient: LinearGradientPaint) {}
583        fn set_fill_radial_gradient(&mut self, _gradient: RadialGradientPaint) {}
584        fn set_line_width(&mut self, _w: f64) {}
585        fn set_line_join(&mut self, _join: LineJoin) {}
586        fn set_line_cap(&mut self, _cap: LineCap) {}
587        fn set_miter_limit(&mut self, _limit: f64) {}
588        fn set_line_dash(&mut self, _dashes: &[f64], _offset: f64) {}
589        fn set_blend_mode(&mut self, _mode: CompOp) {}
590        fn set_global_alpha(&mut self, _alpha: f64) {}
591        fn set_fill_rule(&mut self, _rule: FillRule) {}
592        fn set_font(&mut self, _font: Arc<Font>) {}
593        fn set_font_size(&mut self, _size: f64) {}
594        fn clip_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {}
595        fn reset_clip(&mut self) {}
596        fn clear(&mut self, _color: Color) {}
597        fn begin_path(&mut self) {
598            self.last_was_circle = false;
599        }
600        fn move_to(&mut self, _x: f64, _y: f64) {
601            self.last_was_circle = false;
602        }
603        fn line_to(&mut self, _x: f64, _y: f64) {
604            self.last_was_circle = false;
605        }
606        fn cubic_to(&mut self, _cx1: f64, _cy1: f64, _cx2: f64, _cy2: f64, _x: f64, _y: f64) {
607            self.last_was_circle = false;
608        }
609        fn quad_to(&mut self, _cx: f64, _cy: f64, _x: f64, _y: f64) {
610            self.last_was_circle = false;
611        }
612        fn arc_to(&mut self, _cx: f64, _cy: f64, _r: f64, _s: f64, _e: f64, _ccw: bool) {
613            self.last_was_circle = false;
614        }
615        fn circle(&mut self, _cx: f64, _cy: f64, _r: f64) {
616            self.last_was_circle = true;
617        }
618        fn rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64) {
619            self.last_was_circle = false;
620        }
621        fn rounded_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _r: f64) {
622            self.last_was_circle = false;
623        }
624        fn close_path(&mut self) {}
625        fn fill(&mut self) {
626            if self.last_was_circle {
627                self.filled_circles.push(self.fill_color);
628            }
629        }
630        fn stroke(&mut self) {}
631        fn fill_and_stroke(&mut self) {}
632        fn draw_triangles_aa(&mut self, _vertices: &[[f32; 3]], _indices: &[u32], _color: Color) {}
633        fn fill_text(&mut self, _text: &str, _x: f64, _y: f64) {}
634        fn fill_text_gsv(&mut self, _text: &str, _x: f64, _y: f64, _size: f64) {}
635        fn measure_text(&self, _text: &str) -> Option<TextMetrics> {
636            Some(TextMetrics {
637                width: 40.0,
638                ascent: 10.0,
639                descent: 3.0,
640                line_height: 16.0,
641            })
642        }
643        fn transform(&self) -> TransAffine {
644            self.transform
645        }
646        fn save(&mut self) {
647            self.stack.push(self.transform);
648        }
649        fn restore(&mut self) {
650            if let Some(t) = self.stack.pop() {
651                self.transform = t;
652            }
653        }
654        fn translate(&mut self, tx: f64, ty: f64) {
655            self.transform
656                .premultiply(&TransAffine::new_translation(tx, ty));
657        }
658        fn rotate(&mut self, radians: f64) {
659            self.transform
660                .premultiply(&TransAffine::new_rotation(radians));
661        }
662        fn scale(&mut self, sx: f64, sy: f64) {
663            self.transform.premultiply(&TransAffine::new_scaling(sx, sy));
664        }
665        fn set_transform(&mut self, m: TransAffine) {
666            self.transform = m;
667        }
668        fn reset_transform(&mut self) {
669            self.transform = TransAffine::new();
670        }
671        fn gl_paint(&mut self, _screen_rect: Rect, _painter: &mut dyn GlPaint) {}
672    }
673
674    /// Paint the group and count how many outer dots are filled with the
675    /// theme accent (i.e. painted as "selected").
676    fn accent_dot_count(g: &mut RadioGroup) -> usize {
677        let v = current_visuals();
678        let mut ctx = CircleFillRecorder::new();
679        g.paint(&mut ctx);
680        ctx.filled_circles
681            .iter()
682            .filter(|c| **c == v.accent)
683            .count()
684    }
685
686    /// Regression: exactly ONE radio dot may be painted with the accent
687    /// (selected) surface — the currently-selected option. This pins the
688    /// "every option looks selected" report: neither hover state nor the
689    /// horizontal-wrap layout may leak the accent fill onto other dots.
690    #[test]
691    fn exactly_one_dot_painted_selected() {
692        set_visuals(Visuals::dark());
693        for sel in 0..3 {
694            let mut g = RadioGroup::new(vec!["First", "Second", "Third"], sel, test_font());
695            g.layout(Size::new(200.0, 0.0));
696            assert_eq!(
697                accent_dot_count(&mut g),
698                1,
699                "vertical group with selected={sel} must paint exactly one accent dot"
700            );
701        }
702    }
703
704    /// The same invariant must hold in horizontal-wrap mode, and hovering a
705    /// *different* option must not add a second accent dot.
706    #[test]
707    fn hover_does_not_add_a_second_selected_dot() {
708        set_visuals(Visuals::dark());
709        let mut g = RadioGroup::new(vec!["First", "Second", "Third"], 0, test_font())
710            .with_horizontal_wrap(true);
711        g.layout(Size::new(400.0, 0.0));
712        // Hover the second item's centre.
713        let (cx, cy, _, _, _) = g.hwrap_items[1];
714        let _ = g.on_event(&Event::MouseMove {
715            pos: Point::new(cx, cy),
716        });
717        assert_eq!(
718            accent_dot_count(&mut g),
719            1,
720            "hovering an unselected option must not paint it as selected"
721        );
722    }
723
724    fn empty_group(n: usize) -> RadioGroup {
725        let opts: Vec<String> = (0..n).map(|_| String::new()).collect();
726        RadioGroup::new(opts, 0, test_font()).with_horizontal_wrap(true)
727    }
728
729    #[test]
730    fn empty_labels_wrap_onto_multiple_rows() {
731        let mut g = empty_group(64);
732        // Narrow width forces many rows; a single dot is ~16px + spacing.
733        let size = g.layout(Size::new(80.0, 0.0));
734        assert!(size.height > ROW_H, "64 dots at 80px wide must wrap");
735        assert_eq!(g.hwrap_items.len(), 64);
736        // Row count is height / ROW_H; must be > 1 for a wrapped layout.
737        let rows = (size.height / ROW_H).round() as usize;
738        assert!(rows > 1, "expected multiple rows, got {rows}");
739    }
740
741    #[test]
742    fn single_wide_row_does_not_wrap() {
743        let mut g = empty_group(4);
744        let size = g.layout(Size::new(2000.0, 0.0));
745        assert_eq!(
746            (size.height / ROW_H).round() as usize,
747            1,
748            "4 dots in 2000px must stay on one row"
749        );
750    }
751
752    #[test]
753    fn click_selects_the_hit_dot() {
754        let mut g = empty_group(8);
755        g.layout(Size::new(2000.0, 0.0)); // one row, all dots side by side
756        // Aim at the 3rd dot's centre.
757        let (cx, cy, _, _, _) = g.hwrap_items[2];
758        let down = Event::MouseDown {
759            pos: Point::new(cx, cy),
760            button: MouseButton::Left,
761            modifiers: Modifiers::default(),
762        };
763        let r = g.on_event(&down);
764        assert_eq!(r, EventResult::Consumed);
765        assert_eq!(g.selected(), 2);
766    }
767
768    #[test]
769    fn vertical_mode_is_unaffected() {
770        let mut g = RadioGroup::new(vec!["a", "b", "c"], 0, test_font());
771        let size = g.layout(Size::new(200.0, 0.0));
772        assert_eq!(size.height, 3.0 * ROW_H);
773        assert!(g.hwrap_items.is_empty());
774    }
775}