1use 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
18const ROW_GAP: u16 = 4;
20
21const MARK_STEPS: u32 = 2;
23
24const MARK_ICON: &str = "radio-mark-small";
26
27const MARK_FRAME: Duration = Duration::from_millis(16);
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum RadioStyle {
33 #[default]
39 Square,
40 Mark,
46 Box,
49 Dot,
51}
52
53impl RadioStyle {
54 fn width(self) -> u16 {
56 match self {
57 Self::Square | Self::Mark | Self::Box => BOX,
58 Self::Dot => 1,
59 }
60 }
61}
62
63pub 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 #[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 #[must_use]
110 pub fn selected(mut self, index: Option<usize>) -> Self {
111 self.selected = index;
112 self
113 }
114
115 #[must_use]
117 pub fn horizontal(mut self, horizontal: bool) -> Self {
118 self.horizontal = horizontal;
119 self
120 }
121
122 #[must_use]
124 pub fn style(mut self, style: RadioStyle) -> Self {
125 self.style = style;
126 self
127 }
128
129 #[must_use]
131 pub fn disabled(mut self, disabled: bool) -> Self {
132 self.disabled = disabled;
133 self
134 }
135
136 #[must_use]
139 pub fn wrap(mut self, wrap: bool) -> Self {
140 self.wrap = wrap;
141 self
142 }
143
144 #[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 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 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#[derive(Debug, Default)]
184struct MarkGrowth(Vec<Tween>);
185
186fn 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
210fn 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 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 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 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 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 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 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 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 #[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}