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