use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionGroup {
Move,
Open,
Content,
Destructive,
Marks,
Triage,
Board,
}
impl ActionGroup {
pub const fn label(self) -> &'static str {
match self {
Self::Move => "Move",
Self::Open => "Open",
Self::Content => "Content",
Self::Destructive => "Destructive",
Self::Marks => "Batch",
Self::Triage => "Triage",
Self::Board => "Board",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueAction {
OpenDetail,
EditInEditor,
ViewIssue,
OpenAgent,
CloneIssue,
OpenMentions,
RelatedPicker,
FollowRelated,
MoveTo {
column: String,
},
Comment,
AttachAsset,
NewRelationship,
Relationships,
EditRelationship {
id: String,
},
RemoveRelationship {
id: String,
},
SetParent,
RecordHealth,
AddTimeEntry,
Archive,
Restore,
Delete,
Mark {
on: bool,
},
Bulk {
action: crate::ui::bulk::BatchAction,
},
ClearMarks,
Triage {
decision: crate::triage::DecisionKind,
},
RefreshBoard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionMenuItem {
pub action: IssueAction,
pub label: String,
pub group: ActionGroup,
pub shortcut: Option<String>,
pub danger: bool,
}
impl ActionMenuItem {
pub fn new(action: IssueAction, label: impl Into<String>, group: ActionGroup) -> Self {
Self {
action,
label: label.into(),
group,
shortcut: None,
danger: false,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn danger(mut self, danger: bool) -> Self {
self.danger = danger;
self
}
fn haystack(&self) -> String {
let shortcut = self.shortcut.as_deref().unwrap_or("");
format!("{} {} {}", self.group.label(), self.label, shortcut).to_lowercase()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuEvent {
None,
Dismissed,
Selected(usize),
}
#[derive(Debug, Clone)]
struct MenuPage {
title: String,
items: Vec<ActionMenuItem>,
query: String,
selected: usize,
visible: Vec<usize>,
fuzzy: bool,
}
impl MenuPage {
fn new(title: impl Into<String>, items: Vec<ActionMenuItem>) -> Self {
let mut page = Self {
title: title.into(),
items,
query: String::new(),
selected: 0,
visible: Vec::new(),
fuzzy: false,
};
page.refilter();
page
}
fn refilter(&mut self) {
let query = self.query.trim().to_lowercase();
self.visible.clear();
self.fuzzy = false;
if query.is_empty() {
self.visible.extend(0..self.items.len());
} else {
let literal: Vec<usize> = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.haystack().contains(&query))
.map(|(index, _)| index)
.collect();
if literal.is_empty() {
self.fuzzy = true;
let mut scored: Vec<(u32, usize)> = self
.items
.iter()
.enumerate()
.filter_map(|(index, item)| {
crate::search::fuzzy_score(&query, &item.haystack())
.map(|score| (score, index))
})
.collect();
scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
self.visible
.extend(scored.into_iter().map(|(_, index)| index));
} else {
self.visible = literal;
}
}
self.selected = self.selected.min(self.visible.len().saturating_sub(1));
}
fn selected_item(&self) -> Option<&ActionMenuItem> {
self.visible
.get(self.selected)
.and_then(|index| self.items.get(*index))
}
fn move_by(&mut self, delta: i32) {
if self.visible.is_empty() {
return;
}
let last = self.visible.len() - 1;
let next = self.selected as i32 + delta;
self.selected = next.clamp(0, last as i32) as usize;
}
fn rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
let mut last: Option<ActionGroup> = None;
for index in &self.visible {
let item = &self.items[*index];
if last != Some(item.group) {
rows.push(Row::Header(item.group));
last = Some(item.group);
}
rows.push(Row::Item(*index));
}
rows
}
fn selected_index(&self) -> Option<usize> {
self.visible.get(self.selected).copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Row {
Header(ActionGroup),
Item(usize),
}
#[derive(Debug, Clone)]
pub struct ActionMenu {
pages: Vec<MenuPage>,
offset: std::cell::Cell<usize>,
}
impl ActionMenu {
pub fn new(items: Vec<ActionMenuItem>) -> Self {
let mut menu = Self {
pages: vec![MenuPage::new("Actions", items)],
offset: std::cell::Cell::new(0),
};
if let Some(page) = menu.pages.last_mut()
&& let Some(position) = page
.visible
.iter()
.position(|index| page.items[*index].action == IssueAction::OpenDetail)
{
page.selected = position;
}
menu
}
fn page(&self) -> &MenuPage {
self.pages.last().expect("action menu always has a page")
}
fn page_mut(&mut self) -> &mut MenuPage {
self.pages
.last_mut()
.expect("action menu always has a page")
}
pub fn items(&self) -> &[ActionMenuItem] {
&self.page().items
}
pub fn query(&self) -> &str {
&self.page().query
}
pub fn is_fuzzy(&self) -> bool {
self.page().fuzzy
}
pub fn visible_len(&self) -> usize {
self.page().visible.len()
}
pub fn selected_position(&self) -> usize {
self.page().selected
}
pub fn selected_item(&self) -> Option<&ActionMenuItem> {
self.page().selected_item()
}
pub fn item(&self, index: usize) -> Option<&ActionMenuItem> {
self.page().items.get(index)
}
pub fn push(&mut self, title: impl Into<String>, items: Vec<ActionMenuItem>) {
self.pages.push(MenuPage::new(title, items));
self.offset.set(0);
}
pub fn pop(&mut self) -> bool {
if self.pages.len() <= 1 {
return false;
}
self.pages.pop();
self.offset.set(0);
true
}
pub fn title(&self) -> &str {
&self.page().title
}
pub fn handle_key(&mut self, key: KeyEvent) -> MenuEvent {
match key.code {
KeyCode::Esc => {
if self.pop() {
MenuEvent::None
} else {
MenuEvent::Dismissed
}
}
KeyCode::Left if self.pop() => MenuEvent::None,
KeyCode::Enter => self
.page()
.selected_index()
.map(MenuEvent::Selected)
.unwrap_or(MenuEvent::None),
KeyCode::Up => {
self.page_mut().move_by(-1);
MenuEvent::None
}
KeyCode::Down => {
self.page_mut().move_by(1);
MenuEvent::None
}
KeyCode::PageUp => {
self.page_mut().move_by(-8);
MenuEvent::None
}
KeyCode::PageDown => {
self.page_mut().move_by(8);
MenuEvent::None
}
KeyCode::Backspace => {
self.page_mut().query.pop();
self.page_mut().selected = 0;
self.page_mut().refilter();
MenuEvent::None
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.page_mut().query.clear();
self.page_mut().selected = 0;
self.page_mut().refilter();
MenuEvent::None
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
self.page_mut().query.push(c);
self.page_mut().selected = 0;
self.page_mut().refilter();
MenuEvent::None
}
_ => MenuEvent::None,
}
}
pub fn bounds(&self, area: Rect) -> Rect {
let rows = self.page().rows();
let width = rows
.iter()
.map(|row| match row {
Row::Header(group) => group.label().chars().count(),
Row::Item(index) => {
let item = &self.page().items[*index];
item.label.chars().count()
+ item
.shortcut
.as_deref()
.map_or(0, |value| value.chars().count() + 2)
}
})
.max()
.unwrap_or(0)
.max(self.title().chars().count())
.saturating_add(6);
let height = (rows.len() as u16 + 3).min(area.height);
Rect::new(
area.x + (area.width.saturating_sub(width as u16)) / 2,
area.y + (area.height.saturating_sub(height)) / 2,
(width as u16).min(area.width),
height,
)
}
pub fn rows(&self) -> Vec<Row> {
self.page().rows()
}
pub fn window_offset(&self) -> usize {
self.offset.get()
}
pub fn hit(&self, area: Rect, x: u16, y: u16) -> Option<usize> {
let bounds = self.bounds(area);
if x < bounds.x || x >= bounds.x + bounds.width {
return None;
}
if y <= bounds.y || y >= bounds.y + bounds.height.saturating_sub(1) {
return None;
}
let row = usize::from(y - bounds.y - 1) + self.offset.get();
match self.rows().get(row) {
Some(Row::Item(index)) => Some(*index),
_ => None,
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let rect = self.bounds(area);
if rect.width < 6 || rect.height < 3 {
return;
}
f.render_widget(Clear, rect);
let rows = self.page().rows();
let visible_rows = usize::from(rect.height.saturating_sub(3));
let selected_index = self.page().selected_index();
let selected_row = rows
.iter()
.position(|row| matches!(row, Row::Item(index) if Some(*index) == selected_index))
.unwrap_or(0);
let mut offset = self.offset.get();
if selected_row < offset {
offset = selected_row;
} else if visible_rows > 0 && selected_row >= offset + visible_rows {
offset = selected_row + 1 - visible_rows;
}
offset = offset.min(rows.len().saturating_sub(visible_rows));
self.offset.set(offset);
let inner = usize::from(rect.width.saturating_sub(2));
let mut lines: Vec<Line<'static>> = Vec::new();
for (row_index, row) in rows.iter().enumerate().skip(offset).take(visible_rows) {
match row {
Row::Header(group) => {
lines.push(Line::from(Span::styled(
group.label().to_ascii_uppercase(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
}
Row::Item(index) => {
let item = &self.page().items[*index];
let selected = row_index == selected_row;
let mut style = Style::default();
if item.danger {
style = style.fg(Color::Red);
}
if selected {
style = style
.bg(Color::DarkGray)
.fg(if item.danger {
Color::Red
} else {
Color::White
})
.add_modifier(Modifier::BOLD);
}
let shortcut = item.shortcut.clone().unwrap_or_default();
let gap = inner
.saturating_sub(item.label.chars().count())
.saturating_sub(shortcut.chars().count());
let mut spans = vec![
Span::styled(item.label.clone(), style),
Span::raw(" ".repeat(gap.max(1))),
];
if !shortcut.is_empty() {
spans.push(Span::styled(shortcut, Style::default().fg(Color::DarkGray)));
}
lines.push(Line::from(spans));
}
}
}
let mut title = format!(" {} ", self.title());
if !self.page().query.is_empty() {
title.push_str(&format!(
"— \"{}\"{} ",
self.page().query,
if self.page().fuzzy { " (fuzzy)" } else { "" }
));
}
lines.push(Line::from(Span::styled(
format!(
"{} action{} · type to filter · ↑/↓ move · Enter run · Esc close",
self.page().visible.len(),
if self.page().visible.len() == 1 {
""
} else {
"s"
}
),
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(Paragraph::new(lines).block(block), rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<ActionMenuItem> {
vec![
ActionMenuItem::new(
IssueAction::MoveTo {
column: "done".into(),
},
"Move to done",
ActionGroup::Move,
),
ActionMenuItem::new(IssueAction::OpenDetail, "Open detail", ActionGroup::Open)
.shortcut("enter"),
ActionMenuItem::new(IssueAction::Comment, "Comment", ActionGroup::Content)
.shortcut("c"),
ActionMenuItem::new(IssueAction::Delete, "Delete", ActionGroup::Destructive)
.shortcut("d")
.danger(true),
]
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn starts_on_open_detail_and_skips_headers_for_movement() {
let mut menu = ActionMenu::new(sample());
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::OpenDetail),
"Open detail is preselected even though Move is drawn first"
);
menu.handle_key(key(KeyCode::Up));
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::MoveTo {
column: "done".into()
}),
"Up reaches the first drawn item"
);
menu.handle_key(key(KeyCode::Down));
let _ = menu.handle_key(key(KeyCode::Down));
menu.handle_key(key(KeyCode::Down));
menu.handle_key(key(KeyCode::Down));
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::Delete)
);
menu.handle_key(key(KeyCode::Down));
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::Delete),
"the last item stays selected"
);
}
#[test]
fn typing_filters_literally_and_then_fuzzily() {
let mut menu = ActionMenu::new(sample());
for c in "open".chars() {
menu.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(menu.visible_len(), 1);
assert!(!menu.is_fuzzy());
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::OpenDetail)
);
let mut menu = ActionMenu::new(sample());
for c in "opndt".chars() {
menu.handle_key(key(KeyCode::Char(c)));
}
assert!(menu.is_fuzzy(), "a bare subsequence falls back to fuzzy");
assert_eq!(
menu.selected_item().map(|item| item.action.clone()),
Some(IssueAction::OpenDetail)
);
}
#[test]
fn backspace_restores_the_list_and_esc_dismisses() {
let mut menu = ActionMenu::new(sample());
for c in "dele".chars() {
menu.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(menu.visible_len(), 1);
menu.handle_key(key(KeyCode::Backspace));
assert_eq!(menu.visible_len(), 1, "'dele' still matches only Delete");
menu.handle_key(key(KeyCode::Backspace));
menu.handle_key(key(KeyCode::Backspace));
menu.handle_key(key(KeyCode::Backspace));
assert_eq!(menu.handle_key(key(KeyCode::Backspace)), MenuEvent::None);
assert_eq!(menu.visible_len(), 4);
assert_eq!(menu.handle_key(key(KeyCode::Esc)), MenuEvent::Dismissed);
}
#[test]
fn submenu_pops_back_to_the_parent() {
let mut menu = ActionMenu::new(sample());
menu.push(
"Relationships",
vec![ActionMenuItem::new(
IssueAction::RemoveRelationship { id: "r1".into() },
"Remove #1",
ActionGroup::Content,
)],
);
assert_eq!(menu.title(), "Relationships");
assert!(menu.pop());
assert_eq!(menu.title(), "Actions");
assert!(!menu.pop());
}
#[test]
fn the_selected_item_survives_filtering_when_possible() {
let mut menu = ActionMenu::new(sample());
menu.handle_key(key(KeyCode::Down));
menu.handle_key(key(KeyCode::Down));
menu.handle_key(key(KeyCode::Char('d')));
assert!(menu.selected_item().is_some());
}
#[test]
fn enter_selects_the_highlighted_item() {
let mut menu = ActionMenu::new(sample());
menu.handle_key(key(KeyCode::Down));
assert_eq!(menu.handle_key(key(KeyCode::Enter)), MenuEvent::Selected(2));
}
}