Skip to main content

turbo_vision/views/
cluster_group.rs

1// (C) 2025 - Enzo Lombardi
2
3//! CheckBoxes and RadioButtons - one focusable control holding several items.
4//!
5//! This is the Borland shape the port was missing. `TCheckBoxes` and
6//! `TRadioButtons` each hold a list of items in a single view with one bitmask
7//! value; the focus lands on the cluster, and the arrow keys move within it.
8//!
9//! The existing [`CheckBox`](super::checkbox::CheckBox) and
10//! [`RadioButton`](super::radiobutton::RadioButton) hold one label each, and a
11//! radio group is emulated by broadcasting on a group id. That works and stays
12//! supported, but it costs one focus stop per item and one broadcast per
13//! selection. Reach for these when you want a group to behave as a unit.
14//!
15//! # Keys
16//!
17//! | Key | Action |
18//! |-----|--------|
19//! | Up, Down | Move within the cluster |
20//! | Home, End | First or last item |
21//! | Space | Toggle the item, or select it in a radio group |
22//! | Alt+letter | The item whose label marks that letter with tildes |
23//!
24//! Tab still leaves the cluster, because the whole cluster is one focus stop.
25//!
26//! # Example
27//!
28//! ```rust
29//! use turbo_vision::views::cluster_group::CheckBoxes;
30//! use turbo_vision::core::geometry::Rect;
31//!
32//! let mut boxes = CheckBoxes::new(
33//!     Rect::new(2, 2, 24, 5),
34//!     vec!["~B~old".into(), "~I~talic".into(), "~U~nderline".into()],
35//! );
36//! boxes.set_checked(0, true);
37//! boxes.set_checked(2, true);
38//! assert_eq!(boxes.value(), 0b101);
39//! ```
40
41use super::view::{View, ViewCore, write_line_to_terminal};
42use crate::core::command::CommandId;
43use crate::core::draw::DrawBuffer;
44use crate::core::event::{Event, EventType, KB_DOWN, KB_END, KB_HOME, KB_UP, MB_LEFT_BUTTON};
45use crate::core::geometry::{Point, Rect};
46use crate::core::palette::{
47    Attr, CLUSTER_DISABLED, CLUSTER_FOCUSED, CLUSTER_NORMAL, CLUSTER_SHORTCUT,
48};
49use crate::core::state::{State, StateFlags};
50use crate::terminal::Terminal;
51
52/// Key code for the space bar, which toggles or selects an item.
53const KB_SPACE: u16 = b' ' as u16;
54
55/// Cells a marker such as `[X] ` or `( ) ` occupies before the label.
56const MARKER_WIDTH: usize = 4;
57
58/// The largest number of items a cluster can hold, one per bit of the value.
59pub const MAX_CLUSTER_ITEMS: usize = 32;
60
61/// One item: its drawn label plus the hotkey pulled out of the tildes.
62#[derive(Debug, Clone)]
63struct Item {
64    /// Label with the tilde markers stripped.
65    label: String,
66    /// The letter between tildes, lowercased.
67    hotkey: Option<char>,
68    /// Where that letter sits within `label`.
69    hotkey_pos: Option<usize>,
70    /// Disabled items are drawn dimmed and cannot be chosen.
71    enabled: bool,
72}
73
74/// Split `"~B~old"` into its drawn label, hotkey letter and the letter's index.
75///
76/// A label with no tildes, or an unterminated one, simply has no hotkey.
77fn parse_label(text: &str) -> Item {
78    let mut label = String::new();
79    let mut hotkey = None;
80    let mut hotkey_pos = None;
81    let mut chars = text.chars().peekable();
82
83    while let Some(ch) = chars.next() {
84        if ch != '~' {
85            label.push(ch);
86            continue;
87        }
88        if let Some(letter) = chars.next() {
89            if hotkey.is_none() {
90                hotkey = Some(letter.to_ascii_lowercase());
91                hotkey_pos = Some(label.chars().count());
92            }
93            label.push(letter);
94            if chars.peek() == Some(&'~') {
95                chars.next();
96            }
97        }
98    }
99
100    Item {
101        label,
102        hotkey,
103        hotkey_pos,
104        enabled: true,
105    }
106}
107
108/// What the marker before each label looks like, and how selection behaves.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum Kind {
111    /// Square brackets; any number of items can be on at once.
112    Check,
113    /// Round brackets; exactly one item is on.
114    Radio,
115}
116
117/// The machinery shared by [`CheckBoxes`] and [`RadioButtons`].
118struct ClusterGroup {
119    core: ViewCore,
120    kind: Kind,
121    items: Vec<Item>,
122    /// One bit per item. A radio cluster keeps exactly one bit set.
123    value: u32,
124    /// Item the arrow keys are on.
125    focused_item: usize,
126    /// Command broadcast when the value changes. Zero means none.
127    on_change: CommandId,
128    view_state: StateFlags,
129}
130
131impl ClusterGroup {
132    fn new(bounds: Rect, kind: Kind, labels: Vec<String>) -> Self {
133        let mut group = Self {
134            core: ViewCore {
135                bounds,
136                palette_chain: None,
137                ..ViewCore::default()
138            },
139            kind,
140            items: Vec::new(),
141            value: 0,
142            focused_item: 0,
143            on_change: 0,
144            view_state: State::empty(),
145        };
146        group.set_labels(labels);
147        if kind == Kind::Radio && !group.items.is_empty() {
148            group.value = 1;
149        }
150        group
151    }
152
153    /// Replace the labels. Items past [`MAX_CLUSTER_ITEMS`] are dropped, since
154    /// the value has one bit each and a silently half-stored item would be
155    /// worse than a missing one.
156    fn set_labels(&mut self, labels: Vec<String>) {
157        self.items = labels
158            .iter()
159            .take(MAX_CLUSTER_ITEMS)
160            .map(|l| parse_label(l))
161            .collect();
162        self.focused_item = self.focused_item.min(self.items.len().saturating_sub(1));
163        self.value &= self.item_mask();
164        if self.kind == Kind::Radio && self.value == 0 && !self.items.is_empty() {
165            self.value = 1;
166        }
167    }
168
169    /// Bits that correspond to real items.
170    fn item_mask(&self) -> u32 {
171        if self.items.len() >= MAX_CLUSTER_ITEMS {
172            u32::MAX
173        } else {
174            (1u32 << self.items.len()) - 1
175        }
176    }
177
178    fn is_set(&self, index: usize) -> bool {
179        index < self.items.len() && self.value & (1 << index) != 0
180    }
181
182    /// Turn one item on or off. Out-of-range indices are ignored.
183    ///
184    /// Returns true when the value changed.
185    fn set_bit(&mut self, index: usize, on: bool) -> bool {
186        if index >= self.items.len() {
187            return false;
188        }
189        let before = self.value;
190        match (self.kind, on) {
191            // A radio cluster holds exactly one bit, so selecting clears the rest.
192            (Kind::Radio, true) => self.value = 1 << index,
193            // Turning the only radio item off would leave nothing selected.
194            (Kind::Radio, false) => {}
195            (Kind::Check, true) => self.value |= 1 << index,
196            (Kind::Check, false) => self.value &= !(1 << index),
197        }
198        self.value != before
199    }
200
201    /// Act on one item the way Space would: toggle a check, select a radio.
202    fn activate(&mut self, index: usize) -> bool {
203        if !self.items.get(index).is_some_and(|i| i.enabled) {
204            return false;
205        }
206        match self.kind {
207            Kind::Check => {
208                let on = self.is_set(index);
209                self.set_bit(index, !on)
210            }
211            Kind::Radio => self.set_bit(index, true),
212        }
213    }
214
215    /// Move the focused item by `delta`, clamping at both ends.
216    fn move_focus(&mut self, delta: i32) {
217        if self.items.is_empty() {
218            return;
219        }
220        let last = self.items.len() as i32 - 1;
221        self.focused_item = (self.focused_item as i32 + delta).clamp(0, last) as usize;
222    }
223
224    /// Index of the item whose hotkey is `letter`.
225    fn item_for_hotkey(&self, letter: char) -> Option<usize> {
226        let letter = letter.to_ascii_lowercase();
227        self.items
228            .iter()
229            .position(|i| i.enabled && i.hotkey == Some(letter))
230    }
231
232    /// Item under a screen point, if the point is on one.
233    fn item_at(&self, pos: Point) -> Option<usize> {
234        if !self.core.bounds.contains(pos) {
235            return None;
236        }
237        let row = (pos.y - self.core.bounds.a.y) as usize;
238        (row < self.items.len()).then_some(row)
239    }
240
241    /// The marker drawn before an item's label.
242    fn marker(&self, index: usize) -> &'static str {
243        match (self.kind, self.is_set(index)) {
244            (Kind::Check, true) => "[X] ",
245            (Kind::Check, false) => "[ ] ",
246            (Kind::Radio, true) => "(\u{2022}) ",
247            (Kind::Radio, false) => "( ) ",
248        }
249    }
250
251    fn is_focused_view(&self) -> bool {
252        self.view_state.contains(State::FOCUSED)
253    }
254
255    fn draw_group(&mut self, terminal: &mut Terminal) {
256        let width = self.core.bounds.width_clamped().max(0) as usize;
257        let height = self.core.bounds.height_clamped().max(0) as usize;
258        if width == 0 || height == 0 {
259            return;
260        }
261        let normal = self.map_color(CLUSTER_NORMAL);
262        let focused = self.map_color(CLUSTER_FOCUSED);
263        let shortcut = self.map_color(CLUSTER_SHORTCUT);
264        let disabled = self.map_color(CLUSTER_DISABLED);
265
266        for row in 0..height {
267            let mut buf = DrawBuffer::new(width);
268            buf.move_char(0, ' ', normal, width);
269
270            if let Some(item) = self.items.get(row) {
271                // Only the focused item of a focused cluster is highlighted;
272                // the cluster is one focus stop, so the rest stay plain.
273                let attr: Attr = if !item.enabled {
274                    disabled
275                } else if self.is_focused_view() && row == self.focused_item {
276                    focused
277                } else {
278                    normal
279                };
280                buf.move_str(0, self.marker(row), attr);
281
282                if MARKER_WIDTH < width {
283                    let room = width - MARKER_WIDTH;
284                    let shown: String = item.label.chars().take(room).collect();
285                    buf.move_str(MARKER_WIDTH, &shown, attr);
286                    // Repaint just the hotkey letter, unless the item is
287                    // disabled, where a highlight would invite a click.
288                    if item.enabled {
289                        if let Some(pos) = item.hotkey_pos {
290                            if let Some(letter) = item.label.chars().nth(pos) {
291                                if MARKER_WIDTH + pos < width {
292                                    buf.put_char(MARKER_WIDTH + pos, letter, shortcut);
293                                }
294                            }
295                        }
296                    }
297                }
298            }
299
300            write_line_to_terminal(
301                terminal,
302                self.core.bounds.a.x,
303                self.core.bounds.a.y + row as i16,
304                &buf,
305            );
306        }
307    }
308
309    /// Turn a value change into the outgoing event.
310    fn report(&self, event: &mut Event, changed: bool) {
311        if changed && self.on_change != 0 {
312            *event = Event::broadcast(self.on_change);
313        } else {
314            event.clear();
315        }
316    }
317
318    fn handle(&mut self, event: &mut Event) {
319        if event.what == EventType::MouseDown && event.mouse.buttons & MB_LEFT_BUTTON != 0 {
320            if let Some(index) = self.item_at(event.mouse.pos) {
321                self.focused_item = index;
322                let changed = self.activate(index);
323                self.report(event, changed);
324            }
325            return;
326        }
327
328        if event.what != EventType::Keyboard {
329            return;
330        }
331
332        // Hotkeys work whether or not the cluster holds the focus, which is how
333        // a dialog's Alt shortcuts are expected to behave.
334        if event
335            .key_modifiers
336            .contains(crossterm::event::KeyModifiers::ALT)
337        {
338            let letter = (event.key_code & 0xFF) as u8 as char;
339            if let Some(index) = self.item_for_hotkey(letter) {
340                self.focused_item = index;
341                let changed = self.activate(index);
342                self.report(event, changed);
343                return;
344            }
345        }
346
347        if !self.is_focused_view() {
348            return;
349        }
350
351        match event.key_code {
352            KB_UP => self.move_focus(-1),
353            KB_DOWN => self.move_focus(1),
354            KB_HOME => self.move_focus(i32::MIN / 2),
355            KB_END => self.move_focus(i32::MAX / 2),
356            KB_SPACE => {
357                let index = self.focused_item;
358                let changed = self.activate(index);
359                self.report(event, changed);
360                return;
361            }
362            // Not ours: Tab, Enter and the dialog's own keys must get through.
363            _ => return,
364        }
365        event.clear();
366    }
367}
368
369impl View for ClusterGroup {
370    fn core(&self) -> &ViewCore {
371        &self.core
372    }
373
374    fn core_mut(&mut self) -> &mut ViewCore {
375        &mut self.core
376    }
377
378    fn can_focus(&self) -> bool {
379        true
380    }
381
382    fn state(&self) -> StateFlags {
383        self.view_state
384    }
385
386    fn set_state(&mut self, state: StateFlags) {
387        self.view_state = state;
388    }
389
390    fn draw(&mut self, terminal: &mut Terminal) {
391        self.draw_group(terminal);
392    }
393
394    fn handle_event(&mut self, event: &mut Event) {
395        self.handle(event);
396    }
397
398    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
399        use crate::core::palette::{Palette, palettes};
400        Some(Palette::from_slice(palettes::CP_CLUSTER))
401    }
402
403    fn as_any(&self) -> &dyn std::any::Any {
404        self
405    }
406
407    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
408        self
409    }
410}
411
412/// Generates the shared surface of [`CheckBoxes`] and [`RadioButtons`].
413///
414/// Both wrap the same machinery and differ only in their marker and in whether
415/// more than one item can be on, so the API is written once here rather than
416/// copied twice with one word changed.
417macro_rules! cluster_control {
418    ($name:ident, $kind:expr, $doc:literal) => {
419        #[doc = $doc]
420        pub struct $name {
421            inner: ClusterGroup,
422        }
423
424        impl $name {
425            /// Create the cluster from labels, one per row.
426            ///
427            /// A tilde-wrapped letter, as in `"~B~old"`, becomes that item's Alt
428            /// hotkey. At most [`MAX_CLUSTER_ITEMS`] labels are kept.
429            pub fn new(bounds: Rect, labels: Vec<String>) -> Self {
430                Self {
431                    inner: ClusterGroup::new(bounds, $kind, labels),
432                }
433            }
434
435            /// Replace the labels, keeping the value bits that still apply.
436            pub fn set_labels(&mut self, labels: Vec<String>) {
437                self.inner.set_labels(labels);
438            }
439
440            /// Number of items.
441            pub fn item_count(&self) -> usize {
442                self.inner.items.len()
443            }
444
445            /// The raw bitmask: bit *n* is item *n*.
446            pub fn value(&self) -> u32 {
447                self.inner.value
448            }
449
450            /// Set the raw bitmask. Bits past the last item are dropped.
451            pub fn set_value(&mut self, value: u32) {
452                self.inner.value = value & self.inner.item_mask();
453            }
454
455            /// Index of the item the arrow keys are on.
456            pub fn focused_item(&self) -> usize {
457                self.inner.focused_item
458            }
459
460            /// Move the arrow-key focus within the cluster.
461            pub fn set_focused_item(&mut self, index: usize) {
462                if index < self.inner.items.len() {
463                    self.inner.focused_item = index;
464                }
465            }
466
467            /// Whether one item can be chosen. Disabled items draw dimmed.
468            pub fn set_enabled(&mut self, index: usize, enabled: bool) {
469                if let Some(item) = self.inner.items.get_mut(index) {
470                    item.enabled = enabled;
471                }
472            }
473
474            /// Whether one item can be chosen.
475            pub fn is_enabled(&self, index: usize) -> bool {
476                self.inner.items.get(index).is_some_and(|i| i.enabled)
477            }
478
479            /// Command broadcast when the value changes. Zero, the default,
480            /// sends none.
481            pub fn set_on_change(&mut self, command: CommandId) {
482                self.inner.on_change = command;
483            }
484        }
485
486        impl View for $name {
487            fn core(&self) -> &ViewCore {
488                self.inner.core()
489            }
490
491            fn core_mut(&mut self) -> &mut ViewCore {
492                self.inner.core_mut()
493            }
494
495            fn state(&self) -> StateFlags {
496                self.inner.state()
497            }
498
499            fn set_state(&mut self, state: StateFlags) {
500                // `ClusterGroup::set_state` has focus bookkeeping the default skips.
501                self.inner.set_state(state);
502            }
503
504            fn can_focus(&self) -> bool {
505                true
506            }
507
508            fn draw(&mut self, terminal: &mut Terminal) {
509                self.inner.draw(terminal);
510            }
511
512            fn handle_event(&mut self, event: &mut Event) {
513                self.inner.handle_event(event);
514            }
515
516            fn set_palette_chain(
517                &mut self,
518                node: Option<crate::core::palette_chain::PaletteChainNode>,
519            ) {
520                self.inner.set_palette_chain(node);
521            }
522
523            fn get_palette(&self) -> Option<crate::core::palette::Palette> {
524                self.inner.get_palette()
525            }
526
527            fn as_any(&self) -> &dyn std::any::Any {
528                self
529            }
530
531            fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
532                self
533            }
534        }
535    };
536}
537
538cluster_control!(
539    CheckBoxes,
540    Kind::Check,
541    "Several check boxes in one focusable control, with one bit of `value` each.\n\nMatches Borland: `TCheckBoxes`."
542);
543
544cluster_control!(
545    RadioButtons,
546    Kind::Radio,
547    "Several radio buttons in one focusable control, exactly one of them on.\n\nMatches Borland: `TRadioButtons`."
548);
549
550impl CheckBoxes {
551    /// Whether one box is ticked.
552    pub fn is_checked(&self, index: usize) -> bool {
553        self.inner.is_set(index)
554    }
555
556    /// Tick or untick one box.
557    pub fn set_checked(&mut self, index: usize, checked: bool) {
558        self.inner.set_bit(index, checked);
559    }
560
561    /// The ticked boxes, in order.
562    pub fn checked_items(&self) -> Vec<usize> {
563        (0..self.inner.items.len())
564            .filter(|&i| self.inner.is_set(i))
565            .collect()
566    }
567}
568
569impl RadioButtons {
570    /// Index of the selected button, or `None` when the cluster is empty.
571    pub fn selected(&self) -> Option<usize> {
572        if self.inner.items.is_empty() {
573            return None;
574        }
575        Some(self.inner.value.trailing_zeros() as usize)
576    }
577
578    /// Select one button. Out-of-range indices are ignored.
579    pub fn set_selected(&mut self, index: usize) {
580        self.inner.set_bit(index, true);
581    }
582
583    /// Text of the selected button.
584    pub fn selected_label(&self) -> Option<&str> {
585        self.inner.items.get(self.selected()?).map(|i| &*i.label)
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crossterm::event::KeyModifiers;
593
594    fn labels() -> Vec<String> {
595        vec!["~B~old".into(), "~I~talic".into(), "~U~nderline".into()]
596    }
597
598    fn boxes() -> CheckBoxes {
599        let mut c = CheckBoxes::new(Rect::new(0, 0, 20, 3), labels());
600        c.set_state(State::FOCUSED);
601        c
602    }
603
604    fn radios() -> RadioButtons {
605        let mut r = RadioButtons::new(Rect::new(0, 0, 20, 3), labels());
606        r.set_state(State::FOCUSED);
607        r
608    }
609
610    fn key(code: u16) -> Event {
611        Event::keyboard(code)
612    }
613
614    fn alt(letter: char) -> Event {
615        let mut e = Event::keyboard(letter as u16);
616        e.key_modifiers = KeyModifiers::ALT;
617        e
618    }
619
620    fn click(row: i16) -> Event {
621        let mut e = Event::nothing();
622        e.what = EventType::MouseDown;
623        e.mouse.buttons = MB_LEFT_BUTTON;
624        e.mouse.pos = Point::new(1, row);
625        e
626    }
627
628    #[test]
629    fn labels_lose_their_tilde_markers() {
630        let item = parse_label("~B~old");
631        assert_eq!(item.label, "Bold");
632        assert_eq!(item.hotkey, Some('b'));
633        assert_eq!(item.hotkey_pos, Some(0));
634    }
635
636    #[test]
637    fn a_label_without_tildes_has_no_hotkey() {
638        let item = parse_label("Plain");
639        assert_eq!(item.label, "Plain");
640        assert_eq!(item.hotkey, None);
641    }
642
643    #[test]
644    fn check_boxes_start_empty() {
645        let c = boxes();
646        assert_eq!(c.value(), 0);
647        assert_eq!(c.checked_items(), Vec::<usize>::new());
648    }
649
650    #[test]
651    fn each_box_owns_one_bit() {
652        let mut c = boxes();
653        c.set_checked(0, true);
654        c.set_checked(2, true);
655        assert_eq!(c.value(), 0b101);
656        assert_eq!(c.checked_items(), vec![0, 2]);
657        assert!(c.is_checked(0));
658        assert!(!c.is_checked(1));
659    }
660
661    #[test]
662    fn space_toggles_the_focused_box() {
663        let mut c = boxes();
664        let mut e = key(KB_SPACE);
665        c.handle_event(&mut e);
666        assert_eq!(c.value(), 0b001);
667        let mut e = key(KB_SPACE);
668        c.handle_event(&mut e);
669        assert_eq!(c.value(), 0, "toggled back off");
670    }
671
672    #[test]
673    fn arrows_move_within_the_cluster() {
674        let mut c = boxes();
675        c.handle_event(&mut key(KB_DOWN));
676        assert_eq!(c.focused_item(), 1);
677        c.handle_event(&mut key(KB_SPACE));
678        assert_eq!(c.value(), 0b010, "the second box, not the first");
679
680        for _ in 0..5 {
681            c.handle_event(&mut key(KB_DOWN));
682        }
683        assert_eq!(c.focused_item(), 2, "clamped at the last item");
684    }
685
686    #[test]
687    fn home_and_end_jump_within_the_cluster() {
688        let mut c = boxes();
689        c.handle_event(&mut key(KB_END));
690        assert_eq!(c.focused_item(), 2);
691        c.handle_event(&mut key(KB_HOME));
692        assert_eq!(c.focused_item(), 0);
693    }
694
695    #[test]
696    fn tab_and_enter_are_left_for_the_dialog() {
697        let mut c = boxes();
698        for code in [crate::core::event::KB_TAB, crate::core::event::KB_ENTER] {
699            let mut e = key(code);
700            c.handle_event(&mut e);
701            assert_eq!(
702                e.what,
703                EventType::Keyboard,
704                "key {code:#x} must pass through"
705            );
706        }
707    }
708
709    #[test]
710    fn hotkeys_work_without_the_focus() {
711        let mut c = CheckBoxes::new(Rect::new(0, 0, 20, 3), labels());
712        let mut e = alt('u');
713        c.handle_event(&mut e);
714        assert_eq!(c.value(), 0b100, "Alt+U ticked Underline");
715        assert_eq!(e.what, EventType::Nothing);
716    }
717
718    #[test]
719    fn an_unknown_hotkey_passes_through() {
720        let mut c = boxes();
721        let mut e = alt('z');
722        c.handle_event(&mut e);
723        assert_eq!(c.value(), 0);
724        assert_eq!(e.what, EventType::Keyboard);
725    }
726
727    #[test]
728    fn clicking_a_row_toggles_that_item() {
729        let mut c = boxes();
730        c.handle_event(&mut click(1));
731        assert_eq!(c.value(), 0b010);
732        assert_eq!(c.focused_item(), 1, "the click also moved the focus");
733    }
734
735    #[test]
736    fn clicking_past_the_last_item_does_nothing() {
737        let mut c = boxes();
738        let mut e = click(2);
739        e.mouse.pos = Point::new(1, 9);
740        c.handle_event(&mut e);
741        assert_eq!(c.value(), 0);
742    }
743
744    #[test]
745    fn disabled_items_cannot_be_chosen() {
746        let mut c = boxes();
747        c.set_enabled(1, false);
748        assert!(!c.is_enabled(1));
749        c.handle_event(&mut click(1));
750        assert_eq!(c.value(), 0);
751        // Its hotkey is inert too.
752        c.handle_event(&mut alt('i'));
753        assert_eq!(c.value(), 0);
754    }
755
756    #[test]
757    fn on_change_broadcasts_only_on_a_real_change() {
758        let mut c = boxes();
759        c.set_on_change(321);
760        let mut e = key(KB_SPACE);
761        c.handle_event(&mut e);
762        assert_eq!(e.what, EventType::Broadcast);
763        assert_eq!(e.command, 321);
764
765        c.set_enabled(0, false);
766        let mut e = key(KB_SPACE);
767        c.handle_event(&mut e);
768        assert_eq!(e.what, EventType::Nothing, "disabled, so nothing changed");
769    }
770
771    #[test]
772    fn setting_the_value_drops_bits_past_the_last_item() {
773        let mut c = boxes();
774        c.set_value(0xFFFF);
775        assert_eq!(c.value(), 0b111, "three items, three bits");
776    }
777
778    #[test]
779    fn shrinking_the_label_list_drops_stale_bits() {
780        let mut c = boxes();
781        c.set_value(0b111);
782        c.set_labels(vec!["only".into()]);
783        assert_eq!(c.value(), 0b1);
784        assert_eq!(c.focused_item(), 0);
785    }
786
787    #[test]
788    fn a_cluster_is_capped_at_the_value_width() {
789        let many: Vec<String> = (0..40).map(|i| format!("item{i}")).collect();
790        let c = CheckBoxes::new(Rect::new(0, 0, 20, 40), many);
791        assert_eq!(c.item_count(), MAX_CLUSTER_ITEMS);
792    }
793
794    // --- Radio buttons ---------------------------------------------------
795
796    #[test]
797    fn a_radio_cluster_starts_on_its_first_button() {
798        let r = radios();
799        assert_eq!(r.selected(), Some(0));
800        assert_eq!(r.selected_label(), Some("Bold"));
801        assert_eq!(r.value(), 0b001);
802    }
803
804    #[test]
805    fn an_empty_radio_cluster_has_no_selection() {
806        let r = RadioButtons::new(Rect::new(0, 0, 20, 3), vec![]);
807        assert_eq!(r.selected(), None);
808    }
809
810    #[test]
811    fn selecting_a_radio_button_clears_the_others() {
812        let mut r = radios();
813        r.set_selected(2);
814        assert_eq!(r.value(), 0b100, "exactly one bit");
815        assert_eq!(r.selected(), Some(2));
816    }
817
818    #[test]
819    fn space_selects_rather_than_toggles() {
820        let mut r = radios();
821        r.handle_event(&mut key(KB_DOWN));
822        r.handle_event(&mut key(KB_SPACE));
823        assert_eq!(r.selected(), Some(1));
824        // Pressing again must not turn it off: something is always selected.
825        r.handle_event(&mut key(KB_SPACE));
826        assert_eq!(r.selected(), Some(1));
827    }
828
829    #[test]
830    fn a_radio_hotkey_selects_its_button() {
831        let mut r = radios();
832        r.handle_event(&mut alt('u'));
833        assert_eq!(r.selected(), Some(2));
834    }
835
836    #[test]
837    fn shrinking_a_radio_cluster_keeps_something_selected() {
838        let mut r = radios();
839        r.set_selected(2);
840        r.set_labels(vec!["one".into(), "two".into()]);
841        assert_eq!(r.selected(), Some(0), "the stale bit fell away");
842    }
843}