refactor: add Keymap with default bindings and wire into handle_key

Create keymap.rs with Keymap struct mapping (mode, key) to Cmd trait
objects. Wire into App::handle_key — keymap dispatch is tried first,
falling through to old handlers for unmigrated bindings. Normal mode
navigation, cell ops, mode switches, and Help mode are keymap-driven.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-03 22:40:36 -07:00
parent 0c751b7b8b
commit f7436e73ba
3 changed files with 157 additions and 0 deletions

126
src/command/keymap.rs Normal file
View File

@ -0,0 +1,126 @@
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use super::cmd::{self, Cmd, CmdContext};
/// A key pattern that can be matched against a KeyEvent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyPattern {
/// Single key with modifiers
Key(KeyCode, KeyModifiers),
}
/// Identifies which mode a binding applies to.
/// Uses discriminant matching — mode data (buffers, etc.) is ignored.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey {
Normal,
Help,
// More modes can be added as we migrate handlers
}
impl ModeKey {
pub fn from_app_mode(mode: &AppMode) -> Option<Self> {
match mode {
AppMode::Normal => Some(ModeKey::Normal),
AppMode::Help => Some(ModeKey::Help),
_ => None,
}
}
}
pub struct Keymap {
bindings: HashMap<(ModeKey, KeyPattern), Box<dyn Cmd>>,
}
impl Keymap {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
pub fn bind(&mut self, mode: ModeKey, key: KeyCode, mods: KeyModifiers, cmd: impl Cmd + 'static) {
self.bindings
.insert((mode, KeyPattern::Key(key, mods)), Box::new(cmd));
}
pub fn lookup(
&self,
mode: &AppMode,
key: KeyCode,
mods: KeyModifiers,
) -> Option<&dyn Cmd> {
let mode_key = ModeKey::from_app_mode(mode)?;
self.bindings
.get(&(mode_key, KeyPattern::Key(key, mods)))
.map(|c| c.as_ref())
}
/// Execute a keymap lookup and return effects, or None if no binding.
pub fn dispatch(
&self,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let cmd = self.lookup(ctx.mode, key, mods)?;
Some(cmd.execute(ctx))
}
/// Build the default keymap with all vim-style bindings.
pub fn default_keymap() -> Self {
let mut km = Self::new();
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
// ── Normal mode: Navigation ──────────────────────────────────────
km.bind(ModeKey::Normal, KeyCode::Up, none, cmd::MoveSelection { dr: -1, dc: 0 });
km.bind(ModeKey::Normal, KeyCode::Down, none, cmd::MoveSelection { dr: 1, dc: 0 });
km.bind(ModeKey::Normal, KeyCode::Left, none, cmd::MoveSelection { dr: 0, dc: -1 });
km.bind(ModeKey::Normal, KeyCode::Right, none, cmd::MoveSelection { dr: 0, dc: 1 });
km.bind(ModeKey::Normal, KeyCode::Char('k'), none, cmd::MoveSelection { dr: -1, dc: 0 });
km.bind(ModeKey::Normal, KeyCode::Char('j'), none, cmd::MoveSelection { dr: 1, dc: 0 });
km.bind(ModeKey::Normal, KeyCode::Char('h'), none, cmd::MoveSelection { dr: 0, dc: -1 });
km.bind(ModeKey::Normal, KeyCode::Char('l'), none, cmd::MoveSelection { dr: 0, dc: 1 });
// Jump to boundaries
km.bind(ModeKey::Normal, KeyCode::Char('G'), KeyModifiers::SHIFT, cmd::JumpToLastRow);
km.bind(ModeKey::Normal, KeyCode::Char('0'), none, cmd::JumpToFirstCol);
km.bind(ModeKey::Normal, KeyCode::Char('$'), KeyModifiers::SHIFT, cmd::JumpToLastCol);
// Scroll
km.bind(ModeKey::Normal, KeyCode::Char('d'), ctrl, cmd::ScrollRows(5));
km.bind(ModeKey::Normal, KeyCode::Char('u'), ctrl, cmd::ScrollRows(-5));
// ── Normal mode: Cell operations ─────────────────────────────────
km.bind(ModeKey::Normal, KeyCode::Char('x'), none, cmd::ClearSelectedCell);
km.bind(ModeKey::Normal, KeyCode::Char('p'), none, cmd::PasteCell);
// ── Normal mode: View ────────────────────────────────────────────
km.bind(ModeKey::Normal, KeyCode::Char('t'), none, cmd::TransposeAxes);
// ── Normal mode: Mode changes ────────────────────────────────────
km.bind(ModeKey::Normal, KeyCode::Char('q'), ctrl, cmd::ForceQuit);
km.bind(ModeKey::Normal, KeyCode::Char(':'), KeyModifiers::SHIFT, cmd::EnterMode(AppMode::CommandMode { buffer: String::new() }));
km.bind(ModeKey::Normal, KeyCode::Char('/'), none, cmd::EnterSearchMode);
km.bind(ModeKey::Normal, KeyCode::Char('s'), ctrl, cmd::SaveCmd);
km.bind(ModeKey::Normal, KeyCode::F(1), none, cmd::EnterMode(AppMode::Help));
km.bind(ModeKey::Normal, KeyCode::Char('?'), KeyModifiers::SHIFT, cmd::EnterMode(AppMode::Help));
// Two-key sequence starters
km.bind(ModeKey::Normal, KeyCode::Char('g'), none, cmd::SetPendingKey('g'));
km.bind(ModeKey::Normal, KeyCode::Char('y'), none, cmd::SetPendingKey('y'));
km.bind(ModeKey::Normal, KeyCode::Char('Z'), KeyModifiers::SHIFT, cmd::SetPendingKey('Z'));
// ── Help mode ────────────────────────────────────────────────────
km.bind(ModeKey::Help, KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal));
km.bind(ModeKey::Help, KeyCode::Char('q'), none, cmd::EnterMode(AppMode::Normal));
km
}
}