cuj-tui 0.1.0

cui — a read-only TUI browser for cuj vaults
Documentation
//! The keyboard command system: one static binding table drives
//! both dispatch (`lookup`) and the command bars (`bar_items`), so
//! a binding shown in a bar is by construction the binding that
//! fires.

use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::cmdline::ExCommand;
use crate::state::{AppState, Focus};

/// Everything the reducer can be asked to do. Key bindings produce
/// the simple variants; the ':' command line produces `Ex`.
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
    Quit,
    Help,
    Reload,
    CycleFocus,
    Open,
    Back,
    MoveDown,
    MoveUp,
    MoveTop,
    MoveBottom,
    HalfPageDown,
    HalfPageUp,
    NextProfile,
    /// Enter command mode with the buffer prefilled.
    StartCommand(&'static str),
    Backlinks,
    /// Double the result count of the active search.
    MoreResults,
    /// Toggle transclusion in the content view.
    ToggleExpand,
    /// Open the selected jot in $EDITOR (handled by the run loop,
    /// which owns the terminal — see `run_editor`).
    Edit,
    Ex(ExCommand),
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Bar {
    Global,
    Context,
}

#[derive(Clone, Copy)]
pub struct KeyChord {
    pub code: KeyCode,
    pub mods: KeyModifiers,
}

impl KeyChord {
    fn matches(&self, key: &KeyEvent) -> bool {
        if self.code != key.code {
            return false;
        }
        // Chars arrive with SHIFT already applied to the char
        // itself ('G', '?', ':'), so ignore the SHIFT bit there.
        let mods = match key.code {
            KeyCode::Char(_) => key.modifiers.difference(KeyModifiers::SHIFT),
            _ => key.modifiers,
        };
        mods == self.mods
    }
}

/// Applicability of a binding, shared by dispatch and the bars.
/// Bindings apply in Normal mode only — Command mode keys go to the
/// line editor, and any key closes Help.
#[derive(Clone, Copy)]
pub struct When {
    /// None = either panel.
    pub focus: Option<Focus>,
    /// Only while a find/search/backlinks result list is active.
    pub results_only: bool,
}

impl When {
    fn matches(&self, state: &AppState) -> bool {
        self.focus.is_none_or(|f| f == state.focus)
            && (!self.results_only || !state.source.is_all())
    }
}

pub struct Binding {
    pub chord: KeyChord,
    pub when: When,
    pub cmd: Command,
    /// Which bar shows this binding; None = hidden alias.
    pub bar: Option<Bar>,
    pub key_label: &'static str,
    pub action_label: &'static str,
}

const NONE: KeyModifiers = KeyModifiers::NONE;
const CTRL: KeyModifiers = KeyModifiers::CONTROL;
const ANY: When = When {
    focus: None,
    results_only: false,
};
const LIST: When = When {
    focus: Some(Focus::List),
    results_only: false,
};
const VIEW: When = When {
    focus: Some(Focus::View),
    results_only: false,
};
const LIST_RESULTS: When = When {
    focus: Some(Focus::List),
    results_only: true,
};

macro_rules! bind {
    ($code:expr, $mods:expr, $when:expr, $cmd:expr, $bar:expr, $key:expr, $action:expr) => {
        Binding {
            chord: KeyChord {
                code: $code,
                mods: $mods,
            },
            when: $when,
            cmd: $cmd,
            bar: $bar,
            key_label: $key,
            action_label: $action,
        }
    };
}

use Command as C;
use KeyCode::*;

/// The single source of truth. Order matters twice over: the first
/// matching row dispatches, and bar order follows table order.
pub static KEYMAP: &[Binding] = &[
    // ── global bar ────────────────────────────────────────────
    bind!(
        Char('q'),
        NONE,
        ANY,
        C::Quit,
        Some(Bar::Global),
        "q",
        "quit"
    ),
    bind!(
        Char(':'),
        NONE,
        ANY,
        C::StartCommand(""),
        Some(Bar::Global),
        ":",
        "cmd"
    ),
    bind!(
        Char('/'),
        NONE,
        ANY,
        C::StartCommand("search "),
        Some(Bar::Global),
        "/",
        "search"
    ),
    bind!(
        Char('?'),
        NONE,
        ANY,
        C::Help,
        Some(Bar::Global),
        "?",
        "help"
    ),
    bind!(
        Char('p'),
        NONE,
        ANY,
        C::NextProfile,
        Some(Bar::Global),
        "p",
        "profile"
    ),
    bind!(
        Char('r'),
        NONE,
        ANY,
        C::Reload,
        Some(Bar::Global),
        "r",
        "reload"
    ),
    bind!(
        Tab,
        NONE,
        ANY,
        C::CycleFocus,
        Some(Bar::Global),
        "Tab",
        "focus"
    ),
    // ── context bar: staged Esc first (View back, then results) ──
    bind!(Esc, NONE, VIEW, C::Back, None, "Esc", "back"),
    bind!(
        Esc,
        NONE,
        LIST_RESULTS,
        C::Back,
        Some(Bar::Context),
        "Esc",
        "all jots"
    ),
    // ── context bar: movement ─────────────────────────────────
    bind!(
        Char('j'),
        NONE,
        LIST,
        C::MoveDown,
        Some(Bar::Context),
        "j/k",
        "move"
    ),
    bind!(Char('k'), NONE, LIST, C::MoveUp, None, "k", "up"),
    bind!(
        Char('j'),
        NONE,
        VIEW,
        C::MoveDown,
        Some(Bar::Context),
        "j/k",
        "scroll"
    ),
    bind!(Char('k'), NONE, VIEW, C::MoveUp, None, "k", "up"),
    bind!(Down, NONE, ANY, C::MoveDown, None, "", "down"),
    bind!(Up, NONE, ANY, C::MoveUp, None, "", "up"),
    bind!(
        Char('g'),
        NONE,
        ANY,
        C::MoveTop,
        Some(Bar::Context),
        "g/G",
        "top/end"
    ),
    bind!(Char('G'), NONE, ANY, C::MoveBottom, None, "G", "end"),
    bind!(Home, NONE, ANY, C::MoveTop, None, "Home", "top"),
    bind!(End, NONE, ANY, C::MoveBottom, None, "End", "end"),
    bind!(
        Char('d'),
        NONE,
        ANY,
        C::HalfPageDown,
        Some(Bar::Context),
        "d/u",
        "½page"
    ),
    bind!(Char('u'), NONE, ANY, C::HalfPageUp, None, "u", "½page up"),
    bind!(Char('d'), CTRL, ANY, C::HalfPageDown, None, "C-d", "½page"),
    bind!(Char('u'), CTRL, ANY, C::HalfPageUp, None, "C-u", "½page up"),
    bind!(PageDown, NONE, ANY, C::HalfPageDown, None, "PgDn", "½page"),
    bind!(PageUp, NONE, ANY, C::HalfPageUp, None, "PgUp", "½page up"),
    // ── context bar: panel actions ────────────────────────────
    bind!(Enter, NONE, LIST, C::Open, Some(Bar::Context), "", "open"),
    bind!(Char('l'), NONE, LIST, C::Open, None, "l", "open"),
    bind!(Right, NONE, LIST, C::Open, None, "", "open"),
    bind!(
        Char('h'),
        NONE,
        VIEW,
        C::Back,
        Some(Bar::Context),
        "h",
        "back"
    ),
    bind!(Left, NONE, VIEW, C::Back, None, "", "back"),
    bind!(
        Char('f'),
        NONE,
        LIST,
        C::StartCommand("find "),
        Some(Bar::Context),
        "f",
        "find"
    ),
    bind!(
        Char('b'),
        NONE,
        ANY,
        C::Backlinks,
        Some(Bar::Context),
        "b",
        "backlinks"
    ),
    bind!(
        Char('m'),
        NONE,
        LIST_RESULTS,
        C::MoreResults,
        Some(Bar::Context),
        "m",
        "more"
    ),
    bind!(
        Char('x'),
        NONE,
        VIEW,
        C::ToggleExpand,
        Some(Bar::Context),
        "x",
        "expand"
    ),
    bind!(
        Char('e'),
        NONE,
        ANY,
        C::Edit,
        Some(Bar::Context),
        "e",
        "edit"
    ),
];

/// Resolve a Normal-mode key press to a command: the first binding
/// whose applicability and chord both match.
pub fn lookup(state: &AppState, key: &KeyEvent) -> Option<Command> {
    KEYMAP
        .iter()
        .find(|b| b.when.matches(state) && b.chord.matches(key))
        .map(|b| b.cmd.clone())
}

/// The (key, action) labels a bar shows in the current state —
/// derived from the same rows and predicate `lookup` dispatches
/// through.
pub fn bar_items(state: &AppState, bar: Bar) -> Vec<(&'static str, &'static str)> {
    KEYMAP
        .iter()
        .filter(|b| b.bar == Some(bar) && b.when.matches(state))
        .map(|b| (b.key_label, b.action_label))
        .collect()
}