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 on_select: Option<IndexMessage<Msg>>,
90}
91
92impl<Msg> RadioGroup<Msg> {
93 #[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::default(),
101 disabled: false,
102 on_select: None,
103 }
104 }
105
106 #[must_use]
108 pub fn selected(mut self, index: Option<usize>) -> Self {
109 self.selected = index;
110 self
111 }
112
113 #[must_use]
115 pub fn horizontal(mut self, horizontal: bool) -> Self {
116 self.horizontal = horizontal;
117 self
118 }
119
120 #[must_use]
122 pub fn style(mut self, style: RadioStyle) -> Self {
123 self.style = style;
124 self
125 }
126
127 #[must_use]
129 pub fn disabled(mut self, disabled: bool) -> Self {
130 self.disabled = disabled;
131 self
132 }
133
134 #[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 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 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#[derive(Debug, Default)]
174struct MarkGrowth(Vec<Tween>);
175
176fn 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
200fn 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 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 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 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 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_mark_style_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 #[test]
570 fn a_group_without_a_style_draws_squares_and_no_full_box() {
571 struct Plain;
572 impl App for Plain {
573 type Msg = usize;
574 fn update(&mut self, _: usize) -> Command<usize> {
575 Command::none()
576 }
577 fn view(&self, ui: &mut View<'_, usize>) {
578 ui.add(RadioGroup::new(["Podman", "Docker", "Nerdctl"]).selected(Some(1)).on_select(|i| i));
579 }
580 }
581 let h = Harness::new(Plain, 20, 3);
582 assert_eq!(h.screen(), "🬇🬃 Podman\n🬇🬃 Docker\n🬇🬃 Nerdctl\n", "the chosen option keeps its square");
583 let accent = h.env().theme().color("accent");
584 assert_eq!(h.fg(0, 1), accent, "the chosen square has the chosen colour");
585 assert_eq!((h.bg(0, 1), h.bg(1, 1)), (h.bg(4, 1), h.bg(4, 1)), "no full box behind it");
586 }
587
588 fn frame(h: &Harness<Demo>) -> (String, [Option<Rgb>; 4]) {
591 let rows: Vec<String> = h.screen().lines().map(|line| line.chars().take(2).collect()).collect();
592 (format!("{}|{}", rows[0], rows[2]), [h.fg(0, 0), h.bg(0, 0), h.fg(0, 2), h.bg(0, 2)])
593 }
594
595 #[test]
596 fn choosing_blends_the_colours_and_swaps_square_and_box_halfway_with_no_size_between() {
597 let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
598 let theme = h.env().theme().clone();
599 let step = theme.motion().step;
600 let (quiet, full) = (mark_tone(&theme, &[]), theme.color("accent").expect("accent"));
601 let ground = h.bg(10, 0);
602 let one_ms = Duration::from_millis(1);
603 let expect = |a: &str, b: &str| format!("{a}|{b}");
604 let between = |c: Option<Rgb>| c.is_some_and(|c| c.r > quiet.r && c.r < full.r);
606 h.send(2);
607 let (rows, colours) = frame(&h);
608 assert_eq!(rows, expect(" ", "🬇🬃"), "the change starts where it was");
609 assert_eq!((colours[1], colours[2], colours[3]), (Some(full), Some(quiet), ground));
610 h.advance(step / 2);
611 let (rows, colours) = frame(&h);
612 assert_eq!(rows, expect(" ", "🬇🬃"), "before halfway both keep their shape");
613 assert!(between(colours[1]) && between(colours[2]), "while their colours blend: {colours:?}");
614 h.advance(step / 2 - one_ms);
615 assert_eq!(frame(&h).0, expect(" ", "🬇🬃"));
616 h.advance(one_ms * 2);
617 let (rows, colours) = frame(&h);
618 assert_eq!(rows, expect("🬇🬃", " "), "halfway the shapes swap, with no size in between");
619 assert!(between(colours[0]) && between(colours[3]), "and the colours keep blending: {colours:?}");
620 h.advance(step);
621 let (rows, colours) = frame(&h);
622 assert_eq!(rows, expect("🬇🬃", " "));
623 assert_eq!((colours[0], colours[1], colours[3]), (Some(quiet), ground, Some(full)));
624 for _ in 0..8 {
625 h.advance(step / 4);
626 let rows = frame(&h).0;
627 assert!(!rows.contains('▐') && !rows.contains('▌'), "no medium size ever: {rows}");
628 }
629
630 h.send(0);
632 h.advance(step - one_ms);
633 assert_eq!(frame(&h).0, expect("🬇🬃", " "));
634 h.advance(one_ms * 2);
635 assert_eq!(frame(&h).0, expect(" ", "🬇🬃"));
636 h.advance(step);
637 let (rows, colours) = frame(&h);
638 assert_eq!(rows, expect(" ", "🬇🬃"));
639 assert_eq!((colours[1], colours[2], colours[3]), (Some(full), Some(quiet), ground));
640 assert_eq!(h.screen().lines().nth(1), Some("🬇🬃 Docker"), "the untouched option never moved");
641 }
642
643 #[test]
644 fn reduced_motion_swaps_the_marks_at_once() {
645 let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
646 h.set_reduced_motion(true);
647 h.send(2);
648 assert_eq!(h.screen(), "🬇🬃 Podman\n🬇🬃 Docker\n Nerdctl\n");
649 assert_eq!(h.bg(1, 2), h.env().theme().color("accent"));
650 }
651
652 #[test]
653 fn ascii_marks_fall_back_to_boxes_that_blend_from_faint_to_full() {
654 let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
655 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
656 let theme = h.env().theme().clone();
657 let (faint, full) = (theme.color("raised").expect("raised"), theme.color("accent").expect("accent"));
658 assert_eq!(h.screen(), " Podman\n Docker\n Nerdctl\n");
659 assert_eq!((h.bg(0, 0), h.bg(1, 1), h.bg(0, 2)), (Some(full), Some(faint), Some(faint)));
660 h.send(2);
661 h.advance(theme.motion().step);
662 let middle = faint.mix(full, 0.5);
663 assert_eq!((h.bg(1, 0), h.bg(0, 2)), (Some(middle), Some(middle)), "halfway both boxes have the middle tone");
664 assert_eq!(h.screen(), " Podman\n Docker\n Nerdctl\n", "labels stay in place");
665 h.advance(theme.motion().step);
666 assert_eq!((h.bg(0, 0), h.bg(1, 2)), (Some(faint), Some(full)));
667 h.hover(6, 1);
668 assert_eq!(h.bg(0, 1), theme.color("active"), "hover lifts the faint box like the box style");
669 }
670
671 #[test]
672 fn a_theme_can_replace_the_mark_glyphs() {
673 let dir = std::env::temp_dir().join(format!("quvyta-radio-mark-{}", std::process::id()));
674 std::fs::create_dir_all(&dir).expect("temp dir");
675 let theme = "[meta]\nname = \"Plain marks\"\nextends = \"monochrome\"\n\n[icons]\n\
676 radio-mark-small = { nerd = \"•\", unicode = \"•\", ascii = \".\" }\n";
677 std::fs::write(dir.join("plain-marks.toml"), theme).expect("theme file");
678 let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
679 let env = crate::env::Env::load(&dirs).expect("loads");
680 std::fs::remove_dir_all(&dir).ok();
681 assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
682 let mut h = Harness::with_env(demo(Some(0), false, RadioStyle::Mark), env, 20, 3);
683 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
684 h.set_theme("plain-marks");
685 assert_eq!(h.screen(), " Podman\n• Docker\n• Nerdctl\n", "a narrower glyph keeps two cells");
686 h.send(1);
687 h.advance(h.env().theme().motion().step * 2);
688 assert_eq!(h.screen(), "• Podman\n Docker\n• Nerdctl\n");
689 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
690 assert_eq!(h.screen(), ". Podman\n Docker\n. Nerdctl\n");
691 }
692
693 #[test]
694 fn hover_lifts_a_square_focus_warms_it_and_the_chosen_box_follows_the_box_tones() {
695 let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
696 let theme = h.env().theme().clone();
697 h.hover(6, 2);
698 assert_eq!(h.fg(0, 2), Some(mark_tone(&theme, &[State::Hover])), "the hovered square lifts");
699 assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[])), "the others stay calm");
700 h.hover(6, 0);
701 assert_eq!(h.bg(0, 0), Some(mark_tone(&theme, &[State::Checked, State::Hover])));
702 assert_eq!(
703 h.bg(0, 0),
704 theme.style("radio", Some("box"), &[State::Checked, State::Hover]).paint("bg").map(|p| p.at(0.0))
705 );
706 h.hover(19, 5).press("tab");
707 assert_ne!(h.bg(0, 0), theme.color("accent"), "keyboard focus breathes on the chosen box");
708 assert_eq!(h.screen().matches('▌').count(), 0, "a radio group shows no pillar");
709
710 let mut h = Harness::new(demo(None, false, RadioStyle::Mark), 20, 3);
711 h.press("tab");
712 assert_eq!(h.fg(0, 0), Some(mark_tone(&theme, &[State::Focus])), "focus rests on the first square");
713 assert_ne!(h.fg(0, 0), Some(mark_tone(&theme, &[])));
714 assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[])));
715 h.click(6, 1);
716 assert_eq!(h.fg(0, 0), Some(mark_tone(&theme, &[])), "a pointer focus shows no focus tone");
717 }
718
719 #[test]
720 fn disabled_marks_use_disabled_tones_and_ignore_input() {
721 struct Off;
722 impl App for Off {
723 type Msg = usize;
724 fn update(&mut self, _: usize) -> Command<usize> {
725 Command::none()
726 }
727 fn view(&self, ui: &mut View<'_, usize>) {
728 ui.add(
729 RadioGroup::new(["Podman", "Docker"])
730 .style(RadioStyle::Mark)
731 .selected(Some(0))
732 .disabled(true)
733 .on_select(|i| i),
734 );
735 }
736 }
737 let mut h = Harness::new(Off, 20, 2);
738 let theme = h.env().theme().clone();
739 assert_eq!(h.screen(), " Podman\n🬇🬃 Docker\n");
740 assert_eq!(h.bg(0, 0), Some(mark_tone(&theme, &[State::Checked, State::Disabled])));
741 assert_eq!(h.bg(0, 0), theme.color("active"));
742 assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[State::Disabled])));
743 assert_eq!(h.fg(4, 1), theme.color("muted"));
744 h.hover(6, 1);
745 assert_eq!(h.fg(0, 1), Some(mark_tone(&theme, &[State::Disabled])), "hover changes nothing");
746 h.click_text("Docker").press("tab").press("down");
747 assert_eq!(h.screen(), " Podman\n🬇🬃 Docker\n");
748 }
749
750 #[test]
753 fn marks_read_in_every_theme() {
754 let mut h = Harness::new(demo(Some(0), false, RadioStyle::Mark), 20, 3);
755 for id in ["monochrome", "iris", "nordic", "amber"] {
756 h.set_theme(id);
757 let theme = h.env().theme().clone();
758 let color = |name: &str| theme.color(name).expect("token");
759 let (quiet, hover, full) =
760 (mark_tone(&theme, &[]), mark_tone(&theme, &[State::Hover]), mark_tone(&theme, &[State::Checked]));
761 let disabled = mark_tone(&theme, &[State::Disabled]);
762 for ground in ["canvas", "surface"] {
763 let ground = color(ground);
764 assert!(
765 quiet.contrast_ratio(ground) >= 2.0,
766 "{id}: a square reads {:.2}:1",
767 quiet.contrast_ratio(ground)
768 );
769 assert!(
770 disabled.contrast_ratio(ground) >= 1.4,
771 "{id}: a disabled square reads {:.2}:1",
772 disabled.contrast_ratio(ground)
773 );
774 }
775 assert!(hover.relative_luminance() > quiet.relative_luminance(), "{id}: hover lifts the square");
776 assert!(
777 full.relative_luminance() > hover.relative_luminance() && full.contrast_ratio(hover) >= 1.25,
778 "{id}: a hovered square never looks chosen ({:.2}:1)",
779 full.contrast_ratio(hover)
780 );
781 assert!(
782 full.contrast_ratio(quiet) >= 1.5,
783 "{id}: the chosen box stands apart from a square in tone as well as size"
784 );
785 assert!(disabled.relative_luminance() < quiet.relative_luminance(), "{id}: disabled is quieter");
786 }
787 }
788}