use crossterm::event::KeyCode;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuEvent {
None,
Dismissed,
Selected(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuItemKind {
OpenDetail,
EditInEditor,
ViewIssue,
MoveTo {
column: usize,
},
Delete,
RefreshBoard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
pub kind: MenuItemKind,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct ContextMenu {
items: Vec<MenuItem>,
selected: usize,
}
impl ContextMenu {
pub fn new(items: Vec<MenuItem>) -> Self {
ContextMenu { items, selected: 0 }
}
pub fn selected(&self) -> Option<usize> {
Some(self.selected).filter(|index| *index < self.items.len())
}
pub fn item(&self, index: usize) -> Option<&MenuItem> {
self.items.get(index)
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn items(&self) -> &[MenuItem] {
&self.items
}
pub fn bounds(&self, area: Rect) -> Rect {
let width = (self
.items
.iter()
.map(|item| item.label.chars().count() as u16)
.max()
.unwrap_or(0)
.saturating_add(4))
.min(area.width);
let height = (self.items.len() as u16 + 2).min(area.height);
Rect::new(
area.x + (area.width.saturating_sub(width)) / 2,
area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
)
}
pub fn handle_key(&mut self, code: KeyCode) -> MenuEvent {
match code {
KeyCode::Up | KeyCode::Char('k') => {
if self.selected > 0 {
self.selected -= 1;
}
MenuEvent::None
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected + 1 < self.items.len() {
self.selected += 1;
}
MenuEvent::None
}
KeyCode::Enter => MenuEvent::Selected(self.selected),
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => MenuEvent::Dismissed,
_ => MenuEvent::None,
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let rect = self.bounds(area);
if rect.width < 4 || rect.height < 2 {
return;
}
f.render_widget(Clear, rect);
let items: Vec<ListItem> = self
.items
.iter()
.map(|item| ListItem::new(Line::from(item.label.clone())))
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Issue ")
.border_style(Style::default().fg(Color::Cyan)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
if !self.items.is_empty() {
state.select(Some(self.selected));
}
f.render_stateful_widget(list, rect, &mut state);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ContextMenu {
ContextMenu::new(vec![
MenuItem {
kind: MenuItemKind::OpenDetail,
label: "Open detail".into(),
},
MenuItem {
kind: MenuItemKind::EditInEditor,
label: "Edit in editor".into(),
},
MenuItem {
kind: MenuItemKind::MoveTo { column: 1 },
label: "Move to in progress".into(),
},
])
}
#[test]
fn starts_at_first_item() {
let menu = sample();
assert_eq!(menu.selected(), Some(0));
}
#[test]
fn down_and_up_navigate_without_wrapping() {
let mut menu = sample();
assert_eq!(menu.handle_key(KeyCode::Down), MenuEvent::None);
assert_eq!(menu.selected(), Some(1));
assert_eq!(menu.handle_key(KeyCode::Down), MenuEvent::None);
assert_eq!(menu.selected(), Some(2));
assert_eq!(
menu.handle_key(KeyCode::Down),
MenuEvent::None,
"last item stays selected"
);
assert_eq!(menu.selected(), Some(2));
assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
assert_eq!(menu.selected(), Some(1));
assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
assert_eq!(menu.handle_key(KeyCode::Up), MenuEvent::None);
assert_eq!(menu.selected(), Some(0), "first item stays selected");
}
#[test]
fn enter_selects_highlighted_item_and_esc_dismisses() {
let mut menu = sample();
menu.handle_key(KeyCode::Down);
assert_eq!(menu.handle_key(KeyCode::Enter), MenuEvent::Selected(1));
assert_eq!(menu.handle_key(KeyCode::Esc), MenuEvent::Dismissed);
}
#[test]
fn bounds_are_clamped_to_small_frames() {
let menu = sample();
let roomy = menu.bounds(Rect::new(0, 0, 80, 40));
assert_eq!(roomy.height, 5, "3 items + borders");
assert!(roomy.width <= 80);
let tiny = menu.bounds(Rect::new(0, 0, 6, 3));
assert!(tiny.width <= 6 && tiny.height <= 3);
}
#[test]
fn render_smoke_on_tiny_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(16, 8);
let mut terminal = Terminal::new(backend).unwrap();
let menu = sample();
terminal.draw(|f| menu.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(text.contains("Open detail"));
assert!(text.contains("Issue"));
}
}