1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15use ratatui::Frame;
16use ratatui::layout::Rect;
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ActionGroup {
24 Move,
25 Open,
26 Content,
27 Destructive,
28 Marks,
29 Triage,
30 Board,
31}
32
33impl ActionGroup {
34 pub const fn label(self) -> &'static str {
35 match self {
36 Self::Move => "Move",
37 Self::Open => "Open",
38 Self::Content => "Content",
39 Self::Destructive => "Destructive",
40 Self::Marks => "Batch",
41 Self::Triage => "Triage",
42 Self::Board => "Board",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum IssueAction {
54 OpenDetail,
55 EditInEditor,
56 ViewIssue,
57 OpenAgent,
58 CloneIssue,
59 OpenMentions,
60 RelatedPicker,
61 FollowRelated,
62 MoveTo {
63 column: String,
64 },
65 Comment,
66 AttachAsset,
67 NewRelationship,
68 Relationships,
69 EditRelationship {
70 id: String,
71 },
72 RemoveRelationship {
73 id: String,
74 },
75 SetParent,
76 RecordHealth,
77 AddTimeEntry,
78 Archive,
79 Restore,
80 Delete,
81 Mark {
82 on: bool,
83 },
84 Bulk {
85 action: crate::ui::bulk::BatchAction,
86 },
87 ClearMarks,
88 Triage {
89 decision: crate::triage::DecisionKind,
90 },
91 RefreshBoard,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ActionMenuItem {
98 pub action: IssueAction,
99 pub label: String,
100 pub group: ActionGroup,
101 pub shortcut: Option<String>,
102 pub danger: bool,
103}
104
105impl ActionMenuItem {
106 pub fn new(action: IssueAction, label: impl Into<String>, group: ActionGroup) -> Self {
107 Self {
108 action,
109 label: label.into(),
110 group,
111 shortcut: None,
112 danger: false,
113 }
114 }
115
116 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
117 self.shortcut = Some(shortcut.into());
118 self
119 }
120
121 pub fn danger(mut self, danger: bool) -> Self {
122 self.danger = danger;
123 self
124 }
125
126 fn haystack(&self) -> String {
127 let shortcut = self.shortcut.as_deref().unwrap_or("");
128 format!("{} {} {}", self.group.label(), self.label, shortcut).to_lowercase()
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum MenuEvent {
135 None,
137 Dismissed,
139 Selected(usize),
141}
142
143#[derive(Debug, Clone)]
146struct MenuPage {
147 title: String,
148 items: Vec<ActionMenuItem>,
149 query: String,
150 selected: usize,
151 visible: Vec<usize>,
152 fuzzy: bool,
153}
154
155impl MenuPage {
156 fn new(title: impl Into<String>, items: Vec<ActionMenuItem>) -> Self {
157 let mut page = Self {
158 title: title.into(),
159 items,
160 query: String::new(),
161 selected: 0,
162 visible: Vec::new(),
163 fuzzy: false,
164 };
165 page.refilter();
166 page
167 }
168
169 fn refilter(&mut self) {
170 let query = self.query.trim().to_lowercase();
171 self.visible.clear();
172 self.fuzzy = false;
173 if query.is_empty() {
174 self.visible.extend(0..self.items.len());
175 } else {
176 let literal: Vec<usize> = self
177 .items
178 .iter()
179 .enumerate()
180 .filter(|(_, item)| item.haystack().contains(&query))
181 .map(|(index, _)| index)
182 .collect();
183 if literal.is_empty() {
184 self.fuzzy = true;
185 let mut scored: Vec<(u32, usize)> = self
188 .items
189 .iter()
190 .enumerate()
191 .filter_map(|(index, item)| {
192 crate::search::fuzzy_score(&query, &item.haystack())
193 .map(|score| (score, index))
194 })
195 .collect();
196 scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
197 self.visible
198 .extend(scored.into_iter().map(|(_, index)| index));
199 } else {
200 self.visible = literal;
201 }
202 }
203 self.selected = self.selected.min(self.visible.len().saturating_sub(1));
204 }
205
206 fn selected_item(&self) -> Option<&ActionMenuItem> {
207 self.visible
208 .get(self.selected)
209 .and_then(|index| self.items.get(*index))
210 }
211
212 fn move_by(&mut self, delta: i32) {
213 if self.visible.is_empty() {
214 return;
215 }
216 let last = self.visible.len() - 1;
217 let next = self.selected as i32 + delta;
218 self.selected = next.clamp(0, last as i32) as usize;
219 }
220
221 fn rows(&self) -> Vec<Row> {
223 let mut rows = Vec::new();
224 let mut last: Option<ActionGroup> = None;
225 for index in &self.visible {
226 let item = &self.items[*index];
227 if last != Some(item.group) {
228 rows.push(Row::Header(item.group));
229 last = Some(item.group);
230 }
231 rows.push(Row::Item(*index));
232 }
233 rows
234 }
235
236 fn selected_index(&self) -> Option<usize> {
237 self.visible.get(self.selected).copied()
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum Row {
244 Header(ActionGroup),
245 Item(usize),
246}
247
248#[derive(Debug, Clone)]
251pub struct ActionMenu {
252 pages: Vec<MenuPage>,
253 offset: std::cell::Cell<usize>,
254}
255
256impl ActionMenu {
257 pub fn new(items: Vec<ActionMenuItem>) -> Self {
258 let mut menu = Self {
259 pages: vec![MenuPage::new("Actions", items)],
260 offset: std::cell::Cell::new(0),
261 };
262 if let Some(page) = menu.pages.last_mut()
265 && let Some(position) = page
266 .visible
267 .iter()
268 .position(|index| page.items[*index].action == IssueAction::OpenDetail)
269 {
270 page.selected = position;
271 }
272 menu
273 }
274
275 fn page(&self) -> &MenuPage {
276 self.pages.last().expect("action menu always has a page")
277 }
278
279 fn page_mut(&mut self) -> &mut MenuPage {
280 self.pages
281 .last_mut()
282 .expect("action menu always has a page")
283 }
284
285 pub fn items(&self) -> &[ActionMenuItem] {
286 &self.page().items
287 }
288
289 pub fn query(&self) -> &str {
290 &self.page().query
291 }
292
293 pub fn is_fuzzy(&self) -> bool {
294 self.page().fuzzy
295 }
296
297 pub fn visible_len(&self) -> usize {
298 self.page().visible.len()
299 }
300
301 pub fn selected_position(&self) -> usize {
303 self.page().selected
304 }
305
306 pub fn selected_item(&self) -> Option<&ActionMenuItem> {
307 self.page().selected_item()
308 }
309
310 pub fn item(&self, index: usize) -> Option<&ActionMenuItem> {
311 self.page().items.get(index)
312 }
313
314 pub fn push(&mut self, title: impl Into<String>, items: Vec<ActionMenuItem>) {
317 self.pages.push(MenuPage::new(title, items));
318 self.offset.set(0);
319 }
320
321 pub fn pop(&mut self) -> bool {
323 if self.pages.len() <= 1 {
324 return false;
325 }
326 self.pages.pop();
327 self.offset.set(0);
328 true
329 }
330
331 pub fn title(&self) -> &str {
332 &self.page().title
333 }
334
335 pub fn handle_key(&mut self, key: KeyEvent) -> MenuEvent {
336 match key.code {
337 KeyCode::Esc => {
338 if self.pop() {
339 MenuEvent::None
340 } else {
341 MenuEvent::Dismissed
342 }
343 }
344 KeyCode::Left if self.pop() => MenuEvent::None,
345 KeyCode::Enter => self
346 .page()
347 .selected_index()
348 .map(MenuEvent::Selected)
349 .unwrap_or(MenuEvent::None),
350 KeyCode::Up => {
351 self.page_mut().move_by(-1);
352 MenuEvent::None
353 }
354 KeyCode::Down => {
355 self.page_mut().move_by(1);
356 MenuEvent::None
357 }
358 KeyCode::PageUp => {
359 self.page_mut().move_by(-8);
360 MenuEvent::None
361 }
362 KeyCode::PageDown => {
363 self.page_mut().move_by(8);
364 MenuEvent::None
365 }
366 KeyCode::Backspace => {
367 self.page_mut().query.pop();
368 self.page_mut().selected = 0;
369 self.page_mut().refilter();
370 MenuEvent::None
371 }
372 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
373 self.page_mut().query.clear();
374 self.page_mut().selected = 0;
375 self.page_mut().refilter();
376 MenuEvent::None
377 }
378 KeyCode::Char(c)
379 if !key.modifiers.contains(KeyModifiers::CONTROL)
380 && !key.modifiers.contains(KeyModifiers::ALT) =>
381 {
382 self.page_mut().query.push(c);
383 self.page_mut().selected = 0;
384 self.page_mut().refilter();
385 MenuEvent::None
386 }
387 _ => MenuEvent::None,
388 }
389 }
390
391 pub fn bounds(&self, area: Rect) -> Rect {
393 let rows = self.page().rows();
394 let width = rows
395 .iter()
396 .map(|row| match row {
397 Row::Header(group) => group.label().chars().count(),
398 Row::Item(index) => {
399 let item = &self.page().items[*index];
400 item.label.chars().count()
401 + item
402 .shortcut
403 .as_deref()
404 .map_or(0, |value| value.chars().count() + 2)
405 }
406 })
407 .max()
408 .unwrap_or(0)
409 .max(self.title().chars().count())
410 .saturating_add(6);
411 let height = (rows.len() as u16 + 3).min(area.height);
413 Rect::new(
414 area.x + (area.width.saturating_sub(width as u16)) / 2,
415 area.y + (area.height.saturating_sub(height)) / 2,
416 (width as u16).min(area.width),
417 height,
418 )
419 }
420
421 pub fn rows(&self) -> Vec<Row> {
423 self.page().rows()
424 }
425
426 pub fn window_offset(&self) -> usize {
427 self.offset.get()
428 }
429
430 pub fn hit(&self, area: Rect, x: u16, y: u16) -> Option<usize> {
432 let bounds = self.bounds(area);
433 if x < bounds.x || x >= bounds.x + bounds.width {
434 return None;
435 }
436 if y <= bounds.y || y >= bounds.y + bounds.height.saturating_sub(1) {
437 return None;
438 }
439 let row = usize::from(y - bounds.y - 1) + self.offset.get();
440 match self.rows().get(row) {
441 Some(Row::Item(index)) => Some(*index),
442 _ => None,
443 }
444 }
445
446 pub fn render(&self, f: &mut Frame, area: Rect) {
448 let rect = self.bounds(area);
449 if rect.width < 6 || rect.height < 3 {
450 return;
451 }
452 f.render_widget(Clear, rect);
453 let rows = self.page().rows();
454 let visible_rows = usize::from(rect.height.saturating_sub(3));
455 let selected_index = self.page().selected_index();
456 let selected_row = rows
457 .iter()
458 .position(|row| matches!(row, Row::Item(index) if Some(*index) == selected_index))
459 .unwrap_or(0);
460 let mut offset = self.offset.get();
461 if selected_row < offset {
462 offset = selected_row;
463 } else if visible_rows > 0 && selected_row >= offset + visible_rows {
464 offset = selected_row + 1 - visible_rows;
465 }
466 offset = offset.min(rows.len().saturating_sub(visible_rows));
467 self.offset.set(offset);
468
469 let inner = usize::from(rect.width.saturating_sub(2));
470 let mut lines: Vec<Line<'static>> = Vec::new();
471 for (row_index, row) in rows.iter().enumerate().skip(offset).take(visible_rows) {
472 match row {
473 Row::Header(group) => {
474 lines.push(Line::from(Span::styled(
475 group.label().to_ascii_uppercase(),
476 Style::default()
477 .fg(Color::Yellow)
478 .add_modifier(Modifier::BOLD),
479 )));
480 }
481 Row::Item(index) => {
482 let item = &self.page().items[*index];
483 let selected = row_index == selected_row;
484 let mut style = Style::default();
485 if item.danger {
486 style = style.fg(Color::Red);
487 }
488 if selected {
489 style = style
490 .bg(Color::DarkGray)
491 .fg(if item.danger {
492 Color::Red
493 } else {
494 Color::White
495 })
496 .add_modifier(Modifier::BOLD);
497 }
498 let shortcut = item.shortcut.clone().unwrap_or_default();
499 let gap = inner
500 .saturating_sub(item.label.chars().count())
501 .saturating_sub(shortcut.chars().count());
502 let mut spans = vec![
503 Span::styled(item.label.clone(), style),
504 Span::raw(" ".repeat(gap.max(1))),
505 ];
506 if !shortcut.is_empty() {
507 spans.push(Span::styled(shortcut, Style::default().fg(Color::DarkGray)));
508 }
509 lines.push(Line::from(spans));
510 }
511 }
512 }
513 let mut title = format!(" {} ", self.title());
514 if !self.page().query.is_empty() {
515 title.push_str(&format!(
516 "— \"{}\"{} ",
517 self.page().query,
518 if self.page().fuzzy { " (fuzzy)" } else { "" }
519 ));
520 }
521 lines.push(Line::from(Span::styled(
522 format!(
523 "{} action{} · type to filter · ↑/↓ move · Enter run · Esc close",
524 self.page().visible.len(),
525 if self.page().visible.len() == 1 {
526 ""
527 } else {
528 "s"
529 }
530 ),
531 Style::default().fg(Color::DarkGray),
532 )));
533 let block = Block::default()
534 .borders(Borders::ALL)
535 .title(title)
536 .border_style(Style::default().fg(Color::Cyan));
537 f.render_widget(Paragraph::new(lines).block(block), rect);
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 #[allow(clippy::wildcard_imports)]
544 use super::*;
545
546 fn sample() -> Vec<ActionMenuItem> {
547 vec![
548 ActionMenuItem::new(
549 IssueAction::MoveTo {
550 column: "done".into(),
551 },
552 "Move to done",
553 ActionGroup::Move,
554 ),
555 ActionMenuItem::new(IssueAction::OpenDetail, "Open detail", ActionGroup::Open)
556 .shortcut("enter"),
557 ActionMenuItem::new(IssueAction::Comment, "Comment", ActionGroup::Content)
558 .shortcut("c"),
559 ActionMenuItem::new(IssueAction::Delete, "Delete", ActionGroup::Destructive)
560 .shortcut("d")
561 .danger(true),
562 ]
563 }
564
565 fn key(code: KeyCode) -> KeyEvent {
566 KeyEvent::new(code, KeyModifiers::NONE)
567 }
568
569 #[test]
570 fn starts_on_open_detail_and_skips_headers_for_movement() {
571 let mut menu = ActionMenu::new(sample());
572 assert_eq!(
573 menu.selected_item().map(|item| item.action.clone()),
574 Some(IssueAction::OpenDetail),
575 "Open detail is preselected even though Move is drawn first"
576 );
577 menu.handle_key(key(KeyCode::Up));
578 assert_eq!(
579 menu.selected_item().map(|item| item.action.clone()),
580 Some(IssueAction::MoveTo {
581 column: "done".into()
582 }),
583 "Up reaches the first drawn item"
584 );
585 menu.handle_key(key(KeyCode::Down));
586 let _ = menu.handle_key(key(KeyCode::Down));
587 menu.handle_key(key(KeyCode::Down));
588 menu.handle_key(key(KeyCode::Down));
589 assert_eq!(
590 menu.selected_item().map(|item| item.action.clone()),
591 Some(IssueAction::Delete)
592 );
593 menu.handle_key(key(KeyCode::Down));
594 assert_eq!(
595 menu.selected_item().map(|item| item.action.clone()),
596 Some(IssueAction::Delete),
597 "the last item stays selected"
598 );
599 }
600
601 #[test]
602 fn typing_filters_literally_and_then_fuzzily() {
603 let mut menu = ActionMenu::new(sample());
604 for c in "open".chars() {
605 menu.handle_key(key(KeyCode::Char(c)));
606 }
607 assert_eq!(menu.visible_len(), 1);
608 assert!(!menu.is_fuzzy());
609 assert_eq!(
610 menu.selected_item().map(|item| item.action.clone()),
611 Some(IssueAction::OpenDetail)
612 );
613
614 let mut menu = ActionMenu::new(sample());
615 for c in "opndt".chars() {
616 menu.handle_key(key(KeyCode::Char(c)));
617 }
618 assert!(menu.is_fuzzy(), "a bare subsequence falls back to fuzzy");
619 assert_eq!(
620 menu.selected_item().map(|item| item.action.clone()),
621 Some(IssueAction::OpenDetail)
622 );
623 }
624
625 #[test]
626 fn backspace_restores_the_list_and_esc_dismisses() {
627 let mut menu = ActionMenu::new(sample());
628 for c in "dele".chars() {
629 menu.handle_key(key(KeyCode::Char(c)));
630 }
631 assert_eq!(menu.visible_len(), 1);
632 menu.handle_key(key(KeyCode::Backspace));
633 assert_eq!(menu.visible_len(), 1, "'dele' still matches only Delete");
634 menu.handle_key(key(KeyCode::Backspace));
635 menu.handle_key(key(KeyCode::Backspace));
636 menu.handle_key(key(KeyCode::Backspace));
637 assert_eq!(menu.handle_key(key(KeyCode::Backspace)), MenuEvent::None);
638 assert_eq!(menu.visible_len(), 4);
639 assert_eq!(menu.handle_key(key(KeyCode::Esc)), MenuEvent::Dismissed);
640 }
641
642 #[test]
643 fn submenu_pops_back_to_the_parent() {
644 let mut menu = ActionMenu::new(sample());
645 menu.push(
646 "Relationships",
647 vec![ActionMenuItem::new(
648 IssueAction::RemoveRelationship { id: "r1".into() },
649 "Remove #1",
650 ActionGroup::Content,
651 )],
652 );
653 assert_eq!(menu.title(), "Relationships");
654 assert!(menu.pop());
655 assert_eq!(menu.title(), "Actions");
656 assert!(!menu.pop());
657 }
658
659 #[test]
660 fn the_selected_item_survives_filtering_when_possible() {
661 let mut menu = ActionMenu::new(sample());
662 menu.handle_key(key(KeyCode::Down));
663 menu.handle_key(key(KeyCode::Down));
664 menu.handle_key(key(KeyCode::Char('d')));
665 assert!(menu.selected_item().is_some());
666 }
667
668 #[test]
669 fn enter_selects_the_highlighted_item() {
670 let mut menu = ActionMenu::new(sample());
671 menu.handle_key(key(KeyCode::Down));
673 assert_eq!(menu.handle_key(key(KeyCode::Enter)), MenuEvent::Selected(2));
674 }
675}