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:
126
src/command/keymap.rs
Normal file
126
src/command/keymap.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod dispatch;
|
||||
pub mod keymap;
|
||||
pub mod parse;
|
||||
pub mod types;
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::Keymap;
|
||||
use crate::command::{self, Command};
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
@ -66,6 +68,7 @@ pub struct App {
|
||||
pub pending_key: Option<char>,
|
||||
/// Yanked cell value for `p` paste
|
||||
pub yanked: Option<CellValue>,
|
||||
keymap: Keymap,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@ -88,6 +91,23 @@ impl App {
|
||||
dirty: false,
|
||||
pending_key: None,
|
||||
yanked: None,
|
||||
keymap: Keymap::default_keymap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_context(&self) -> CmdContext {
|
||||
let view = self.model.active_view();
|
||||
CmdContext {
|
||||
model: &self.model,
|
||||
mode: &self.mode,
|
||||
selected: view.selected,
|
||||
row_offset: view.row_offset,
|
||||
col_offset: view.col_offset,
|
||||
search_query: &self.search_query,
|
||||
yanked: &self.yanked,
|
||||
pending_key: self.pending_key,
|
||||
dirty: self.dirty,
|
||||
file_path_set: self.file_path.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,9 +123,19 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
// Try keymap first — if a binding matches, apply effects and return
|
||||
let ctx = self.cmd_context();
|
||||
if let Some(effects) = self.keymap.dispatch(&ctx, key.code, key.modifiers) {
|
||||
drop(ctx);
|
||||
self.apply_effects(effects);
|
||||
return Ok(());
|
||||
}
|
||||
drop(ctx);
|
||||
|
||||
match &self.mode.clone() {
|
||||
AppMode::Quit => {}
|
||||
AppMode::Help => {
|
||||
// Handled by keymap now, but keep as fallback
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
AppMode::ImportWizard => {
|
||||
|
||||
Reference in New Issue
Block a user