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