use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::tui::theme::TuiTheme;
use crate::tui::widgets::popup::highlight_backticks;
const WIDTH: u16 = 80;
pub(crate) struct MenuItem {
pub label: String,
pub key: Option<char>,
pub help: Option<String>,
}
impl MenuItem {
pub fn new(label: impl Into<String>) -> Self {
MenuItem {
label: label.into(),
key: None,
help: None,
}
}
#[allow(dead_code)] pub fn key(mut self, key: char) -> Self {
self.key = Some(key);
self
}
pub fn help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
}
pub(crate) enum MenuOutcome {
Pending,
Choose(usize),
Cancel,
}
pub(crate) struct Menu {
title: String,
items: Vec<MenuItem>,
cursor: usize,
}
impl Menu {
pub fn new(title: impl Into<String>, items: Vec<MenuItem>) -> Self {
Menu {
title: title.into(),
items,
cursor: 0,
}
}
#[cfg(test)]
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> MenuOutcome {
match code {
KeyCode::Esc => return MenuOutcome::Cancel,
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return MenuOutcome::Cancel;
}
KeyCode::Up | KeyCode::Char('k') => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
self.cursor = (self.cursor + 1).min(self.items.len().saturating_sub(1));
}
KeyCode::Enter if !self.items.is_empty() => return MenuOutcome::Choose(self.cursor),
KeyCode::Char(c) => {
if let Some(i) = self.items.iter().position(|item| item.key == Some(c)) {
return MenuOutcome::Choose(i);
}
}
_ => {}
}
MenuOutcome::Pending
}
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &TuiTheme) {
let width = WIDTH.min(area.width.saturating_sub(2)).max(20);
let inner = width.saturating_sub(2) as usize;
let has_key = self.items.iter().any(|i| i.key.is_some());
let help: Vec<Line<'static>> = self
.items
.get(self.cursor)
.and_then(|i| i.help.as_deref())
.map(|help| {
help.lines()
.map(|l| Line::from(highlight_backticks(l, Style::default(), theme)))
.collect()
})
.unwrap_or_default();
let rows = (self.items.len() as u16 + 2).min(area.height);
let top = area.y + area.height.saturating_sub(rows) / 2;
let help_rows = if help.is_empty() {
0
} else {
(help.len() as u16 + 2).min(area.bottom().saturating_sub(top + rows))
};
let x = area.x + (area.width - width) / 2;
let menu_rect = Rect {
x,
y: top,
width,
height: rows,
};
let lines: Vec<Line<'static>> = self
.items
.iter()
.enumerate()
.map(|(i, item)| {
let base = if i == self.cursor {
theme.file_selected.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let gutter = match (has_key, item.key) {
(true, Some(k)) => format!("{} ", k),
(true, None) => " ".to_string(),
(false, _) => " ".to_string(),
};
let mut spans = vec![Span::styled(gutter, base.patch(theme.hint))];
spans.extend(highlight_backticks(&item.label, base, theme));
let used: usize = spans.iter().map(|s| s.width()).sum();
spans.push(Span::styled(" ".repeat(inner.saturating_sub(used)), base));
Line::from(spans)
})
.collect();
frame.render_widget(Clear, menu_rect);
let counter = format!(" {} of {} ", self.cursor + 1, self.items.len());
let block = Block::default()
.title(format!(" {} ", self.title))
.title_bottom(Line::from(counter).right_aligned())
.borders(Borders::ALL)
.border_style(theme.border_active);
frame.render_widget(Paragraph::new(lines).block(block), menu_rect);
if help_rows > 0 {
let help_rect = Rect {
x,
y: top + rows,
width,
height: help_rows,
};
frame.render_widget(Clear, help_rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border);
frame.render_widget(Paragraph::new(help).block(block), help_rect);
}
}
}
#[cfg(test)]
#[path = "menu_test.rs"]
mod tests;