Skip to main content

tix/ui/
action_menu.rs

1//! The issue **action menu**: one grouped, filterable list of everything that can
2//! be done with the card under the board cursor.
3//!
4//! Opened by `Enter`, by a plain left click, and by right-click (which adds the
5//! board footer), so the keyboard and the pointer read the same list. The widget
6//! owns the query, the filtered order and the cursor; activation is App-owned and
7//! routes through the same semantic action path the keyboard uses.
8//!
9//! Filtering follows the help overlay's rule exactly: literal substring first,
10//! then `crate::search::fuzzy_score` as a fallback, with a `(fuzzy)` marker. The
11//! tool therefore has one notion of "fuzzy", not a second one that behaves
12//! almost the same.
13
14use 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/// Which block of the menu an entry belongs to.
22#[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/// What a menu entry runs.
48///
49/// Every variant is an *identity*: the App's one dispatcher turns it into the
50/// same method a key press calls. Parameterised variants (`MoveTo`, `Mark`,
51/// `Triage`) carry the target so the menu can name it.
52#[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/// One rendered entry: its semantic action, its label, its group, and the
95/// effective shortcut when one exists.
96#[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/// The outcome of routing a key event to the menu.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum MenuEvent {
135    /// The key moved the cursor, changed the query, or was a no-op.
136    None,
137    /// The user dismissed the menu (`Esc` on the root page).
138    Dismissed,
139    /// The user confirmed an item; the index addresses the current page's items.
140    Selected(usize),
141}
142
143/// One page of the menu. The root page is the issue's action list; a submenu is
144/// pushed by an entry that has more than one target (relationships today).
145#[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                // Best first; `sort_by` is stable, so equal scores keep the
186                // declared order — the order the menu is normally read in.
187                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    /// Rows in draw order: a group header, then its items.
222    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/// One drawn row of a page.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum Row {
244    Header(ActionGroup),
245    Item(usize),
246}
247
248/// The action menu: a stack of pages, a query per page, and the last-drawn
249/// window offset so pointer hit-testing agrees with what was rendered.
250#[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        // `Open detail` is the preselected entry even though the Move group is
263        // drawn first, so `Enter` `Enter` reproduces the old `Enter`.
264        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    /// The cursor's position in the filtered list.
302    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    /// Push a submenu. Its own query starts empty, so the parent filter does not
315    /// silently hide rows in a list the reader has not seen yet.
316    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    /// Return to the parent page. `false` when this is already the root.
322    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    /// The popup rect for `area`, size clamped to the content and the area.
392    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        // Borders + the footer line.
412        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    /// The row list, shared by the renderer and the pointer handler.
422    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    /// The item under a terminal cell, if the cell is an item row.
431    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    /// Render the current page, keeping the selected item inside the window.
447    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        // Open detail is preselected; Down moves to the next item.
672        menu.handle_key(key(KeyCode::Down));
673        assert_eq!(menu.handle_key(key(KeyCode::Enter)), MenuEvent::Selected(2));
674    }
675}