1use crossterm::event::KeyCode;
9use ratatui::Frame;
10use ratatui::layout::Rect;
11use ratatui::style::{Color, Modifier, Style};
12use ratatui::text::Line;
13use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum MenuEvent {
18 None,
20 Dismissed,
22 Selected(usize),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MenuItemKind {
29 OpenDetail,
30 EditInEditor,
31 MoveTo { column: usize },
32 Delete,
33 RefreshBoard,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct MenuItem {
39 pub kind: MenuItemKind,
40 pub label: String,
41}
42
43#[derive(Debug, Clone)]
45pub struct ContextMenu {
46 items: Vec<MenuItem>,
47 selected: usize,
48}
49
50impl ContextMenu {
51 pub fn new(items: Vec<MenuItem>) -> Self {
52 ContextMenu { items, selected: 0 }
53 }
54
55 pub fn selected(&self) -> Option<usize> {
57 Some(self.selected).filter(|index| *index < self.items.len())
58 }
59
60 pub fn item(&self, index: usize) -> Option<&MenuItem> {
61 self.items.get(index)
62 }
63
64 pub fn len(&self) -> usize {
65 self.items.len()
66 }
67
68 pub fn is_empty(&self) -> bool {
69 self.items.is_empty()
70 }
71
72 pub fn items(&self) -> &[MenuItem] {
73 &self.items
74 }
75
76 pub fn bounds(&self, area: Rect) -> Rect {
78 let width = (self
79 .items
80 .iter()
81 .map(|item| item.label.chars().count() as u16)
82 .max()
83 .unwrap_or(0)
84 .saturating_add(4))
85 .min(area.width);
86 let height = (self.items.len() as u16 + 2).min(area.height);
87 Rect::new(
88 area.x + (area.width.saturating_sub(width)) / 2,
89 area.y + (area.height.saturating_sub(height)) / 2,
90 width,
91 height,
92 )
93 }
94
95 pub fn handle_key(&mut self, code: KeyCode) -> MenuEvent {
97 match code {
98 KeyCode::Up | KeyCode::Char('k') => {
99 if self.selected > 0 {
100 self.selected -= 1;
101 }
102 MenuEvent::None
103 }
104 KeyCode::Down | KeyCode::Char('j') => {
105 if self.selected + 1 < self.items.len() {
106 self.selected += 1;
107 }
108 MenuEvent::None
109 }
110 KeyCode::Enter => MenuEvent::Selected(self.selected),
111 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => MenuEvent::Dismissed,
112 _ => MenuEvent::None,
113 }
114 }
115
116 pub fn render(&self, f: &mut Frame, area: Rect) {
118 let rect = self.bounds(area);
119 if rect.width < 4 || rect.height < 2 {
120 return;
121 }
122 f.render_widget(Clear, rect);
123 let items: Vec<ListItem> = self
124 .items
125 .iter()
126 .map(|item| ListItem::new(Line::from(item.label.clone())))
127 .collect();
128 let list = List::new(items)
129 .block(
130 Block::default()
131 .borders(Borders::ALL)
132 .title(" Issue ")
133 .border_style(Style::default().fg(Color::Cyan)),
134 )
135 .highlight_style(
136 Style::default()
137 .bg(Color::DarkGray)
138 .add_modifier(Modifier::BOLD),
139 );
140 let mut state = ListState::default();
141 if !self.items.is_empty() {
142 state.select(Some(self.selected));
143 }
144 f.render_stateful_widget(list, rect, &mut state);
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn sample() -> ContextMenu {
153 ContextMenu::new(vec![
154 MenuItem {
155 kind: MenuItemKind::OpenDetail,
156 label: "Open detail".into(),
157 },
158 MenuItem {
159 kind: MenuItemKind::EditInEditor,
160 label: "Edit in editor".into(),
161 },
162 MenuItem {
163 kind: MenuItemKind::MoveTo { column: 1 },
164 label: "Move to in progress".into(),
165 },
166 ])
167 }
168
169 #[test]
170 fn starts_at_first_item() {
171 let menu = sample();
172 assert_eq!(menu.selected(), Some(0));
173 }
174
175 #[test]
176 fn down_and_up_navigate_without_wrapping() {
177 let mut menu = sample();
178 assert_eq!(menu.handle_key(KeyCode::Down), MenuEvent::None);
179 assert_eq!(menu.selected(), Some(1));
180 assert_eq!(menu.handle_key(KeyCode::Down), MenuEvent::None);
181 assert_eq!(menu.selected(), Some(2));
182 assert_eq!(
183 menu.handle_key(KeyCode::Down),
184 MenuEvent::None,
185 "last item stays selected"
186 );
187 assert_eq!(menu.selected(), Some(2));
188 assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
189 assert_eq!(menu.selected(), Some(1));
190 assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
191 assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
192 assert_eq!(menu.selected(), Some(0), "first item stays selected");
193 }
194
195 #[test]
196 fn enter_selects_highlighted_item_and_esc_dismisses() {
197 let mut menu = sample();
198 menu.handle_key(KeyCode::Down);
199 assert_eq!(menu.handle_key(KeyCode::Enter), MenuEvent::Selected(1));
200 assert_eq!(menu.handle_key(KeyCode::Esc), MenuEvent::Dismissed);
201 }
202
203 #[test]
204 fn bounds_are_clamped_to_small_frames() {
205 let menu = sample();
206 let roomy = menu.bounds(Rect::new(0, 0, 80, 40));
207 assert_eq!(roomy.height, 5, "3 items + borders");
208 assert!(roomy.width <= 80);
209 let tiny = menu.bounds(Rect::new(0, 0, 6, 3));
210 assert!(tiny.width <= 6 && tiny.height <= 3);
211 }
212
213 #[test]
214 fn render_smoke_on_tiny_frame() {
215 use ratatui::Terminal;
216 use ratatui::backend::TestBackend;
217
218 let backend = TestBackend::new(16, 8);
219 let mut terminal = Terminal::new(backend).unwrap();
220 let menu = sample();
221 terminal.draw(|f| menu.render(f, f.area())).unwrap();
222 let buffer = terminal.backend().buffer();
223 let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
224 assert!(text.contains("Open detail"));
225 assert!(text.contains("Issue"));
226 }
227}