1use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
27use ratatui::{
28 Frame,
29 buffer::Buffer,
30 layout::Rect,
31 style::{Color, Modifier, Style},
32 text::{Line, Span},
33 widgets::{Block, Borders, Clear, Paragraph, Widget},
34};
35
36use crate::traits::{ClickRegion, FocusId};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SelectAction {
41 Focus,
43 Open,
45 Close,
47 Select(usize),
49}
50
51#[derive(Debug, Clone)]
53pub struct SelectState {
54 pub selected_index: Option<usize>,
56 pub is_open: bool,
58 pub focused: bool,
60 pub enabled: bool,
62 pub highlighted_index: usize,
64 pub scroll_offset: u16,
66 pub total_options: usize,
68}
69
70impl Default for SelectState {
71 fn default() -> Self {
72 Self {
73 selected_index: None,
74 is_open: false,
75 focused: false,
76 enabled: true,
77 highlighted_index: 0,
78 scroll_offset: 0,
79 total_options: 0,
80 }
81 }
82}
83
84impl SelectState {
85 pub fn new(total_options: usize) -> Self {
87 Self {
88 total_options,
89 ..Default::default()
90 }
91 }
92
93 pub fn with_selected(total_options: usize, selected: usize) -> Self {
95 let mut state = Self::new(total_options);
96 if selected < total_options {
97 state.selected_index = Some(selected);
98 state.highlighted_index = selected;
99 }
100 state
101 }
102
103 pub fn open(&mut self) {
105 if self.enabled {
106 self.is_open = true;
107 if let Some(idx) = self.selected_index {
109 self.highlighted_index = idx;
110 }
111 }
112 }
113
114 pub fn close(&mut self) {
116 self.is_open = false;
117 }
118
119 pub fn toggle(&mut self) {
121 if self.is_open {
122 self.close();
123 } else {
124 self.open();
125 }
126 }
127
128 pub fn highlight_prev(&mut self) {
130 if self.highlighted_index > 0 {
131 self.highlighted_index -= 1;
132 }
133 }
134
135 pub fn highlight_next(&mut self) {
137 if self.highlighted_index + 1 < self.total_options {
138 self.highlighted_index += 1;
139 }
140 }
141
142 pub fn highlight_first(&mut self) {
144 self.highlighted_index = 0;
145 self.scroll_offset = 0;
146 }
147
148 pub fn highlight_last(&mut self) {
150 if self.total_options > 0 {
151 self.highlighted_index = self.total_options - 1;
152 }
153 }
154
155 pub fn select_highlighted(&mut self) {
157 if self.total_options > 0 {
158 self.selected_index = Some(self.highlighted_index);
159 }
160 self.close();
161 }
162
163 pub fn select(&mut self, index: usize) {
165 if index < self.total_options {
166 self.selected_index = Some(index);
167 self.highlighted_index = index;
168 }
169 self.close();
170 }
171
172 pub fn clear_selection(&mut self) {
174 self.selected_index = None;
175 }
176
177 pub fn set_total(&mut self, total: usize) {
179 self.total_options = total;
180 if let Some(idx) = self.selected_index {
181 if idx >= total {
182 self.selected_index = if total > 0 { Some(total - 1) } else { None };
183 }
184 }
185 if self.highlighted_index >= total && total > 0 {
186 self.highlighted_index = total - 1;
187 }
188 }
189
190 pub fn ensure_visible(&mut self, viewport_height: usize) {
192 if viewport_height == 0 {
193 return;
194 }
195 if self.highlighted_index < self.scroll_offset as usize {
196 self.scroll_offset = self.highlighted_index as u16;
197 } else if self.highlighted_index >= self.scroll_offset as usize + viewport_height {
198 self.scroll_offset = (self.highlighted_index - viewport_height + 1) as u16;
199 }
200 }
201
202 pub fn selected(&self) -> Option<usize> {
204 self.selected_index
205 }
206
207 pub fn has_selection(&self) -> bool {
209 self.selected_index.is_some()
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct SelectStyle {
216 pub focused_border: Color,
218 pub unfocused_border: Color,
220 pub disabled_border: Color,
222 pub text_fg: Color,
224 pub placeholder_fg: Color,
226 pub dropdown_indicator: &'static str,
228 pub highlight_style: Style,
230 pub option_style: Style,
232 pub selected_indicator: &'static str,
234 pub unselected_indicator: &'static str,
236 pub dropdown_border: Color,
238 pub max_visible_options: u16,
240}
241
242impl Default for SelectStyle {
243 fn default() -> Self {
244 Self {
245 focused_border: Color::Yellow,
246 unfocused_border: Color::Gray,
247 disabled_border: Color::DarkGray,
248 text_fg: Color::White,
249 placeholder_fg: Color::DarkGray,
250 dropdown_indicator: "▼",
251 highlight_style: Style::default()
252 .fg(Color::Black)
253 .bg(Color::Yellow)
254 .add_modifier(Modifier::BOLD),
255 option_style: Style::default().fg(Color::White),
256 selected_indicator: "✓ ",
257 unselected_indicator: " ",
258 dropdown_border: Color::Cyan,
259 max_visible_options: 8,
260 }
261 }
262}
263
264impl SelectStyle {
265 pub fn minimal() -> Self {
267 Self {
268 highlight_style: Style::default()
269 .fg(Color::Yellow)
270 .add_modifier(Modifier::BOLD),
271 ..Default::default()
272 }
273 }
274
275 pub fn arrow() -> Self {
277 Self {
278 selected_indicator: "→ ",
279 unselected_indicator: " ",
280 ..Default::default()
281 }
282 }
283
284 pub fn bracket() -> Self {
286 Self {
287 selected_indicator: "[x] ",
288 unselected_indicator: "[ ] ",
289 ..Default::default()
290 }
291 }
292
293 pub fn max_options(mut self, max: u16) -> Self {
295 self.max_visible_options = max;
296 self
297 }
298
299 pub fn focused_border(mut self, color: Color) -> Self {
301 self.focused_border = color;
302 self
303 }
304
305 pub fn unfocused_border(mut self, color: Color) -> Self {
307 self.unfocused_border = color;
308 self
309 }
310
311 pub fn indicator(mut self, indicator: &'static str) -> Self {
313 self.dropdown_indicator = indicator;
314 self
315 }
316
317 pub fn highlight(mut self, style: Style) -> Self {
319 self.highlight_style = style;
320 self
321 }
322}
323
324type DefaultRenderFn<T> = fn(&T) -> String;
326
327pub struct Select<'a, T, F = DefaultRenderFn<T>>
332where
333 F: Fn(&T) -> String,
334{
335 options: &'a [T],
336 state: &'a SelectState,
337 style: SelectStyle,
338 placeholder: &'a str,
339 label: Option<&'a str>,
340 render_option: F,
341 focus_id: FocusId,
342}
343
344impl<'a, T: std::fmt::Display> Select<'a, T, DefaultRenderFn<T>> {
345 pub fn new(options: &'a [T], state: &'a SelectState) -> Self {
347 Self {
348 options,
349 state,
350 style: SelectStyle::default(),
351 placeholder: "Please select an option",
352 label: None,
353 render_option: |opt| opt.to_string(),
354 focus_id: FocusId::default(),
355 }
356 }
357}
358
359impl<'a, T, F> Select<'a, T, F>
360where
361 F: Fn(&T) -> String,
362{
363 pub fn render_option<G>(self, render_fn: G) -> Select<'a, T, G>
365 where
366 G: Fn(&T) -> String,
367 {
368 Select {
369 options: self.options,
370 state: self.state,
371 style: self.style,
372 placeholder: self.placeholder,
373 label: self.label,
374 render_option: render_fn,
375 focus_id: self.focus_id,
376 }
377 }
378
379 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
381 self.placeholder = placeholder;
382 self
383 }
384
385 pub fn label(mut self, label: &'a str) -> Self {
387 self.label = Some(label);
388 self
389 }
390
391 pub fn style(mut self, style: SelectStyle) -> Self {
393 self.style = style;
394 self
395 }
396
397 pub fn focus_id(mut self, id: FocusId) -> Self {
399 self.focus_id = id;
400 self
401 }
402
403 pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<SelectAction> {
408 let border_color = if !self.state.enabled {
409 self.style.disabled_border
410 } else if self.state.focused {
411 self.style.focused_border
412 } else {
413 self.style.unfocused_border
414 };
415
416 let mut block = Block::default()
417 .borders(Borders::ALL)
418 .border_style(Style::default().fg(border_color));
419
420 if let Some(label) = self.label {
421 block = block.title(format!(" {} ", label));
422 }
423
424 let inner = block.inner(area);
425 frame.render_widget(block, area);
426
427 let display_text = if let Some(idx) = self.state.selected_index {
429 if idx < self.options.len() {
430 let text = (self.render_option)(&self.options[idx]);
431 Span::styled(text, Style::default().fg(self.style.text_fg))
432 } else {
433 Span::styled(
434 self.placeholder,
435 Style::default().fg(self.style.placeholder_fg),
436 )
437 }
438 } else {
439 Span::styled(
440 self.placeholder,
441 Style::default().fg(self.style.placeholder_fg),
442 )
443 };
444
445 let indicator_color = if self.state.focused {
447 self.style.focused_border
448 } else {
449 self.style.unfocused_border
450 };
451
452 let indicator = Span::styled(
453 format!(" {}", self.style.dropdown_indicator),
454 Style::default().fg(indicator_color),
455 );
456
457 let line = Line::from(vec![display_text, indicator]);
458 let paragraph = Paragraph::new(line);
459 frame.render_widget(paragraph, inner);
460
461 ClickRegion::new(area, SelectAction::Focus)
462 }
463
464 pub fn render_dropdown(
474 &self,
475 frame: &mut Frame,
476 anchor: Rect,
477 screen: Rect,
478 ) -> Vec<ClickRegion<SelectAction>> {
479 let mut regions = Vec::new();
480
481 if self.options.is_empty() {
482 return regions;
483 }
484
485 let visible_count = (self.options.len() as u16).min(self.style.max_visible_options);
486 let dropdown_height = visible_count + 2; let dropdown_width = anchor.width;
489
490 let space_below = screen.height.saturating_sub(anchor.y + anchor.height);
492 let space_above = anchor.y.saturating_sub(screen.y);
493
494 let (dropdown_y, flip_up) = if space_below >= dropdown_height {
495 (anchor.y + anchor.height, false)
496 } else if space_above >= dropdown_height {
497 (anchor.y.saturating_sub(dropdown_height), true)
498 } else {
499 (anchor.y + anchor.height, false)
501 };
502
503 let dropdown_area = Rect::new(
504 anchor.x,
505 dropdown_y,
506 dropdown_width,
507 dropdown_height.min(if flip_up { space_above } else { space_below }),
508 );
509
510 frame.render_widget(Clear, dropdown_area);
512
513 let block = Block::default()
515 .borders(Borders::ALL)
516 .border_style(Style::default().fg(self.style.dropdown_border));
517
518 let inner = block.inner(dropdown_area);
519 frame.render_widget(block, dropdown_area);
520
521 let actual_visible = inner.height as usize;
523 let scroll = self.state.scroll_offset as usize;
524
525 for (i, option) in self
526 .options
527 .iter()
528 .enumerate()
529 .skip(scroll)
530 .take(actual_visible)
531 {
532 let y = inner.y + (i - scroll) as u16;
533 let option_area = Rect::new(inner.x, y, inner.width, 1);
534
535 let is_highlighted = i == self.state.highlighted_index;
536 let is_selected = self.state.selected_index == Some(i);
537
538 let style = if is_highlighted {
539 self.style.highlight_style
540 } else {
541 self.style.option_style
542 };
543
544 let prefix = if is_selected {
545 self.style.selected_indicator
546 } else {
547 self.style.unselected_indicator
548 };
549
550 let text = format!("{}{}", prefix, (self.render_option)(option));
551
552 let max_width = inner.width as usize;
554 let display_text: String = text.chars().take(max_width).collect();
555
556 let paragraph = Paragraph::new(Span::styled(display_text, style));
557 frame.render_widget(paragraph, option_area);
558
559 regions.push(ClickRegion::new(option_area, SelectAction::Select(i)));
561 }
562
563 regions
564 }
565
566 pub fn render_to_buffer(self, area: Rect, buf: &mut Buffer) -> ClickRegion<SelectAction> {
570 let border_color = if !self.state.enabled {
571 self.style.disabled_border
572 } else if self.state.focused {
573 self.style.focused_border
574 } else {
575 self.style.unfocused_border
576 };
577
578 let mut block = Block::default()
579 .borders(Borders::ALL)
580 .border_style(Style::default().fg(border_color));
581
582 if let Some(label) = self.label {
583 block = block.title(format!(" {} ", label));
584 }
585
586 let inner = block.inner(area);
587 block.render(area, buf);
588
589 let display_text = if let Some(idx) = self.state.selected_index {
591 if idx < self.options.len() {
592 let text = (self.render_option)(&self.options[idx]);
593 Span::styled(text, Style::default().fg(self.style.text_fg))
594 } else {
595 Span::styled(
596 self.placeholder,
597 Style::default().fg(self.style.placeholder_fg),
598 )
599 }
600 } else {
601 Span::styled(
602 self.placeholder,
603 Style::default().fg(self.style.placeholder_fg),
604 )
605 };
606
607 let indicator_color = if self.state.focused {
608 self.style.focused_border
609 } else {
610 self.style.unfocused_border
611 };
612
613 let indicator = Span::styled(
614 format!(" {}", self.style.dropdown_indicator),
615 Style::default().fg(indicator_color),
616 );
617
618 let line = Line::from(vec![display_text, indicator]);
619 let paragraph = Paragraph::new(line);
620 paragraph.render(inner, buf);
621
622 ClickRegion::new(area, SelectAction::Focus)
623 }
624}
625
626pub fn handle_select_key(key: &KeyEvent, state: &mut SelectState) -> Option<SelectAction> {
645 if !state.enabled {
646 return None;
647 }
648
649 if state.is_open {
650 match key.code {
652 KeyCode::Esc => {
653 state.close();
654 Some(SelectAction::Close)
655 }
656 KeyCode::Enter | KeyCode::Char(' ') => {
657 let idx = state.highlighted_index;
658 state.select_highlighted();
659 Some(SelectAction::Select(idx))
660 }
661 KeyCode::Up => {
662 state.highlight_prev();
663 state.ensure_visible(8); None
665 }
666 KeyCode::Down => {
667 state.highlight_next();
668 state.ensure_visible(8);
669 None
670 }
671 KeyCode::Home => {
672 state.highlight_first();
673 None
674 }
675 KeyCode::End => {
676 state.highlight_last();
677 state.ensure_visible(8);
678 None
679 }
680 KeyCode::PageUp => {
681 for _ in 0..5 {
682 state.highlight_prev();
683 }
684 state.ensure_visible(8);
685 None
686 }
687 KeyCode::PageDown => {
688 for _ in 0..5 {
689 state.highlight_next();
690 }
691 state.ensure_visible(8);
692 None
693 }
694 _ => None,
695 }
696 } else {
697 match key.code {
699 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Down => {
700 state.open();
701 Some(SelectAction::Open)
702 }
703 _ => None,
704 }
705 }
706}
707
708pub fn handle_select_mouse(
719 mouse: &MouseEvent,
720 state: &mut SelectState,
721 select_area: Rect,
722 dropdown_regions: &[ClickRegion<SelectAction>],
723) -> Option<SelectAction> {
724 if !state.enabled {
725 return None;
726 }
727
728 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
729 let col = mouse.column;
730 let row = mouse.row;
731
732 if state.is_open {
733 for region in dropdown_regions {
735 if region.contains(col, row) {
736 if let SelectAction::Select(idx) = region.data {
737 state.select(idx);
738 return Some(SelectAction::Select(idx));
739 }
740 }
741 }
742
743 if col >= select_area.x
745 && col < select_area.x + select_area.width
746 && row >= select_area.y
747 && row < select_area.y + select_area.height
748 {
749 state.close();
750 return Some(SelectAction::Close);
751 }
752
753 state.close();
755 Some(SelectAction::Close)
756 } else {
757 if col >= select_area.x
759 && col < select_area.x + select_area.width
760 && row >= select_area.y
761 && row < select_area.y + select_area.height
762 {
763 state.open();
764 return Some(SelectAction::Open);
765 }
766 None
767 }
768 } else {
769 None
770 }
771}
772
773pub fn calculate_dropdown_height(option_count: usize, max_visible: u16) -> u16 {
777 let visible = (option_count as u16).min(max_visible);
778 visible + 2 }
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn test_state_default() {
787 let state = SelectState::default();
788 assert!(state.selected_index.is_none());
789 assert!(!state.is_open);
790 assert!(!state.focused);
791 assert!(state.enabled);
792 assert_eq!(state.highlighted_index, 0);
793 }
794
795 #[test]
796 fn test_state_new() {
797 let state = SelectState::new(5);
798 assert_eq!(state.total_options, 5);
799 assert!(state.selected_index.is_none());
800 }
801
802 #[test]
803 fn test_state_with_selected() {
804 let state = SelectState::with_selected(5, 2);
805 assert_eq!(state.selected_index, Some(2));
806 assert_eq!(state.highlighted_index, 2);
807 }
808
809 #[test]
810 fn test_state_with_selected_out_of_bounds() {
811 let state = SelectState::with_selected(5, 10);
812 assert!(state.selected_index.is_none());
813 assert_eq!(state.highlighted_index, 0);
814 }
815
816 #[test]
817 fn test_open_close() {
818 let mut state = SelectState::new(5);
819
820 state.open();
821 assert!(state.is_open);
822
823 state.close();
824 assert!(!state.is_open);
825
826 state.toggle();
827 assert!(state.is_open);
828
829 state.toggle();
830 assert!(!state.is_open);
831 }
832
833 #[test]
834 fn test_open_disabled() {
835 let mut state = SelectState::new(5);
836 state.enabled = false;
837
838 state.open();
839 assert!(!state.is_open);
840 }
841
842 #[test]
843 fn test_highlight_navigation() {
844 let mut state = SelectState::new(5);
845
846 state.highlight_next();
847 assert_eq!(state.highlighted_index, 1);
848
849 state.highlight_next();
850 assert_eq!(state.highlighted_index, 2);
851
852 state.highlight_prev();
853 assert_eq!(state.highlighted_index, 1);
854
855 state.highlight_first();
856 assert_eq!(state.highlighted_index, 0);
857
858 state.highlight_last();
859 assert_eq!(state.highlighted_index, 4);
860 }
861
862 #[test]
863 fn test_highlight_bounds() {
864 let mut state = SelectState::new(3);
865
866 state.highlight_prev();
868 assert_eq!(state.highlighted_index, 0);
869
870 state.highlighted_index = 2;
872 state.highlight_next();
873 assert_eq!(state.highlighted_index, 2);
874 }
875
876 #[test]
877 fn test_select() {
878 let mut state = SelectState::new(5);
879 state.is_open = true;
880
881 state.select(2);
882 assert_eq!(state.selected_index, Some(2));
883 assert_eq!(state.highlighted_index, 2);
884 assert!(!state.is_open); }
886
887 #[test]
888 fn test_select_highlighted() {
889 let mut state = SelectState::new(5);
890 state.is_open = true;
891 state.highlighted_index = 3;
892
893 state.select_highlighted();
894 assert_eq!(state.selected_index, Some(3));
895 assert!(!state.is_open);
896 }
897
898 #[test]
899 fn test_clear_selection() {
900 let mut state = SelectState::with_selected(5, 2);
901 assert!(state.has_selection());
902
903 state.clear_selection();
904 assert!(!state.has_selection());
905 assert!(state.selected_index.is_none());
906 }
907
908 #[test]
909 fn test_set_total() {
910 let mut state = SelectState::with_selected(10, 8);
911 state.highlighted_index = 9;
912
913 state.set_total(5);
914 assert_eq!(state.total_options, 5);
915 assert_eq!(state.selected_index, Some(4)); assert_eq!(state.highlighted_index, 4); }
918
919 #[test]
920 fn test_ensure_visible() {
921 let mut state = SelectState::new(20);
922 state.highlighted_index = 15;
923 state.scroll_offset = 0;
924
925 state.ensure_visible(10);
926 assert!(state.scroll_offset >= 6); }
928
929 #[test]
930 fn test_style_default() {
931 let style = SelectStyle::default();
932 assert_eq!(style.focused_border, Color::Yellow);
933 assert_eq!(style.max_visible_options, 8);
934 }
935
936 #[test]
937 fn test_style_builders() {
938 let style = SelectStyle::minimal();
939 assert_eq!(style.highlight_style.add_modifier, Modifier::BOLD);
940
941 let style = SelectStyle::arrow();
942 assert_eq!(style.selected_indicator, "→ ");
943
944 let style = SelectStyle::bracket();
945 assert_eq!(style.selected_indicator, "[x] ");
946 }
947
948 #[test]
949 fn test_style_builder_methods() {
950 let style = SelectStyle::default()
951 .max_options(10)
952 .focused_border(Color::Cyan)
953 .indicator("↓");
954
955 assert_eq!(style.max_visible_options, 10);
956 assert_eq!(style.focused_border, Color::Cyan);
957 assert_eq!(style.dropdown_indicator, "↓");
958 }
959
960 #[test]
961 fn test_handle_key_closed() {
962 let mut state = SelectState::new(5);
963
964 let key = KeyEvent::from(KeyCode::Enter);
966 let action = handle_select_key(&key, &mut state);
967 assert_eq!(action, Some(SelectAction::Open));
968 assert!(state.is_open);
969 }
970
971 #[test]
972 fn test_handle_key_open_navigation() {
973 let mut state = SelectState::new(5);
974 state.open();
975
976 let key = KeyEvent::from(KeyCode::Down);
978 handle_select_key(&key, &mut state);
979 assert_eq!(state.highlighted_index, 1);
980
981 let key = KeyEvent::from(KeyCode::Up);
983 handle_select_key(&key, &mut state);
984 assert_eq!(state.highlighted_index, 0);
985 }
986
987 #[test]
988 fn test_handle_key_open_select() {
989 let mut state = SelectState::new(5);
990 state.open();
991 state.highlighted_index = 2;
992
993 let key = KeyEvent::from(KeyCode::Enter);
994 let action = handle_select_key(&key, &mut state);
995
996 assert_eq!(action, Some(SelectAction::Select(2)));
997 assert_eq!(state.selected_index, Some(2));
998 assert!(!state.is_open);
999 }
1000
1001 #[test]
1002 fn test_handle_key_open_escape() {
1003 let mut state = SelectState::new(5);
1004 state.open();
1005
1006 let key = KeyEvent::from(KeyCode::Esc);
1007 let action = handle_select_key(&key, &mut state);
1008
1009 assert_eq!(action, Some(SelectAction::Close));
1010 assert!(!state.is_open);
1011 }
1012
1013 #[test]
1014 fn test_handle_key_disabled() {
1015 let mut state = SelectState::new(5);
1016 state.enabled = false;
1017
1018 let key = KeyEvent::from(KeyCode::Enter);
1019 let action = handle_select_key(&key, &mut state);
1020
1021 assert!(action.is_none());
1022 assert!(!state.is_open);
1023 }
1024
1025 #[test]
1026 fn test_calculate_dropdown_height() {
1027 assert_eq!(calculate_dropdown_height(3, 8), 5); assert_eq!(calculate_dropdown_height(10, 8), 10); assert_eq!(calculate_dropdown_height(0, 8), 2); }
1031
1032 #[test]
1033 fn test_click_region_contains() {
1034 let region = ClickRegion::new(Rect::new(10, 5, 20, 3), SelectAction::Select(0));
1035
1036 assert!(region.contains(10, 5));
1037 assert!(region.contains(29, 7));
1038 assert!(!region.contains(9, 5));
1039 assert!(!region.contains(30, 5));
1040 }
1041}