1use 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
52const KB_SPACE: u16 = b' ' as u16;
54
55const MARKER_WIDTH: usize = 4;
57
58pub const MAX_CLUSTER_ITEMS: usize = 32;
60
61#[derive(Debug, Clone)]
63struct Item {
64 label: String,
66 hotkey: Option<char>,
68 hotkey_pos: Option<usize>,
70 enabled: bool,
72}
73
74fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum Kind {
111 Check,
113 Radio,
115}
116
117struct ClusterGroup {
119 core: ViewCore,
120 kind: Kind,
121 items: Vec<Item>,
122 value: u32,
124 focused_item: usize,
126 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 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 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 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 (Kind::Radio, true) => self.value = 1 << index,
193 (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 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 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 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 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 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 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 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 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 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 _ => 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
412macro_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 pub fn new(bounds: Rect, labels: Vec<String>) -> Self {
430 Self {
431 inner: ClusterGroup::new(bounds, $kind, labels),
432 }
433 }
434
435 pub fn set_labels(&mut self, labels: Vec<String>) {
437 self.inner.set_labels(labels);
438 }
439
440 pub fn item_count(&self) -> usize {
442 self.inner.items.len()
443 }
444
445 pub fn value(&self) -> u32 {
447 self.inner.value
448 }
449
450 pub fn set_value(&mut self, value: u32) {
452 self.inner.value = value & self.inner.item_mask();
453 }
454
455 pub fn focused_item(&self) -> usize {
457 self.inner.focused_item
458 }
459
460 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 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 pub fn is_enabled(&self, index: usize) -> bool {
476 self.inner.items.get(index).is_some_and(|i| i.enabled)
477 }
478
479 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 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 pub fn is_checked(&self, index: usize) -> bool {
553 self.inner.is_set(index)
554 }
555
556 pub fn set_checked(&mut self, index: usize, checked: bool) {
558 self.inner.set_bit(index, checked);
559 }
560
561 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 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 pub fn set_selected(&mut self, index: usize) {
580 self.inner.set_bit(index, true);
581 }
582
583 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 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 #[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 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}