Skip to main content

frus_widgets/
radio.rs

1//! [`RadioGroup`]: a group of radio buttons, with one option selected.
2
3use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4
5use crate::widgetstate::{WidgetState, WidgetStates};
6use frus_layout::{Dimension, FlexDirection, Style};
7
8use crate::disabled::disabled_content;
9use crate::interaction::Status;
10use crate::theme::{TapTarget, Theme};
11use crate::widget::Widget;
12
13/// The ring's radius, to the middle of its stroke (`radio.dart:31`).
14const OUTER: f32 = 8.0;
15/// The dot's radius once chosen (`radio.dart:32`).
16const INNER: f32 = 4.5;
17/// The ring's stroke.
18const STROKE: f32 = 2.0;
19/// How long the dot takes to grow (the reference's toggle animation).
20const TOGGLE_SECONDS: f32 = 0.2;
21/// The label's size beside a radio, where nothing says otherwise.
22const LABEL_SIZE: f32 = 18.0;
23
24/// One radio option, internal to the group.
25/// **One radio button.**
26///
27/// It existed before this, as a private `Radio` a [`RadioGroup`] built for each of
28/// its labels, and there was no way to have one on its own. The reference's `Radio` is a
29/// widget in its own right — which is what lets a radio sit in a list row, in a table
30/// cell, or anywhere else the group's fixed column of labels is the wrong shape.
31///
32/// ```
33/// # use frus_widgets::Radio;
34/// # #[derive(Clone)] enum Msg { Pick }
35/// Radio::<Msg>::new(true).label("Every day").on_select(Msg::Pick);
36/// ```
37///
38/// A radio does **not** know how to turn itself off: the reference's takes a value and a
39/// group value and reports the value it stands for, and turning one off is choosing
40/// another. So this reports one message when pressed and says nothing about what the
41/// answer becomes.
42pub struct Radio<Msg = crate::callback::Callback> {
43    label: String,
44    selected: bool,
45    size: f32,
46    /// The group's colours, handed down with everything else it decides.
47    colors: RadioColors,
48    /// The group's availability, handed down. An option that stayed live under a disabled
49    /// group would be the whole group, since a group is only ever its options.
50    enabled: bool,
51    /// The group's answer on how much room to reserve for a finger, handed down.
52    tap_target: Option<TapTarget>,
53    /// How compact it is; the theme's radio density, then the theme's.
54    visual_density: Option<crate::VisualDensity>,
55    /// The halo's radius.
56    splash_radius: Option<f32>,
57    on_click: Option<Msg>,
58}
59
60impl<Msg> Radio<Msg> {
61    /// A radio, chosen or not. It says nothing and answers nothing until it is given a
62    /// [`label`](Self::label) and an [`on_select`](Self::on_select).
63    pub fn new(selected: bool) -> Self {
64        Self {
65            label: String::new(),
66            selected,
67            size: LABEL_SIZE,
68            colors: RadioColors::default(),
69            enabled: true,
70            tap_target: None,
71            visual_density: None,
72            splash_radius: None,
73            on_click: None,
74        }
75    }
76
77    /// The words beside it. Empty by default, which is what a radio in a row that has its
78    /// own title wants — see [`RadioListTile`](crate::RadioListTile).
79    #[must_use]
80    pub fn label(mut self, label: impl Into<String>) -> Self {
81        self.label = label.into();
82        self
83    }
84
85    /// The label's size, in pixels.
86    #[must_use]
87    pub fn label_size(mut self, size: f32) -> Self {
88        self.size = size;
89        self
90    }
91
92    /// What to say when it is pressed. A radio with no message is inert.
93    #[must_use]
94    pub fn on_select(mut self, message: impl Into<Msg>) -> Self {
95        self.on_click = Some(message.into());
96        self
97    }
98
99    /// Whether it can be pressed.
100    #[must_use]
101    pub fn enabled(mut self, enabled: bool) -> Self {
102        self.enabled = enabled;
103        self
104    }
105
106    /// The ring and the dot while it is the chosen one, over the theme's and `primary`.
107    #[must_use]
108    pub fn selected_color(mut self, color: Color) -> Self {
109        self.colors.selected = Some(color);
110        self
111    }
112
113    /// The ring while it is not, at rest.
114    #[must_use]
115    pub fn border_color(mut self, color: Color) -> Self {
116        self.colors.border = Some(color);
117        self
118    }
119
120    /// And the ring while it is not, under a finger, a pointer or focus.
121    #[must_use]
122    pub fn active_border_color(mut self, color: Color) -> Self {
123        self.colors.active_border = Some(color);
124        self
125    }
126
127    /// The label's colour.
128    #[must_use]
129    pub fn label_color(mut self, color: Color) -> Self {
130        self.colors.label = Some(color);
131        self
132    }
133
134    /// **How compact it is.** Unset, the theme's radio density, then the theme's own: a
135    /// radio follows the theme's density, as the reference's does (`radio.dart:1008`).
136    #[must_use]
137    pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
138        self.visual_density = Some(density);
139        self
140    }
141
142    /// **The halo's radius** under a pointer, the keyboard or a finger. Unset, the
143    /// theme's, then 20.
144    #[must_use]
145    pub fn splash_radius(mut self, radius: f32) -> Self {
146        self.splash_radius = Some(radius);
147        self
148    }
149
150    /// How much room it reserves for the finger that works it.
151    #[must_use]
152    pub fn tap_target(mut self, target: TapTarget) -> Self {
153        self.tap_target = Some(target);
154        self
155    }
156
157    /// The label's style, **resolved once** so that the number the box is measured with is
158    /// the number the glyphs are drawn at. Resolving is the single place the reader's font
159    /// setting is applied (milestone 403).
160    fn label_style(&self) -> ResolvedTextStyle {
161        TextStyle::new(self.size).resolved()
162    }
163
164    /// The square the ring sits in: 48 px padded or 40 shrink-wrapped, moved by the
165    /// density (`radio.dart:738`).
166    fn square(&self, theme: &Theme) -> f32 {
167        let target = self
168            .tap_target
169            .or(theme.widgets.radio.tap_target)
170            .unwrap_or(theme.tap_target);
171        let side = match target {
172            TapTarget::Padded => crate::MIN_TAP_TARGET,
173            TapTarget::ShrinkWrap => crate::MIN_TAP_TARGET - 8.0,
174        };
175        let density = self
176            .visual_density
177            .or(theme.widgets.radio.visual_density)
178            .unwrap_or_else(|| theme.visual_density());
179        (side + density.base_size_adjustment().1).max(2.0 * (OUTER + STROKE * 0.5))
180    }
181
182    /// The ring's and the dot's colour for these states (`radio.dart:943`): the chosen
183    /// colour once chosen, `on_surface` under a pointer, the keyboard or a finger and
184    /// `on_surface_variant` at rest otherwise, `on_surface` at 38 % disabled.
185    fn fill(&self, theme: &Theme, states: WidgetStates) -> Color {
186        let c = self.colors;
187        if states.contains(WidgetState::Disabled) {
188            return disabled_content(theme);
189        }
190        if states.contains(WidgetState::Selected) {
191            return c
192                .selected
193                .or(theme.widgets.radio.selected_color)
194                .unwrap_or(theme.scheme.primary);
195        }
196        let resting = c.border.or(theme.widgets.radio.border_color);
197        let active = states.contains(WidgetState::Pressed)
198            || states.contains(WidgetState::Hovered)
199            || states.contains(WidgetState::Focused);
200        if active {
201            c.active_border
202                .or(theme.widgets.radio.active_border_color)
203                .or(resting)
204                .unwrap_or(theme.scheme.on_surface)
205        } else {
206            resting.unwrap_or(theme.scheme.on_surface_variant)
207        }
208    }
209
210    /// The states it is in.
211    fn states(&self, status: &Status) -> WidgetStates {
212        let states = if self.enabled {
213            status.states()
214        } else {
215            WidgetStates::of(WidgetState::Disabled)
216        };
217        states.set(WidgetState::Selected, self.selected)
218    }
219}
220
221impl<Msg: Clone> Widget<Msg> for Radio<Msg> {
222    fn style(&self) -> Style {
223        Widget::<Msg>::style_themed(self, &Theme::default())
224    }
225
226    /// **A square a finger can hit**, 48 px at the standard density with the ring in its
227    /// middle (`radio.dart:738`), and the label after it.
228    fn style_themed(&self, theme: &Theme) -> Style {
229        let square = self.square(theme);
230        let style = self.label_style();
231        let label_w = if self.label.is_empty() {
232            0.0
233        } else {
234            frus_text::measure_resolved(&self.label, &style).width
235        };
236        Style {
237            width: Dimension::Length((square + label_w).ceil()),
238            height: Dimension::Length(style.line_height().max(square).ceil()),
239            ..Default::default()
240        }
241    }
242
243    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
244        &[]
245    }
246
247    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
248        let o = status.opacity;
249        let square = self.square(theme);
250        let centre = Point::new(bounds.x + square * 0.5, bounds.y + bounds.height * 0.5);
251        let states = self.states(&status);
252
253        if self.enabled {
254            let radius = self
255                .splash_radius
256                .or(theme.widgets.radio.splash_radius)
257                .unwrap_or(crate::toggleable::SPLASH);
258            let overlay = theme.widgets.radio.overlay_color.as_ref();
259            crate::toggleable::paint_halos(scene, centre, radius, &status, |state| {
260                let s = states.set(state, true);
261                overlay
262                    .and_then(|p| p.resolve(s).copied())
263                    .unwrap_or_else(|| crate::toggleable::overlay(theme, s))
264            });
265        }
266
267        // The ring, its colour moving from the unchosen to the chosen one, and the dot
268        // growing from nothing to 4.5 px as the radio is chosen (`radio.dart:849`).
269        let t = status.value.clamp(0.0, 1.0);
270        let off = self.fill(theme, states.set(WidgetState::Selected, false));
271        let on = self.fill(theme, states.set(WidgetState::Selected, true));
272        // At either end, the colour itself: a lerp to 1 is not exactly its target.
273        let ring = if t >= 1.0 {
274            on
275        } else if t <= 0.0 {
276            off
277        } else {
278            off.lerp(on, t)
279        };
280        // A stroke centred on the 8 px circle: the box is the circle's outer edge, and the
281        // stroke is drawn inside it.
282        let edge = OUTER + STROKE * 0.5;
283        scene.draw_rect(
284            Rect::new(centre.x - edge, centre.y - edge, 2.0 * edge, 2.0 * edge),
285            Color::TRANSPARENT,
286            edge,
287            STROKE,
288            ring.fade(o),
289        );
290        if t > 0.0 {
291            let r = INNER * t;
292            scene.draw_rect(
293                Rect::new(centre.x - r, centre.y - r, 2.0 * r, 2.0 * r),
294                ring.fade(o),
295                r,
296                0.0,
297                Color::TRANSPARENT,
298            );
299        }
300
301        if !self.label.is_empty() {
302            let colour = if self.enabled {
303                self.colors
304                    .label
305                    .or(theme.widgets.radio.label_color)
306                    .unwrap_or(theme.on_surface)
307            } else {
308                disabled_content(theme)
309            };
310            let style = self.label_style();
311            scene.text(
312                Point::new(
313                    bounds.x + square,
314                    bounds.y + (bounds.height - style.line_height()) * 0.5,
315                ),
316                self.label.clone(),
317                &style,
318                colour.fade(o),
319            );
320        }
321    }
322
323    /// Chosen or not: choosing grows the dot over a fifth of a second.
324    fn anim_target(&self) -> Option<f32> {
325        Some(if self.selected { 1.0 } else { 0.0 })
326    }
327
328    fn anim_duration(&self) -> f32 {
329        TOGGLE_SECONDS
330    }
331
332    fn on_click(&self) -> Option<Msg> {
333        if !self.enabled {
334            return None;
335        }
336        self.on_click.clone()
337    }
338
339    fn focusable(&self) -> bool {
340        self.enabled
341    }
342
343    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
344        // A radio group had no semantics at all before this: a reader was told nothing
345        // about which option was chosen. Adding the disabled announcement without the
346        // announcement it qualifies would have been announcing an absence.
347        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::RadioButton)
348            .label(self.label.clone())
349            .toggled(self.selected);
350        Some(if self.enabled {
351            semantics.clickable()
352        } else {
353            semantics.disabled(true)
354        })
355    }
356}
357
358/// What a [`RadioGroup`] was told about its own colours, handed to each option.
359///
360/// Unset entries fall through to the theme and then the scheme, resolved where they are
361/// painted rather than here: a group built under one theme and rendered under another --
362/// which [`crate::Themed`] makes ordinary -- must take the theme it is *painted* in.
363#[derive(Clone, Copy, Debug, Default, PartialEq)]
364struct RadioColors {
365    selected: Option<Color>,
366    border: Option<Color>,
367    active_border: Option<Color>,
368    label: Option<Color>,
369}
370
371/// A single-selection group of radio buttons.
372pub struct RadioGroup<Msg = crate::callback::Callback> {
373    selected: usize,
374    size: f32,
375    gap: f32,
376    enabled: bool,
377    colors: RadioColors,
378    tap_target: Option<TapTarget>,
379    on_select: Box<dyn Fn(usize) -> Msg>,
380    /// The labels as given. The options are **derived** from these, so that a builder
381    /// called after them — `enabled`, and any that follow — still reaches every option
382    /// rather than only the ones added afterwards.
383    labels: Vec<String>,
384    options: Vec<Box<dyn Widget<Msg>>>,
385}
386
387impl<Msg: Clone + 'static> RadioGroup<Msg> {
388    /// Creates a group: the selected index plus an `index -> message` closure.
389    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
390        Self {
391            selected,
392            size: 18.0,
393            gap: 8.0,
394            enabled: true,
395            colors: RadioColors::default(),
396            tap_target: None,
397            on_select: Box::new(on_select),
398            labels: Vec::new(),
399            options: Vec::new(),
400        }
401    }
402
403    /// The ring and the dot of the **chosen** option; the theme's `primary` otherwise.
404    pub fn selected_color(mut self, color: Color) -> Self {
405        self.colors.selected = Some(color);
406        self.rebuild();
407        self
408    }
409
410    /// The ring of an option that is not chosen, at rest.
411    ///
412    /// Set on its own it also becomes the ring under a pointer or focus, unless
413    /// [`active_border_color`](RadioGroup::active_border_color) says otherwise.
414    pub fn border_color(mut self, color: Color) -> Self {
415        self.colors.border = Some(color);
416        self.rebuild();
417        self
418    }
419
420    /// That ring under a pointer, a finger or focus.
421    pub fn active_border_color(mut self, color: Color) -> Self {
422        self.colors.active_border = Some(color);
423        self.rebuild();
424        self
425    }
426
427    /// The labels' colour; the theme's `on_surface` otherwise.
428    pub fn label_color(mut self, color: Color) -> Self {
429        self.colors.label = Some(color);
430        self.rebuild();
431        self
432    }
433
434    /// Adds an option, in order.
435    pub fn option(mut self, label: impl Into<String>) -> Self {
436        self.labels.push(label.into());
437        self.rebuild();
438        self
439    }
440
441    /// Whether the group can be chosen from. Disabled it is **inert** — no message, out
442    /// of the tab order, announced as unavailable — and it still shows which option is
443    /// chosen.
444    ///
445    /// It disables the **whole** group; a single unavailable option among live ones is
446    /// not expressible yet. See [`crate::disabled`] for the whole contract.
447    pub fn enabled(mut self, enabled: bool) -> Self {
448        self.enabled = enabled;
449        self.rebuild();
450        self
451    }
452
453    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
454    /// answer, which is [`Padded`](TapTarget::Padded) — at least 48 pixels either way,
455    /// whatever this control paints in the middle of it.
456    ///
457    /// It reaches every option, as the group's other answers do.
458    pub fn tap_target(mut self, target: TapTarget) -> Self {
459        self.tap_target = Some(target);
460        self.rebuild();
461        self
462    }
463
464    /// Rebuilds the options from the labels, so that the order of the builders does not
465    /// change what comes out.
466    fn rebuild(&mut self) {
467        self.options = self
468            .labels
469            .iter()
470            .enumerate()
471            .map(|(index, label)| {
472                Box::new(Radio {
473                    label: label.clone(),
474                    selected: index == self.selected,
475                    size: self.size,
476                    colors: self.colors,
477                    enabled: self.enabled,
478                    tap_target: self.tap_target,
479                    visual_density: None,
480                    splash_radius: None,
481                    on_click: Some((self.on_select)(index)),
482                }) as Box<dyn Widget<Msg>>
483            })
484            .collect();
485    }
486}
487
488impl<Msg: Clone> Widget<Msg> for RadioGroup<Msg> {
489    fn style(&self) -> Style {
490        Style {
491            flex_direction: FlexDirection::Column,
492            gap: self.gap,
493            ..Default::default()
494        }
495    }
496
497    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
498        &self.options
499    }
500
501    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
502
503    fn on_click(&self) -> Option<Msg> {
504        None
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::interaction::Interaction;
512    use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
513
514    #[derive(Clone, Debug, PartialEq)]
515    enum Msg {
516        Pick(usize),
517    }
518
519    /// **A 20-pixel ring is not something a finger can be asked to hit** (milestone 442).
520    ///
521    /// The reference lays a radio out inside a 48-pixel square whatever it paints in the
522    /// middle (`radio.dart:734`). Here the target is the whole option, words included,
523    /// because a `Radio` carries its label where the reference's radio does not.
524    #[test]
525    fn an_option_reserves_room_for_a_finger() {
526        // A phone's theme: a radio follows the theme's density, compact on a desktop.
527        let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Android);
528        let option = |target: Option<TapTarget>| Radio::<Msg> {
529            label: "Daily".into(),
530            selected: false,
531            size: 18.0,
532            colors: RadioColors::default(),
533            enabled: true,
534            tap_target: target,
535            visual_density: None,
536            splash_radius: None,
537            on_click: None,
538        };
539        let height =
540            |option: &Radio<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(option, theme)
541                .height
542            {
543                Dimension::Length(h) => h,
544                other => panic!("{other:?}"),
545            };
546        assert_eq!(height(&option(None), &theme), MIN_TAP_TARGET);
547        assert_eq!(
548            height(&option(Some(TapTarget::ShrinkWrap)), &theme),
549            SHRUNK_TAP_TARGET
550        );
551
552        // The group hands its answer to every option, whenever it is said.
553        let group = RadioGroup::new(0, Msg::Pick)
554            .option("Daily")
555            .tap_target(TapTarget::ShrinkWrap);
556        for child in Widget::<Msg>::children(&group) {
557            assert_eq!(
558                match child.style_themed(&theme).height {
559                    Dimension::Length(h) => h,
560                    other => panic!("{other:?}"),
561                },
562                SHRUNK_TAP_TARGET
563            );
564        }
565    }
566
567    /// And the label is centred down that room. It used to be drawn at the top of the
568    /// bounds, which was the same sentence while the bounds *were* the line.
569    #[test]
570    fn the_label_is_centred_in_the_room() {
571        let theme = Theme::dark();
572        let option = Radio::<Msg> {
573            label: "Daily".into(),
574            selected: false,
575            size: 18.0,
576            colors: RadioColors::default(),
577            enabled: true,
578            tap_target: None,
579            visual_density: None,
580            splash_radius: None,
581            on_click: None,
582        };
583        let mut scene = Scene::new();
584        Widget::<Msg>::paint(
585            &option,
586            Rect::new(0.0, 0.0, 200.0, MIN_TAP_TARGET),
587            Status::default(),
588            &theme,
589            &mut scene,
590        );
591        let at = scene
592            .primitives()
593            .iter()
594            .find_map(|p| match p {
595                frus_core::Primitive::Text { position, .. } => Some(*position),
596                _ => None,
597            })
598            .expect("an option paints its label");
599        let line = option.label_style().line_height();
600        assert!(
601            (at.y - (MIN_TAP_TARGET - line) * 0.5).abs() < 0.01,
602            "the label sits in the middle: {at:?}"
603        );
604    }
605
606    /// An unselected ring, state by state — the same rule as a checkbox's box, and for
607    /// the same reason: it is the mark, not a container's edge. See
608    /// `checkbox::tests::an_unticked_box_is_a_mark_not_a_container_edge`.
609    #[test]
610    fn an_unselected_ring_is_a_mark_not_a_container_edge() {
611        let theme = Theme::dark();
612        let ring = |selected: bool, interaction, focused| {
613            let mut scene = Scene::new();
614            Widget::<Msg>::paint(
615                &Radio {
616                    label: "Daily".into(),
617                    selected,
618                    size: 18.0,
619                    colors: RadioColors::default(),
620                    enabled: true,
621                    tap_target: None,
622                    visual_density: None,
623                    splash_radius: None,
624                    on_click: Some(Msg::Pick(0)),
625                },
626                Rect::new(0.0, 0.0, 120.0, 20.0),
627                Status {
628                    opacity: 1.0,
629                    interaction,
630                    focused,
631                    // Settled: the dot grown or gone.
632                    value: if selected { 1.0 } else { 0.0 },
633                    ..Default::default()
634                },
635                &theme,
636                &mut scene,
637            );
638            scene
639                .primitives()
640                .iter()
641                .find_map(|p| match p {
642                    // The ring is the stroked circle; a halo has no stroke.
643                    frus_core::Primitive::Rect {
644                        border_color,
645                        border_width,
646                        ..
647                    } if *border_width > 0.0 => Some(*border_color),
648                    _ => None,
649                })
650                .expect("the ring")
651        };
652        assert_eq!(
653            ring(false, Interaction::None, false),
654            theme.scheme.on_surface_variant,
655            "at rest"
656        );
657        assert_eq!(
658            ring(false, Interaction::Hovered, false),
659            theme.scheme.on_surface,
660            "hovered"
661        );
662        assert_eq!(
663            ring(false, Interaction::None, true),
664            theme.scheme.on_surface,
665            "focused"
666        );
667        // The chosen one is the accent in every state, which is what the reference does.
668        for interaction in [
669            Interaction::None,
670            Interaction::Hovered,
671            Interaction::Pressed,
672        ] {
673            assert_eq!(ring(true, interaction, false), theme.primary, "selected");
674        }
675        assert_ne!(
676            ring(false, Interaction::None, false),
677            theme.scheme.outline,
678            "a ring is a mark, not a container's edge"
679        );
680    }
681
682    /// **The reference's ring and dot** (`radio.dart:849`): a ring of radius 8 drawn with
683    /// a 2 px stroke centred on it, in the middle of the square, and a dot of 4.5 grown
684    /// with the animation.
685    #[test]
686    fn the_ring_and_the_dot_are_the_reference_s() {
687        let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Android);
688        let painted = |value: f32| {
689            let mut scene = Scene::new();
690            Widget::<Msg>::paint(
691                &Radio::<Msg>::new(true),
692                Rect::new(0.0, 0.0, 48.0, 48.0),
693                Status {
694                    opacity: 1.0,
695                    value,
696                    ..Default::default()
697                },
698                &theme,
699                &mut scene,
700            );
701            scene
702                .primitives()
703                .iter()
704                .filter_map(|p| match p {
705                    frus_core::Primitive::Rect {
706                        rect, border_width, ..
707                    } => Some((*rect, *border_width)),
708                    _ => None,
709                })
710                .collect::<Vec<_>>()
711        };
712        let settled = painted(1.0);
713        assert_eq!(
714            settled[0],
715            (Rect::new(15.0, 15.0, 18.0, 18.0), 2.0),
716            "the ring"
717        );
718        assert_eq!(
719            settled[1].0,
720            Rect::new(19.5, 19.5, 9.0, 9.0),
721            "the dot, 4.5 round"
722        );
723        let half = painted(0.5);
724        assert_eq!(half[1].0, Rect::new(21.75, 21.75, 4.5, 4.5), "half grown");
725        assert_eq!(painted(0.0).len(), 1, "no dot before it is chosen");
726    }
727
728    fn group(enabled_first: bool) -> RadioGroup<Msg> {
729        // The same group built in both orders. `enabled` before the options and `enabled`
730        // after them must come out identical — the reason the options are derived from
731        // the labels rather than frozen as each one is added.
732        if enabled_first {
733            RadioGroup::new(1, Msg::Pick)
734                .enabled(false)
735                .option("Daily")
736                .option("Weekly")
737        } else {
738            RadioGroup::new(1, Msg::Pick)
739                .option("Daily")
740                .option("Weekly")
741                .enabled(false)
742        }
743    }
744
745    #[test]
746    fn picking_an_option_reports_its_index() {
747        let live = RadioGroup::new(0, Msg::Pick)
748            .option("Daily")
749            .option("Weekly");
750        let options = Widget::children(&live);
751        assert_eq!(options.len(), 2);
752        assert_eq!(options[1].on_click(), Some(Msg::Pick(1)));
753    }
754
755    #[test]
756    fn a_disabled_group_disables_every_option_whichever_order_it_was_built_in() {
757        for first in [true, false] {
758            let dead = group(first);
759            let options = Widget::children(&dead);
760            assert_eq!(options.len(), 2, "built in order {first}");
761            for (i, option) in options.iter().enumerate() {
762                assert_eq!(option.on_click(), None, "option {i} still answers");
763                assert!(!option.focusable(), "option {i} is still in the tab order");
764                let semantics = option.semantics().expect("still announced");
765                assert!(semantics.disabled, "option {i} does not say it is disabled");
766            }
767            // And the chosen one is still legible to a reader who cannot change it.
768            assert_eq!(
769                options[1].semantics().unwrap().toggled,
770                frus_core::Toggled::True
771            );
772        }
773    }
774
775    /// The trap the rebuild exists for: with the options frozen as they were added,
776    /// `.enabled(false)` at the end of the chain would have reached none of them, and the
777    /// group would have looked disabled to a reader of the call site and answered every
778    /// tap.
779    #[test]
780    fn the_builder_order_does_not_change_what_comes_out() {
781        let before = Widget::children(&group(true))
782            .iter()
783            .map(|o| o.focusable())
784            .collect::<Vec<_>>();
785        let after = Widget::children(&group(false))
786            .iter()
787            .map(|o| o.focusable())
788            .collect::<Vec<_>>();
789        assert_eq!(before, after);
790        assert_eq!(after, vec![false, false]);
791    }
792}
793
794#[cfg(test)]
795mod color_tests {
796    use super::*;
797    use crate::interaction::Interaction;
798    use crate::widget::Widget;
799    use frus_core::Primitive;
800
801    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
802    const MARK: Color = Color::rgb(0.9, 0.9, 0.2);
803
804    /// The ring's colour, then the dot's if the option is the chosen one.
805    fn painted(
806        group: &RadioGroup<usize>,
807        index: usize,
808        status: Status,
809        theme: &Theme,
810    ) -> Vec<Color> {
811        let option = &Widget::<usize>::children(group)[index];
812        let mut scene = Scene::new();
813        // Settled: these groups choose their first option.
814        let status = Status {
815            value: if index == 0 { 1.0 } else { 0.0 },
816            ..status
817        };
818        option.paint(Rect::new(0.0, 0.0, 200.0, 48.0), status, theme, &mut scene);
819        scene
820            .primitives()
821            .iter()
822            .filter_map(|p| match p {
823                Primitive::Rect {
824                    border_width,
825                    border_color,
826                    color,
827                    ..
828                } => Some(if *border_width > 0.0 {
829                    *border_color
830                } else {
831                    *color
832                }),
833                Primitive::Text { color, .. } => Some(*color),
834                _ => None,
835            })
836            .collect()
837    }
838
839    fn group() -> RadioGroup<usize> {
840        RadioGroup::new(0, |i| i).option("One").option("Two")
841    }
842
843    fn rest() -> Status {
844        Status {
845            opacity: 1.0,
846            ..Default::default()
847        }
848    }
849
850    /// Nothing said: what it always painted.
851    #[test]
852    fn the_defaults_are_what_they_were() {
853        let theme = Theme::default();
854        let chosen = painted(&group(), 0, rest(), &theme);
855        assert_eq!(chosen[0], theme.primary, "the ring of the chosen option");
856        assert_eq!(chosen[1], theme.primary, "and its dot");
857        let other = painted(&group(), 1, rest(), &theme);
858        assert_eq!(other[0], theme.scheme.on_surface_variant);
859    }
860
861    /// The chosen ring and dot take one colour; the labels take another.
862    #[test]
863    fn the_chosen_option_takes_its_colour() {
864        let theme = Theme::default();
865        let g = group().selected_color(BRAND).label_color(MARK);
866        let chosen = painted(&g, 0, rest(), &theme);
867        assert_eq!((chosen[0], chosen[1]), (BRAND, BRAND));
868        assert_eq!(*chosen.last().expect("the label"), MARK);
869    }
870
871    /// One ring colour covers both states, as the checkbox's outline does.
872    #[test]
873    fn one_ring_colour_covers_both_states() {
874        let theme = Theme::default();
875        let hovered = Status {
876            opacity: 1.0,
877            interaction: Interaction::Hovered,
878            ..Default::default()
879        };
880        let g = group().border_color(BRAND);
881        assert_eq!(painted(&g, 1, rest(), &theme)[0], BRAND);
882        assert_eq!(painted(&g, 1, hovered, &theme)[0], BRAND);
883
884        let both = group().border_color(BRAND).active_border_color(MARK);
885        assert_eq!(painted(&both, 1, hovered, &theme)[0], MARK);
886    }
887
888    /// A builder called after the options still reaches every one of them.
889    #[test]
890    fn a_colour_set_last_still_reaches_the_options() {
891        let theme = Theme::default();
892        let g = RadioGroup::new(0, |i: usize| i)
893            .option("One")
894            .selected_color(BRAND);
895        assert_eq!(painted(&g, 0, rest(), &theme)[0], BRAND);
896    }
897
898    /// The theme answers when the instance does not, and loses when it does.
899    #[test]
900    fn the_theme_answers_and_the_instance_overrules_it() {
901        let mut theme = Theme::default();
902        theme.widgets.radio.selected_color = Some(MARK);
903        assert_eq!(painted(&group(), 0, rest(), &theme)[0], MARK);
904        assert_eq!(
905            painted(&group().selected_color(BRAND), 0, rest(), &theme)[0],
906            BRAND
907        );
908    }
909}