From bfc30cb7b26a5ebe2f693191814f02249859d974 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 4 Apr 2026 09:31:49 -0700 Subject: [PATCH] overhaul keymap API and add Debug - Replaced ModeKey with direct KeyPattern keys. - Stored bindings as Arc for cheap sharing. - Added Debug implementation for Keymap. - Updated bind, bind_cmd, bind_prefix, lookup, and dispatch signatures. - Introduced PrefixKey command and SetTransientKeymap effect. - Added KeymapSet for mode-specific keymaps. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b) --- src/command/keymap.rs | 304 +++++++++++++++++++++++++++++++++--------- 1 file changed, 243 insertions(+), 61 deletions(-) diff --git a/src/command/keymap.rs b/src/command/keymap.rs index c17fbba..f258717 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; use crossterm::event::{KeyCode, KeyModifiers}; @@ -33,8 +35,22 @@ impl ModeKey { } } +/// A keymap maps key patterns to commands. +/// +/// Keymaps are stored as `Arc` so they can be cheaply shared. +/// A prefix key binding stores a sub-keymap as a `PrefixKey` command, +/// which when executed sets the sub-keymap as the transient keymap +/// (Emacs-style prefix dispatch). pub struct Keymap { - bindings: HashMap<(ModeKey, KeyPattern), Box>, + bindings: HashMap>, +} + +impl fmt::Debug for Keymap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Keymap") + .field("binding_count", &self.bindings.len()) + .finish() + } } impl Keymap { @@ -44,20 +60,23 @@ impl Keymap { } } - 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 bind(&mut self, key: KeyCode, mods: KeyModifiers, cmd: Arc) { + self.bindings.insert(KeyPattern::Key(key, mods), cmd); } - pub fn lookup( - &self, - mode: &AppMode, - key: KeyCode, - mods: KeyModifiers, - ) -> Option<&dyn Cmd> { - let mode_key = ModeKey::from_app_mode(mode)?; + /// Convenience: bind a concrete Cmd value (wraps in Arc). + pub fn bind_cmd(&mut self, key: KeyCode, mods: KeyModifiers, cmd: impl Cmd + 'static) { + self.bind(key, mods, Arc::new(cmd)); + } + + /// Bind a prefix key that activates a sub-keymap. + pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc) { + self.bind(key, mods, Arc::new(PrefixKey(sub))); + } + + pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&dyn Cmd> { self.bindings - .get(&(mode_key, KeyPattern::Key(key, mods))) + .get(&KeyPattern::Key(key, mods)) .map(|c| c.as_ref()) } @@ -68,59 +87,222 @@ impl Keymap { key: KeyCode, mods: KeyModifiers, ) -> Option>> { - let cmd = self.lookup(ctx.mode, key, mods)?; + let cmd = self.lookup(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; +/// A prefix key command that activates a sub-keymap as transient. +#[derive(Debug, Clone)] +pub struct PrefixKey(pub Arc); - // ── 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 }); +impl Cmd for PrefixKey { + fn name(&self) -> &str { + "prefix-key" + } - // 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 + fn execute(&self, _ctx: &CmdContext) -> Vec> { + vec![Box::new(SetTransientKeymap(self.0.clone()))] + } +} + +/// Effect that sets the transient keymap on the App. +#[derive(Debug)] +pub struct SetTransientKeymap(pub Arc); + +impl Effect for SetTransientKeymap { + fn apply(&self, app: &mut crate::ui::app::App) { + app.transient_keymap = Some(self.0.clone()); + } +} + +/// Maps modes to their root keymaps. +pub struct KeymapSet { + mode_maps: HashMap>, +} + +impl KeymapSet { + pub fn new() -> Self { + Self { + mode_maps: HashMap::new(), + } + } + + pub fn insert(&mut self, mode: ModeKey, keymap: Arc) { + self.mode_maps.insert(mode, keymap); + } + + /// Look up the root keymap for a given app mode. + pub fn get(&self, mode: &AppMode) -> Option<&Arc> { + let mode_key = ModeKey::from_app_mode(mode)?; + self.mode_maps.get(&mode_key) + } + + /// Dispatch a key event: returns effects if a binding matched. + pub fn dispatch( + &self, + ctx: &CmdContext, + key: KeyCode, + mods: KeyModifiers, + ) -> Option>> { + let keymap = self.get(ctx.mode)?; + keymap.dispatch(ctx, key, mods) + } + + /// Build the default keymap set with all vim-style bindings. + pub fn default_keymaps() -> Self { + let mut set = Self::new(); + + // ── Normal mode ────────────────────────────────────────────────── + let mut normal = Keymap::new(); + let none = KeyModifiers::NONE; + let ctrl = KeyModifiers::CONTROL; + let shift = KeyModifiers::SHIFT; + + // Navigation + normal.bind_cmd(KeyCode::Up, none, cmd::MoveSelection { dr: -1, dc: 0 }); + normal.bind_cmd(KeyCode::Down, none, cmd::MoveSelection { dr: 1, dc: 0 }); + normal.bind_cmd(KeyCode::Left, none, cmd::MoveSelection { dr: 0, dc: -1 }); + normal.bind_cmd(KeyCode::Right, none, cmd::MoveSelection { dr: 0, dc: 1 }); + normal.bind_cmd( + KeyCode::Char('k'), + none, + cmd::MoveSelection { dr: -1, dc: 0 }, + ); + normal.bind_cmd( + KeyCode::Char('j'), + none, + cmd::MoveSelection { dr: 1, dc: 0 }, + ); + normal.bind_cmd( + KeyCode::Char('h'), + none, + cmd::MoveSelection { dr: 0, dc: -1 }, + ); + normal.bind_cmd( + KeyCode::Char('l'), + none, + cmd::MoveSelection { dr: 0, dc: 1 }, + ); + + // Jump to boundaries + normal.bind_cmd(KeyCode::Char('G'), shift, cmd::JumpToLastRow); + normal.bind_cmd(KeyCode::Char('0'), none, cmd::JumpToFirstCol); + normal.bind_cmd(KeyCode::Char('$'), shift, cmd::JumpToLastCol); + + // Scroll + normal.bind_cmd(KeyCode::Char('d'), ctrl, cmd::ScrollRows(5)); + normal.bind_cmd(KeyCode::Char('u'), ctrl, cmd::ScrollRows(-5)); + + // Cell operations + normal.bind_cmd(KeyCode::Char('x'), none, cmd::ClearSelectedCell); + normal.bind_cmd(KeyCode::Char('p'), none, cmd::PasteCell); + + // View + normal.bind_cmd(KeyCode::Char('t'), none, cmd::TransposeAxes); + + // Mode changes + normal.bind_cmd(KeyCode::Char('q'), ctrl, cmd::ForceQuit); + normal.bind_cmd( + KeyCode::Char(':'), + shift, + cmd::EnterMode(AppMode::CommandMode { + buffer: String::new(), + }), + ); + normal.bind_cmd(KeyCode::Char('/'), none, cmd::EnterSearchMode); + normal.bind_cmd(KeyCode::Char('s'), ctrl, cmd::SaveCmd); + normal.bind_cmd(KeyCode::F(1), none, cmd::EnterMode(AppMode::Help)); + normal.bind_cmd(KeyCode::Char('?'), shift, cmd::EnterMode(AppMode::Help)); + + // Panel toggles (uppercase = toggle + focus) + normal.bind_cmd( + KeyCode::Char('F'), + shift, + cmd::TogglePanelAndFocus(crate::ui::effect::Panel::Formula), + ); + normal.bind_cmd( + KeyCode::Char('C'), + shift, + cmd::TogglePanelAndFocus(crate::ui::effect::Panel::Category), + ); + normal.bind_cmd( + KeyCode::Char('V'), + shift, + cmd::TogglePanelAndFocus(crate::ui::effect::Panel::View), + ); + + // Legacy Ctrl+ panel toggles (visibility only) + normal.bind_cmd( + KeyCode::Char('f'), + ctrl, + cmd::TogglePanelVisibility(crate::ui::effect::Panel::Formula), + ); + normal.bind_cmd( + KeyCode::Char('c'), + ctrl, + cmd::TogglePanelVisibility(crate::ui::effect::Panel::Category), + ); + normal.bind_cmd( + KeyCode::Char('v'), + ctrl, + cmd::TogglePanelVisibility(crate::ui::effect::Panel::View), + ); + + // Tab cycles open panels + normal.bind_cmd(KeyCode::Tab, none, cmd::CyclePanelFocus); + + // Editing entry + normal.bind_cmd(KeyCode::Char('i'), none, cmd::EnterEditMode); + normal.bind_cmd(KeyCode::Char('a'), none, cmd::EnterEditMode); + normal.bind_cmd(KeyCode::Enter, none, cmd::EnterAdvance); + normal.bind_cmd(KeyCode::Char('e'), ctrl, cmd::EnterExportPrompt); + + // Search / category add + normal.bind_cmd(KeyCode::Char('n'), none, cmd::SearchNavigate(true)); + normal.bind_cmd(KeyCode::Char('N'), shift, cmd::SearchOrCategoryAdd); + + // Page navigation + normal.bind_cmd(KeyCode::Char(']'), none, cmd::PageNext); + normal.bind_cmd(KeyCode::Char('['), none, cmd::PagePrev); + + // Group / hide + normal.bind_cmd(KeyCode::Char('z'), none, cmd::ToggleGroupUnderCursor); + normal.bind_cmd(KeyCode::Char('H'), shift, cmd::HideSelectedRowItem); + + // Tile select + normal.bind_cmd(KeyCode::Char('T'), shift, cmd::EnterTileSelect); + normal.bind_cmd(KeyCode::Left, ctrl, cmd::EnterTileSelect); + normal.bind_cmd(KeyCode::Right, ctrl, cmd::EnterTileSelect); + normal.bind_cmd(KeyCode::Up, ctrl, cmd::EnterTileSelect); + normal.bind_cmd(KeyCode::Down, ctrl, cmd::EnterTileSelect); + + // ── Prefix keys (Emacs-style sub-keymaps) ──────────────────────── + + // g-prefix + let mut g_map = Keymap::new(); + g_map.bind_cmd(KeyCode::Char('g'), none, cmd::JumpToFirstRow); + g_map.bind_cmd(KeyCode::Char('z'), none, cmd::ToggleColGroupUnderCursor); + normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map)); + + // y-prefix + let mut y_map = Keymap::new(); + y_map.bind_cmd(KeyCode::Char('y'), none, cmd::YankCell); + normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map)); + + // Z-prefix + let mut z_map = Keymap::new(); + z_map.bind_cmd(KeyCode::Char('Z'), shift, cmd::SaveAndQuit); + normal.bind_prefix(KeyCode::Char('Z'), shift, Arc::new(z_map)); + + set.insert(ModeKey::Normal, Arc::new(normal)); + + // ── Help mode ──────────────────────────────────────────────────── + let mut help = Keymap::new(); + help.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); + help.bind_cmd(KeyCode::Char('q'), none, cmd::EnterMode(AppMode::Normal)); + set.insert(ModeKey::Help, Arc::new(help)); + + set } }