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 ContextMenuStyle {
413 pub fn light() -> Self {
415 Self {
416 background: Color::Rgb(250, 250, 250),
417 border: Color::Rgb(180, 180, 180),
418 normal_fg: Color::Rgb(30, 30, 30),
419 highlight_bg: Color::Rgb(0, 120, 215),
420 highlight_fg: Color::White,
421 disabled_fg: Color::Rgb(160, 160, 160),
422 shortcut_fg: Color::Rgb(100, 100, 100),
423 separator_fg: Color::Rgb(200, 200, 200),
424 ..Default::default()
425 }
426 }
427
428 pub fn minimal() -> Self {
430 Self {
431 background: Color::Reset,
432 border: Color::Gray,
433 normal_fg: Color::White,
434 highlight_bg: Color::Blue,
435 highlight_fg: Color::White,
436 disabled_fg: Color::DarkGray,
437 shortcut_fg: Color::Gray,
438 separator_fg: Color::DarkGray,
439 ..Default::default()
440 }
441 }
442
443 pub fn min_width(mut self, width: u16) -> Self {
445 self.min_width = width;
446 self
447 }
448
449 pub fn max_width(mut self, width: u16) -> Self {
451 self.max_width = width;
452 self
453 }
454
455 pub fn max_visible_items(mut self, count: u16) -> Self {
457 self.max_visible_items = count;
458 self
459 }
460
461 pub fn submenu_indicator(mut self, indicator: &'static str) -> Self {
463 self.submenu_indicator = indicator;
464 self
465 }
466
467 pub fn highlight(mut self, fg: Color, bg: Color) -> Self {
469 self.highlight_fg = fg;
470 self.highlight_bg = bg;
471 self
472 }
473}
474
475pub struct ContextMenu<'a> {
480 items: &'a [ContextMenuItem],
481 state: &'a ContextMenuState,
482 style: ContextMenuStyle,
483}
484
485impl<'a> ContextMenu<'a> {
486 pub fn new(items: &'a [ContextMenuItem], state: &'a ContextMenuState) -> Self {
488 Self {
489 items,
490 state,
491 style: ContextMenuStyle::default(),
492 }
493 }
494
495 pub fn style(mut self, style: ContextMenuStyle) -> Self {
497 self.style = style;
498 self
499 }
500
501 fn calculate_width(&self) -> u16 {
503 let mut max_label_width = 0u16;
504 let mut max_shortcut_width = 0u16;
505
506 for item in self.items {
507 match item {
508 ContextMenuItem::Action {
509 label,
510 icon,
511 shortcut,
512 ..
513 } => {
514 let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
515 let label_width = label.chars().count() + icon_width;
516 max_label_width = max_label_width.max(label_width as u16);
517 if let Some(s) = shortcut {
518 max_shortcut_width = max_shortcut_width.max(s.chars().count() as u16);
519 }
520 }
521 ContextMenuItem::Submenu { label, icon, .. } => {
522 let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
523 let label_width = label.chars().count() + icon_width + 2;
525 max_label_width = max_label_width.max(label_width as u16);
526 }
527 ContextMenuItem::Separator => {}
528 }
529 }
530
531 let content_width = self.style.padding
533 + max_label_width
534 + if max_shortcut_width > 0 {
535 2 + max_shortcut_width
536 } else {
537 0
538 }
539 + self.style.padding;
540
541 (content_width + 2) .max(self.style.min_width)
544 .min(self.style.max_width)
545 }
546
547 fn calculate_height(&self) -> u16 {
549 let item_count = self.items.len() as u16;
550 let visible = item_count.min(self.style.max_visible_items);
551 visible + 2 }
553
554 fn calculate_menu_area(&self, screen: Rect) -> Rect {
556 let (anchor_x, anchor_y) = self.state.anchor_position;
557 let width = self.calculate_width();
558 let height = self.calculate_height();
559
560 let x = if anchor_x + width <= screen.x + screen.width {
562 anchor_x
563 } else {
564 anchor_x.saturating_sub(width)
565 };
566
567 let y = if anchor_y + height <= screen.y + screen.height {
568 anchor_y
569 } else {
570 anchor_y.saturating_sub(height)
571 };
572
573 let final_width = width.min(screen.width.saturating_sub(x.saturating_sub(screen.x)));
575 let final_height = height.min(screen.height.saturating_sub(y.saturating_sub(screen.y)));
576
577 Rect::new(x, y, final_width, final_height)
578 }
579
580 pub fn render_stateful(
584 &self,
585 frame: &mut Frame,
586 screen: Rect,
587 ) -> (Rect, Vec<ClickRegion<ContextMenuAction>>) {
588 let mut regions = Vec::new();
589
590 if !self.state.is_open || self.items.is_empty() {
591 return (Rect::default(), regions);
592 }
593
594 let menu_area = self.calculate_menu_area(screen);
595
596 frame.render_widget(Clear, menu_area);
598
599 let block = Block::default()
601 .borders(Borders::ALL)
602 .border_style(Style::default().fg(self.style.border))
603 .style(Style::default().bg(self.style.background));
604
605 let inner = block.inner(menu_area);
606 frame.render_widget(block, menu_area);
607
608 let visible_count = inner.height as usize;
610 let scroll = self.state.scroll_offset as usize;
611
612 for (display_idx, (item_idx, item)) in self
613 .items
614 .iter()
615 .enumerate()
616 .skip(scroll)
617 .take(visible_count)
618 .enumerate()
619 {
620 let y = inner.y + display_idx as u16;
621 let item_area = Rect::new(inner.x, y, inner.width, 1);
622
623 let is_highlighted = item_idx == self.state.highlighted_index;
624
625 match item {
626 ContextMenuItem::Separator => {
627 let sep_line: String =
629 std::iter::repeat_n(self.style.separator_char, inner.width as usize)
630 .collect();
631 let para = Paragraph::new(Span::styled(
632 sep_line,
633 Style::default().fg(self.style.separator_fg),
634 ));
635 frame.render_widget(para, item_area);
636 }
637 ContextMenuItem::Action {
638 label,
639 icon,
640 shortcut,
641 enabled,
642 id,
643 } => {
644 let (fg, bg) = if !enabled {
645 (self.style.disabled_fg, self.style.background)
646 } else if is_highlighted {
647 (self.style.highlight_fg, self.style.highlight_bg)
648 } else {
649 (self.style.normal_fg, self.style.background)
650 };
651
652 let style = Style::default().fg(fg).bg(bg);
653 let shortcut_style = Style::default()
654 .fg(if *enabled {
655 self.style.shortcut_fg
656 } else {
657 self.style.disabled_fg
658 })
659 .bg(bg);
660
661 let mut spans = Vec::new();
662
663 spans.push(Span::styled(
665 " ".repeat(self.style.padding as usize),
666 style,
667 ));
668
669 if let Some(ic) = icon {
671 spans.push(Span::styled(format!("{} ", ic), style));
672 }
673
674 spans.push(Span::styled(label.clone(), style));
676
677 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
679 let shortcut_len = shortcut.as_ref().map(|s| s.chars().count()).unwrap_or(0);
680 let fill_len = (inner.width as usize)
681 .saturating_sub(current_len)
682 .saturating_sub(shortcut_len)
683 .saturating_sub(self.style.padding as usize);
684
685 if fill_len > 0 {
686 spans.push(Span::styled(" ".repeat(fill_len), style));
687 }
688
689 if let Some(sc) = shortcut {
691 spans.push(Span::styled(sc.clone(), shortcut_style));
692 }
693
694 spans.push(Span::styled(
696 " ".repeat(self.style.padding as usize),
697 style,
698 ));
699
700 let para = Paragraph::new(Line::from(spans));
701 frame.render_widget(para, item_area);
702
703 if *enabled {
705 regions.push(ClickRegion::new(
706 item_area,
707 ContextMenuAction::Select(id.clone()),
708 ));
709 }
710 }
711 ContextMenuItem::Submenu {
712 label,
713 icon,
714 enabled,
715 ..
716 } => {
717 let (fg, bg) = if !enabled {
718 (self.style.disabled_fg, self.style.background)
719 } else if is_highlighted {
720 (self.style.highlight_fg, self.style.highlight_bg)
721 } else {
722 (self.style.normal_fg, self.style.background)
723 };
724
725 let style = Style::default().fg(fg).bg(bg);
726
727 let mut spans = Vec::new();
728
729 spans.push(Span::styled(
731 " ".repeat(self.style.padding as usize),
732 style,
733 ));
734
735 if let Some(ic) = icon {
737 spans.push(Span::styled(format!("{} ", ic), style));
738 }
739
740 spans.push(Span::styled(label.clone(), style));
742
743 let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
745 let indicator_len = self.style.submenu_indicator.chars().count();
746 let fill_len = (inner.width as usize)
747 .saturating_sub(current_len)
748 .saturating_sub(indicator_len)
749 .saturating_sub(self.style.padding as usize);
750
751 if fill_len > 0 {
752 spans.push(Span::styled(" ".repeat(fill_len), style));
753 }
754
755 spans.push(Span::styled(self.style.submenu_indicator, style));
756
757 spans.push(Span::styled(
759 " ".repeat(self.style.padding as usize),
760 style,
761 ));
762
763 let para = Paragraph::new(Line::from(spans));
764 frame.render_widget(para, item_area);
765
766 if *enabled {
768 regions.push(ClickRegion::new(
769 item_area,
770 ContextMenuAction::SubmenuOpen(item_idx),
771 ));
772 }
773 }
774 }
775 }
776
777 if let (Some(submenu_idx), Some(submenu_state)) =
779 (self.state.active_submenu, &self.state.submenu_state)
780 {
781 if let Some(ContextMenuItem::Submenu { items, .. }) = self.items.get(submenu_idx) {
782 let submenu_anchor_x = menu_area.x + menu_area.width;
784 let submenu_anchor_y =
785 menu_area.y + 1 + (submenu_idx as u16).saturating_sub(self.state.scroll_offset);
786
787 let mut adjusted_state = (**submenu_state).clone();
788 adjusted_state.anchor_position = (submenu_anchor_x, submenu_anchor_y);
789
790 let adjusted_submenu =
791 ContextMenu::new(items, &adjusted_state).style(self.style.clone());
792
793 let (_, submenu_regions) = adjusted_submenu.render_stateful(frame, screen);
794 regions.extend(submenu_regions);
795 }
796 }
797
798 (menu_area, regions)
799 }
800}
801
802pub fn handle_context_menu_key(
817 key: &KeyEvent,
818 state: &mut ContextMenuState,
819 items: &[ContextMenuItem],
820) -> Option<ContextMenuAction> {
821 if !state.is_open {
822 return None;
823 }
824
825 if let (Some(submenu_idx), Some(submenu_state)) =
827 (state.active_submenu, &mut state.submenu_state)
828 {
829 if let Some(ContextMenuItem::Submenu { items: sub_items, .. }) = items.get(submenu_idx) {
830 match key.code {
831 KeyCode::Left | KeyCode::Esc => {
832 state.close_submenu();
833 return Some(ContextMenuAction::SubmenuClose);
834 }
835 _ => {
836 if let Some(action) =
837 handle_context_menu_key(key, submenu_state.as_mut(), sub_items)
838 {
839 return Some(action);
840 }
841 }
842 }
843 return None;
844 }
845 }
846
847 match key.code {
848 KeyCode::Esc => {
849 state.close();
850 Some(ContextMenuAction::Close)
851 }
852 KeyCode::Up => {
853 state.highlight_prev(items);
854 state.ensure_visible(8);
855 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
856 }
857 KeyCode::Down => {
858 state.highlight_next(items);
859 state.ensure_visible(8);
860 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
861 }
862 KeyCode::Home => {
863 state.highlight_first(items);
864 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
865 }
866 KeyCode::End => {
867 state.highlight_last(items);
868 state.ensure_visible(items.len());
869 Some(ContextMenuAction::HighlightChange(state.highlighted_index))
870 }
871 KeyCode::Enter | KeyCode::Char(' ') => {
872 if let Some(item) = items.get(state.highlighted_index) {
873 match item {
874 ContextMenuItem::Action { id, enabled, .. } if *enabled => {
875 let action_id = id.clone();
876 state.close();
877 Some(ContextMenuAction::Select(action_id))
878 }
879 ContextMenuItem::Submenu { enabled, .. } if *enabled => {
880 state.open_submenu();
881 Some(ContextMenuAction::SubmenuOpen(state.highlighted_index))
882 }
883 _ => None,
884 }
885 } else {
886 None
887 }
888 }
889 KeyCode::Right => {
890 if let Some(item) = items.get(state.highlighted_index) {
891 if item.has_submenu() && item.is_enabled() {
892 state.open_submenu();
893 return Some(ContextMenuAction::SubmenuOpen(state.highlighted_index));
894 }
895 }
896 None
897 }
898 KeyCode::Left => {
899 None
901 }
902 _ => None,
903 }
904}
905
906pub fn handle_context_menu_mouse(
917 mouse: &MouseEvent,
918 state: &mut ContextMenuState,
919 menu_area: Rect,
920 item_regions: &[ClickRegion<ContextMenuAction>],
921) -> Option<ContextMenuAction> {
922 if !state.is_open {
923 return None;
924 }
925
926 let col = mouse.column;
927 let row = mouse.row;
928
929 match mouse.kind {
930 MouseEventKind::Down(MouseButton::Left) => {
931 for region in item_regions {
933 if region.contains(col, row) {
934 match ®ion.data {
935 ContextMenuAction::Select(id) => {
936 let action_id = id.clone();
937 state.close();
938 return Some(ContextMenuAction::Select(action_id));
939 }
940 ContextMenuAction::SubmenuOpen(idx) => {
941 state.highlighted_index = *idx;
942 state.open_submenu();
943 return Some(ContextMenuAction::SubmenuOpen(*idx));
944 }
945 _ => {}
946 }
947 }
948 }
949
950 if !menu_area.intersects(Rect::new(col, row, 1, 1)) {
952 state.close();
953 return Some(ContextMenuAction::Close);
954 }
955 None
956 }
957 MouseEventKind::Moved => {
958 for region in item_regions.iter() {
960 if region.contains(col, row) {
961 if let ContextMenuAction::Select(_) | ContextMenuAction::SubmenuOpen(_) =
963 ®ion.data
964 {
965 let inner_start_y = menu_area.y + 1; let item_idx = (row - inner_start_y) as usize + state.scroll_offset as usize;
969
970 if item_idx < item_regions.len() + state.scroll_offset as usize
971 && state.highlighted_index != item_idx
972 {
973 state.highlighted_index = item_idx;
974 return Some(ContextMenuAction::HighlightChange(item_idx));
975 }
976 }
977 break;
978 }
979 }
980 None
981 }
982 _ => None,
983 }
984}
985
986pub fn is_context_menu_trigger(mouse: &MouseEvent) -> bool {
988 matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
989}
990
991pub fn calculate_menu_height(item_count: usize, max_visible: u16) -> u16 {
993 let visible = (item_count as u16).min(max_visible);
994 visible + 2 }
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000
1001 #[test]
1002 fn test_context_menu_item_action() {
1003 let item = ContextMenuItem::action("copy", "Copy")
1004 .icon("📋")
1005 .shortcut("Ctrl+C");
1006
1007 assert!(item.is_selectable());
1008 assert!(!item.has_submenu());
1009 assert_eq!(item.id(), Some("copy"));
1010 assert_eq!(item.label(), Some("Copy"));
1011 assert_eq!(item.get_icon(), Some("📋"));
1012 assert_eq!(item.get_shortcut(), Some("Ctrl+C"));
1013 }
1014
1015 #[test]
1016 fn test_context_menu_item_separator() {
1017 let item = ContextMenuItem::separator();
1018
1019 assert!(!item.is_selectable());
1020 assert!(!item.has_submenu());
1021 assert_eq!(item.label(), None);
1022 }
1023
1024 #[test]
1025 fn test_context_menu_item_submenu() {
1026 let items = vec![ContextMenuItem::action("sub1", "Sub Item 1")];
1027 let item = ContextMenuItem::submenu("More", items).icon("â–¶");
1028
1029 assert!(item.is_selectable());
1030 assert!(item.has_submenu());
1031 assert_eq!(item.label(), Some("More"));
1032 assert!(item.submenu_items().is_some());
1033 }
1034
1035 #[test]
1036 fn test_context_menu_item_disabled() {
1037 let item = ContextMenuItem::action("delete", "Delete").enabled(false);
1038
1039 assert!(!item.is_selectable());
1040 assert!(!item.is_enabled());
1041 }
1042
1043 #[test]
1044 fn test_context_menu_state_open_close() {
1045 let mut state = ContextMenuState::new();
1046
1047 assert!(!state.is_open);
1048
1049 state.open_at(10, 5);
1050 assert!(state.is_open);
1051 assert_eq!(state.anchor_position, (10, 5));
1052 assert_eq!(state.highlighted_index, 0);
1053
1054 state.close();
1055 assert!(!state.is_open);
1056 }
1057
1058 #[test]
1059 fn test_context_menu_state_navigation() {
1060 let mut state = ContextMenuState::new();
1061 state.open_at(0, 0);
1062
1063 let items = vec![
1064 ContextMenuItem::action("a", "A"),
1065 ContextMenuItem::separator(),
1066 ContextMenuItem::action("b", "B"),
1067 ContextMenuItem::action("c", "C"),
1068 ];
1069
1070 assert_eq!(state.highlighted_index, 0);
1072
1073 state.highlight_next(&items);
1075 assert_eq!(state.highlighted_index, 2); state.highlight_next(&items);
1079 assert_eq!(state.highlighted_index, 3);
1080
1081 state.highlight_prev(&items);
1083 assert_eq!(state.highlighted_index, 2);
1084
1085 state.highlight_prev(&items);
1087 assert_eq!(state.highlighted_index, 0);
1088 }
1089
1090 #[test]
1091 fn test_context_menu_state_submenu() {
1092 let mut state = ContextMenuState::new();
1093 state.open_at(0, 0);
1094 state.highlighted_index = 2;
1095
1096 assert!(!state.has_open_submenu());
1097
1098 state.open_submenu();
1099 assert!(state.has_open_submenu());
1100 assert_eq!(state.active_submenu, Some(2));
1101 assert!(state.submenu_state.is_some());
1102
1103 state.close_submenu();
1104 assert!(!state.has_open_submenu());
1105 assert!(state.submenu_state.is_none());
1106 }
1107
1108 #[test]
1109 fn test_context_menu_style_default() {
1110 let style = ContextMenuStyle::default();
1111 assert_eq!(style.min_width, 15);
1112 assert_eq!(style.max_width, 50);
1113 assert_eq!(style.max_visible_items, 15);
1114 assert_eq!(style.submenu_indicator, "â–¶");
1115 }
1116
1117 #[test]
1118 fn test_context_menu_style_builders() {
1119 let style = ContextMenuStyle::default()
1120 .min_width(20)
1121 .max_width(60)
1122 .max_visible_items(10)
1123 .submenu_indicator("→");
1124
1125 assert_eq!(style.min_width, 20);
1126 assert_eq!(style.max_width, 60);
1127 assert_eq!(style.max_visible_items, 10);
1128 assert_eq!(style.submenu_indicator, "→");
1129 }
1130
1131 #[test]
1132 fn test_context_menu_style_presets() {
1133 let light = ContextMenuStyle::light();
1134 assert_eq!(light.background, Color::Rgb(250, 250, 250));
1135
1136 let minimal = ContextMenuStyle::minimal();
1137 assert_eq!(minimal.background, Color::Reset);
1138 }
1139
1140 #[test]
1141 fn test_handle_key_escape() {
1142 let mut state = ContextMenuState::new();
1143 state.open_at(0, 0);
1144
1145 let items = vec![ContextMenuItem::action("a", "A")];
1146 let key = KeyEvent::from(KeyCode::Esc);
1147 let action = handle_context_menu_key(&key, &mut state, &items);
1148
1149 assert_eq!(action, Some(ContextMenuAction::Close));
1150 assert!(!state.is_open);
1151 }
1152
1153 #[test]
1154 fn test_handle_key_navigation() {
1155 let mut state = ContextMenuState::new();
1156 state.open_at(0, 0);
1157
1158 let items = vec![
1159 ContextMenuItem::action("a", "A"),
1160 ContextMenuItem::action("b", "B"),
1161 ContextMenuItem::action("c", "C"),
1162 ];
1163
1164 let key = KeyEvent::from(KeyCode::Down);
1166 let action = handle_context_menu_key(&key, &mut state, &items);
1167 assert_eq!(action, Some(ContextMenuAction::HighlightChange(1)));
1168 assert_eq!(state.highlighted_index, 1);
1169
1170 let key = KeyEvent::from(KeyCode::Up);
1172 let action = handle_context_menu_key(&key, &mut state, &items);
1173 assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1174 assert_eq!(state.highlighted_index, 0);
1175 }
1176
1177 #[test]
1178 fn test_handle_key_select() {
1179 let mut state = ContextMenuState::new();
1180 state.open_at(0, 0);
1181 state.highlighted_index = 1;
1182
1183 let items = vec![
1184 ContextMenuItem::action("a", "A"),
1185 ContextMenuItem::action("b", "B"),
1186 ];
1187
1188 let key = KeyEvent::from(KeyCode::Enter);
1189 let action = handle_context_menu_key(&key, &mut state, &items);
1190
1191 assert_eq!(action, Some(ContextMenuAction::Select("b".to_string())));
1192 assert!(!state.is_open);
1193 }
1194
1195 #[test]
1196 fn test_is_context_menu_trigger() {
1197 use crossterm::event::KeyModifiers;
1198
1199 let right_click = MouseEvent {
1200 kind: MouseEventKind::Down(MouseButton::Right),
1201 column: 10,
1202 row: 5,
1203 modifiers: KeyModifiers::NONE,
1204 };
1205 assert!(is_context_menu_trigger(&right_click));
1206
1207 let left_click = MouseEvent {
1208 kind: MouseEventKind::Down(MouseButton::Left),
1209 column: 10,
1210 row: 5,
1211 modifiers: KeyModifiers::NONE,
1212 };
1213 assert!(!is_context_menu_trigger(&left_click));
1214 }
1215
1216 #[test]
1217 fn test_calculate_menu_height() {
1218 assert_eq!(calculate_menu_height(5, 15), 7); assert_eq!(calculate_menu_height(20, 15), 17); assert_eq!(calculate_menu_height(0, 15), 2); }
1222
1223 #[test]
1226 fn test_context_menu_item_icon_on_separator() {
1227 let item = ContextMenuItem::separator().icon("x");
1229 assert_eq!(item.get_icon(), None);
1230 }
1231
1232 #[test]
1233 fn test_context_menu_item_shortcut_on_submenu() {
1234 let item = ContextMenuItem::submenu("Menu", vec![]).shortcut("Ctrl+X");
1236 assert_eq!(item.get_shortcut(), None);
1237 }
1238
1239 #[test]
1240 fn test_context_menu_item_enabled_on_separator() {
1241 let item = ContextMenuItem::separator().enabled(true);
1243 assert!(!item.is_enabled());
1244 }
1245
1246 #[test]
1247 fn test_context_menu_item_submenu_items() {
1248 let sub_items = vec![
1249 ContextMenuItem::action("a", "A"),
1250 ContextMenuItem::action("b", "B"),
1251 ];
1252 let item = ContextMenuItem::submenu("Menu", sub_items);
1253 let items = item.submenu_items().unwrap();
1254 assert_eq!(items.len(), 2);
1255 }
1256
1257 #[test]
1258 fn test_context_menu_item_action_no_submenu_items() {
1259 let item = ContextMenuItem::action("test", "Test");
1260 assert!(item.submenu_items().is_none());
1261 }
1262
1263 #[test]
1264 fn test_context_menu_state_default() {
1265 let state = ContextMenuState::default();
1266 assert!(!state.is_open);
1267 assert_eq!(state.anchor_position, (0, 0));
1268 assert_eq!(state.highlighted_index, 0);
1269 assert_eq!(state.scroll_offset, 0);
1270 assert!(state.active_submenu.is_none());
1271 assert!(state.submenu_state.is_none());
1272 }
1273
1274 #[test]
1275 fn test_context_menu_state_open_resets_state() {
1276 let mut state = ContextMenuState::new();
1277 state.highlighted_index = 5;
1278 state.scroll_offset = 10;
1279 state.open_submenu();
1280
1281 state.open_at(20, 30);
1282
1283 assert!(state.is_open);
1284 assert_eq!(state.anchor_position, (20, 30));
1285 assert_eq!(state.highlighted_index, 0);
1286 assert_eq!(state.scroll_offset, 0);
1287 assert!(!state.has_open_submenu());
1288 }
1289
1290 #[test]
1291 fn test_context_menu_state_highlight_first_last() {
1292 let mut state = ContextMenuState::new();
1293 state.open_at(0, 0);
1294
1295 let items = vec![
1296 ContextMenuItem::separator(), ContextMenuItem::action("a", "A"), ContextMenuItem::action("b", "B"), ContextMenuItem::separator(), ContextMenuItem::action("c", "C"), ];
1302
1303 state.highlight_first(&items);
1304 assert_eq!(state.highlighted_index, 1); state.highlight_last(&items);
1307 assert_eq!(state.highlighted_index, 4); }
1309
1310 #[test]
1311 fn test_context_menu_state_navigation_bounds() {
1312 let mut state = ContextMenuState::new();
1313 state.open_at(0, 0);
1314 state.highlighted_index = 0;
1315
1316 let items = vec![
1317 ContextMenuItem::action("a", "A"),
1318 ContextMenuItem::action("b", "B"),
1319 ];
1320
1321 state.highlight_prev(&items);
1323 assert_eq!(state.highlighted_index, 0);
1324
1325 state.highlighted_index = 1;
1327 state.highlight_next(&items);
1329 assert_eq!(state.highlighted_index, 1);
1330 }
1331
1332 #[test]
1333 fn test_context_menu_state_navigation_empty_items() {
1334 let mut state = ContextMenuState::new();
1335 state.open_at(0, 0);
1336 state.highlighted_index = 5;
1337
1338 let items: Vec<ContextMenuItem> = vec![];
1339
1340 state.highlight_next(&items);
1341 assert_eq!(state.highlighted_index, 5); state.highlight_prev(&items);
1344 assert_eq!(state.highlighted_index, 5); }
1346
1347 #[test]
1348 fn test_context_menu_state_ensure_visible() {
1349 let mut state = ContextMenuState::new();
1350 state.highlighted_index = 15;
1351 state.scroll_offset = 0;
1352
1353 state.ensure_visible(10);
1354 assert!(state.scroll_offset >= 6);
1356
1357 state.highlighted_index = 3;
1359 state.ensure_visible(10);
1360 assert!(state.scroll_offset <= 3);
1361 }
1362
1363 #[test]
1364 fn test_context_menu_state_ensure_visible_zero_viewport() {
1365 let mut state = ContextMenuState::new();
1366 state.highlighted_index = 10;
1367 state.scroll_offset = 5;
1368
1369 state.ensure_visible(0);
1371 assert_eq!(state.scroll_offset, 5);
1372 }
1373
1374 #[test]
1375 fn test_context_menu_style_highlight() {
1376 let style = ContextMenuStyle::default()
1377 .highlight(Color::Red, Color::Blue);
1378
1379 assert_eq!(style.highlight_fg, Color::Red);
1380 assert_eq!(style.highlight_bg, Color::Blue);
1381 }
1382
1383 #[test]
1384 fn test_handle_key_when_closed() {
1385 let mut state = ContextMenuState::new();
1386 assert!(!state.is_open);
1387
1388 let items = vec![ContextMenuItem::action("a", "A")];
1389 let key = KeyEvent::from(KeyCode::Down);
1390 let action = handle_context_menu_key(&key, &mut state, &items);
1391
1392 assert!(action.is_none());
1393 }
1394
1395 #[test]
1396 fn test_handle_key_space_select() {
1397 let mut state = ContextMenuState::new();
1398 state.open_at(0, 0);
1399
1400 let items = vec![ContextMenuItem::action("a", "Action A")];
1401
1402 let key = KeyEvent::from(KeyCode::Char(' '));
1403 let action = handle_context_menu_key(&key, &mut state, &items);
1404
1405 assert_eq!(action, Some(ContextMenuAction::Select("a".to_string())));
1406 assert!(!state.is_open);
1407 }
1408
1409 #[test]
1410 fn test_handle_key_home_end() {
1411 let mut state = ContextMenuState::new();
1412 state.open_at(0, 0);
1413
1414 let items = vec![
1415 ContextMenuItem::action("a", "A"),
1416 ContextMenuItem::action("b", "B"),
1417 ContextMenuItem::action("c", "C"),
1418 ContextMenuItem::action("d", "D"),
1419 ];
1420
1421 let key = KeyEvent::from(KeyCode::End);
1423 let action = handle_context_menu_key(&key, &mut state, &items);
1424 assert_eq!(action, Some(ContextMenuAction::HighlightChange(3)));
1425 assert_eq!(state.highlighted_index, 3);
1426
1427 let key = KeyEvent::from(KeyCode::Home);
1429 let action = handle_context_menu_key(&key, &mut state, &items);
1430 assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1431 assert_eq!(state.highlighted_index, 0);
1432 }
1433
1434 #[test]
1435 fn test_handle_key_select_disabled_item() {
1436 let mut state = ContextMenuState::new();
1437 state.open_at(0, 0);
1438
1439 let items = vec![ContextMenuItem::action("a", "A").enabled(false)];
1440
1441 let key = KeyEvent::from(KeyCode::Enter);
1442 let action = handle_context_menu_key(&key, &mut state, &items);
1443
1444 assert!(action.is_none());
1446 assert!(state.is_open); }
1448
1449 #[test]
1450 fn test_handle_key_open_submenu() {
1451 let mut state = ContextMenuState::new();
1452 state.open_at(0, 0);
1453
1454 let items = vec![ContextMenuItem::submenu(
1455 "More",
1456 vec![ContextMenuItem::action("sub", "Sub Action")],
1457 )];
1458
1459 let key = KeyEvent::from(KeyCode::Enter);
1461 let action = handle_context_menu_key(&key, &mut state, &items);
1462
1463 assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1464 assert!(state.has_open_submenu());
1465 }
1466
1467 #[test]
1468 fn test_handle_key_right_arrow_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::Right);
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_no_submenu() {
1487 let mut state = ContextMenuState::new();
1488 state.open_at(0, 0);
1489
1490 let items = vec![ContextMenuItem::action("a", "A")];
1491
1492 let key = KeyEvent::from(KeyCode::Right);
1494 let action = handle_context_menu_key(&key, &mut state, &items);
1495
1496 assert!(action.is_none());
1497 assert!(!state.has_open_submenu());
1498 }
1499
1500 #[test]
1501 fn test_handle_key_left_arrow() {
1502 let mut state = ContextMenuState::new();
1503 state.open_at(0, 0);
1504
1505 let items = vec![ContextMenuItem::action("a", "A")];
1506
1507 let key = KeyEvent::from(KeyCode::Left);
1509 let action = handle_context_menu_key(&key, &mut state, &items);
1510
1511 assert!(action.is_none());
1512 }
1513
1514 #[test]
1515 fn test_handle_key_unknown_key() {
1516 let mut state = ContextMenuState::new();
1517 state.open_at(0, 0);
1518
1519 let items = vec![ContextMenuItem::action("a", "A")];
1520
1521 let key = KeyEvent::from(KeyCode::Char('x'));
1523 let action = handle_context_menu_key(&key, &mut state, &items);
1524
1525 assert!(action.is_none());
1526 assert!(state.is_open);
1527 }
1528
1529 #[test]
1530 fn test_handle_mouse_when_closed() {
1531 use crossterm::event::KeyModifiers;
1532
1533 let mut state = ContextMenuState::new();
1534 assert!(!state.is_open);
1535
1536 let mouse = MouseEvent {
1537 kind: MouseEventKind::Down(MouseButton::Left),
1538 column: 10,
1539 row: 5,
1540 modifiers: KeyModifiers::NONE,
1541 };
1542
1543 let action =
1544 handle_context_menu_mouse(&mouse, &mut state, Rect::default(), &[]);
1545
1546 assert!(action.is_none());
1547 }
1548
1549 #[test]
1550 fn test_handle_mouse_click_outside() {
1551 use crossterm::event::KeyModifiers;
1552
1553 let mut state = ContextMenuState::new();
1554 state.open_at(10, 10);
1555
1556 let menu_area = Rect::new(10, 10, 20, 10);
1557
1558 let mouse = MouseEvent {
1560 kind: MouseEventKind::Down(MouseButton::Left),
1561 column: 5,
1562 row: 5,
1563 modifiers: KeyModifiers::NONE,
1564 };
1565
1566 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &[]);
1567
1568 assert_eq!(action, Some(ContextMenuAction::Close));
1569 assert!(!state.is_open);
1570 }
1571
1572 #[test]
1573 fn test_handle_mouse_click_item() {
1574 use crossterm::event::KeyModifiers;
1575 use crate::traits::ClickRegion;
1576
1577 let mut state = ContextMenuState::new();
1578 state.open_at(10, 10);
1579
1580 let menu_area = Rect::new(10, 10, 20, 10);
1581 let item_area = Rect::new(11, 11, 18, 1);
1582 let regions = vec![ClickRegion::new(
1583 item_area,
1584 ContextMenuAction::Select("test".to_string()),
1585 )];
1586
1587 let mouse = MouseEvent {
1589 kind: MouseEventKind::Down(MouseButton::Left),
1590 column: 15,
1591 row: 11,
1592 modifiers: KeyModifiers::NONE,
1593 };
1594
1595 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, ®ions);
1596
1597 assert_eq!(action, Some(ContextMenuAction::Select("test".to_string())));
1598 assert!(!state.is_open);
1599 }
1600
1601 #[test]
1602 fn test_handle_mouse_click_submenu_item() {
1603 use crossterm::event::KeyModifiers;
1604 use crate::traits::ClickRegion;
1605
1606 let mut state = ContextMenuState::new();
1607 state.open_at(10, 10);
1608
1609 let menu_area = Rect::new(10, 10, 20, 10);
1610 let item_area = Rect::new(11, 11, 18, 1);
1611 let regions = vec![ClickRegion::new(
1612 item_area,
1613 ContextMenuAction::SubmenuOpen(0),
1614 )];
1615
1616 let mouse = MouseEvent {
1618 kind: MouseEventKind::Down(MouseButton::Left),
1619 column: 15,
1620 row: 11,
1621 modifiers: KeyModifiers::NONE,
1622 };
1623
1624 let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, ®ions);
1625
1626 assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1627 assert!(state.has_open_submenu());
1628 }
1629
1630 #[test]
1631 fn test_context_menu_action_equality() {
1632 assert_eq!(ContextMenuAction::Open, ContextMenuAction::Open);
1633 assert_eq!(ContextMenuAction::Close, ContextMenuAction::Close);
1634 assert_eq!(
1635 ContextMenuAction::Select("a".to_string()),
1636 ContextMenuAction::Select("a".to_string())
1637 );
1638 assert_ne!(
1639 ContextMenuAction::Select("a".to_string()),
1640 ContextMenuAction::Select("b".to_string())
1641 );
1642 assert_eq!(
1643 ContextMenuAction::SubmenuOpen(1),
1644 ContextMenuAction::SubmenuOpen(1)
1645 );
1646 assert_eq!(ContextMenuAction::SubmenuClose, ContextMenuAction::SubmenuClose);
1647 assert_eq!(
1648 ContextMenuAction::HighlightChange(5),
1649 ContextMenuAction::HighlightChange(5)
1650 );
1651 }
1652
1653 #[test]
1654 fn test_context_menu_item_all_disabled() {
1655 let items = vec![
1656 ContextMenuItem::separator(),
1657 ContextMenuItem::action("a", "A").enabled(false),
1658 ContextMenuItem::separator(),
1659 ];
1660
1661 let mut state = ContextMenuState::new();
1662 state.open_at(0, 0);
1663 state.highlighted_index = 1;
1664
1665 state.highlight_next(&items);
1667 assert_eq!(state.highlighted_index, 1); state.highlight_prev(&items);
1670 assert_eq!(state.highlighted_index, 1); }
1672
1673 #[test]
1674 fn test_context_menu_widget_new() {
1675 let items = vec![ContextMenuItem::action("test", "Test")];
1676 let state = ContextMenuState::new();
1677 let menu = ContextMenu::new(&items, &state);
1678
1679 assert!(!state.is_open);
1681 }
1682
1683 #[test]
1684 fn test_context_menu_widget_style() {
1685 let items = vec![ContextMenuItem::action("test", "Test")];
1686 let state = ContextMenuState::new();
1687 let style = ContextMenuStyle::light();
1688 let _menu = ContextMenu::new(&items, &state).style(style);
1689 }
1690
1691 #[test]
1692 fn test_is_context_menu_trigger_other_events() {
1693 use crossterm::event::KeyModifiers;
1694
1695 let mouse_move = MouseEvent {
1697 kind: MouseEventKind::Moved,
1698 column: 10,
1699 row: 5,
1700 modifiers: KeyModifiers::NONE,
1701 };
1702 assert!(!is_context_menu_trigger(&mouse_move));
1703
1704 let mouse_up = MouseEvent {
1706 kind: MouseEventKind::Up(MouseButton::Right),
1707 column: 10,
1708 row: 5,
1709 modifiers: KeyModifiers::NONE,
1710 };
1711 assert!(!is_context_menu_trigger(&mouse_up));
1712
1713 let middle_click = MouseEvent {
1715 kind: MouseEventKind::Down(MouseButton::Middle),
1716 column: 10,
1717 row: 5,
1718 modifiers: KeyModifiers::NONE,
1719 };
1720 assert!(!is_context_menu_trigger(&middle_click));
1721
1722 let scroll = MouseEvent {
1724 kind: MouseEventKind::ScrollUp,
1725 column: 10,
1726 row: 5,
1727 modifiers: KeyModifiers::NONE,
1728 };
1729 assert!(!is_context_menu_trigger(&scroll));
1730 }
1731
1732 #[test]
1733 fn test_context_menu_submenu_disabled() {
1734 let mut state = ContextMenuState::new();
1735 state.open_at(0, 0);
1736
1737 let items = vec![ContextMenuItem::submenu(
1738 "More",
1739 vec![ContextMenuItem::action("sub", "Sub")],
1740 )
1741 .enabled(false)];
1742
1743 let key = KeyEvent::from(KeyCode::Right);
1745 let action = handle_context_menu_key(&key, &mut state, &items);
1746
1747 assert!(action.is_none());
1748 assert!(!state.has_open_submenu());
1749
1750 let key = KeyEvent::from(KeyCode::Enter);
1752 let action = handle_context_menu_key(&key, &mut state, &items);
1753
1754 assert!(action.is_none());
1755 assert!(!state.has_open_submenu());
1756 }
1757}