rootle 0.8.6

Modal TUI for browsing remote source-control systems (GitHub in-tree, any forge via stdio providers) from the terminal
Documentation
//! Keymap tables: mode → key → Action, plus the hint rows derived from
//! the same source (PLAN.md §6). Dispatch and hints can never drift apart.

use crate::action::Action;
use crate::mode::Mode;
use ratatui::crossterm::event::KeyCode;

/// (key label, description) shown in the modeline / popup hint row.
pub fn hints(mode: Mode) -> &'static [(&'static str, &'static str)] {
    match mode {
        Mode::Browse => &[
            ("j/k", "move"),
            ("h/l", "out/in"),
            ("J/K", "preview line"),
            ("/", "filter"),
            ("n/N", "match"),
            ("v", "visual"),
            ("␣", "leader"),
            (":", "command"),
            ("?", "keys"),
            ("q", "quit"),
        ],
        Mode::Search => &[("type", "filter"), ("enter", "commit"), ("esc", "cancel")],
        Mode::Find => &[("type", "query"), ("enter", "jump"), ("esc", "cancel")],
        Mode::Insert => &[("enter", "submit"), ("tab", "results"), ("esc", "normal")],
        Mode::Normal => &[("i", "insert"), ("tab", "switch"), ("esc", "close")],
        Mode::Leader => &[
            ("s", "search"),
            ("f", "find file"),
            ("g", "grep"),
            ("b", "branches"),
            ("p", "preview"),
            ("/", "find in file"),
            ("y", "yank url"),
            ("c", "clear marks"),
            ("d", "del org"),
            ("r", "reload"),
            ("q", "quit"),
            ("esc", "back"),
        ],
        Mode::Visual => &[
            ("j/k", "move"),
            ("␣", "select"),
            ("h/l", "out/in"),
            (":", "command"),
            ("v", "exit"),
        ],
        Mode::History => &[
            ("j/k", "commit"),
            ("enter", "file at commit"),
            ("y", "yank at commit"),
            ("/", "filter"),
            ("esc", "back"),
        ],
        Mode::Preview => &[
            ("j/k", "line"),
            ("gg/G", "top/bottom"),
            ("^D/^U", "½ page"),
            ("{/}", "paragraph"),
            ("%", "match bracket"),
            (":42", "goto line"),
            ("/", "find"),
            ("h", "history"),
            ("b", "blame"),
            ("v", "select lines"),
            ("Y", "copy lines"),
            ("y", "yank url"),
            ("enter", "editor or commit"),
            ("esc", "back"),
        ],
    }
}

/// The preview submode's NAMED keys (plans/0016 M1, `␣ p`). Motions
/// (j/k with counts, gg, G, pages, paragraphs, %, zt/zz/zb) are owned
/// by `Preview::motion_key` — the same situation as the search view:
/// these rows are hint-source, dispatch lives with the component.
pub fn preview_named(code: KeyCode) -> Action {
    match code {
        // The leader layer raises over the submode, same as over the
        // search view's file pane.
        KeyCode::Char(' ') => Action::Leader,
        KeyCode::Char('/') => Action::LeaderFindInFile,
        KeyCode::Char(':') => Action::CommandLine,
        KeyCode::Char('h') => Action::LeaderHistory,
        KeyCode::Char('b') => Action::BlameToggle,
        // Yank the cursor line's URL — a main reason to be here.
        KeyCode::Char('y') => Action::LeaderYank,
        // Find-match cycling — same as Browse mode.
        KeyCode::Char('n') => Action::FindNext,
        KeyCode::Char('N') => Action::FindPrev,
        KeyCode::Enter => Action::PreviewEnter,
        KeyCode::Esc | KeyCode::Char('q') => Action::ExitPreview,
        _ => Action::Noop,
    }
}

/// File-history lens (plans/0016 M1b): the preview pane lists commits.
pub fn history(code: KeyCode) -> Action {
    match code {
        KeyCode::Char('j') | KeyCode::Down => Action::HistoryDown,
        KeyCode::Char('k') | KeyCode::Up => Action::HistoryUp,
        KeyCode::Enter => Action::HistoryOpen,
        KeyCode::Char('y') => Action::HistoryYank,
        KeyCode::Char('/') => Action::HistoryFilterBegin,
        KeyCode::Esc => Action::HistoryClose,
        _ => Action::Noop,
    }
}

/// Browsing-mode dispatch. Text input modes are owned by `VimInput`;
/// SEARCHING-mode keys are owned by the pane filter input.
pub fn browsing(code: KeyCode) -> Action {
    match code {
        KeyCode::Char('q') => Action::Quit,
        KeyCode::Char(' ') => Action::Leader,
        KeyCode::Char('j') | KeyCode::Down => Action::MoveDown,
        KeyCode::Char('k') | KeyCode::Up => Action::MoveUp,
        KeyCode::Char('J') => Action::PreviewLineDown,
        KeyCode::Char('K') => Action::PreviewLineUp,
        KeyCode::Char('l') | KeyCode::Right => Action::DrillIn,
        KeyCode::Char('h') | KeyCode::Left => Action::DrillOut,
        KeyCode::Char('/') => Action::EnterSearch,
        KeyCode::Char('n') => Action::FindNext,
        KeyCode::Char('N') => Action::FindPrev,
        KeyCode::Char('v') => Action::Visual,
        KeyCode::Char('?') => Action::KeybindsPopup,
        KeyCode::Char(':') => Action::CommandLine,
        KeyCode::Enter => Action::OpenSelected,
        KeyCode::Esc => Action::ClearFilter,
        _ => Action::Noop,
    }
}

/// VISUAL mode (plans/0004 §1): multi-select with checkboxes.
pub fn visual(code: KeyCode) -> Action {
    match code {
        KeyCode::Char('j') | KeyCode::Down => Action::MoveDown,
        KeyCode::Char('k') | KeyCode::Up => Action::MoveUp,
        KeyCode::Char('h') | KeyCode::Left => Action::DrillOut,
        KeyCode::Char('l') | KeyCode::Right => Action::DrillIn,
        KeyCode::Char(' ') => Action::ToggleSelect,
        KeyCode::Char(':') => Action::CommandLine,
        KeyCode::Char('?') => Action::KeybindsPopup,
        KeyCode::Char('v') | KeyCode::Esc => Action::ExitVisual,
        _ => Action::Noop,
    }
}

pub fn leader(code: KeyCode) -> Action {
    match code {
        KeyCode::Char('s') => Action::LeaderSearch,
        KeyCode::Char('f') => Action::LeaderFileFind,
        KeyCode::Char('g') => Action::LeaderGrep,
        KeyCode::Char('/') => Action::LeaderFindInFile,
        KeyCode::Char('y') => Action::LeaderYank,
        KeyCode::Char('b') => Action::LeaderRefs,
        KeyCode::Char('p') => Action::LeaderPreview,
        KeyCode::Char('c') => Action::ClearMarks,
        KeyCode::Char('d') => Action::DeleteMarked,
        KeyCode::Char('r') => Action::LeaderReload,
        KeyCode::Char('q') => Action::LeaderQuit,
        KeyCode::Esc => Action::ClosePopup, // reused: "back to previous mode"
        _ => Action::Noop,
    }
}

/// Global search view (plans/0012 M2): the view owns dispatch — its
/// state decides what a key means (fields, scope popup, results,
/// expanded file pane) — so these rows are hint-source, not dispatch.
/// They live with the other tables so the view's hint row can never
/// drift from the keys `keys.rs` actually matches.
pub fn search_results() -> &'static [(&'static str, &'static str)] {
    &[
        ("enter", "file"),
        ("j/k", "hits"),
        ("/", "filter"),
        ("tab", "fields"),
        ("esc", "close"),
    ]
}

/// The facet chip row (plans/0012 M3), reached by `tab` once results
/// stream in: h/l walk chips, Enter/Space toggles the chip under the
/// cursor — the active chip is the committed filter over the
/// accumulated set; toggling it again restores everything.
pub fn search_facets() -> &'static [(&'static str, &'static str)] {
    &[
        ("h/l", "chips"),
        ("enter", "toggle"),
        ("tab", "fields"),
        ("esc", "clear/close"),
    ]
}

/// The expanded full-file pane (`Enter` on a hit): j/k walk lines,
/// `Enter` opens the editor, `/` finds in the file, `y` yanks the
/// cursor line's URL, `b` blames, `Esc`/`h` folds back to the list.
pub fn search_file() -> &'static [(&'static str, &'static str)] {
    &[
        ("j/k", "lines"),
        ("gg/G", "top/bottom"),
        ("^D/^U", "½ page"),
        (":42", "goto line"),
        ("v", "select lines"),
        ("Y", "copy lines"),
        ("y", "yank url"),
        ("b", "blame"),
        ("enter", "open"),
        ("/", "find"),
        ("n/N", "match"),
        ("esc/h", "results"),
    ]
}

/// Hint-row text for a table: ` k1 d1 · k2 d2 · … ` — the same rows
/// the modeline and `?` popup render, packed into a border title.
pub fn hint_row(rows: &[(&'static str, &'static str)]) -> String {
    let body = rows
        .iter()
        .map(|(key, desc)| format!("{key} {desc}"))
        .collect::<Vec<_>>()
        .join(" · ");
    format!(" {body} ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::crossterm::event::KeyCode;

    /// One representative KeyCode per table row's key string (multi-key
    /// rows pick their first form — the point is "resolves", not "the
    /// full grammar matrix").
    fn code_of(key: &str) -> Option<KeyCode> {
        let single = |c: char| Some(KeyCode::Char(c));
        match key {
            "esc" => Some(KeyCode::Esc),
            "enter" => Some(KeyCode::Enter),
            "tab" => Some(KeyCode::Tab),
            "/" => single('/'),
            ":" | ":42" => single(':'),
            "space" | "␣" => single(' '),
            "?" => single('?'),
            "y" => single('y'),
            "Y" => single('Y'),
            "b" => single('b'),
            "v" => single('v'),
            "q" => single('q'),
            "h" => single('h'),
            "l" => single('l'),
            "g" => single('g'),
            "f" => single('f'),
            "s" => single('s'),
            "p" => single('p'),
            "r" => single('r'),
            "c" => single('c'),
            "d" => single('d'),
            "i" => single('i'),
            "n" => single('n'),
            "N" => single('N'),
            _ => None, // motion multi-keys (gg/G, ^D/^U, j/k…) — same fn, same guarantee
        }
    }

    /// 0021 M3 hygiene: every row the modeline/hint tables advertise
    /// must resolve to a real action through the context's dispatch —
    /// no advertised-but-dead keys.
    #[test]
    fn keymap_tables_resolve_to_real_actions() {
        for (key, _desc) in hints(Mode::Leader) {
            if let Some(code) = code_of(key) {
                assert_ne!(
                    leader(code),
                    Action::Noop,
                    "leader table row {key:?} resolves"
                );
            }
        }
        // The expanded file pane (0019 parity): action-bearing rows
        // resolve through the pane's own dispatch; pane-local motion
        // rows are exercised by the preview motion tests.
        {
            use crate::action::Action as A;
            use crate::components::global_search::{GlobalSearch, SearchKind, mock};
            let mut view = GlobalSearch::new(SearchKind::Grep, None, None, None, None);
            view.update(&A::GlobalSearchResults {
                hits: mock::hits(SearchKind::Grep, "query", ""),
                clipped: false,
                index: None,
                client_filtered: 0,
                unfiltered: vec![],
            });
            // Tab to the results field, then Enter expands the hit.
            for _ in 0..4 {
                view.handle_key(ratatui::crossterm::event::KeyEvent::new(
                    KeyCode::Tab,
                    ratatui::crossterm::event::KeyModifiers::NONE,
                ));
            }
            view.handle_key(ratatui::crossterm::event::KeyEvent::new(
                KeyCode::Enter,
                ratatui::crossterm::event::KeyModifiers::NONE,
            ));
            for (key, action) in [
                ("y", A::LeaderYank),
                (":", A::CommandLine),
                ("b", A::BlameToggle),
                ("Y", A::PreviewCopy),
            ] {
                let code = code_of(key).unwrap();
                assert_eq!(
                    view.handle_key(ratatui::crossterm::event::KeyEvent::new(
                        code,
                        ratatui::crossterm::event::KeyModifiers::NONE,
                    )),
                    action,
                    "search_file row {key:?} resolves in the pane"
                );
            }
        }
        for (key, _desc) in hints(Mode::History) {
            if let Some(code) = code_of(key) {
                assert_ne!(history(code), Action::Noop, "history row {key:?} resolves");
            }
        }
        // The preview submode: named verbs resolve through
        // preview_named; the motion rows (v, gg/G, ^D, zt…, {/}, %)
        // are pane-local and covered by the preview motion tests.
        const PREVIEW_NAMED: &[&str] = &[
            "/", ":", "h", "b", "y", "n", "N", "enter", "esc", "q", "space",
        ];
        for (key, _desc) in hints(Mode::Preview) {
            if PREVIEW_NAMED.contains(key)
                && let Some(code) = code_of(key)
            {
                assert_ne!(
                    preview_named(code),
                    Action::Noop,
                    "preview row {key:?} resolves"
                );
            }
        }
        for (key, _desc) in hints(Mode::Browse) {
            if let Some(code) = code_of(key) {
                assert_ne!(browsing(code), Action::Noop, "browse row {key:?} resolves");
            }
        }
    }
}