tix-tracker 0.5.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Issue context menu popup (right-click, D5).
//!
//! Owns the item list, selection, key handling and popup layout, mirroring the
//! `dialog.rs` / `date_picker.rs` widget pattern. Rendering is a plain bordered
//! list at a clamped popup position; pointer interactions (task 4.2) feed the
//! same [`ContextMenu::handle_key`] path.

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};

/// The outcome of routing a key event to a [`ContextMenu`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuEvent {
    /// The key moved the selection or was otherwise a no-op.
    None,
    /// The user dismissed the menu (`Esc`).
    Dismissed,
    /// The user confirmed the currently highlighted item (`Enter`).
    Selected(usize),
}

/// Which existing action a menu item triggers when activated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuItemKind {
    OpenDetail,
    EditInEditor,
    /// Open the card in the read-only viewer (tix #1507).
    ViewIssue,
    MoveTo {
        column: usize,
    },
    Delete,
    RefreshBoard,
}

/// One entry in the context menu: the action it runs plus its rendered label.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
    pub kind: MenuItemKind,
    pub label: String,
}

/// A vertically stacked popup menu with a single selection.
#[derive(Debug, Clone)]
pub struct ContextMenu {
    items: Vec<MenuItem>,
    selected: usize,
}

impl ContextMenu {
    pub fn new(items: Vec<MenuItem>) -> Self {
        ContextMenu { items, selected: 0 }
    }

    /// The highlighted item index, when the menu has items.
    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
    }

    /// The popup rect for `area`, size clamped to the items and to the area.
    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,
        )
    }

    /// Route a key press, returning the resulting [`MenuEvent`].
    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,
        }
    }

    /// Render the popup, centered in `area` and clamped to fit.
    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"));
    }
}