1use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
34use ratatui::{
35 Frame,
36 layout::Rect,
37 style::{Color, Style},
38 text::{Line, Span},
39 widgets::{Block, Borders, Clear, Paragraph},
40};
41
42use crate::traits::ClickRegion;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ContextMenuAction {
47 Open,
49 Close,
51 Select(String),
53 SubmenuOpen(usize),
55 SubmenuClose,
57 HighlightChange(usize),
59}
60
61#[derive(Debug, Clone)]
63pub enum ContextMenuItem {
64 Action {
66 id: String,
68 label: String,
70 icon: Option<String>,
72 shortcut: Option<String>,
74 enabled: bool,
76 },
77 Separator,
79 Submenu {
81 label: String,
83 icon: Option<String>,
85 items: Vec<ContextMenuItem>,
87 enabled: bool,
89 },
90}
91
92impl ContextMenuItem {
93 pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
95 Self::Action {
96 id: id.into(),
97 label: label.into(),
98 icon: None,
99 shortcut: None,
100 enabled: true,
101 }
102 }
103
104 pub fn separator() -> Self {
106 Self::Separator
107 }
108
109 pub fn submenu(label: impl Into<String>, items: Vec<ContextMenuItem>) -> Self {
111 Self::Submenu {
112 label: label.into(),
113 icon: None,
114 items,
115 enabled: true,
116 }
117 }
118
119 pub fn icon(mut self, icon: impl Into<String>) -> Self {
121 match &mut self {
122 Self::Action { icon: i, .. } => *i = Some(icon.into()),
123 Self::Submenu { icon: i, .. } => *i = Some(icon.into()),
124 Self::Separator => {}
125 }
126 self
127 }
128
129 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
131 if let Self::Action { shortcut: s, .. } = &mut self {
132 *s = Some(shortcut.into());
133 }
134 self
135 }
136
137 pub fn enabled(mut self, enabled: bool) -> Self {
139 match &mut self {
140 Self::Action { enabled: e, .. } => *e = enabled,
141 Self::Submenu { enabled: e, .. } => *e = enabled,
142 Self::Separator => {}
143 }
144 self
145 }
146
147 pub fn is_selectable(&self) -> bool {
149 match self {
150 Self::Action { enabled, .. } => *enabled,
151 Self::Separator => false,
152 Self::Submenu { enabled, .. } => *enabled,
153 }
154 }
155
156 pub fn has_submenu(&self) -> bool {
158 matches!(self, Self::Submenu { .. })
159 }
160
161 pub fn id(&self) -> Option<&str> {
163 if let Self::Action { id, .. } = self {
164 Some(id)
165 } else {
166 None
167 }
168 }
169
170 pub fn label(&self) -> Option<&str> {
172 match self {
173 Self::Action { label, .. } => Some(label),
174 Self::Submenu { label, .. } => Some(label),
175 Self::Separator => None,
176 }
177 }
178
179 pub fn get_icon(&self) -> Option<&str> {
181 match self {
182 Self::Action { icon, .. } => icon.as_deref(),
183 Self::Submenu { icon, .. } => icon.as_deref(),
184 Self::Separator => None,
185 }
186 }
187
188 pub fn get_shortcut(&self) -> Option<&str> {
190 if let Self::Action { shortcut, .. } = self {
191 shortcut.as_deref()
192 } else {
193 None
194 }
195 }
196
197 pub fn is_enabled(&self) -> bool {
199 match self {
200 Self::Action { enabled, .. } => *enabled,
201 Self::Separator => false,
202 Self::Submenu { enabled, .. } => *enabled,
203 }
204 }
205
206 pub fn submenu_items(&self) -> Option<&[ContextMenuItem]> {
208 if let Self::Submenu { items, .. } = self {
209 Some(items)
210 } else {
211 None
212 }
213 }
214}
215
216#[derive(Debug, Clone)]
218pub struct ContextMenuState {
219 pub is_open: bool,
221 pub anchor_position: (u16, u16),
223 pub highlighted_index: usize,
225 pub scroll_offset: u16,
227 pub active_submenu: Option<usize>,
229 pub submenu_state: Option<Box<ContextMenuState>>,
231}
232
233impl Default for ContextMenuState {
234 fn default() -> Self {
235 Self::new()
236 }
237}
238
239impl ContextMenuState {
240 pub fn new() -> Self {
242 Self {
243 is_open: false,
244 anchor_position: (0, 0),
245 highlighted_index: 0,
246 scroll_offset: 0,
247 active_submenu: None,
248 submenu_state: None,
249 }
250 }
251
252 pub fn open_at(&mut self, x: u16, y: u16) {
254 self.is_open = true;
255 self.anchor_position = (x, y);
256 self.highlighted_index = 0;
257 self.scroll_offset = 0;
258 self.close_submenu();
259 }
260
261 pub fn close(&mut self) {
263 self.is_open = false;
264 self.close_submenu();
265 }
266
267 pub fn highlight_prev(&mut self, items: &[ContextMenuItem]) {
269 if items.is_empty() {
270 return;
271 }
272
273 let mut new_index = self.highlighted_index;
274 loop {
275 if new_index == 0 {
276 break;
277 }
278 new_index -= 1;
279 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
280 self.highlighted_index = new_index;
281 break;
282 }
283 }
284 }
285
286 pub fn highlight_next(&mut self, items: &[ContextMenuItem]) {
288 if items.is_empty() {
289 return;
290 }
291
292 let mut new_index = self.highlighted_index;
293 loop {
294 new_index += 1;
295 if new_index >= items.len() {
296 break;
297 }
298 if items.get(new_index).is_some_and(|i| i.is_selectable()) {
299 self.highlighted_index = new_index;
300 break;
301 }
302 }
303 }
304
305 pub fn highlight_first(&mut self, items: &[ContextMenuItem]) {
307 for (i, item) in items.iter().enumerate() {
308 if item.is_selectable() {
309 self.highlighted_index = i;
310 self.scroll_offset = 0;
311 break;
312 }
313 }
314 }
315
316 pub fn highlight_last(&mut self, items: &[ContextMenuItem]) {
318 for (i, item) in items.iter().enumerate().rev() {
319 if item.is_selectable() {
320 self.highlighted_index = i;
321 break;
322 }
323 }
324 }
325
326 pub fn open_submenu(&mut self) {
328 self.active_submenu = Some(self.highlighted_index);
329 let mut submenu_state = ContextMenuState::new();
330 submenu_state.is_open = true;
331 self.submenu_state = Some(Box::new(submenu_state));
332 }
333
334 pub fn close_submenu(&mut self) {
336 self.active_submenu = None;
337 self.submenu_state = None;
338 }
339
340 pub fn has_open_submenu(&self) -> bool {
342 self.active_submenu.is_some()
343 }
344
345 pub fn ensure_visible(&mut self, viewport_height: usize) {
347 if viewport_height == 0 {
348 return;
349 }
350 if self.highlighted_index < self.scroll_offset as usize {
351 self.scroll_offset = self.highlighted_index as u16;
352 } else if self.highlighted_index >= self.scroll_offset as usize + viewport_height {
353 self.scroll_offset = (self.highlighted_index - viewport_height + 1) as u16;
354 }
355 }
356}
357
358#[derive(Debug, Clone)]
360pub struct ContextMenuStyle {
361 pub background: Color,
363 pub border: Color,
365 pub normal_fg: Color,
367 pub highlight_bg: Color,
369 pub highlight_fg: Color,
371 pub disabled_fg: Color,
373 pub shortcut_fg: Color,
375 pub separator_fg: Color,
377 pub min_width: u16,
379 pub max_width: u16,
381 pub max_visible_items: u16,
383 pub padding: u16,
385 pub submenu_indicator: &'static str,
387 pub separator_char: char,
389}
390
391impl Default for ContextMenuStyle {
392 fn default() -> Self {
393 Self {
394 background: Color::Rgb(40, 40, 40),
395 border: Color::Rgb(80, 80, 80),
396 normal_fg: Color::White,
397 highlight_bg: Color::Rgb(60, 100, 180),
398 highlight_fg: Color::White,
399 disabled_fg: Color::DarkGray,
400 shortcut_fg: Color::Rgb(140, 140, 140),
401 separator_fg: Color::Rgb(80, 80, 80),
402 min_width: 15,
403 max_width: 50,
404 max_visible_items: 15,
405 padding: 1,
406 submenu_indicator: "â–¶",
407 separator_char: '─',
408 }
409 }
410}
411
412impl From<&crate::theme::Theme> for ContextMenuStyle {
413 fn from(theme: &crate::theme::Theme) -> Self {
414 let p = &theme.palette;
415 Self {
416 background: p.surface,
417 border: p.separator,
418 normal_fg: p.text,
419 highlight_bg: p.menu_highlight_bg,
420 highlight_fg: p.menu_highlight_fg,
421 disabled_fg: p.text_disabled,
422 shortcut_fg: p.text_muted,
423 separator_fg: p.separator,
424 min_width: 15,
425 max_width: 50,
426 max_visible_items: 15,
427 padding: 1,
428 submenu_indicator: "â–¶",
429 separator_char: '─',
430 }
431 }
432}
433
434impl ContextMenuStyle {
435 pub fn light() -> Self {
437 Self {
438 background: Color::Rgb(250, 250, 250),
439 border: Color::Rgb(180, 180, 180),
440 normal_fg: Color::Rgb(30, 30, 30),
441 highlight_bg: Color::Rgb(0, 120, 215),
442 highlight_fg: Color::White,
443 disabled_fg: Color::Rgb(160, 160, 160),
444 shortcut_fg: Color::Rgb(100, 100, 100),
445 separator_fg: Color::Rgb(200, 200, 200),
446 ..Default::default()
447 }
448 }
449
450 pub fn minimal() -> Self {
452 Self {
453 background: Color::Reset,
454 border: Color::Gray,
455 normal_fg: Color::White,
456 highlight_bg: Color::Blue,
457 highlight_fg: Color::White,
458 disabled_fg: Color::DarkGray,
459 shortcut_fg: Color::Gray,
460 separator_fg: Color::DarkGray,
461 ..Default::default()
462 }
463 }
464
465 pub fn min_width(mut self, width: u16) -> Self {
467 self.min_width = width;
468 self
469 }
470
471 pub fn max_width(mut self, width: u16) -> Self {
473 self.max_width = width;
474 self
475 }
476
477 pub fn max_visible_items(mut self, count: u16) -> Self {
479 self.max_visible_items = count;
480 self
481 }
482
483 pub fn submenu_indicator(mut self, indicator: &'static str) -> Self {
485 self.submenu_indicator = indicator;
486 self
487 }
488
489 pub fn highlight(mut self, fg: Color, bg: Color) -> Self {
491 self.highlight_fg = fg;
492 self.highlight_bg = bg;
493 self
494 }
495}
496
497pub struct ContextMenu<'a> {
502 items: &'a [ContextMenuItem],
503 state: &'a ContextMenuState,
504 style: ContextMenuStyle,
505}
506
507impl<'a> ContextMenu<'a> {
508 pub fn new(items: &'a [ContextMenuItem], state: &'a ContextMenuState) -> Self {
510 Self {
511 items,
512 state,
513 style: ContextMenuStyle::default(),
514 }
515 }
516
517 pub fn style(mut self, style: ContextMenuStyle) -> Self {
519 self.style = style;
520 self
521 }
522
523 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
525 self.style(ContextMenuStyle::from(theme))
526 }
527
528 fn calculate_width(&self) -> u16 {
530 let mut max_label_width = 0u16;
531 let mut max_shortcut_width = 0u16;
532
533 for item in self.items {
534 match item {
535 ContextMenuItem::Action {
536 label,
537 icon,
538 shortcut,
539 ..
540 } => {
541 let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
542 let label_width = label.chars().count() + icon_width;
543 max_label_width = max_label_width.max(label_width as u16);
544 if let Some(s) = shortcut {
545 max_shortcut_width = max_shortcut_width.max(s.chars().count() as u16);
546 }
547 }
548 ContextMenuItem::Submenu { label, icon, .. } => {
549 let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
550 let label_width = label.chars().count() + icon_width + 2;
552 max_label_width = max_label_width.max(label_width as u16);
553 }
554 ContextMenuItem::Separator => {}
555 }
556 }
557
558 let content_width = self.style.padding
560 + max_label_width
561 + if max_shortcut_width > 0 {
562 2 + max_shortcut_width
563 } else {
564 0
565 }
566 + self.style.padding;
567
568 (content_width + 2) .max(self.style.min_width)
571 .min(self.style.max_width)
572 }
573
574 fn calculate_height(&self) -> u16 {
576 let item_count = self.items.len() as u16;
577 let visible = item_count.min(self.style.max_visible_items);
578 visible + 2 }
580
581 fn calculate_menu_area(&self, screen: Rect) -> Rect {
583 let (anchor_x, anchor_y) = self.state.anchor_position;
584 let width = self.calculate_width();
585 let height = self.calculate_height();
586
587 let x = if anchor_x + width <= screen.x + screen.width {
589 anchor_x
590 } else {
591 anchor_x.saturating_sub(width)
592 };
593
594 let y = if anchor_y + height <= screen.y + screen.height {
595 anchor_y
596 } else {
597 anchor_y.saturating_sub(height)
598 };
599
600 let final_width = width.min(screen.width.saturating_sub(x.saturating_sub(screen.x)));
602 let final_height = height.min(screen.height.saturating_sub(y.saturating_sub(screen.y)));
603
604 Rect::new(x, y, final_width, final_height)
605 }
606
607 pub fn render_stateful(
611 &self,
612 frame: &mut Frame,
613 screen: Rect,
614 ) -> (Rect, Vec<ClickRegion<ContextMenuAction>>) {
615 let mut regions = Vec::new();
616
617 if !self.state.is_open || self.items.is_empty() {
618 return (Rect::default(), regions);
619 }
620
621 let menu_area = self.calculate_menu_area(screen);
622
623 frame.render_widget(Clear, menu_area);
625
626 let block = Block::default()
628 .borders(Borders::ALL)
629 .border_style(Style::default().fg(self.style.border))
630 .style(Style::default().bg(self.style.background));
631
632 let inner = block.inner(menu_area);
633 frame.render_widget(block, menu_area);
634
635 let visible_count = inner.height as usize;
637 let scroll = self.state.scroll_offset as usize;
638
639 for (display_idx, (item_idx, item)) in self
640 .items
641 .iter()
642 .enumerate()
643 .skip(scroll)
644 .take(visible_count)
645 .enumerate()
646 {
647 let y = inner.y + display_idx as u16;
648 let item_area = Rect::new(inner.x, y, inner.width, 1);
649
650 let is_highlighted = item_idx == self.state.highlighted_index;
651
652 match item {
653 ContextMenuItem::Separator => {
654 let sep_line: String =
656 std::iter::repeat_n(self.style.separator_char, inner.width as usize)
657 .collect();
658 let para = Paragraph::new(Span::styled(
659 sep_line,
660 Style::default().fg(self.style.separator_fg),
661 ));
662 frame.render_widget(para, item_area);
663 }
664 ContextMenuItem::Action {
665 label,
666 icon,
667 shortcut,
668 enabled,
669 id,
670 } => {
671 let (fg, bg) = if !enabled {
672 (self.style.disabled_fg, self.style.background)
673 } else if is_highlighted {
674 (self.style.highlight_fg, self.style.highlight_bg)
675 } else {
676 (self.style.normal_fg, self.style.background)
677 };
678
679 let style = Style::default().fg(fg).bg(bg);
680 let shortcut_style = Style::default()
681 .fg(if *enabled {
682 self.style.shortcut_fg
683 } else {
684 self.style.disabled_fg
685 })
686 .bg(bg);
687
688 let mut spans = Vec::new();
689
690 spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
692
693 if let Some(ic) = icon {
695 spans.push(Span::styled(format!("{} ", ic), style));
696 }
697
698 spans.push(Span::styled(label.clone(), style));
700
701 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
703 let shortcut_len = shortcut.as_ref().map(|s| s.chars().count()).unwrap_or(0);
704 let fill_len = (inner.width as usize)
705 .saturating_sub(current_len)
706 .saturating_sub(shortcut_len)
707 .saturating_sub(self.style.padding as usize);
708
709 if fill_len > 0 {
710 spans.push(Span::styled(" ".repeat(fill_len), style));
711 }
712
713 if let Some(sc) = shortcut {
715 spans.push(Span::styled(sc.clone(), shortcut_style));
716 }
717
718 spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
720
721 let para = Paragraph::new(Line::from(spans));
722 frame.render_widget(para, item_area);
723
724 if *enabled {
726 regions.push(ClickRegion::new(
727 item_area,
728 ContextMenuAction::Select(id.clone()),
729 ));
730 }
731 }
732 ContextMenuItem::Submenu {
733 label,
734 icon,
735 enabled,
736 ..
737 } => {
738 let (fg, bg) = if !enabled {
739 (self.style.disabled_fg, self.style.background)
740 } else if is_highlighted {
741 (self.style.highlight_fg, self.style.highlight_bg)
742 } else {
743 (self.style.normal_fg, self.style.background)
744 };
745
746 let style = Style::default().fg(fg).bg(bg);
747
748 let mut spans = Vec::new();
749
750 spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
752
753 if let Some(ic) = icon {
755 spans.push(Span::styled(format!("{} ", ic), style));
756 }
757
758 spans.push(Span::styled(label.clone(), style));
760
761 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
763 let indicator_len = self.style.submenu_indicator.chars().count();
764 let fill_len = (inner.width as usize)
765 .saturating_sub(current_len)
766 .saturating_sub(indicator_len)
767 .saturating_sub(self.style.padding as usize);
768
769 if fill_len > 0 {
770 spans.push(Span::styled(" ".repeat(fill_len), style));
771 }
772
773 spans.push(Span::styled(self.style.submenu_indicator, style));
774
775 spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
777
778 let para = Paragraph::new(Line::from(spans));
779 frame.render_widget(para, item_area);
780
781 if *enabled {
783 regions.push(ClickRegion::new(
784 item_area,
785 ContextMenuAction::SubmenuOpen(item_idx),
786 ));
787 }
788 }
789 }
790 }
791
792 if let (Some(submenu_idx), Some(submenu_state)) =
794 (self.state.active_submenu, &self.state.submenu_state)
795 {
796 if let Some(ContextMenuItem::Submenu { items, .. }) = self.items.get(submenu_idx) {
797 let submenu_anchor_x = menu_area.x + menu_area.width;
799 let submenu_anchor_y =
800 menu_area.y + 1 + (submenu_idx as u16).saturating_sub(self.state.scroll_offset);
801
802 let mut adjusted_state = (**submenu_state).clone();
803 adjusted_state.anchor_position = (submenu_anchor_x, submenu_anchor_y);
804
805 let adjusted_submenu =
806 ContextMenu::new(items, &adjusted_state).style(self.style.clone());
807
808 let (_, submenu_regions) = adjusted_submenu.render_stateful(frame, screen);
809 regions.extend(submenu_regions);
810 }
811 }
812
813 (menu_area, regions)
814 }
815}
816
817pub fn handle_context_menu_key(
832 key: &KeyEvent,
833 state: &mut ContextMenuState,
834 items: &[ContextMenuItem],
835) -> Option<ContextMenuAction> {
836 if !state.is_open {
837 return None;
838 }
839
840 if let (Some(submenu_idx), Some(submenu_state)) =
842 (state.active_submenu, &mut state.submenu_state)
843 {
844 if let Some(ContextMenuItem::Submenu {
845 items: sub_items, ..
846 }) = items.get(submenu_idx)
847 {
848 match key.code {
849 KeyCode::Left | KeyCode::Esc => {
850 state.close_submenu();
851 return Some(ContextMenuAction::SubmenuClose);
852 }
853 _ => {
854 if let Some(action) =
855 handle_context_menu_key(key, submenu_state.as_mut(), sub_items)
856 {
857 return Some(action);
858 }
859 }
860 }
861 return None;
862 }
863 }
864
865 match key.code {
866 KeyCode::Esc => {
867 state.close();
868 Some(ContextMenuAction::Close)
869 }
870 KeyCode::Up => {
871 state.highlight_prev(items);
872 state.ensure_visible(8);
873 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
874 }
875 KeyCode::Down => {
876 state.highlight_next(items);
877 state.ensure_visible(8);
878 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
879 }
880 KeyCode::Home => {
881 state.highlight_first(items);
882 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
883 }
884 KeyCode::End => {
885 state.highlight_last(items);
886 state.ensure_visible(items.len());
887 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
888 }
889 KeyCode::Enter | KeyCode::Char(' ') => {
890 if let Some(item) = items.get(state.highlighted_index) {
891 match item {
892 ContextMenuItem::Action { id, enabled, .. } if *enabled => {
893 let action_id = id.clone();
894 state.close();
895 Some(ContextMenuAction::Select(action_id))
896 }
897 ContextMenuItem::Submenu { enabled, .. } if *enabled => {
898 state.open_submenu();
899 Some(ContextMenuAction::SubmenuOpen(state.highlighted_index))
900 }
901 _ => None,
902 }
903 } else {
904 None
905 }
906 }
907 KeyCode::Right => {
908 if let Some(item) = items.get(state.highlighted_index) {
909 if item.has_submenu() && item.is_enabled() {
910 state.open_submenu();
911 return Some(ContextMenuAction::SubmenuOpen(state.highlighted_index));
912 }
913 }
914 None
915 }
916 KeyCode::Left => {
917 None
919 }
920 _ => None,
921 }
922}
923
924pub fn handle_context_menu_mouse(
935 mouse: &MouseEvent,
936 state: &mut ContextMenuState,
937 menu_area: Rect,
938 item_regions: &[ClickRegion<ContextMenuAction>],
939) -> Option<ContextMenuAction> {
940 if !state.is_open {
941 return None;
942 }
943
944 let col = mouse.column;
945 let row = mouse.row;
946
947 match mouse.kind {
948 MouseEventKind::Down(MouseButton::Left) => {
949 for region in item_regions {
951 if region.contains(col, row) {
952 match ®ion.data {
953 ContextMenuAction::Select(id) => {
954 let action_id = id.clone();
955 state.close();
956 return Some(ContextMenuAction::Select(action_id));
957 }
958 ContextMenuAction::SubmenuOpen(idx) => {
959 state.highlighted_index = *idx;
960 state.open_submenu();
961 return Some(ContextMenuAction::SubmenuOpen(*idx));
962 }
963 _ => {}
964 }
965 }
966 }
967
968 if !menu_area.intersects(Rect::new(col, row, 1, 1)) {
970 state.close();
971 return Some(ContextMenuAction::Close);
972 }
973 None
974 }
975 MouseEventKind::Moved => {
976 for region in item_regions.iter() {
978 if region.contains(col, row) {
979 if let ContextMenuAction::Select(_) | ContextMenuAction::SubmenuOpen(_) =
981 ®ion.data
982 {
983 let inner_start_y = menu_area.y + 1; let item_idx =
987 (row - inner_start_y) as usize + state.scroll_offset as usize;
988
989 if item_idx < item_regions.len() + state.scroll_offset as usize
990 && state.highlighted_index != item_idx
991 {
992 state.highlighted_index = item_idx;
993 return Some(ContextMenuAction::HighlightChange(item_idx));
994 }
995 }
996 break;
997 }
998 }
999 None
1000 }
1001 _ => None,
1002 }
1003}
1004
1005pub fn is_context_menu_trigger(mouse: &MouseEvent) -> bool {
1007 matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1008}
1009
1010pub fn calculate_menu_height(item_count: usize, max_visible: u16) -> u16 {
1012 let visible = (item_count as u16).min(max_visible);
1013 visible + 2 }
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 #[test]
1021 fn test_context_menu_item_action() {
1022 let item = ContextMenuItem::action("copy", "Copy")
1023 .icon("📋")
1024 .shortcut("Ctrl+C");
1025
1026 assert!(item.is_selectable());
1027 assert!(!item.has_submenu());
1028 assert_eq!(item.id(), Some("copy"));
1029 assert_eq!(item.label(), Some("Copy"));
1030 assert_eq!(item.get_icon(), Some("📋"));
1031 assert_eq!(item.get_shortcut(), Some("Ctrl+C"));
1032 }
1033
1034 #[test]
1035 fn test_context_menu_item_separator() {
1036 let item = ContextMenuItem::separator();
1037
1038 assert!(!item.is_selectable());
1039 assert!(!item.has_submenu());
1040 assert_eq!(item.label(), None);
1041 }
1042
1043 #[test]
1044 fn test_context_menu_item_submenu() {
1045 let items = vec![ContextMenuItem::action("sub1", "Sub Item 1")];
1046 let item = ContextMenuItem::submenu("More", items).icon("â–¶");
1047
1048 assert!(item.is_selectable());
1049 assert!(item.has_submenu());
1050 assert_eq!(item.label(), Some("More"));
1051 assert!(item.submenu_items().is_some());
1052 }
1053
1054 #[test]
1055 fn test_context_menu_item_disabled() {
1056 let item = ContextMenuItem::action("delete", "Delete").enabled(false);
1057
1058 assert!(!item.is_selectable());
1059 assert!(!item.is_enabled());
1060 }
1061
1062 #[test]
1063 fn test_context_menu_state_open_close() {
1064 let mut state = ContextMenuState::new();
1065
1066 assert!(!state.is_open);
1067
1068 state.open_at(10, 5);
1069 assert!(state.is_open);
1070 assert_eq!(state.anchor_position, (10, 5));
1071 assert_eq!(state.highlighted_index, 0);
1072
1073 state.close();
1074 assert!(!state.is_open);
1075 }
1076
1077 #[test]
1078 fn test_context_menu_state_navigation() {
1079 let mut state = ContextMenuState::new();
1080 state.open_at(0, 0);
1081
1082 let items = vec![
1083 ContextMenuItem::action("a", "A"),
1084 ContextMenuItem::separator(),
1085 ContextMenuItem::action("b", "B"),
1086 ContextMenuItem::action("c", "C"),
1087 ];
1088
1089 assert_eq!(state.highlighted_index, 0);
1091
1092 state.highlight_next(&items);
1094 assert_eq!(state.highlighted_index, 2); state.highlight_next(&items);
1098 assert_eq!(state.highlighted_index, 3);
1099
1100 state.highlight_prev(&items);
1102 assert_eq!(state.highlighted_index, 2);
1103
1104 state.highlight_prev(&items);
1106 assert_eq!(state.highlighted_index, 0);
1107 }
1108
1109 #[test]
1110 fn test_context_menu_state_submenu() {
1111 let mut state = ContextMenuState::new();
1112 state.open_at(0, 0);
1113 state.highlighted_index = 2;
1114
1115 assert!(!state.has_open_submenu());
1116
1117 state.open_submenu();
1118 assert!(state.has_open_submenu());
1119 assert_eq!(state.active_submenu, Some(2));
1120 assert!(state.submenu_state.is_some());
1121
1122 state.close_submenu();
1123 assert!(!state.has_open_submenu());
1124 assert!(state.submenu_state.is_none());
1125 }
1126
1127 #[test]
1128 fn test_context_menu_style_default() {
1129 let style = ContextMenuStyle::default();
1130 assert_eq!(style.min_width, 15);
1131 assert_eq!(style.max_width, 50);
1132 assert_eq!(style.max_visible_items, 15);
1133 assert_eq!(style.submenu_indicator, "â–¶");
1134 }
1135
1136 #[test]
1137 fn test_context_menu_style_builders() {
1138 let style = ContextMenuStyle::default()
1139 .min_width(20)
1140 .max_width(60)
1141 .max_visible_items(10)
1142 .submenu_indicator("→");
1143
1144 assert_eq!(style.min_width, 20);
1145 assert_eq!(style.max_width, 60);
1146 assert_eq!(style.max_visible_items, 10);
1147 assert_eq!(style.submenu_indicator, "→");
1148 }
1149
1150 #[test]
1151 fn test_context_menu_style_presets() {
1152 let light = ContextMenuStyle::light();
1153 assert_eq!(light.background, Color::Rgb(250, 250, 250));
1154
1155 let minimal = ContextMenuStyle::minimal();
1156 assert_eq!(minimal.background, Color::Reset);
1157 }
1158
1159 #[test]
1160 fn test_handle_key_escape() {
1161 let mut state = ContextMenuState::new();
1162 state.open_at(0, 0);
1163
1164 let items = vec![ContextMenuItem::action("a", "A")];
1165 let key = KeyEvent::from(KeyCode::Esc);
1166 let action = handle_context_menu_key(&key, &mut state, &items);
1167
1168 assert_eq!(action, Some(ContextMenuAction::Close));
1169 assert!(!state.is_open);
1170 }
1171
1172 #[test]
1173 fn test_handle_key_navigation() {
1174 let mut state = ContextMenuState::new();
1175 state.open_at(0, 0);
1176
1177 let items = vec![
1178 ContextMenuItem::action("a", "A"),
1179 ContextMenuItem::action("b", "B"),
1180 ContextMenuItem::action("c", "C"),
1181 ];
1182
1183 let key = KeyEvent::from(KeyCode::Down);
1185 let action = handle_context_menu_key(&key, &mut state, &items);
1186 assert_eq!(action, Some(ContextMenuAction::HighlightChange(1)));
1187 assert_eq!(state.highlighted_index, 1);
1188
1189 let key = KeyEvent::from(KeyCode::Up);
1191 let action = handle_context_menu_key(&key, &mut state, &items);
1192 assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1193 assert_eq!(state.highlighted_index, 0);
1194 }
1195
1196 #[test]
1197 fn test_handle_key_select() {
1198 let mut state = ContextMenuState::new();
1199 state.open_at(0, 0);
1200 state.highlighted_index = 1;
1201
1202 let items = vec![
1203 ContextMenuItem::action("a", "A"),
1204 ContextMenuItem::action("b", "B"),
1205 ];
1206
1207 let key = KeyEvent::from(KeyCode::Enter);
1208 let action = handle_context_menu_key(&key, &mut state, &items);
1209
1210 assert_eq!(action, Some(ContextMenuAction::Select("b".to_string())));
1211 assert!(!state.is_open);
1212 }
1213
1214 #[test]
1215 fn test_is_context_menu_trigger() {
1216 use crossterm::event::KeyModifiers;
1217
1218 let right_click = MouseEvent {
1219 kind: MouseEventKind::Down(MouseButton::Right),
1220 column: 10,
1221 row: 5,
1222 modifiers: KeyModifiers::NONE,
1223 };
1224 assert!(is_context_menu_trigger(&right_click));
1225
1226 let left_click = MouseEvent {
1227 kind: MouseEventKind::Down(MouseButton::Left),
1228 column: 10,
1229 row: 5,
1230 modifiers: KeyModifiers::NONE,
1231 };
1232 assert!(!is_context_menu_trigger(&left_click));
1233 }
1234
1235 #[test]
1236 fn test_calculate_menu_height() {
1237 assert_eq!(calculate_menu_height(5, 15), 7); assert_eq!(calculate_menu_height(20, 15), 17); assert_eq!(calculate_menu_height(0, 15), 2); }
1241
1242 #[test]
1245 fn test_context_menu_item_icon_on_separator() {
1246 let item = ContextMenuItem::separator().icon("x");
1248 assert_eq!(item.get_icon(), None);
1249 }
1250
1251 #[test]
1252 fn test_context_menu_item_shortcut_on_submenu() {
1253 let item = ContextMenuItem::submenu("Menu", vec![]).shortcut("Ctrl+X");
1255 assert_eq!(item.get_shortcut(), None);
1256 }
1257
1258 #[test]
1259 fn test_context_menu_item_enabled_on_separator() {
1260 let item = ContextMenuItem::separator().enabled(true);
1262 assert!(!item.is_enabled());
1263 }
1264
1265 #[test]
1266 fn test_context_menu_item_submenu_items() {
1267 let sub_items = vec![
1268 ContextMenuItem::action("a", "A"),
1269 ContextMenuItem::action("b", "B"),
1270 ];
1271 let item = ContextMenuItem::submenu("Menu", sub_items);
1272 let items = item.submenu_items().unwrap();
1273 assert_eq!(items.len(), 2);
1274 }
1275
1276 #[test]
1277 fn test_context_menu_item_action_no_submenu_items() {
1278 let item = ContextMenuItem::action("test", "Test");
1279 assert!(item.submenu_items().is_none());
1280 }
1281
1282 #[test]
1283 fn test_context_menu_state_default() {
1284 let state = ContextMenuState::default();
1285 assert!(!state.is_open);
1286 assert_eq!(state.anchor_position, (0, 0));
1287 assert_eq!(state.highlighted_index, 0);
1288 assert_eq!(state.scroll_offset, 0);
1289 assert!(state.active_submenu.is_none());
1290 assert!(state.submenu_state.is_none());
1291 }
1292
1293 #[test]
1294 fn test_context_menu_state_open_resets_state() {
1295 let mut state = ContextMenuState::new();
1296 state.highlighted_index = 5;
1297 state.scroll_offset = 10;
1298 state.open_submenu();
1299
1300 state.open_at(20, 30);
1301
1302 assert!(state.is_open);
1303 assert_eq!(state.anchor_position, (20, 30));
1304 assert_eq!(state.highlighted_index, 0);
1305 assert_eq!(state.scroll_offset, 0);
1306 assert!(!state.has_open_submenu());
1307 }
1308
1309 #[test]
1310 fn test_context_menu_state_highlight_first_last() {
1311 let mut state = ContextMenuState::new();
1312 state.open_at(0, 0);
1313
1314 let items = vec![
1315 ContextMenuItem::separator(), ContextMenuItem::action("a", "A"), ContextMenuItem::action("b", "B"), ContextMenuItem::separator(), ContextMenuItem::action("c", "C"), ];
1321
1322 state.highlight_first(&items);
1323 assert_eq!(state.highlighted_index, 1); state.highlight_last(&items);
1326 assert_eq!(state.highlighted_index, 4); }
1328
1329 #[test]
1330 fn test_context_menu_state_navigation_bounds() {
1331 let mut state = ContextMenuState::new();
1332 state.open_at(0, 0);
1333 state.highlighted_index = 0;
1334
1335 let items = vec![
1336 ContextMenuItem::action("a", "A"),
1337 ContextMenuItem::action("b", "B"),
1338 ];
1339
1340 state.highlight_prev(&items);
1342 assert_eq!(state.highlighted_index, 0);
1343
1344 state.highlighted_index = 1;
1346 state.highlight_next(&items);
1348 assert_eq!(state.highlighted_index, 1);
1349 }
1350
1351 #[test]
1352 fn test_context_menu_state_navigation_empty_items() {
1353 let mut state = ContextMenuState::new();
1354 state.open_at(0, 0);
1355 state.highlighted_index = 5;
1356
1357 let items: Vec<ContextMenuItem> = vec![];
1358
1359 state.highlight_next(&items);
1360 assert_eq!(state.highlighted_index, 5); state.highlight_prev(&items);
1363 assert_eq!(state.highlighted_index, 5); }
1365
1366 #[test]
1367 fn test_context_menu_state_ensure_visible() {
1368 let mut state = ContextMenuState::new();
1369 state.highlighted_index = 15;
1370 state.scroll_offset = 0;
1371
1372 state.ensure_visible(10);
1373 assert!(state.scroll_offset >= 6);
1375
1376 state.highlighted_index = 3;
1378 state.ensure_visible(10);
1379 assert!(state.scroll_offset <= 3);
1380 }
1381
1382 #[test]
1383 fn test_context_menu_state_ensure_visible_zero_viewport() {
1384 let mut state = ContextMenuState::new();
1385 state.highlighted_index = 10;
1386 state.scroll_offset = 5;
1387
1388 state.ensure_visible(0);
1390 assert_eq!(state.scroll_offset, 5);
1391 }
1392
1393 #[test]
1394 fn test_context_menu_style_highlight() {
1395 let style = ContextMenuStyle::default().highlight(Color::Red, Color::Blue);
1396
1397 assert_eq!(style.highlight_fg, Color::Red);
1398 assert_eq!(style.highlight_bg, Color::Blue);
1399 }
1400
1401 #[test]
1402 fn test_handle_key_when_closed() {
1403 let mut state = ContextMenuState::new();
1404 assert!(!state.is_open);
1405
1406 let items = vec![ContextMenuItem::action("a", "A")];
1407 let key = KeyEvent::from(KeyCode::Down);
1408 let action = handle_context_menu_key(&key, &mut state, &items);
1409
1410 assert!(action.is_none());
1411 }
1412
1413 #[test]
1414 fn test_handle_key_space_select() {
1415 let mut state = ContextMenuState::new();
1416 state.open_at(0, 0);
1417
1418 let items = vec![ContextMenuItem::action("a", "Action A")];
1419
1420 let key = KeyEvent::from(KeyCode::Char(' '));
1421 let action = handle_context_menu_key(&key, &mut state, &items);
1422
1423 assert_eq!(action, Some(ContextMenuAction::Select("a".to_string())));
1424 assert!(!state.is_open);
1425 }
1426
1427 #[test]
1428 fn test_handle_key_home_end() {
1429 let mut state = ContextMenuState::new();
1430 state.open_at(0, 0);
1431
1432 let items = vec![
1433 ContextMenuItem::action("a", "A"),
1434 ContextMenuItem::action("b", "B"),
1435 ContextMenuItem::action("c", "C"),
1436 ContextMenuItem::action("d", "D"),
1437 ];
1438
1439 let key = KeyEvent::from(KeyCode::End);
1441 let action = handle_context_menu_key(&key, &mut state, &items);
1442 assert_eq!(action, Some(ContextMenuAction::HighlightChange(3)));
1443 assert_eq!(state.highlighted_index, 3);
1444
1445 let key = KeyEvent::from(KeyCode::Home);
1447 let action = handle_context_menu_key(&key, &mut state, &items);
1448 assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1449 assert_eq!(state.highlighted_index, 0);
1450 }
1451
1452 #[test]
1453 fn test_handle_key_select_disabled_item() {
1454 let mut state = ContextMenuState::new();
1455 state.open_at(0, 0);
1456
1457 let items = vec![ContextMenuItem::action("a", "A").enabled(false)];
1458
1459 let key = KeyEvent::from(KeyCode::Enter);
1460 let action = handle_context_menu_key(&key, &mut state, &items);
1461
1462 assert!(action.is_none());
1464 assert!(state.is_open); }
1466
1467 #[test]
1468 fn test_handle_key_open_submenu() {
1469 let mut state = ContextMenuState::new();
1470 state.open_at(0, 0);
1471
1472 let items = vec![ContextMenuItem::submenu(
1473 "More",
1474 vec![ContextMenuItem::action("sub", "Sub Action")],
1475 )];
1476
1477 let key = KeyEvent::from(KeyCode::Enter);
1479 let action = handle_context_menu_key(&key, &mut state, &items);
1480
1481 assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1482 assert!(state.has_open_submenu());
1483 }
1484
1485 #[test]
1486 fn test_handle_key_right_arrow_submenu() {
1487 let mut state = ContextMenuState::new();
1488 state.open_at(0, 0);
1489
1490 let items = vec![ContextMenuItem::submenu(
1491 "More",
1492 vec![ContextMenuItem::action("sub", "Sub Action")],
1493 )];
1494
1495 let key = KeyEvent::from(KeyCode::Right);
1497 let action = handle_context_menu_key(&key, &mut state, &items);
1498
1499 assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1500 assert!(state.has_open_submenu());
1501 }
1502
1503 #[test]
1504 fn test_handle_key_right_arrow_no_submenu() {
1505 let mut state = ContextMenuState::new();
1506 state.open_at(0, 0);
1507
1508 let items = vec![ContextMenuItem::action("a", "A")];
1509
1510 let key = KeyEvent::from(KeyCode::Right);
1512 let action = handle_context_menu_key(&key, &mut state, &items);
1513
1514 assert!(action.is_none());
1515 assert!(!state.has_open_submenu());
1516 }
1517
1518 #[test]
1519 fn test_handle_key_left_arrow() {
1520 let mut state = ContextMenuState::new();
1521 state.open_at(0, 0);
1522
1523 let items = vec![ContextMenuItem::action("a", "A")];
1524
1525 let key = KeyEvent::from(KeyCode::Left);
1527 let action = handle_context_menu_key(&key, &mut state, &items);
1528
1529 assert!(action.is_none());
1530 }
1531
1532 #[test]
1533 fn test_handle_key_unknown_key() {
1534 let mut state = ContextMenuState::new();
1535 state.open_at(0, 0);
1536
1537 let items = vec![ContextMenuItem::action("a", "A")];
1538
1539 let key = KeyEvent::from(KeyCode::Char('x'));
1541 let action = handle_context_menu_key(&key, &mut state, &items);
1542
1543 assert!(action.is_none());
1544 assert!(state.is_open);
1545 }
1546
1547 #[test]
1548 fn test_handle_mouse_when_closed() {
1549 use crossterm::event::KeyModifiers;
1550
1551 let mut state = ContextMenuState::new();
1552 assert!(!state.is_open);
1553
1554 let mouse = MouseEvent {
1555 kind: MouseEventKind::Down(MouseButton::Left),
1556 column: 10,
1557 row: 5,
1558 modifiers: KeyModifiers::NONE,
1559 };
1560
1561 let action = handle_context_menu_mouse(&mouse, &mut state, Rect::default(), &[]);
1562
1563 assert!(action.is_none());
1564 }
1565
1566 #[test]
1567 fn test_handle_mouse_click_outside() {
1568 use crossterm::event::KeyModifiers;
1569
1570 let mut state = ContextMenuState::new();
1571 state.open_at(10, 10);
1572
1573 let menu_area = Rect::new(10, 10, 20, 10);
1574
1575 let mouse = MouseEvent {
1577 kind: MouseEventKind::Down(MouseButton::Left),
1578 column: 5,
1579 row: 5,
1580 modifiers: KeyModifiers::NONE,
1581 };
1582
1583 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &[]);
1584
1585 assert_eq!(action, Some(ContextMenuAction::Close));
1586 assert!(!state.is_open);
1587 }
1588
1589 #[test]
1590 fn test_handle_mouse_click_item() {
1591 use crate::traits::ClickRegion;
1592 use crossterm::event::KeyModifiers;
1593
1594 let mut state = ContextMenuState::new();
1595 state.open_at(10, 10);
1596
1597 let menu_area = Rect::new(10, 10, 20, 10);
1598 let item_area = Rect::new(11, 11, 18, 1);
1599 let regions = vec![ClickRegion::new(
1600 item_area,
1601 ContextMenuAction::Select("test".to_string()),
1602 )];
1603
1604 let mouse = MouseEvent {
1606 kind: MouseEventKind::Down(MouseButton::Left),
1607 column: 15,
1608 row: 11,
1609 modifiers: KeyModifiers::NONE,
1610 };
1611
1612 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, ®ions);
1613
1614 assert_eq!(action, Some(ContextMenuAction::Select("test".to_string())));
1615 assert!(!state.is_open);
1616 }
1617
1618 #[test]
1619 fn test_handle_mouse_click_submenu_item() {
1620 use crate::traits::ClickRegion;
1621 use crossterm::event::KeyModifiers;
1622
1623 let mut state = ContextMenuState::new();
1624 state.open_at(10, 10);
1625
1626 let menu_area = Rect::new(10, 10, 20, 10);
1627 let item_area = Rect::new(11, 11, 18, 1);
1628 let regions = vec![ClickRegion::new(
1629 item_area,
1630 ContextMenuAction::SubmenuOpen(0),
1631 )];
1632
1633 let mouse = MouseEvent {
1635 kind: MouseEventKind::Down(MouseButton::Left),
1636 column: 15,
1637 row: 11,
1638 modifiers: KeyModifiers::NONE,
1639 };
1640
1641 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, ®ions);
1642
1643 assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1644 assert!(state.has_open_submenu());
1645 }
1646
1647 #[test]
1648 fn test_context_menu_action_equality() {
1649 assert_eq!(ContextMenuAction::Open, ContextMenuAction::Open);
1650 assert_eq!(ContextMenuAction::Close, ContextMenuAction::Close);
1651 assert_eq!(
1652 ContextMenuAction::Select("a".to_string()),
1653 ContextMenuAction::Select("a".to_string())
1654 );
1655 assert_ne!(
1656 ContextMenuAction::Select("a".to_string()),
1657 ContextMenuAction::Select("b".to_string())
1658 );
1659 assert_eq!(
1660 ContextMenuAction::SubmenuOpen(1),
1661 ContextMenuAction::SubmenuOpen(1)
1662 );
1663 assert_eq!(
1664 ContextMenuAction::SubmenuClose,
1665 ContextMenuAction::SubmenuClose
1666 );
1667 assert_eq!(
1668 ContextMenuAction::HighlightChange(5),
1669 ContextMenuAction::HighlightChange(5)
1670 );
1671 }
1672
1673 #[test]
1674 fn test_context_menu_item_all_disabled() {
1675 let items = vec![
1676 ContextMenuItem::separator(),
1677 ContextMenuItem::action("a", "A").enabled(false),
1678 ContextMenuItem::separator(),
1679 ];
1680
1681 let mut state = ContextMenuState::new();
1682 state.open_at(0, 0);
1683 state.highlighted_index = 1;
1684
1685 state.highlight_next(&items);
1687 assert_eq!(state.highlighted_index, 1); state.highlight_prev(&items);
1690 assert_eq!(state.highlighted_index, 1); }
1692
1693 #[test]
1694 fn test_context_menu_widget_new() {
1695 let items = vec![ContextMenuItem::action("test", "Test")];
1696 let state = ContextMenuState::new();
1697 let _menu = ContextMenu::new(&items, &state);
1698
1699 assert!(!state.is_open);
1701 }
1702
1703 #[test]
1704 fn test_context_menu_widget_style() {
1705 let items = vec![ContextMenuItem::action("test", "Test")];
1706 let state = ContextMenuState::new();
1707 let style = ContextMenuStyle::light();
1708 let _menu = ContextMenu::new(&items, &state).style(style);
1709 }
1710
1711 #[test]
1712 fn test_is_context_menu_trigger_other_events() {
1713 use crossterm::event::KeyModifiers;
1714
1715 let mouse_move = MouseEvent {
1717 kind: MouseEventKind::Moved,
1718 column: 10,
1719 row: 5,
1720 modifiers: KeyModifiers::NONE,
1721 };
1722 assert!(!is_context_menu_trigger(&mouse_move));
1723
1724 let mouse_up = MouseEvent {
1726 kind: MouseEventKind::Up(MouseButton::Right),
1727 column: 10,
1728 row: 5,
1729 modifiers: KeyModifiers::NONE,
1730 };
1731 assert!(!is_context_menu_trigger(&mouse_up));
1732
1733 let middle_click = MouseEvent {
1735 kind: MouseEventKind::Down(MouseButton::Middle),
1736 column: 10,
1737 row: 5,
1738 modifiers: KeyModifiers::NONE,
1739 };
1740 assert!(!is_context_menu_trigger(&middle_click));
1741
1742 let scroll = MouseEvent {
1744 kind: MouseEventKind::ScrollUp,
1745 column: 10,
1746 row: 5,
1747 modifiers: KeyModifiers::NONE,
1748 };
1749 assert!(!is_context_menu_trigger(&scroll));
1750 }
1751
1752 #[test]
1753 fn test_context_menu_submenu_disabled() {
1754 let mut state = ContextMenuState::new();
1755 state.open_at(0, 0);
1756
1757 let items = vec![
1758 ContextMenuItem::submenu("More", vec![ContextMenuItem::action("sub", "Sub")])
1759 .enabled(false),
1760 ];
1761
1762 let key = KeyEvent::from(KeyCode::Right);
1764 let action = handle_context_menu_key(&key, &mut state, &items);
1765
1766 assert!(action.is_none());
1767 assert!(!state.has_open_submenu());
1768
1769 let key = KeyEvent::from(KeyCode::Enter);
1771 let action = handle_context_menu_key(&key, &mut state, &items);
1772
1773 assert!(action.is_none());
1774 assert!(!state.has_open_submenu());
1775 }
1776}