1use super::view::{View, ViewCore, write_line_to_terminal};
42use crate::core::command::{CM_SHOW_DROPDOWN, CommandId};
43use crate::core::draw::DrawBuffer;
44use crate::core::event::{
45 Event, EventType, KB_DOWN, KB_END, KB_ENTER, KB_ESC, KB_F4, KB_HOME, KB_UP,
46};
47use crate::core::geometry::{Point, Rect};
48use crate::core::palette::{INPUT_ARROWS, INPUT_NORMAL, INPUT_SELECTED};
49use crate::core::state::{State, StateFlags};
50use crate::terminal::Terminal;
51use std::cell::RefCell;
52use std::collections::HashMap;
53use std::rc::Rc;
54
55const DROP_ARROW: char = '\u{25BC}'; const MAX_DROPDOWN_ROWS: i16 = 8;
60
61#[derive(Debug, Default)]
66pub struct ComboState {
67 pub items: Vec<String>,
69 pub selected: Option<usize>,
71 pub field: Rect,
73}
74
75impl ComboState {
76 pub fn selected_text(&self) -> Option<&str> {
78 self.selected.and_then(|i| self.items.get(i)).map(|s| &**s)
79 }
80
81 fn clamp(&mut self) {
84 if self.items.is_empty() {
85 self.selected = None;
86 } else if let Some(i) = self.selected {
87 self.selected = Some(i.min(self.items.len() - 1));
88 }
89 }
90}
91
92thread_local! {
93 static REGISTRY: RefCell<HashMap<u16, Rc<RefCell<ComboState>>>> =
96 RefCell::new(HashMap::new());
97}
98
99pub fn lookup(id: u16) -> Option<Rc<RefCell<ComboState>>> {
104 REGISTRY.with(|r| r.borrow().get(&id).cloned())
105}
106
107pub struct ComboBox {
109 core: ViewCore,
110 id: u16,
111 state: Rc<RefCell<ComboState>>,
112 on_change: CommandId,
114 view_state: StateFlags,
115}
116
117impl ComboBox {
118 pub fn new(bounds: Rect, id: u16) -> Self {
125 let state = Rc::new(RefCell::new(ComboState {
126 items: Vec::new(),
127 selected: None,
128 field: bounds,
129 }));
130 REGISTRY.with(|r| r.borrow_mut().insert(id, Rc::clone(&state)));
131 Self {
132 core: ViewCore {
133 bounds,
134 palette_chain: None,
135 ..ViewCore::default()
136 },
137 id,
138 state,
139 on_change: 0,
140 view_state: State::empty(),
141 }
142 }
143
144 pub fn with_items(bounds: Rect, id: u16, items: Vec<String>) -> Self {
146 let mut combo = Self::new(bounds, id);
147 combo.set_items(items);
148 combo
149 }
150
151 pub fn id(&self) -> u16 {
153 self.id
154 }
155
156 pub fn state(&self) -> Rc<RefCell<ComboState>> {
159 Rc::clone(&self.state)
160 }
161
162 pub fn set_items(&mut self, items: Vec<String>) {
165 let mut state = self.state.borrow_mut();
166 let had_selection = state.selected.is_some();
167 state.items = items;
168 if !had_selection && !state.items.is_empty() {
169 state.selected = Some(0);
170 }
171 state.clamp();
172 }
173
174 pub fn add_item(&mut self, item: impl Into<String>) {
176 let mut state = self.state.borrow_mut();
177 state.items.push(item.into());
178 if state.selected.is_none() {
179 state.selected = Some(0);
180 }
181 }
182
183 pub fn item_count(&self) -> usize {
185 self.state.borrow().items.len()
186 }
187
188 pub fn selected(&self) -> Option<usize> {
190 self.state.borrow().selected
191 }
192
193 pub fn set_selected(&mut self, index: Option<usize>) {
196 let mut state = self.state.borrow_mut();
197 match index {
198 None => state.selected = None,
199 Some(i) if i < state.items.len() => state.selected = Some(i),
200 Some(_) => {}
201 }
202 }
203
204 pub fn selected_text(&self) -> Option<String> {
206 self.state.borrow().selected_text().map(str::to_string)
207 }
208
209 pub fn set_on_change(&mut self, command: CommandId) {
211 self.on_change = command;
212 }
213
214 fn step(&mut self, delta: i32) -> bool {
218 let mut state = self.state.borrow_mut();
219 if state.items.is_empty() {
220 return false;
221 }
222 let last = state.items.len() as i32 - 1;
223 let current = state.selected.map_or(0, |i| i as i32);
224 let next = (current + delta).clamp(0, last);
225 if Some(next as usize) == state.selected {
226 return false;
227 }
228 state.selected = Some(next as usize);
229 true
230 }
231
232 fn open_request(&self) -> Event {
237 let mut event = Event::command(CM_SHOW_DROPDOWN);
238 event.info = self.id;
239 event
240 }
241}
242
243impl Drop for ComboBox {
244 fn drop(&mut self) {
245 REGISTRY.with(|r| {
248 let mut map = r.borrow_mut();
249 if let Some(existing) = map.get(&self.id) {
250 if Rc::ptr_eq(existing, &self.state) {
251 map.remove(&self.id);
252 }
253 }
254 });
255 }
256}
257
258impl View for ComboBox {
259 fn core(&self) -> &ViewCore {
260 &self.core
261 }
262
263 fn core_mut(&mut self) -> &mut ViewCore {
264 &mut self.core
265 }
266
267 fn set_bounds(&mut self, bounds: Rect) {
268 self.core.bounds = bounds;
269 self.state.borrow_mut().field = bounds;
270 }
271
272 fn can_focus(&self) -> bool {
273 true
274 }
275
276 fn state(&self) -> StateFlags {
277 self.view_state
278 }
279
280 fn set_state(&mut self, state: StateFlags) {
281 self.view_state = state;
282 }
283
284 fn draw(&mut self, terminal: &mut Terminal) {
285 let width = self.core.bounds.width_clamped().max(0) as usize;
286 if width == 0 {
287 return;
288 }
289 let text_attr = if self.is_focused() {
294 self.map_color(INPUT_SELECTED)
295 } else {
296 self.map_color(INPUT_NORMAL)
297 };
298 let arrow_attr = self.map_color(INPUT_ARROWS);
299
300 let mut buf = DrawBuffer::new(width);
301 buf.move_char(0, ' ', text_attr, width);
302
303 let caption_width = width.saturating_sub(1);
305 if caption_width > 0 {
306 if let Some(text) = self.state.borrow().selected_text() {
307 let shown: String = text.chars().take(caption_width).collect();
308 buf.move_str(1.min(caption_width), &shown, text_attr);
309 }
310 }
311 buf.put_char(width - 1, DROP_ARROW, arrow_attr);
312
313 write_line_to_terminal(terminal, 0, 0, &buf);
314 }
315
316 fn handle_event(&mut self, event: &mut Event) {
317 if event.what == EventType::MouseDown && self.extent().contains(event.mouse.pos) {
320 *event = self.open_request();
321 return;
322 }
323
324 if !self.is_focused() || event.what != EventType::Keyboard {
325 return;
326 }
327
328 let alt = event
329 .key_modifiers
330 .contains(crossterm::event::KeyModifiers::ALT);
331
332 match event.key_code {
333 KB_F4 => {
334 *event = self.open_request();
335 }
336 KB_DOWN if alt => {
337 *event = self.open_request();
338 }
339 KB_DOWN => {
340 if self.step(1) {
341 let cmd = self.on_change;
342 event.clear();
343 if cmd != 0 {
344 *event = Event::broadcast_with_info(cmd, self.id);
345 }
346 } else {
347 event.clear();
348 }
349 }
350 KB_UP => {
351 if self.step(-1) {
352 let cmd = self.on_change;
353 event.clear();
354 if cmd != 0 {
355 *event = Event::broadcast_with_info(cmd, self.id);
356 }
357 } else {
358 event.clear();
359 }
360 }
361 KB_HOME => {
362 self.step(i32::MIN / 2);
363 event.clear();
364 }
365 KB_END => {
366 self.step(i32::MAX / 2);
367 event.clear();
368 }
369 _ => {}
370 }
371 }
372
373 fn get_palette(&self) -> Option<crate::core::palette::Palette> {
374 use crate::core::palette::{Palette, palettes};
375 Some(Palette::from_slice(palettes::CP_INPUT_LINE))
377 }
378
379 fn as_any(&self) -> &dyn std::any::Any {
380 self
381 }
382
383 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
384 self
385 }
386}
387
388const FRAME_CHARS: [char; 6] = [
390 '\u{250C}', '\u{2500}', '\u{2510}', '\u{2502}', '\u{2514}', '\u{2518}',
391];
392
393pub struct DropdownWindow {
400 core: ViewCore,
401 state: Rc<RefCell<ComboState>>,
402 cursor: usize,
404 top: usize,
406 list_rect: Rect,
408}
409
410impl DropdownWindow {
411 pub fn new(state: Rc<RefCell<ComboState>>, screen: Rect) -> Self {
416 let (field, count, selected) = {
417 let s = state.borrow();
418 (s.field, s.items.len(), s.selected)
419 };
420
421 let rows = (count as i16).clamp(1, MAX_DROPDOWN_ROWS);
422 let height = rows + 2; let width = field.width().max(8);
424
425 let mut x = field.a.x;
426 let mut y = field.b.y; if y + height > screen.b.y {
429 y = (field.a.y - height).max(screen.a.y);
430 }
431 if x + width > screen.b.x {
432 x = (screen.b.x - width).max(screen.a.x);
433 }
434
435 let window_bounds = Rect::new(x, y, x + width, y + height);
436 let list_rect = Rect::new(1, 1, width - 1, 1 + rows);
438
439 Self {
440 core: ViewCore {
441 bounds: window_bounds,
442 ..ViewCore::default()
443 },
444 state,
445 cursor: selected.unwrap_or(0),
446 top: 0,
447 list_rect,
448 }
449 }
450
451 fn visible_rows(&self) -> usize {
452 self.list_rect.height_clamped().max(0) as usize
453 }
454
455 fn scroll_into_view(&mut self) {
457 let rows = self.visible_rows();
458 if rows == 0 {
459 return;
460 }
461 if self.cursor < self.top {
462 self.top = self.cursor;
463 } else if self.cursor >= self.top + rows {
464 self.top = self.cursor + 1 - rows;
465 }
466 }
467
468 fn move_cursor(&mut self, delta: i32) {
469 let count = self.state.borrow().items.len();
470 if count == 0 {
471 return;
472 }
473 let last = count as i32 - 1;
474 self.cursor = (self.cursor as i32 + delta).clamp(0, last) as usize;
475 self.scroll_into_view();
476 }
477
478 fn item_at(&self, pos: Point) -> Option<usize> {
480 if !self.list_rect.contains(pos) {
481 return None;
482 }
483 let row = (pos.y - self.list_rect.a.y) as usize;
484 let idx = self.top + row;
485 (idx < self.state.borrow().items.len()).then_some(idx)
486 }
487
488 fn draw_popup(&mut self, terminal: &mut Terminal) {
490 let width = self.list_rect.width_clamped().max(0) as usize;
491 let outer = self.core.bounds.width_clamped().max(0) as usize;
492 if width == 0 || outer == 0 {
493 return;
494 }
495 let normal = self.map_color(crate::core::palette::LISTBOX_NORMAL);
496 let selected = self.map_color(crate::core::palette::LISTBOX_SELECTED);
497
498 let [tl, horiz, tr, vert, bl, br] = FRAME_CHARS;
499
500 let mut top = DrawBuffer::new(outer);
502 top.move_char(0, horiz, normal, outer);
503 top.put_char(0, tl, normal);
504 top.put_char(outer - 1, tr, normal);
505 write_line_to_terminal(terminal, 0, 0, &top);
506
507 let mut bottom = DrawBuffer::new(outer);
508 bottom.move_char(0, horiz, normal, outer);
509 bottom.put_char(0, bl, normal);
510 bottom.put_char(outer - 1, br, normal);
511 write_line_to_terminal(
512 terminal,
513 0,
514 self.extent().b.y - 1,
515 &bottom,
516 );
517
518 let state = self.state.borrow();
519 for row in 0..self.visible_rows() {
520 let mut buf = DrawBuffer::new(width);
521 let idx = self.top + row;
522 let attr = if idx == self.cursor { selected } else { normal };
523 buf.move_char(0, ' ', attr, width);
524 if let Some(text) = state.items.get(idx) {
525 let shown: String = text.chars().take(width).collect();
526 buf.move_str(0, &shown, attr);
527 }
528 let y = self.list_rect.a.y + row as i16;
529 let mut edge = DrawBuffer::new(1);
531 edge.put_char(0, vert, normal);
532 write_line_to_terminal(terminal, 0, y, &edge);
533 write_line_to_terminal(terminal, self.extent().b.x - 1, y, &edge);
534 write_line_to_terminal(terminal, self.list_rect.a.x, y, &buf);
535 }
536 }
537
538 pub fn execute(&mut self, terminal: &mut Terminal) -> Option<usize> {
543 self.scroll_into_view();
544 loop {
545 terminal.push_origin(self.core.bounds.a);
548 self.draw_popup(terminal);
549 terminal.pop_origin();
550 let _ = terminal.flush();
551
552 let Ok(Some(mut event)) = terminal.poll_event(std::time::Duration::from_millis(50))
553 else {
554 continue;
555 };
556 let origin = self.core.bounds.a;
557 event.mouse.pos.x -= origin.x;
558 event.mouse.pos.y -= origin.y;
559
560 match event.what {
561 EventType::Keyboard => match event.key_code {
562 KB_ENTER => return self.commit(),
563 KB_ESC | KB_F4 => return None,
564 KB_UP => self.move_cursor(-1),
565 KB_DOWN => self.move_cursor(1),
566 KB_HOME => self.move_cursor(i32::MIN / 2),
567 KB_END => self.move_cursor(i32::MAX / 2),
568 crate::core::event::KB_PGUP => {
569 let rows = self.visible_rows() as i32;
570 self.move_cursor(-rows);
571 }
572 crate::core::event::KB_PGDN => {
573 let rows = self.visible_rows() as i32;
574 self.move_cursor(rows);
575 }
576 _ => {}
577 },
578 EventType::MouseDown => {
579 match self.item_at(event.mouse.pos) {
580 Some(idx) => {
581 self.cursor = idx;
582 return self.commit();
583 }
584 None if !self.extent().contains(event.mouse.pos) => return None,
587 None => {}
588 }
589 event.clear();
590 }
591 EventType::MouseWheelUp => self.move_cursor(-1),
592 EventType::MouseWheelDown => self.move_cursor(1),
593 _ => {}
594 }
595 }
596 }
597
598 fn commit(&mut self) -> Option<usize> {
600 let mut state = self.state.borrow_mut();
601 if state.items.is_empty() {
602 return None;
603 }
604 let idx = self.cursor.min(state.items.len() - 1);
605 state.selected = Some(idx);
606 Some(idx)
607 }
608}
609
610impl View for DropdownWindow {
611 fn core(&self) -> &ViewCore {
612 &self.core
613 }
614
615 fn core_mut(&mut self) -> &mut ViewCore {
616 &mut self.core
617 }
618
619 fn draw(&mut self, terminal: &mut Terminal) {
622 self.draw_popup(terminal);
623 }
624
625 fn handle_event(&mut self, _event: &mut Event) {}
626
627 fn get_palette(&self) -> Option<crate::core::palette::Palette> {
628 use crate::core::palette::{Palette, palettes};
629 Some(Palette::from_slice(palettes::CP_LISTBOX))
630 }
631
632 fn as_any(&self) -> &dyn std::any::Any {
633 self
634 }
635
636 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
637 self
638 }
639}
640
641pub struct ComboBoxBuilder {
643 bounds: Option<Rect>,
644 id: u16,
645 items: Vec<String>,
646 selected: Option<usize>,
647 on_change: CommandId,
648}
649
650impl ComboBoxBuilder {
651 pub fn new() -> Self {
652 Self {
653 bounds: None,
654 id: 0,
655 items: Vec::new(),
656 selected: None,
657 on_change: 0,
658 }
659 }
660
661 #[must_use]
662 pub fn bounds(mut self, bounds: Rect) -> Self {
663 self.bounds = Some(bounds);
664 self
665 }
666
667 #[must_use]
668 pub fn id(mut self, id: u16) -> Self {
669 self.id = id;
670 self
671 }
672
673 #[must_use]
674 pub fn items<I: Into<String>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
675 self.items = items.into_iter().map(Into::into).collect();
676 self
677 }
678
679 #[must_use]
680 pub fn selected(mut self, index: usize) -> Self {
681 self.selected = Some(index);
682 self
683 }
684
685 #[must_use]
686 pub fn on_change(mut self, command: CommandId) -> Self {
687 self.on_change = command;
688 self
689 }
690
691 pub fn build(self) -> ComboBox {
692 let bounds = self.bounds.expect("ComboBox bounds must be set");
693 let mut combo = ComboBox::with_items(bounds, self.id, self.items);
694 if let Some(i) = self.selected {
695 combo.set_selected(Some(i));
696 }
697 combo.set_on_change(self.on_change);
698 combo
699 }
700
701 pub fn build_boxed(self) -> Box<ComboBox> {
702 Box::new(self.build())
703 }
704}
705
706impl Default for ComboBoxBuilder {
707 fn default() -> Self {
708 Self::new()
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 fn combo(id: u16) -> ComboBox {
717 ComboBox::with_items(
718 Rect::new(0, 0, 20, 1),
719 id,
720 vec!["one".into(), "two".into(), "three".into()],
721 )
722 }
723
724 fn key(code: u16) -> Event {
725 Event::keyboard(code)
726 }
727
728 #[test]
729 fn first_item_is_selected_by_default() {
730 let c = combo(900);
731 assert_eq!(c.selected(), Some(0));
732 assert_eq!(c.selected_text().as_deref(), Some("one"));
733 }
734
735 #[test]
736 fn empty_combo_has_no_selection() {
737 let c = ComboBox::new(Rect::new(0, 0, 10, 1), 901);
738 assert_eq!(c.selected(), None);
739 assert_eq!(c.selected_text(), None);
740 }
741
742 #[test]
743 fn out_of_range_selection_is_ignored() {
744 let mut c = combo(902);
745 c.set_selected(Some(99));
746 assert_eq!(
747 c.selected(),
748 Some(0),
749 "stale index must not move the choice"
750 );
751 }
752
753 #[test]
754 fn shrinking_the_item_list_clamps_the_choice() {
755 let mut c = combo(903);
756 c.set_selected(Some(2));
757 c.set_items(vec!["only".into()]);
758 assert_eq!(c.selected(), Some(0));
759 }
760
761 #[test]
762 fn arrows_step_the_choice_and_clamp() {
763 let mut c = combo(904);
764 c.set_state(State::FOCUSED);
765 let mut e = key(KB_DOWN);
766 c.handle_event(&mut e);
767 assert_eq!(c.selected(), Some(1));
768 for _ in 0..5 {
769 let mut e = key(KB_DOWN);
770 c.handle_event(&mut e);
771 }
772 assert_eq!(c.selected(), Some(2), "clamped at the last item");
773 let mut e = key(KB_UP);
774 c.handle_event(&mut e);
775 assert_eq!(c.selected(), Some(1));
776 }
777
778 #[test]
779 fn home_and_end_jump_to_the_ends() {
780 let mut c = combo(905);
781 c.set_state(State::FOCUSED);
782 let mut e = key(KB_END);
783 c.handle_event(&mut e);
784 assert_eq!(c.selected(), Some(2));
785 let mut e = key(KB_HOME);
786 c.handle_event(&mut e);
787 assert_eq!(c.selected(), Some(0));
788 }
789
790 #[test]
791 fn f4_asks_the_dialog_to_open_the_list() {
792 let mut c = combo(906);
793 c.set_state(State::FOCUSED);
794 let mut e = key(KB_F4);
795 c.handle_event(&mut e);
796 assert_eq!(e.what, EventType::Command);
797 assert_eq!(e.command, CM_SHOW_DROPDOWN);
798 assert_eq!(e.info, 906, "the popup is told which combo asked");
799 }
800
801 #[test]
802 fn a_click_opens_the_list_even_when_unfocused() {
803 let mut c = combo(907);
804 let mut e = Event::nothing();
805 e.what = EventType::MouseDown;
806 e.mouse.pos = Point::new(3, 0);
807 c.handle_event(&mut e);
808 assert_eq!(e.command, CM_SHOW_DROPDOWN);
809 }
810
811 #[test]
812 fn keys_are_ignored_when_not_focused() {
813 let mut c = combo(908);
814 let mut e = key(KB_DOWN);
815 c.handle_event(&mut e);
816 assert_eq!(c.selected(), Some(0));
817 assert_eq!(e.what, EventType::Keyboard, "event left for other views");
818 }
819
820 #[test]
821 fn on_change_command_is_broadcast_when_the_choice_moves() {
822 let mut c = combo(909);
823 c.set_state(State::FOCUSED);
824 c.set_on_change(777);
825 let mut e = key(KB_DOWN);
826 c.handle_event(&mut e);
827 assert_eq!(e.what, EventType::Broadcast);
828 assert_eq!(e.command, 777);
829
830 c.set_selected(Some(2));
832 let mut e = key(KB_DOWN);
833 c.handle_event(&mut e);
834 assert_eq!(e.what, EventType::Nothing);
835 }
836
837 #[test]
838 fn state_is_registered_for_the_popup_and_freed_on_drop() {
839 {
840 let c = combo(910);
841 let found = lookup(910).expect("registered while alive");
842 assert_eq!(found.borrow().items.len(), 3);
843 drop(c);
844 }
845 assert!(lookup(910).is_none(), "registration freed on drop");
846 }
847
848 #[test]
849 fn moving_the_control_moves_the_popup_anchor() {
850 let mut c = combo(911);
851 c.set_bounds(Rect::new(5, 9, 25, 10));
852 assert_eq!(lookup(911).unwrap().borrow().field, Rect::new(5, 9, 25, 10));
853 }
854
855 #[test]
856 fn popup_sits_under_the_field() {
857 let c = combo(912);
858 let screen = Rect::new(0, 0, 80, 25);
859 let popup = DropdownWindow::new(c.state(), screen);
860 assert_eq!(popup.bounds().a, Point::new(0, 1), "just below");
861 assert_eq!(popup.visible_rows(), 3, "one row per item");
862 }
863
864 #[test]
865 fn popup_flips_above_a_field_near_the_bottom() {
866 let mut c = combo(913);
867 c.set_bounds(Rect::new(0, 23, 20, 24));
868 let screen = Rect::new(0, 0, 80, 25);
869 let popup = DropdownWindow::new(c.state(), screen);
870 assert!(
871 popup.bounds().b.y <= 23,
872 "popup must not run off the bottom: {:?}",
873 popup.bounds()
874 );
875 }
876
877 #[test]
878 fn popup_height_is_capped_for_long_lists() {
879 let mut c = combo(914);
880 c.set_items((0..50).map(|i| format!("item {i}")).collect());
881 let popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
882 assert_eq!(popup.visible_rows(), MAX_DROPDOWN_ROWS as usize);
883 }
884
885 #[test]
886 fn popup_scrolls_to_keep_the_cursor_visible() {
887 let mut c = combo(915);
888 c.set_items((0..20).map(|i| format!("item {i}")).collect());
889 let mut popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
890 popup.move_cursor(15);
891 assert!(popup.top > 0, "list scrolled");
892 assert!(popup.cursor >= popup.top && popup.cursor < popup.top + popup.visible_rows());
893 }
894
895 #[test]
896 fn popup_commit_writes_the_choice_back() {
897 let c = combo(916);
898 let mut popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
899 popup.move_cursor(2);
900 assert_eq!(popup.commit(), Some(2));
901 assert_eq!(c.selected(), Some(2));
902 }
903
904 #[test]
905 fn popup_click_maps_to_the_right_item() {
906 let c = combo(917);
907 let popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
908 let inside = Point::new(popup.list_rect.a.x, popup.list_rect.a.y + 1);
909 assert_eq!(popup.item_at(inside), Some(1));
910 assert_eq!(popup.item_at(Point::new(0, 0)), None, "outside the list");
911 }
912}