Skip to main content

qframe/widgets/
radio_group.rs

1//! Radio groups.
2
3use std::time::Duration;
4
5use super::IndexMessage;
6use super::cells;
7use super::checkbox::{self, BOX, LABEL_GAP};
8use super::press::{self, Press};
9use crate::event::Event;
10use crate::geometry::{Rect, Size};
11use crate::keymap::Key;
12use crate::motion::{Easing, Tween};
13use crate::style::CellStyle;
14use crate::text;
15use crate::theme::State;
16use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
17
18/// Cells between options laid out in a row.
19const ROW_GAP: u16 = 4;
20
21/// How many `motion.step`s a mark takes to change between small and full.
22const MARK_STEPS: u32 = 2;
23
24/// The icon of the mark style's small size, two cells.
25const MARK_ICON: &str = "radio-mark-small";
26
27/// Time between frames while a mark grows or shrinks.
28const MARK_FRAME: Duration = Duration::from_millis(16);
29
30/// How the options of a [`RadioGroup`] are marked.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum RadioStyle {
33    /// A small square centred in two cells for every option; only its colour tells the chosen one.
34    /// Choosing blends the new square from the quiet tone to the chosen tone over two
35    /// `motion.step`s while the option left behind blends back; the shape never changes. The
36    /// square is the icon `radio-mark-small`; a blank icon (as in ASCII) draws a box whose tone
37    /// blends the same way.
38    #[default]
39    Square,
40    /// A small square centred in two cells for the options not chosen and a full two-cell box of
41    /// solid colour for the chosen one. Choosing blends the mark's colour towards the chosen tone
42    /// and swaps the small square for the full box halfway, with no size in between; the option
43    /// left behind blends back and shrinks the same way. The small square is the icon
44    /// `radio-mark-small`; a blank icon (as in ASCII) draws a faint box instead.
45    Mark,
46    /// A two-cell box of solid colour, filled for the chosen option and the empty tone for the
47    /// others; exactly the box of a checkbox. No glyph, so it looks the same in every glyph mode.
48    Box,
49    /// A filled dot `●` for the chosen option and a ring `○` for the others.
50    Dot,
51}
52
53impl RadioStyle {
54    /// Width of the mark, in cells.
55    fn width(self) -> u16 {
56        match self {
57            Self::Square | Self::Mark | Self::Box => BOX,
58            Self::Dot => 1,
59        }
60    }
61}
62
63/// A set of options of which exactly one is chosen, for a few choices that should all stay
64/// visible. For many choices use a `Select`.
65///
66/// The group takes focus as one control: arrow keys choose the previous or next option (going
67/// round the ends with [`wrap`](Self::wrap)), Home and End the first and last, and a click anywhere on an option, mark or label, chooses it.
68/// By default ([`RadioStyle::Square`]) every option shows a small square centred in two cells and
69/// the chosen one differs by colour: choosing blends the new square towards the chosen tone over
70/// two `motion.step`s while the old one blends back. [`RadioStyle::Mark`] also grows the chosen
71/// square into a full two-cell box, swapping shape halfway through the same blend. With reduced
72/// motion the change is immediate. [`RadioStyle::Box`] gives every option the checkbox's
73/// box and [`RadioStyle::Dot`] a dot or a ring. Nothing is bracketed, and every style keeps two
74/// cells (one for dots) in every state, so labels never move. The application owns the choice.
75///
76/// The box style looks exactly like a checkbox: what differs is the meaning. A radio group
77/// chooses one option and a checkbox turns each option on or off by itself.
78///
79/// Style keys: `radio.mark` (`fg`, also the colour of the full box) for marks, whose blank icons
80/// take their tones from `radio.box` (`bg`); `radio.box` (`bg`) for boxes; `radio` (`fg`) for
81/// dots; `radio-label` (`fg`, `bold`); all with states `hover`, `focus`, `checked`, `disabled`.
82/// Icons: `radio-mark-small` for marks, `dot` and `dot-outline` for dots.
83pub struct RadioGroup<Msg> {
84    options: Vec<String>,
85    selected: Option<usize>,
86    horizontal: bool,
87    style: RadioStyle,
88    disabled: bool,
89    wrap: bool,
90    on_select: Option<IndexMessage<Msg>>,
91}
92
93impl<Msg> RadioGroup<Msg> {
94    /// A vertical group of `options` with nothing chosen.
95    #[must_use]
96    pub fn new(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
97        Self {
98            options: options.into_iter().map(Into::into).collect(),
99            selected: None,
100            horizontal: false,
101            style: RadioStyle::default(),
102            disabled: false,
103            wrap: false,
104            on_select: None,
105        }
106    }
107
108    /// The chosen option.
109    #[must_use]
110    pub fn selected(mut self, index: Option<usize>) -> Self {
111        self.selected = index;
112        self
113    }
114
115    /// Lays the options out in one row instead of one per line.
116    #[must_use]
117    pub fn horizontal(mut self, horizontal: bool) -> Self {
118        self.horizontal = horizontal;
119        self
120    }
121
122    /// Chooses how options are marked.
123    #[must_use]
124    pub fn style(mut self, style: RadioStyle) -> Self {
125        self.style = style;
126        self
127    }
128
129    /// Greys the group out; it cannot be focused or changed.
130    #[must_use]
131    pub fn disabled(mut self, disabled: bool) -> Self {
132        self.disabled = disabled;
133        self
134    }
135
136    /// Lets the next-option arrow on the last option choose the first and the previous-option
137    /// arrow on the first choose the last. Home and End still stop at the ends. Off by default.
138    #[must_use]
139    pub fn wrap(mut self, wrap: bool) -> Self {
140        self.wrap = wrap;
141        self
142    }
143
144    /// Message for choosing option `index`.
145    #[must_use]
146    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
147        self.on_select = Some(Box::new(message));
148        self
149    }
150
151    fn active(&self) -> bool {
152        !self.disabled && self.on_select.is_some() && !self.options.is_empty()
153    }
154
155    /// Where option `index` sits inside `area`.
156    fn slot(&self, area: Rect, index: usize) -> Rect {
157        let width = |label: &str| self.label_offset().saturating_add(text::width(label));
158        if self.horizontal {
159            let x = cells::sum(self.options[..index].iter().map(|label| width(label).saturating_add(ROW_GAP)));
160            Rect::new(area.x + i32::from(x), area.y, width(&self.options[index]), 1)
161        } else {
162            let row = i32::try_from(index).unwrap_or(i32::MAX);
163            Rect::new(area.x, area.y.saturating_add(row), area.width, 1)
164        }
165    }
166
167    /// Where labels start, after the mark and its gap.
168    fn label_offset(&self) -> u16 {
169        self.style.width() + LABEL_GAP
170    }
171
172    fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
173        let index = index.min(self.options.len() - 1);
174        if Some(index) != self.selected
175            && let Some(message) = &self.on_select
176        {
177            cx.emit(message(index));
178        }
179    }
180}
181
182/// How far each option's mark has grown, by index: 0 small, 1 full.
183#[derive(Debug, Default)]
184struct MarkGrowth(Vec<Tween>);
185
186/// How far option `index`'s mark has moved towards full (`chosen`) or small, from 0 to 1, over
187/// two `motion.step`s. A mark starts where it belongs without animating.
188fn mark_progress(cx: &mut PaintCx<'_>, index: usize, chosen: bool) -> f32 {
189    let target = if chosen { 1.0 } else { 0.0 };
190    if cx.reduced_motion() {
191        return target;
192    }
193    let now = cx.now();
194    let duration = cx.env().theme().motion().step * MARK_STEPS;
195    let growth = &mut cx.memory::<MarkGrowth>().0;
196    while growth.len() <= index {
197        growth.push(Tween::settled(target));
198    }
199    let tween = &mut growth[index];
200    if tween.target() != target {
201        tween.retarget(target, now, duration, Easing::Linear);
202    }
203    let (progress, running) = (tween.value(now), tween.is_running(now));
204    if running {
205        cx.request_frame_in(MARK_FRAME);
206    }
207    progress
208}
209
210/// Paints the two-cell mark of option `index` at `(x, y)`. With `grow` only two shapes exist, the
211/// small square and the full box; without it the square keeps its shape. The colour mixes the quiet tone (the `radio.mark` `fg` of the states without
212/// `checked`) into the chosen tone (with `checked`) as the mark moves, and the shape changes once,
213/// halfway. A blank small icon draws a box instead, mixing the `radio.box` empty tone the same way.
214fn paint_mark(cx: &mut PaintCx<'_>, at: (i32, i32), states: &[State], index: usize, chosen: bool, grow: bool) {
215    let t = mark_progress(cx, index, chosen);
216    let mut calm: Vec<State> = states.iter().copied().filter(|s| *s != State::Checked).collect();
217    let quiet = cx.style("radio", Some("mark"), &calm).text().fg.unwrap_or_else(|| cx.color("muted"));
218    let empty = cx.style("radio", Some("box"), &calm).text().bg.unwrap_or_else(|| cx.color("raised"));
219    calm.push(State::Checked);
220    let full = cx.style("radio", Some("mark"), &calm).text().fg.unwrap_or_else(|| cx.color("accent"));
221    let cells = Rect::new(at.0, at.1, BOX, 1);
222    let glyph = cx.env().icons().glyph(MARK_ICON).into_owned();
223    if glyph.trim().is_empty() {
224        cx.clear(cells, empty.mix(full, t));
225        return;
226    }
227    let colour = quiet.mix(full, t);
228    if grow && t >= 0.5 {
229        cx.clear(cells, colour);
230        return;
231    }
232    // Whatever an icon override draws, the mark keeps its two cells so the label never moves.
233    let mut shown = text::truncate(&glyph, BOX).into_owned();
234    for _ in text::width(&shown)..BOX {
235        shown.push(' ');
236    }
237    cx.text(at.0, at.1, &shown, CellStyle::fg(colour), BOX);
238}
239
240impl<Msg: 'static> Widget<Msg> for RadioGroup<Msg> {
241    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
242        let widths = self.options.iter().map(|label| self.label_offset().saturating_add(text::width(label)));
243        let size = if self.horizontal {
244            let count = u16::try_from(self.options.len()).unwrap_or(u16::MAX);
245            Size::new(cells::sum(widths).saturating_add(ROW_GAP.saturating_mul(count.saturating_sub(1))), 1)
246        } else {
247            Size::new(widths.max().unwrap_or(0), u16::try_from(self.options.len()).unwrap_or(u16::MAX))
248        };
249        size.min(available)
250    }
251
252    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
253        let active = self.active();
254        let focused = active && cx.is_focus_visible();
255        let pointer = if active { cx.pointer() } else { None };
256        let (on, off) =
257            (cx.env().icons().glyph("dot").into_owned(), cx.env().icons().glyph("dot-outline").into_owned());
258        for (index, label) in self.options.iter().enumerate() {
259            let slot = self.slot(area, index).intersect(area);
260            if slot.is_empty() {
261                continue;
262            }
263            let chosen = self.selected == Some(index);
264            let mut states = Vec::new();
265            if pointer.is_some_and(|(x, y)| slot.contains(x, y)) {
266                states.push(State::Hover);
267            }
268            // With nothing chosen yet, focus rests on the first option.
269            if focused && (chosen || (self.selected.is_none() && index == 0)) {
270                states.push(State::Focus);
271            }
272            if chosen {
273                states.push(State::Checked);
274            }
275            if self.disabled {
276                states.push(State::Disabled);
277            }
278            match self.style {
279                RadioStyle::Square => paint_mark(cx, (slot.x, slot.y), &states, index, chosen, false),
280                RadioStyle::Mark => paint_mark(cx, (slot.x, slot.y), &states, index, chosen, true),
281                RadioStyle::Box => {
282                    let fill = if chosen { 1.0 } else { 0.0 };
283                    checkbox::paint_box(cx, (slot.x, slot.y), ("radio", Some("box")), &states, (index, [fill; 2]));
284                }
285                RadioStyle::Dot => {
286                    let mark = cx.style("radio", None, &states).text();
287                    cx.text(slot.x, slot.y, if chosen { &on } else { &off }, CellStyle { bg: None, ..mark }, 1);
288                }
289            }
290            let label_style = cx.style("radio-label", None, &states).text();
291            let offset = self.label_offset();
292            let budget = slot.width.saturating_sub(offset);
293            let shown = text::truncate(label, budget).into_owned();
294            cx.text(slot.x + i32::from(offset), slot.y, &shown, label_style, budget);
295        }
296        if active {
297            cx.register_hit(area);
298        }
299    }
300
301    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
302        if !self.active() {
303            return false;
304        }
305        let last = self.options.len() - 1;
306        if let Event::Key(key) = event {
307            let (back, forward) = if self.horizontal { (Key::Left, Key::Right) } else { (Key::Up, Key::Down) };
308            let current = self.selected;
309            let target = if key.is_plain(back) {
310                Some(match current {
311                    Some(0) if self.wrap => last,
312                    _ => current.map_or(0, |i| i.saturating_sub(1)),
313                })
314            } else if key.is_plain(forward) {
315                Some(match current {
316                    Some(i) if self.wrap && i >= last => 0,
317                    _ => current.map_or(0, |i| (i + 1).min(last)),
318                })
319            } else if key.is_plain(Key::Home) {
320                Some(0)
321            } else if key.is_plain(Key::End) {
322                Some(last)
323            } else {
324                None
325            };
326            if let Some(index) = target {
327                self.choose(cx, index);
328                return true;
329            }
330        }
331        match press::read(cx, event) {
332            Press::Ignored => false,
333            Press::Used => true,
334            Press::Key => {
335                self.choose(cx, self.selected.unwrap_or(0));
336                true
337            }
338            Press::Click(x, y) => {
339                let area = cx.area();
340                if let Some(index) = (0..self.options.len()).find(|&i| self.slot(area, i).contains(x, y)) {
341                    self.choose(cx, index);
342                }
343                true
344            }
345        }
346    }
347
348    fn focusable(&self) -> bool {
349        self.active()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::color::Rgb;
357    use crate::runtime::{App, Command, Harness};
358    use crate::widget::View;
359
360    struct Demo {
361        chosen: Option<usize>,
362        horizontal: bool,
363        style: RadioStyle,
364    }
365
366    impl App for Demo {
367        type Msg = usize;
368        fn update(&mut self, index: usize) -> Command<usize> {
369            self.chosen = Some(index);
370            Command::none()
371        }
372        fn view(&self, ui: &mut View<'_, usize>) {
373            ui.add(
374                RadioGroup::new(["Podman", "Docker", "Nerdctl"])
375                    .selected(self.chosen)
376                    .horizontal(self.horizontal)
377                    .style(self.style)
378                    .on_select(|i| i),
379            )
380            .id("engine");
381        }
382    }
383
384    fn demo(chosen: Option<usize>, horizontal: bool, style: RadioStyle) -> Demo {
385        Demo { chosen, horizontal, style }
386    }
387
388    #[test]
389    fn vertical_group_chooses_by_arrows_and_clicks() {
390        let mut h = Harness::new(demo(None, false, RadioStyle::Box), 20, 3);
391        h.set_reduced_motion(true);
392        assert_eq!(h.screen(), "    Podman\n    Docker\n    Nerdctl\n", "boxes are colour, not glyphs");
393        h.press("tab").press("down");
394        assert_eq!(h.app().chosen, Some(0));
395        h.press("down").press("end");
396        assert_eq!(h.app().chosen, Some(2));
397        h.click_text("Docker");
398        assert_eq!(h.app().chosen, Some(1));
399        let theme = h.env().theme().clone();
400        h.hover(19, 2);
401        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (theme.color("raised"), theme.color("raised")));
402        assert_eq!(
403            (h.bg(0, 1), h.bg(1, 1)),
404            (theme.color("accent"), theme.color("accent")),
405            "the chosen box is filled"
406        );
407    }
408
409    struct Wrapping {
410        chosen: Option<usize>,
411        horizontal: bool,
412        wrap: bool,
413    }
414
415    impl App for Wrapping {
416        type Msg = usize;
417        fn update(&mut self, index: usize) -> Command<usize> {
418            self.chosen = Some(index);
419            Command::none()
420        }
421        fn view(&self, ui: &mut View<'_, usize>) {
422            let group = RadioGroup::new(["Podman", "Docker", "Nerdctl"])
423                .selected(self.chosen)
424                .horizontal(self.horizontal)
425                .style(RadioStyle::Box)
426                .wrap(self.wrap)
427                .on_select(|i| i);
428            ui.add(group);
429        }
430    }
431
432    #[test]
433    fn a_wrapping_group_goes_round_its_ends_and_stops_otherwise() {
434        let mut h = Harness::new(Wrapping { chosen: Some(2), horizontal: false, wrap: true }, 20, 3);
435        h.set_reduced_motion(true);
436        let accent = h.env().theme().color("accent");
437        h.press("tab").press("down");
438        assert_eq!(h.bg(0, 0), accent, "down on the last option fills the first box");
439        h.press("up");
440        assert_eq!(h.bg(0, 2), accent, "up on the first option fills the last box");
441        h.press("home").press("up").press("end").press("end");
442        assert_eq!(h.app().chosen, Some(2), "home and end stop at the ends");
443
444        let mut h = Harness::new(Wrapping { chosen: Some(2), horizontal: true, wrap: true }, 40, 1);
445        h.press("tab").press("right");
446        assert_eq!(h.app().chosen, Some(0), "a row wraps with left and right");
447        h.press("left");
448        assert_eq!(h.app().chosen, Some(2));
449
450        let mut h = Harness::new(Wrapping { chosen: Some(2), horizontal: false, wrap: false }, 20, 3);
451        h.press("tab").press("down");
452        assert_eq!(h.app().chosen, Some(2));
453    }
454
455    #[test]
456    fn horizontal_group_uses_left_and_right() {
457        let mut h = Harness::new(demo(Some(1), true, RadioStyle::Box), 40, 1);
458        assert_eq!(h.screen(), "    Podman        Docker        Nerdctl\n");
459        h.press("tab").press("right");
460        assert_eq!(h.app().chosen, Some(2));
461        h.click_text("Podman");
462        assert_eq!(h.app().chosen, Some(0));
463    }
464
465    #[test]
466    fn every_cell_of_an_option_chooses_it_and_the_gap_between_does_not() {
467        let mut h = Harness::new(demo(None, true, RadioStyle::Box), 40, 1);
468        for x in [14, 15, 17, 21] {
469            h.click(x, 0);
470            assert_eq!(h.app().chosen, Some(1), "column {x} is part of Docker");
471            h.click(1, 0);
472            assert_eq!(h.app().chosen, Some(0));
473        }
474        h.click(11, 0);
475        assert_eq!(h.app().chosen, Some(0), "the gap between options chooses nothing");
476    }
477
478    #[test]
479    fn hover_lightens_one_empty_box_and_keyboard_focus_tints_the_chosen_one() {
480        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Box), 20, 3);
481        let theme = h.env().theme().clone();
482        h.hover(6, 2);
483        assert_eq!(h.bg(0, 2), theme.color("active"), "the hovered empty box lightens");
484        assert_eq!(h.bg(0, 1), theme.color("raised"), "the others stay calm");
485        h.hover(19, 0).press("tab");
486        assert_ne!(h.bg(0, 0), theme.color("accent"), "keyboard focus breathes on the chosen box");
487        assert_eq!(h.screen().matches('▌').count(), 0, "a radio group shows no pillar");
488    }
489
490    #[test]
491    fn choosing_blends_the_old_box_out_and_the_new_box_in() {
492        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Box), 20, 3);
493        let theme = h.env().theme().clone();
494        let step = theme.motion().step;
495        let (empty, filled) = (theme.color("raised").expect("raised"), theme.color("accent").expect("accent"));
496        h.hover(19, 2);
497        h.send(2);
498        assert_eq!((h.bg(0, 0), h.bg(0, 2)), (Some(filled), Some(empty)), "the change starts where it was");
499        h.advance(step * 3 / 2);
500        let (old, new) = (h.bg(0, 0).expect("colour"), h.bg(0, 2).expect("colour"));
501        assert!(old != filled && old != empty && new != filled && new != empty, "both are in between");
502        assert!(old.r.abs_diff(new.r) <= 3, "halfway both boxes are the middle colour: {old:?} {new:?}");
503        h.advance(step * 2);
504        assert_eq!((h.bg(0, 0), h.bg(0, 2)), (Some(empty), Some(filled)));
505        h.set_reduced_motion(true);
506        h.send(1);
507        assert_eq!((h.bg(0, 1), h.bg(0, 2)), (Some(filled), Some(empty)), "reduced motion switches at once");
508    }
509
510    #[test]
511    fn the_box_is_the_checkbox_box_in_every_theme_and_ascii_changes_nothing() {
512        struct Both;
513        impl App for Both {
514            type Msg = usize;
515            fn update(&mut self, _: usize) -> Command<usize> {
516                Command::none()
517            }
518            fn view(&self, ui: &mut View<'_, usize>) {
519                ui.add(RadioGroup::new(["On", "Off"]).style(RadioStyle::Box).selected(Some(0)).on_select(|i| i));
520                ui.add(crate::widgets::Checkbox::new(true).label("On").on_toggle(|_| 0));
521                ui.add(crate::widgets::Checkbox::new(false).label("Off").on_toggle(|_| 0));
522            }
523        }
524        let mut h = Harness::new(Both, 20, 4);
525        for theme in ["monochrome", "iris", "nordic", "amber"] {
526            h.set_theme(theme);
527            // Rows 0 and 2 are chosen and checked, rows 1 and 3 empty; at rest and under the pointer.
528            for (radio_row, box_row) in [(0_u16, 2_u16), (1, 3)] {
529                for hovered in [false, true] {
530                    let x = if hovered { 1 } else { 19 };
531                    h.hover(x, i32::from(radio_row));
532                    let radio = (h.bg(0, radio_row), h.bg(1, radio_row));
533                    h.hover(x, i32::from(box_row));
534                    let checkbox = (h.bg(0, box_row), h.bg(1, box_row));
535                    assert_eq!(checkbox, radio, "{theme}, row {radio_row}, hovered {hovered}");
536                }
537            }
538        }
539        let unicode = (h.screen(), h.bg(0, 0), h.bg(0, 1));
540        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
541        assert_eq!((h.screen(), h.bg(0, 0), h.bg(0, 1)), unicode);
542    }
543
544    #[test]
545    fn the_dot_style_keeps_dots_and_rings() {
546        let mut h = Harness::new(demo(Some(1), false, RadioStyle::Dot), 20, 3);
547        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
548        assert_eq!(h.screen(), "○  Podman\n●  Docker\n○  Nerdctl\n");
549        assert_eq!(h.fg(0, 1), h.env().theme().color("accent"));
550        let mut h = Harness::new(demo(Some(1), true, RadioStyle::Dot), 40, 1);
551        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
552        assert_eq!(h.screen(), "○  Podman    ●  Docker    ○  Nerdctl\n");
553        h.click_text("Nerdctl");
554        assert_eq!(h.app().chosen, Some(2));
555    }
556
557    #[test]
558    fn disabled_boxes_use_disabled_tones_and_ignore_input() {
559        struct Off;
560        impl App for Off {
561            type Msg = usize;
562            fn update(&mut self, _: usize) -> Command<usize> {
563                Command::none()
564            }
565            fn view(&self, ui: &mut View<'_, usize>) {
566                ui.add(
567                    RadioGroup::new(["Podman", "Docker"])
568                        .style(RadioStyle::Box)
569                        .selected(Some(0))
570                        .disabled(true)
571                        .on_select(|i| i),
572                );
573            }
574        }
575        let mut h = Harness::new(Off, 20, 2);
576        let theme = h.env().theme().clone();
577        assert_eq!((h.bg(0, 0), h.bg(0, 1)), (theme.color("active"), theme.color("raised")));
578        assert_eq!(h.fg(4, 1), theme.color("muted"));
579        h.click_text("Docker").press("tab").press("down");
580        assert_eq!(h.screen(), "    Podman\n    Docker\n");
581    }
582
583    /// The `fg` of `radio.mark` in `states`, at the start of any pulse.
584    fn mark_tone(theme: &crate::theme::Theme, states: &[State]) -> Rgb {
585        theme.style("radio", Some("mark"), states).paint("fg").expect("radio.mark has an fg").at(0.0)
586    }
587
588    #[test]
589    fn the_mark_style_is_a_small_square_and_the_chosen_option_a_full_box() {
590        let mut h = Harness::new(demo(Some(1), false, RadioStyle::Mark), 20, 3);
591        assert_eq!(h.screen(), "🬇🬃  Podman\n    Docker\n🬇🬃  Nerdctl\n");
592        let theme = h.env().theme().clone();
593        let (quiet, full) = (Some(mark_tone(&theme, &[])), theme.color("accent"));
594        assert_eq!((h.fg(0, 0), h.fg(1, 0), h.fg(0, 2)), (quiet, quiet, quiet), "unchosen squares are quiet");
595        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (h.bg(4, 0), h.bg(4, 0)), "a square has no box behind it");
596        assert_eq!((h.bg(0, 1), h.bg(1, 1)), (full, full), "the chosen option is a full box");
597        h.set_glyph_mode(crate::icons::GlyphMode::Nerd);
598        assert_eq!(h.screen(), "🬇🬃  Podman\n    Docker\n🬇🬃  Nerdctl\n", "Nerd Font mode draws the same sextants");
599        let mut h = Harness::new(demo(Some(1), true, RadioStyle::Mark), 40, 1);
600        assert_eq!(h.screen(), "🬇🬃  Podman        Docker    🬇🬃  Nerdctl\n");
601        h.click_text("Nerdctl");
602        assert_eq!(h.app().chosen, Some(2));
603    }
604
605    #[test]
606    fn the_default_square_keeps_its_shape_and_the_chosen_option_blends_to_the_chosen_colour() {
607        assert_eq!(RadioStyle::default(), RadioStyle::Square);
608        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Square), 20, 3);
609        assert_eq!(h.screen(), "🬇🬃  Podman\n🬇🬃  Docker\n🬇🬃  Nerdctl\n", "every option is the same square");
610        let theme = h.env().theme().clone();
611        let step = theme.motion().step;
612        let (quiet, full) = (mark_tone(&theme, &[]), theme.color("accent").expect("accent"));
613        assert_eq!((h.fg(0, 0), h.fg(0, 2)), (Some(full), Some(quiet)), "the chosen square has the chosen colour");
614        assert_eq!(h.bg(0, 0), h.bg(4, 0), "and no box behind it");
615        let between = |c: Option<Rgb>| c.is_some_and(|c| c.r > quiet.r && c.r < full.r);
616        h.send(2);
617        for _ in 0..8 {
618            h.advance(step / 4);
619            assert_eq!(h.screen(), "🬇🬃  Podman\n🬇🬃  Docker\n🬇🬃  Nerdctl\n", "the shape never changes");
620        }
621        h.send(0);
622        h.advance(step);
623        assert!(between(h.fg(0, 0)) && between(h.fg(0, 2)), "halfway both squares have a blended colour");
624        h.advance(step);
625        assert_eq!((h.fg(0, 0), h.fg(0, 2)), (Some(full), Some(quiet)));
626        h.set_reduced_motion(true);
627        h.send(1);
628        assert_eq!((h.fg(0, 1), h.fg(0, 0)), (Some(full), Some(quiet)), "reduced motion changes at once");
629    }
630
631    #[test]
632    fn a_group_without_a_style_draws_squares_and_no_full_box() {
633        struct Plain;
634        impl App for Plain {
635            type Msg = usize;
636            fn update(&mut self, _: usize) -> Command<usize> {
637                Command::none()
638            }
639            fn view(&self, ui: &mut View<'_, usize>) {
640                ui.add(RadioGroup::new(["Podman", "Docker", "Nerdctl"]).selected(Some(1)).on_select(|i| i));
641            }
642        }
643        let h = Harness::new(Plain, 20, 3);
644        assert_eq!(h.screen(), "🬇🬃  Podman\n🬇🬃  Docker\n🬇🬃  Nerdctl\n", "the chosen option keeps its square");
645        let accent = h.env().theme().color("accent");
646        assert_eq!(h.fg(0, 1), accent, "the chosen square has the chosen colour");
647        assert_eq!((h.bg(0, 1), h.bg(1, 1)), (h.bg(4, 1), h.bg(4, 1)), "no full box behind it");
648    }
649
650    /// Screen rows and colours of the first and third options while the mark moves from one to the
651    /// other.
652    fn frame(h: &Harness<Demo>) -> (String, [Option<Rgb>; 4]) {
653        let rows: Vec<String> = h.screen().lines().map(|line| line.chars().take(2).collect()).collect();
654        (format!("{}|{}", rows[0], rows[2]), [h.fg(0, 0), h.bg(0, 0), h.fg(0, 2), h.bg(0, 2)])
655    }
656
657    #[test]
658    fn choosing_blends_the_colours_and_swaps_square_and_box_halfway_with_no_size_between() {
659        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
660        let theme = h.env().theme().clone();
661        let step = theme.motion().step;
662        let (quiet, full) = (mark_tone(&theme, &[]), theme.color("accent").expect("accent"));
663        let ground = h.bg(10, 0);
664        let one_ms = Duration::from_millis(1);
665        let expect = |a: &str, b: &str| format!("{a}|{b}");
666        // Strictly between the quiet and the chosen tone, by red channel (both are greys here).
667        let between = |c: Option<Rgb>| c.is_some_and(|c| c.r > quiet.r && c.r < full.r);
668        h.send(2);
669        let (rows, colours) = frame(&h);
670        assert_eq!(rows, expect("  ", "🬇🬃"), "the change starts where it was");
671        assert_eq!((colours[1], colours[2], colours[3]), (Some(full), Some(quiet), ground));
672        h.advance(step / 2);
673        let (rows, colours) = frame(&h);
674        assert_eq!(rows, expect("  ", "🬇🬃"), "before halfway both keep their shape");
675        assert!(between(colours[1]) && between(colours[2]), "while their colours blend: {colours:?}");
676        h.advance(step / 2 - one_ms);
677        assert_eq!(frame(&h).0, expect("  ", "🬇🬃"));
678        h.advance(one_ms * 2);
679        let (rows, colours) = frame(&h);
680        assert_eq!(rows, expect("🬇🬃", "  "), "halfway the shapes swap, with no size in between");
681        assert!(between(colours[0]) && between(colours[3]), "and the colours keep blending: {colours:?}");
682        h.advance(step);
683        let (rows, colours) = frame(&h);
684        assert_eq!(rows, expect("🬇🬃", "  "));
685        assert_eq!((colours[0], colours[1], colours[3]), (Some(quiet), ground, Some(full)));
686        for _ in 0..8 {
687            h.advance(step / 4);
688            let rows = frame(&h).0;
689            assert!(!rows.contains('▐') && !rows.contains('▌'), "no medium size ever: {rows}");
690        }
691
692        // Back again: the same two shapes, swapping halfway.
693        h.send(0);
694        h.advance(step - one_ms);
695        assert_eq!(frame(&h).0, expect("🬇🬃", "  "));
696        h.advance(one_ms * 2);
697        assert_eq!(frame(&h).0, expect("  ", "🬇🬃"));
698        h.advance(step);
699        let (rows, colours) = frame(&h);
700        assert_eq!(rows, expect("  ", "🬇🬃"));
701        assert_eq!((colours[1], colours[2], colours[3]), (Some(full), Some(quiet), ground));
702        assert_eq!(h.screen().lines().nth(1), Some("🬇🬃  Docker"), "the untouched option never moved");
703    }
704
705    #[test]
706    fn reduced_motion_swaps_the_marks_at_once() {
707        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
708        h.set_reduced_motion(true);
709        h.send(2);
710        assert_eq!(h.screen(), "🬇🬃  Podman\n🬇🬃  Docker\n    Nerdctl\n");
711        assert_eq!(h.bg(1, 2), h.env().theme().color("accent"));
712    }
713
714    #[test]
715    fn ascii_marks_fall_back_to_boxes_that_blend_from_faint_to_full() {
716        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
717        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
718        let theme = h.env().theme().clone();
719        let (faint, full) = (theme.color("raised").expect("raised"), theme.color("accent").expect("accent"));
720        assert_eq!(h.screen(), "    Podman\n    Docker\n    Nerdctl\n");
721        assert_eq!((h.bg(0, 0), h.bg(1, 1), h.bg(0, 2)), (Some(full), Some(faint), Some(faint)));
722        h.send(2);
723        h.advance(theme.motion().step);
724        let middle = faint.mix(full, 0.5);
725        assert_eq!((h.bg(1, 0), h.bg(0, 2)), (Some(middle), Some(middle)), "halfway both boxes have the middle tone");
726        assert_eq!(h.screen(), "    Podman\n    Docker\n    Nerdctl\n", "labels stay in place");
727        h.advance(theme.motion().step);
728        assert_eq!((h.bg(0, 0), h.bg(1, 2)), (Some(faint), Some(full)));
729        h.hover(6, 1);
730        assert_eq!(h.bg(0, 1), theme.color("active"), "hover lifts the faint box like the box style");
731    }
732
733    #[test]
734    fn a_theme_can_replace_the_mark_glyphs() {
735        let dir = std::env::temp_dir().join(format!("quvyta-radio-mark-{}", std::process::id()));
736        std::fs::create_dir_all(&dir).expect("temp dir");
737        let theme = "[meta]\nname = \"Plain marks\"\nextends = \"monochrome\"\n\n[icons]\n\
738                     radio-mark-small = { nerd = \"•\", unicode = \"•\", ascii = \".\" }\n";
739        std::fs::write(dir.join("plain-marks.toml"), theme).expect("theme file");
740        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
741        let env = crate::env::Env::load(&dirs).expect("loads");
742        std::fs::remove_dir_all(&dir).ok();
743        assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
744        let mut h = Harness::with_env(demo(Some(0), false, RadioStyle::Mark), env, 20, 3);
745        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
746        h.set_theme("plain-marks");
747        assert_eq!(h.screen(), "    Podman\n•   Docker\n•   Nerdctl\n", "a narrower glyph keeps two cells");
748        h.send(1);
749        h.advance(h.env().theme().motion().step * 2);
750        assert_eq!(h.screen(), "•   Podman\n    Docker\n•   Nerdctl\n");
751        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
752        assert_eq!(h.screen(), ".   Podman\n    Docker\n.   Nerdctl\n");
753    }
754
755    #[test]
756    fn hover_lifts_a_square_focus_warms_it_and_the_chosen_box_follows_the_box_tones() {
757        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
758        let theme = h.env().theme().clone();
759        h.hover(6, 2);
760        assert_eq!(h.fg(0, 2), Some(mark_tone(&theme, &[State::Hover])), "the hovered square lifts");
761        assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[])), "the others stay calm");
762        h.hover(6, 0);
763        assert_eq!(h.bg(0, 0), Some(mark_tone(&theme, &[State::Checked, State::Hover])));
764        assert_eq!(
765            h.bg(0, 0),
766            theme.style("radio", Some("box"), &[State::Checked, State::Hover]).paint("bg").map(|p| p.at(0.0))
767        );
768        h.hover(19, 5).press("tab");
769        assert_ne!(h.bg(0, 0), theme.color("accent"), "keyboard focus breathes on the chosen box");
770        assert_eq!(h.screen().matches('▌').count(), 0, "a radio group shows no pillar");
771
772        let mut h = Harness::new(demo(None, false, RadioStyle::Mark), 20, 3);
773        h.press("tab");
774        assert_eq!(h.fg(0, 0), Some(mark_tone(&theme, &[State::Focus])), "focus rests on the first square");
775        assert_ne!(h.fg(0, 0), Some(mark_tone(&theme, &[])));
776        assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[])));
777        h.click(6, 1);
778        assert_eq!(h.fg(0, 0), Some(mark_tone(&theme, &[])), "a pointer focus shows no focus tone");
779    }
780
781    #[test]
782    fn disabled_marks_use_disabled_tones_and_ignore_input() {
783        struct Off;
784        impl App for Off {
785            type Msg = usize;
786            fn update(&mut self, _: usize) -> Command<usize> {
787                Command::none()
788            }
789            fn view(&self, ui: &mut View<'_, usize>) {
790                ui.add(
791                    RadioGroup::new(["Podman", "Docker"])
792                        .style(RadioStyle::Mark)
793                        .selected(Some(0))
794                        .disabled(true)
795                        .on_select(|i| i),
796                );
797            }
798        }
799        let mut h = Harness::new(Off, 20, 2);
800        let theme = h.env().theme().clone();
801        assert_eq!(h.screen(), "    Podman\n🬇🬃  Docker\n");
802        assert_eq!(h.bg(0, 0), Some(mark_tone(&theme, &[State::Checked, State::Disabled])));
803        assert_eq!(h.bg(0, 0), theme.color("active"));
804        assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[State::Disabled])));
805        assert_eq!(h.fg(4, 1), theme.color("muted"));
806        h.hover(6, 1);
807        assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[State::Disabled])), "hover changes nothing");
808        h.click_text("Docker").press("tab").press("down");
809        assert_eq!(h.screen(), "    Podman\n🬇🬃  Docker\n");
810    }
811
812    /// Marks sit on the canvas or a panel surface. In every theme a square must show on both, stay
813    /// quieter than the chosen box, lift under the pointer, and stay visible when disabled.
814    #[test]
815    fn marks_read_in_every_theme() {
816        let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
817        for id in ["monochrome", "iris", "nordic", "amber"] {
818            h.set_theme(id);
819            let theme = h.env().theme().clone();
820            let color = |name: &str| theme.color(name).expect("token");
821            let (quiet, hover, full) =
822                (mark_tone(&theme, &[]), mark_tone(&theme, &[State::Hover]), mark_tone(&theme, &[State::Checked]));
823            let disabled = mark_tone(&theme, &[State::Disabled]);
824            for ground in ["canvas", "surface"] {
825                let ground = color(ground);
826                assert!(
827                    quiet.contrast_ratio(ground) >= 2.0,
828                    "{id}: a square reads {:.2}:1",
829                    quiet.contrast_ratio(ground)
830                );
831                assert!(
832                    disabled.contrast_ratio(ground) >= 1.4,
833                    "{id}: a disabled square reads {:.2}:1",
834                    disabled.contrast_ratio(ground)
835                );
836            }
837            assert!(hover.relative_luminance() > quiet.relative_luminance(), "{id}: hover lifts the square");
838            assert!(
839                full.relative_luminance() > hover.relative_luminance() && full.contrast_ratio(hover) >= 1.25,
840                "{id}: a hovered square never looks chosen ({:.2}:1)",
841                full.contrast_ratio(hover)
842            );
843            assert!(
844                full.contrast_ratio(quiet) >= 1.5,
845                "{id}: the chosen box stands apart from a square in tone as well as size"
846            );
847            assert!(disabled.relative_luminance() < quiet.relative_luminance(), "{id}: disabled is quieter");
848        }
849    }
850}