diff --git a/src/command/keymap.rs b/src/command/keymap.rs new file mode 100644 index 0000000..c17fbba --- /dev/null +++ b/src/command/keymap.rs @@ -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 { + match mode { + AppMode::Normal => Some(ModeKey::Normal), + AppMode::Help => Some(ModeKey::Help), + _ => None, + } + } +} + +pub struct Keymap { + bindings: HashMap<(ModeKey, KeyPattern), Box>, +} + +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>> { + 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 + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs index 535da43..cf30c06 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -7,6 +7,7 @@ pub mod cmd; pub mod dispatch; +pub mod keymap; pub mod parse; pub mod types; diff --git a/src/ui/app.rs b/src/ui/app.rs index 2e0673c..1e73525 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, /// Yanked cell value for `p` paste pub yanked: Option, + 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 => {