oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Generic context menu overlay.
//!
//! Any view can open a context menu by emitting
//! [`Operation::OpenContextMenu`].  The menu is stored on [`AppState`],
//! rendered as a floating overlay by `app.rs`, and closed after the user
//! selects an item or presses Escape.

use crate::commands::CommandId;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// A single item in a context menu.
#[derive(Debug, Clone)]
pub struct ContextMenuItem {
    pub label: String,
    pub command: CommandId,
    pub enabled: bool,
}

impl ContextMenuItem {
    pub fn new(label: impl Into<String>, command: CommandId) -> Self {
        Self { label: label.into(), command, enabled: true }
    }

    pub fn with_enabled(label: impl Into<String>, command: CommandId, enabled: bool) -> Self {
        Self { label: label.into(), command, enabled }
    }
}

/// An open context menu.
///
/// Stored on [`AppState`] while the menu is visible.  `app.rs` handles all
/// keyboard/mouse input and rendering.
#[derive(Debug, Clone)]
pub struct ContextMenu {
    pub items: Vec<ContextMenuItem>,
    pub cursor: usize,
    /// Screen column at which the top-left corner of the menu is anchored.
    pub x: u16,
    /// Screen row at which the top-left corner of the menu is anchored.
    pub y: u16,
}

impl ContextMenu {
    /// Create a new context menu anchored at `(x, y)`.
    pub fn new(items: Vec<ContextMenuItem>, x: u16, y: u16) -> Self {
        // Set cursor to first enabled item if any, otherwise 0.
        let cursor = items.iter().position(|i| i.enabled).unwrap_or(0);
        Self { items, cursor, x, y }
    }

    /// Move the cursor up, wrapping around and skipping disabled items.
    pub fn move_up(&mut self) {
        if self.items.is_empty() { return; }
        let n = self.items.len();
        let mut idx = self.cursor;
        for _ in 0..n {
            idx = if idx == 0 { n - 1 } else { idx - 1 };
            if self.items[idx].enabled {
                self.cursor = idx;
                return;
            }
        }
    }

    /// Move the cursor down, wrapping around and skipping disabled items.
    pub fn move_down(&mut self) {
        if self.items.is_empty() { return; }
        let n = self.items.len();
        let mut idx = self.cursor;
        for _ in 0..n {
            idx = (idx + 1) % n;
            if self.items[idx].enabled {
                self.cursor = idx;
                return;
            }
        }
    }

    /// Return the command for the currently selected item, if any.
    pub fn selected_command(&self) -> Option<&CommandId> {
        self.items.get(self.cursor).and_then(|it| if it.enabled { Some(&it.command) } else { None })
    }

    /// Clamp the anchor position so the menu fits within `(screen_width, screen_height)`.
    pub fn clamped(mut self, screen_width: u16, screen_height: u16) -> Self {
        let menu_w = self.items.iter().map(|i| i.label.len() + 4).max().unwrap_or(10) as u16;
        let menu_h = self.items.len() as u16 + 2; // border rows
        if self.x + menu_w > screen_width {
            self.x = screen_width.saturating_sub(menu_w);
        }
        if self.y + menu_h > screen_height {
            self.y = screen_height.saturating_sub(menu_h);
        }
        self
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn cmd(name: &'static str) -> CommandId {
        CommandId::new_static("test", name)
    }

    fn three_item_menu() -> ContextMenu {
        ContextMenu::new(
            vec![
                ContextMenuItem::new("New Tab", cmd("new_tab")),
                ContextMenuItem::new("Rename Tab", cmd("rename_tab")),
                ContextMenuItem::new("Close Tab", cmd("close_tab")),
            ],
            5,
            10,
        )
    }

    #[test]
    fn initial_cursor_is_zero() {
        let m = three_item_menu();
        assert_eq!(m.cursor, 0);
    }

    #[test]
    fn move_down_advances_cursor() {
        let mut m = three_item_menu();
        m.move_down();
        assert_eq!(m.cursor, 1);
        m.move_down();
        assert_eq!(m.cursor, 2);
    }

    #[test]
    fn move_down_wraps_around() {
        let mut m = three_item_menu();
        m.cursor = 2;
        m.move_down();
        assert_eq!(m.cursor, 0);
    }

    #[test]
    fn move_up_wraps_around() {
        let mut m = three_item_menu();
        m.move_up();
        assert_eq!(m.cursor, 2); // 0 → 2 (wrap)
    }

    #[test]
    fn move_up_decrements_cursor() {
        let mut m = three_item_menu();
        m.cursor = 2;
        m.move_up();
        assert_eq!(m.cursor, 1);
    }

    #[test]
    fn selected_command_returns_correct_item() {
        let mut m = three_item_menu();
        assert_eq!(m.selected_command(), Some(&cmd("new_tab")));
        m.cursor = 2;
        assert_eq!(m.selected_command(), Some(&cmd("close_tab")));
    }

    #[test]
    fn selected_command_on_empty_menu_is_none() {
        let m = ContextMenu::new(vec![], 0, 0);
        assert_eq!(m.selected_command(), None);
    }

    #[test]
    fn clamped_does_not_move_when_fits() {
        let m = three_item_menu().clamped(80, 24);
        assert_eq!(m.x, 5);
        assert_eq!(m.y, 10);
    }

    #[test]
    fn clamped_shifts_left_when_overflows_right_edge() {
        // items are "New Tab", "Rename Tab", "Close Tab"
        // longest = "Rename Tab" = 10 chars → width = 14
        let m = ContextMenu::new(
            vec![
                ContextMenuItem::new("Rename Tab", cmd("rename_tab")),
            ],
            76, // x + 14 > 80 → should clamp
            5,
        )
        .clamped(80, 24);
        assert!(m.x + 14 <= 80, "x={} should be clamped", m.x);
    }

    #[test]
    fn clamped_shifts_up_when_overflows_bottom() {
        // 3 items → height = 5; start at y=22 → overflows 24
        let m = three_item_menu();
        let m2 = ContextMenu { y: 22, ..m }.clamped(80, 24);
        assert!(m2.y + 5 <= 24, "y={} should be clamped", m2.y);
    }
}