Skip to main content

tix/ui/
context_menu.rs

1//! Issue context menu popup (right-click, D5).
2//!
3//! Owns the item list, selection, key handling and popup layout, mirroring the
4//! `dialog.rs` / `date_picker.rs` widget pattern. Rendering is a plain bordered
5//! list at a clamped popup position; pointer interactions (task 4.2) feed the
6//! same [`ContextMenu::handle_key`] path.
7
8use 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/// The outcome of routing a key event to a [`ContextMenu`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum MenuEvent {
18    /// The key moved the selection or was otherwise a no-op.
19    None,
20    /// The user dismissed the menu (`Esc`).
21    Dismissed,
22    /// The user confirmed the currently highlighted item (`Enter`).
23    Selected(usize),
24}
25
26/// Which existing action a menu item triggers when activated.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MenuItemKind {
29    OpenDetail,
30    EditInEditor,
31    MoveTo { column: usize },
32    Delete,
33    RefreshBoard,
34}
35
36/// One entry in the context menu: the action it runs plus its rendered label.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct MenuItem {
39    pub kind: MenuItemKind,
40    pub label: String,
41}
42
43/// A vertically stacked popup menu with a single selection.
44#[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    /// The highlighted item index, when the menu has items.
56    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    /// The popup rect for `area`, size clamped to the items and to the area.
77    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    /// Route a key press, returning the resulting [`MenuEvent`].
96    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    /// Render the popup, centered in `area` and clamped to fit.
117    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}