Skip to main content

presentar_widgets/
radio_group.rs

1//! `RadioGroup` widget for selecting one option from a list.
2
3use presentar_core::{
4    widget::{
5        AccessibleRole, Brick, BrickAssertion, BrickBudget, BrickVerification, LayoutResult,
6        TextStyle,
7    },
8    Canvas, Color, Constraints, Event, MouseButton, Point, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12use std::time::Duration;
13
14/// A single radio option.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct RadioOption {
17    /// Option value
18    pub value: String,
19    /// Display label
20    pub label: String,
21    /// Whether the option is disabled
22    pub disabled: bool,
23}
24
25impl RadioOption {
26    /// Create a new radio option.
27    #[must_use]
28    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
29        Self {
30            value: value.into(),
31            label: label.into(),
32            disabled: false,
33        }
34    }
35
36    /// Set the option as disabled.
37    #[must_use]
38    pub const fn disabled(mut self) -> Self {
39        self.disabled = true;
40        self
41    }
42}
43
44/// Message emitted when radio selection changes.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RadioChanged {
47    /// The newly selected value
48    pub value: String,
49    /// Index of the selected option
50    pub index: usize,
51}
52
53/// Radio group orientation.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
55pub enum RadioOrientation {
56    /// Vertical layout (default)
57    #[default]
58    Vertical,
59    /// Horizontal layout
60    Horizontal,
61}
62
63/// `RadioGroup` widget for selecting one option from multiple choices.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct RadioGroup {
66    /// Radio options
67    options: Vec<RadioOption>,
68    /// Currently selected index
69    selected: Option<usize>,
70    /// Layout orientation
71    orientation: RadioOrientation,
72    /// Spacing between options
73    spacing: f32,
74    /// Radio button size
75    radio_size: f32,
76    /// Gap between radio and label
77    label_gap: f32,
78    /// Unselected border color
79    border_color: Color,
80    /// Selected fill color
81    fill_color: Color,
82    /// Label text color
83    label_color: Color,
84    /// Disabled text color
85    disabled_color: Color,
86    /// Accessible name
87    accessible_name_value: Option<String>,
88    /// Test ID
89    test_id_value: Option<String>,
90    /// Cached bounds
91    #[serde(skip)]
92    bounds: Rect,
93}
94
95impl Default for RadioGroup {
96    fn default() -> Self {
97        Self {
98            options: Vec::new(),
99            selected: None,
100            orientation: RadioOrientation::Vertical,
101            spacing: 8.0,
102            radio_size: 20.0,
103            label_gap: 8.0,
104            border_color: Color::new(0.6, 0.6, 0.6, 1.0),
105            fill_color: Color::new(0.2, 0.47, 0.96, 1.0),
106            label_color: Color::new(0.1, 0.1, 0.1, 1.0),
107            disabled_color: Color::new(0.6, 0.6, 0.6, 1.0),
108            accessible_name_value: None,
109            test_id_value: None,
110            bounds: Rect::default(),
111        }
112    }
113}
114
115impl RadioGroup {
116    /// Create a new radio group.
117    #[must_use]
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Add an option.
123    #[must_use]
124    pub fn option(mut self, option: RadioOption) -> Self {
125        self.options.push(option);
126        self
127    }
128
129    /// Add multiple options.
130    #[must_use]
131    pub fn options(mut self, options: impl IntoIterator<Item = RadioOption>) -> Self {
132        self.options.extend(options);
133        self
134    }
135
136    /// Set selected value.
137    #[must_use]
138    pub fn selected(mut self, value: &str) -> Self {
139        self.selected = self.options.iter().position(|o| o.value == value);
140        self
141    }
142
143    /// Set selected index.
144    #[must_use]
145    pub fn selected_index(mut self, index: usize) -> Self {
146        if index < self.options.len() {
147            self.selected = Some(index);
148        }
149        self
150    }
151
152    /// Set orientation.
153    #[must_use]
154    pub const fn orientation(mut self, orientation: RadioOrientation) -> Self {
155        self.orientation = orientation;
156        self
157    }
158
159    /// Set spacing between options.
160    #[must_use]
161    pub fn spacing(mut self, spacing: f32) -> Self {
162        self.spacing = spacing.max(0.0);
163        self
164    }
165
166    /// Set radio button size.
167    #[must_use]
168    pub fn radio_size(mut self, size: f32) -> Self {
169        self.radio_size = size.max(12.0);
170        self
171    }
172
173    /// Set gap between radio and label.
174    #[must_use]
175    pub fn label_gap(mut self, gap: f32) -> Self {
176        self.label_gap = gap.max(0.0);
177        self
178    }
179
180    /// Set border color.
181    #[must_use]
182    pub const fn border_color(mut self, color: Color) -> Self {
183        self.border_color = color;
184        self
185    }
186
187    /// Set fill color for selected state.
188    #[must_use]
189    pub const fn fill_color(mut self, color: Color) -> Self {
190        self.fill_color = color;
191        self
192    }
193
194    /// Set label text color.
195    #[must_use]
196    pub const fn label_color(mut self, color: Color) -> Self {
197        self.label_color = color;
198        self
199    }
200
201    /// Set accessible name.
202    #[must_use]
203    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
204        self.accessible_name_value = Some(name.into());
205        self
206    }
207
208    /// Set test ID.
209    #[must_use]
210    pub fn test_id(mut self, id: impl Into<String>) -> Self {
211        self.test_id_value = Some(id.into());
212        self
213    }
214
215    /// Get the options.
216    #[must_use]
217    pub fn get_options(&self) -> &[RadioOption] {
218        &self.options
219    }
220
221    /// Get selected value.
222    #[must_use]
223    pub fn get_selected(&self) -> Option<&str> {
224        self.selected
225            .and_then(|i| self.options.get(i))
226            .map(|o| o.value.as_str())
227    }
228
229    /// Get selected index.
230    #[must_use]
231    pub const fn get_selected_index(&self) -> Option<usize> {
232        self.selected
233    }
234
235    /// Get selected option.
236    #[must_use]
237    pub fn get_selected_option(&self) -> Option<&RadioOption> {
238        self.selected.and_then(|i| self.options.get(i))
239    }
240
241    /// Check if a value is selected.
242    #[must_use]
243    pub fn is_selected(&self, value: &str) -> bool {
244        self.get_selected() == Some(value)
245    }
246
247    /// Check if an index is selected.
248    #[must_use]
249    pub fn is_index_selected(&self, index: usize) -> bool {
250        self.selected == Some(index)
251    }
252
253    /// Check if any option is selected.
254    #[must_use]
255    pub const fn has_selection(&self) -> bool {
256        self.selected.is_some()
257    }
258
259    /// Get option count.
260    #[must_use]
261    pub fn option_count(&self) -> usize {
262        self.options.len()
263    }
264
265    /// Check if empty.
266    #[must_use]
267    pub fn is_empty(&self) -> bool {
268        self.options.is_empty()
269    }
270
271    /// Set selection by value (mutable).
272    pub fn set_selected(&mut self, value: &str) {
273        if let Some(index) = self.options.iter().position(|o| o.value == value) {
274            if !self.options[index].disabled {
275                self.selected = Some(index);
276            }
277        }
278    }
279
280    /// Set selection by index (mutable).
281    pub fn set_selected_index(&mut self, index: usize) {
282        if index < self.options.len() && !self.options[index].disabled {
283            self.selected = Some(index);
284        }
285    }
286
287    /// Clear selection.
288    pub fn clear_selection(&mut self) {
289        self.selected = None;
290    }
291
292    /// Select next option.
293    pub fn select_next(&mut self) {
294        if self.options.is_empty() {
295            return;
296        }
297        let start = self.selected.map_or(0, |i| i + 1);
298        for offset in 0..self.options.len() {
299            let idx = (start + offset) % self.options.len();
300            if !self.options[idx].disabled {
301                self.selected = Some(idx);
302                return;
303            }
304        }
305    }
306
307    /// Select previous option.
308    pub fn select_prev(&mut self) {
309        if self.options.is_empty() {
310            return;
311        }
312        let start = self.selected.map_or(self.options.len() - 1, |i| {
313            if i == 0 {
314                self.options.len() - 1
315            } else {
316                i - 1
317            }
318        });
319        for offset in 0..self.options.len() {
320            let idx = if start >= offset {
321                start - offset
322            } else {
323                self.options.len() - (offset - start)
324            };
325            if !self.options[idx].disabled {
326                self.selected = Some(idx);
327                return;
328            }
329        }
330    }
331
332    /// Calculate item size (radio + gap + label).
333    fn item_size(&self) -> Size {
334        // Approximate label width
335        let label_width = 100.0;
336        Size::new(
337            self.radio_size + self.label_gap + label_width,
338            self.radio_size.max(20.0),
339        )
340    }
341
342    /// Get rect for option at index.
343    fn option_rect(&self, index: usize) -> Rect {
344        let item = self.item_size();
345        match self.orientation {
346            RadioOrientation::Vertical => {
347                let y = (index as f32).mul_add(item.height + self.spacing, self.bounds.y);
348                Rect::new(self.bounds.x, y, self.bounds.width, item.height)
349            }
350            RadioOrientation::Horizontal => {
351                let x = (index as f32).mul_add(item.width + self.spacing, self.bounds.x);
352                Rect::new(x, self.bounds.y, item.width, item.height)
353            }
354        }
355    }
356
357    /// Find option at point.
358    fn option_at_point(&self, x: f32, y: f32) -> Option<usize> {
359        for (i, _) in self.options.iter().enumerate() {
360            let rect = self.option_rect(i);
361            if x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height {
362                return Some(i);
363            }
364        }
365        None
366    }
367}
368
369impl Widget for RadioGroup {
370    fn type_id(&self) -> TypeId {
371        TypeId::of::<Self>()
372    }
373
374    fn measure(&self, constraints: Constraints) -> Size {
375        let item = self.item_size();
376        let count = self.options.len() as f32;
377
378        let preferred = match self.orientation {
379            RadioOrientation::Vertical => {
380                let total_spacing = if count > 1.0 {
381                    self.spacing * (count - 1.0)
382                } else {
383                    0.0
384                };
385                Size::new(item.width, count.mul_add(item.height, total_spacing))
386            }
387            RadioOrientation::Horizontal => {
388                let total_spacing = if count > 1.0 {
389                    self.spacing * (count - 1.0)
390                } else {
391                    0.0
392                };
393                Size::new(count.mul_add(item.width, total_spacing), item.height)
394            }
395        };
396
397        constraints.constrain(preferred)
398    }
399
400    fn layout(&mut self, bounds: Rect) -> LayoutResult {
401        self.bounds = bounds;
402        LayoutResult {
403            size: bounds.size(),
404        }
405    }
406
407    fn paint(&self, canvas: &mut dyn Canvas) {
408        for (i, option) in self.options.iter().enumerate() {
409            let rect = self.option_rect(i);
410            let is_selected = self.selected == Some(i);
411
412            // Radio button circle position
413            let cx = rect.x + self.radio_size / 2.0;
414            let cy = rect.y + rect.height / 2.0;
415            let radius = self.radio_size / 2.0;
416
417            // Draw outer circle (border)
418            let border_rect = Rect::new(cx - radius, cy - radius, self.radio_size, self.radio_size);
419            let border_color = if option.disabled {
420                self.disabled_color
421            } else if is_selected {
422                self.fill_color
423            } else {
424                self.border_color
425            };
426            canvas.stroke_rect(border_rect, border_color, 2.0);
427
428            // Draw inner circle if selected
429            if is_selected {
430                let inner_radius = radius * 0.5;
431                let inner_rect = Rect::new(
432                    cx - inner_radius,
433                    cy - inner_radius,
434                    inner_radius * 2.0,
435                    inner_radius * 2.0,
436                );
437                canvas.fill_rect(inner_rect, self.fill_color);
438            }
439
440            // Draw label
441            let text_color = if option.disabled {
442                self.disabled_color
443            } else {
444                self.label_color
445            };
446
447            let text_style = TextStyle {
448                size: 14.0,
449                color: text_color,
450                ..TextStyle::default()
451            };
452
453            canvas.draw_text(
454                &option.label,
455                Point::new(rect.x + self.radio_size + self.label_gap, cy),
456                &text_style,
457            );
458        }
459    }
460
461    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
462        if let Event::MouseDown {
463            position,
464            button: MouseButton::Left,
465        } = event
466        {
467            if let Some(index) = self.option_at_point(position.x, position.y) {
468                if !self.options[index].disabled && self.selected != Some(index) {
469                    self.selected = Some(index);
470                    return Some(Box::new(RadioChanged {
471                        value: self.options[index].value.clone(),
472                        index,
473                    }));
474                }
475            }
476        }
477        None
478    }
479
480    fn children(&self) -> &[Box<dyn Widget>] {
481        &[]
482    }
483
484    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
485        &mut []
486    }
487
488    fn is_interactive(&self) -> bool {
489        !self.options.is_empty()
490    }
491
492    fn is_focusable(&self) -> bool {
493        !self.options.is_empty()
494    }
495
496    fn accessible_name(&self) -> Option<&str> {
497        self.accessible_name_value.as_deref()
498    }
499
500    fn accessible_role(&self) -> AccessibleRole {
501        AccessibleRole::RadioGroup
502    }
503
504    fn test_id(&self) -> Option<&str> {
505        self.test_id_value.as_deref()
506    }
507}
508
509// PROBAR-SPEC-009: Brick Architecture - Tests define interface
510impl Brick for RadioGroup {
511    fn brick_name(&self) -> &'static str {
512        "RadioGroup"
513    }
514
515    fn assertions(&self) -> &[BrickAssertion] {
516        &[BrickAssertion::MaxLatencyMs(16)]
517    }
518
519    fn budget(&self) -> BrickBudget {
520        BrickBudget::uniform(16)
521    }
522
523    fn verify(&self) -> BrickVerification {
524        BrickVerification {
525            passed: self.assertions().to_vec(),
526            failed: vec![],
527            verification_time: Duration::from_micros(10),
528        }
529    }
530
531    fn to_html(&self) -> String {
532        r#"<div class="brick-radiogroup"></div>"#.to_string()
533    }
534
535    fn to_css(&self) -> String {
536        ".brick-radiogroup { display: block; }".to_string()
537    }
538}
539
540#[cfg(test)]
541#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
542mod tests {
543    use super::*;
544
545    // ===== RadioOption Tests =====
546
547    #[test]
548    fn test_radio_option_new() {
549        let opt = RadioOption::new("val", "Label");
550        assert_eq!(opt.value, "val");
551        assert_eq!(opt.label, "Label");
552        assert!(!opt.disabled);
553    }
554
555    #[test]
556    fn test_radio_option_disabled() {
557        let opt = RadioOption::new("val", "Label").disabled();
558        assert!(opt.disabled);
559    }
560
561    #[test]
562    fn test_radio_option_equality() {
563        let opt1 = RadioOption::new("a", "A");
564        let opt2 = RadioOption::new("a", "A");
565        let opt3 = RadioOption::new("b", "B");
566        assert_eq!(opt1, opt2);
567        assert_ne!(opt1, opt3);
568    }
569
570    // ===== RadioChanged Tests =====
571
572    #[test]
573    fn test_radio_changed() {
574        let msg = RadioChanged {
575            value: "option1".to_string(),
576            index: 1,
577        };
578        assert_eq!(msg.value, "option1");
579        assert_eq!(msg.index, 1);
580    }
581
582    // ===== RadioOrientation Tests =====
583
584    #[test]
585    fn test_radio_orientation_default() {
586        assert_eq!(RadioOrientation::default(), RadioOrientation::Vertical);
587    }
588
589    // ===== RadioGroup Construction Tests =====
590
591    #[test]
592    fn test_radio_group_new() {
593        let group = RadioGroup::new();
594        assert!(group.is_empty());
595        assert_eq!(group.option_count(), 0);
596        assert!(!group.has_selection());
597    }
598
599    #[test]
600    fn test_radio_group_builder() {
601        let group = RadioGroup::new()
602            .option(RadioOption::new("a", "Option A"))
603            .option(RadioOption::new("b", "Option B"))
604            .option(RadioOption::new("c", "Option C"))
605            .selected("b")
606            .orientation(RadioOrientation::Horizontal)
607            .spacing(12.0)
608            .radio_size(24.0)
609            .label_gap(10.0)
610            .accessible_name("Choose option")
611            .test_id("radio-test");
612
613        assert_eq!(group.option_count(), 3);
614        assert_eq!(group.get_selected(), Some("b"));
615        assert_eq!(group.get_selected_index(), Some(1));
616        assert_eq!(Widget::accessible_name(&group), Some("Choose option"));
617        assert_eq!(Widget::test_id(&group), Some("radio-test"));
618    }
619
620    #[test]
621    fn test_radio_group_options_iter() {
622        let opts = vec![RadioOption::new("x", "X"), RadioOption::new("y", "Y")];
623        let group = RadioGroup::new().options(opts);
624        assert_eq!(group.option_count(), 2);
625    }
626
627    #[test]
628    fn test_radio_group_selected_index() {
629        let group = RadioGroup::new()
630            .option(RadioOption::new("a", "A"))
631            .option(RadioOption::new("b", "B"))
632            .selected_index(1);
633
634        assert_eq!(group.get_selected(), Some("b"));
635    }
636
637    #[test]
638    fn test_radio_group_selected_not_found() {
639        let group = RadioGroup::new()
640            .option(RadioOption::new("a", "A"))
641            .selected("nonexistent");
642
643        assert!(!group.has_selection());
644    }
645
646    // ===== Selection Tests =====
647
648    #[test]
649    fn test_radio_group_get_selected_option() {
650        let group = RadioGroup::new()
651            .option(RadioOption::new("first", "First"))
652            .option(RadioOption::new("second", "Second"))
653            .selected("second");
654
655        let opt = group.get_selected_option().unwrap();
656        assert_eq!(opt.value, "second");
657        assert_eq!(opt.label, "Second");
658    }
659
660    #[test]
661    fn test_radio_group_is_selected() {
662        let group = RadioGroup::new()
663            .option(RadioOption::new("a", "A"))
664            .option(RadioOption::new("b", "B"))
665            .selected("a");
666
667        assert!(group.is_selected("a"));
668        assert!(!group.is_selected("b"));
669    }
670
671    #[test]
672    fn test_radio_group_is_index_selected() {
673        let group = RadioGroup::new()
674            .option(RadioOption::new("a", "A"))
675            .option(RadioOption::new("b", "B"))
676            .selected_index(0);
677
678        assert!(group.is_index_selected(0));
679        assert!(!group.is_index_selected(1));
680    }
681
682    // ===== Mutable Selection Tests =====
683
684    #[test]
685    fn test_radio_group_set_selected() {
686        let mut group = RadioGroup::new()
687            .option(RadioOption::new("a", "A"))
688            .option(RadioOption::new("b", "B"));
689
690        group.set_selected("b");
691        assert_eq!(group.get_selected(), Some("b"));
692    }
693
694    #[test]
695    fn test_radio_group_set_selected_disabled() {
696        let mut group = RadioGroup::new()
697            .option(RadioOption::new("a", "A"))
698            .option(RadioOption::new("b", "B").disabled());
699
700        group.set_selected("b");
701        assert!(!group.has_selection()); // Should not select disabled
702    }
703
704    #[test]
705    fn test_radio_group_set_selected_index() {
706        let mut group = RadioGroup::new()
707            .option(RadioOption::new("a", "A"))
708            .option(RadioOption::new("b", "B"));
709
710        group.set_selected_index(1);
711        assert_eq!(group.get_selected_index(), Some(1));
712    }
713
714    #[test]
715    fn test_radio_group_set_selected_index_out_of_bounds() {
716        let mut group = RadioGroup::new().option(RadioOption::new("a", "A"));
717
718        group.set_selected_index(10);
719        assert!(!group.has_selection());
720    }
721
722    #[test]
723    fn test_radio_group_clear_selection() {
724        let mut group = RadioGroup::new()
725            .option(RadioOption::new("a", "A"))
726            .selected("a");
727
728        assert!(group.has_selection());
729        group.clear_selection();
730        assert!(!group.has_selection());
731    }
732
733    // ===== Navigation Tests =====
734
735    #[test]
736    fn test_radio_group_select_next() {
737        let mut group = RadioGroup::new()
738            .option(RadioOption::new("a", "A"))
739            .option(RadioOption::new("b", "B"))
740            .option(RadioOption::new("c", "C"))
741            .selected_index(0);
742
743        group.select_next();
744        assert_eq!(group.get_selected_index(), Some(1));
745
746        group.select_next();
747        assert_eq!(group.get_selected_index(), Some(2));
748
749        group.select_next(); // Wrap around
750        assert_eq!(group.get_selected_index(), Some(0));
751    }
752
753    #[test]
754    fn test_radio_group_select_next_skip_disabled() {
755        let mut group = RadioGroup::new()
756            .option(RadioOption::new("a", "A"))
757            .option(RadioOption::new("b", "B").disabled())
758            .option(RadioOption::new("c", "C"))
759            .selected_index(0);
760
761        group.select_next();
762        assert_eq!(group.get_selected_index(), Some(2));
763    }
764
765    #[test]
766    fn test_radio_group_select_next_no_selection() {
767        let mut group = RadioGroup::new()
768            .option(RadioOption::new("a", "A"))
769            .option(RadioOption::new("b", "B"));
770
771        group.select_next();
772        assert_eq!(group.get_selected_index(), Some(0));
773    }
774
775    #[test]
776    fn test_radio_group_select_prev() {
777        let mut group = RadioGroup::new()
778            .option(RadioOption::new("a", "A"))
779            .option(RadioOption::new("b", "B"))
780            .option(RadioOption::new("c", "C"))
781            .selected_index(2);
782
783        group.select_prev();
784        assert_eq!(group.get_selected_index(), Some(1));
785
786        group.select_prev();
787        assert_eq!(group.get_selected_index(), Some(0));
788
789        group.select_prev(); // Wrap around
790        assert_eq!(group.get_selected_index(), Some(2));
791    }
792
793    #[test]
794    fn test_radio_group_select_prev_skip_disabled() {
795        let mut group = RadioGroup::new()
796            .option(RadioOption::new("a", "A"))
797            .option(RadioOption::new("b", "B").disabled())
798            .option(RadioOption::new("c", "C"))
799            .selected_index(2);
800
801        group.select_prev();
802        assert_eq!(group.get_selected_index(), Some(0));
803    }
804
805    // ===== Dimension Tests =====
806
807    #[test]
808    fn test_radio_group_spacing_min() {
809        let group = RadioGroup::new().spacing(-5.0);
810        assert_eq!(group.spacing, 0.0);
811    }
812
813    #[test]
814    fn test_radio_group_radio_size_min() {
815        let group = RadioGroup::new().radio_size(5.0);
816        assert_eq!(group.radio_size, 12.0);
817    }
818
819    #[test]
820    fn test_radio_group_label_gap_min() {
821        let group = RadioGroup::new().label_gap(-5.0);
822        assert_eq!(group.label_gap, 0.0);
823    }
824
825    // ===== Widget Trait Tests =====
826
827    #[test]
828    fn test_radio_group_type_id() {
829        let group = RadioGroup::new();
830        assert_eq!(Widget::type_id(&group), TypeId::of::<RadioGroup>());
831    }
832
833    #[test]
834    fn test_radio_group_measure_vertical() {
835        let group = RadioGroup::new()
836            .option(RadioOption::new("a", "A"))
837            .option(RadioOption::new("b", "B"))
838            .option(RadioOption::new("c", "C"))
839            .orientation(RadioOrientation::Vertical)
840            .radio_size(20.0)
841            .spacing(8.0);
842
843        let size = group.measure(Constraints::loose(Size::new(500.0, 500.0)));
844        // 3 items * 20 height + 2 * 8 spacing = 60 + 16 = 76
845        assert!(size.height > 0.0);
846        assert!(size.width > 0.0);
847    }
848
849    #[test]
850    fn test_radio_group_measure_horizontal() {
851        let group = RadioGroup::new()
852            .option(RadioOption::new("a", "A"))
853            .option(RadioOption::new("b", "B"))
854            .orientation(RadioOrientation::Horizontal)
855            .spacing(8.0);
856
857        let size = group.measure(Constraints::loose(Size::new(500.0, 500.0)));
858        assert!(size.width > 0.0);
859        assert!(size.height > 0.0);
860    }
861
862    #[test]
863    fn test_radio_group_layout() {
864        let mut group = RadioGroup::new().option(RadioOption::new("a", "A"));
865        let bounds = Rect::new(10.0, 20.0, 200.0, 100.0);
866        let result = group.layout(bounds);
867        assert_eq!(result.size, Size::new(200.0, 100.0));
868        assert_eq!(group.bounds, bounds);
869    }
870
871    #[test]
872    fn test_radio_group_children() {
873        let group = RadioGroup::new();
874        assert!(group.children().is_empty());
875    }
876
877    #[test]
878    fn test_radio_group_is_interactive() {
879        let group = RadioGroup::new();
880        assert!(!group.is_interactive()); // Empty
881
882        let group = RadioGroup::new().option(RadioOption::new("a", "A"));
883        assert!(group.is_interactive());
884    }
885
886    #[test]
887    fn test_radio_group_is_focusable() {
888        let group = RadioGroup::new();
889        assert!(!group.is_focusable()); // Empty
890
891        let group = RadioGroup::new().option(RadioOption::new("a", "A"));
892        assert!(group.is_focusable());
893    }
894
895    #[test]
896    fn test_radio_group_accessible_role() {
897        let group = RadioGroup::new();
898        assert_eq!(group.accessible_role(), AccessibleRole::RadioGroup);
899    }
900
901    #[test]
902    fn test_radio_group_accessible_name() {
903        let group = RadioGroup::new().accessible_name("Select size");
904        assert_eq!(Widget::accessible_name(&group), Some("Select size"));
905    }
906
907    #[test]
908    fn test_radio_group_test_id() {
909        let group = RadioGroup::new().test_id("size-radio");
910        assert_eq!(Widget::test_id(&group), Some("size-radio"));
911    }
912
913    // ===== Event Tests =====
914
915    #[test]
916    fn test_radio_group_click_selects() {
917        let mut group = RadioGroup::new()
918            .option(RadioOption::new("a", "A"))
919            .option(RadioOption::new("b", "B"))
920            .radio_size(20.0)
921            .spacing(8.0);
922        group.bounds = Rect::new(0.0, 0.0, 200.0, 56.0);
923
924        // Click on second option (y = 28 + some offset)
925        let event = Event::MouseDown {
926            position: Point::new(10.0, 38.0),
927            button: MouseButton::Left,
928        };
929
930        let result = group.event(&event);
931        assert!(result.is_some());
932        assert_eq!(group.get_selected_index(), Some(1));
933
934        let msg = result.unwrap().downcast::<RadioChanged>().unwrap();
935        assert_eq!(msg.value, "b");
936        assert_eq!(msg.index, 1);
937    }
938
939    #[test]
940    fn test_radio_group_click_disabled_no_change() {
941        let mut group = RadioGroup::new()
942            .option(RadioOption::new("a", "A"))
943            .option(RadioOption::new("b", "B").disabled())
944            .radio_size(20.0)
945            .spacing(8.0);
946        group.bounds = Rect::new(0.0, 0.0, 200.0, 56.0);
947
948        // Click on disabled option
949        let event = Event::MouseDown {
950            position: Point::new(10.0, 38.0),
951            button: MouseButton::Left,
952        };
953
954        let result = group.event(&event);
955        assert!(result.is_none());
956        assert!(!group.has_selection());
957    }
958
959    #[test]
960    fn test_radio_group_click_same_no_event() {
961        let mut group = RadioGroup::new()
962            .option(RadioOption::new("a", "A"))
963            .option(RadioOption::new("b", "B"))
964            .selected_index(0)
965            .radio_size(20.0);
966        group.bounds = Rect::new(0.0, 0.0, 200.0, 56.0);
967
968        // Click on already selected option
969        let event = Event::MouseDown {
970            position: Point::new(10.0, 10.0),
971            button: MouseButton::Left,
972        };
973
974        let result = group.event(&event);
975        assert!(result.is_none());
976    }
977
978    // ===== Color Tests =====
979
980    #[test]
981    fn test_radio_group_colors() {
982        let group = RadioGroup::new()
983            .border_color(Color::RED)
984            .fill_color(Color::GREEN)
985            .label_color(Color::BLUE);
986
987        assert_eq!(group.border_color, Color::RED);
988        assert_eq!(group.fill_color, Color::GREEN);
989        assert_eq!(group.label_color, Color::BLUE);
990    }
991
992    #[test]
993    fn test_radio_group_right_click_no_select() {
994        let mut group = RadioGroup::new()
995            .option(RadioOption::new("a", "A"))
996            .radio_size(20.0);
997        group.bounds = Rect::new(0.0, 0.0, 200.0, 28.0);
998
999        let result = group.event(&Event::MouseDown {
1000            position: Point::new(10.0, 10.0),
1001            button: MouseButton::Right,
1002        });
1003        assert!(group.selected.is_none());
1004        assert!(result.is_none());
1005    }
1006
1007    #[test]
1008    fn test_radio_group_click_outside_no_select() {
1009        let mut group = RadioGroup::new()
1010            .option(RadioOption::new("a", "A"))
1011            .radio_size(20.0);
1012        group.bounds = Rect::new(0.0, 0.0, 200.0, 28.0);
1013
1014        let result = group.event(&Event::MouseDown {
1015            position: Point::new(10.0, 100.0),
1016            button: MouseButton::Left,
1017        });
1018        assert!(group.selected.is_none());
1019        assert!(result.is_none());
1020    }
1021
1022    #[test]
1023    fn test_radio_group_click_with_offset_bounds() {
1024        let mut group = RadioGroup::new()
1025            .option(RadioOption::new("a", "A"))
1026            .option(RadioOption::new("b", "B"))
1027            .radio_size(20.0)
1028            .spacing(8.0);
1029        group.bounds = Rect::new(50.0, 100.0, 200.0, 56.0);
1030
1031        let result = group.event(&Event::MouseDown {
1032            position: Point::new(60.0, 138.0), // Second option
1033            button: MouseButton::Left,
1034        });
1035        assert_eq!(group.selected, Some(1));
1036        assert!(result.is_some());
1037    }
1038}