overhaul keymap API and add Debug

- Replaced ModeKey with direct KeyPattern keys.
- Stored bindings as Arc<dyn Cmd> 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)
This commit is contained in:
Edward Langley
2026-04-04 09:31:49 -07:00
parent c188ce3f9d
commit bfc30cb7b2

View File

@ -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<Keymap>` 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<dyn Cmd>>,
bindings: HashMap<KeyPattern, Arc<dyn Cmd>>,
}
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<dyn Cmd>) {
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<Keymap>) {
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<Vec<Box<dyn Effect>>> {
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<Keymap>);
// ── 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<Box<dyn Effect>> {
vec![Box::new(SetTransientKeymap(self.0.clone()))]
}
}
/// Effect that sets the transient keymap on the App.
#[derive(Debug)]
pub struct SetTransientKeymap(pub Arc<Keymap>);
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<ModeKey, Arc<Keymap>>,
}
impl KeymapSet {
pub fn new() -> Self {
Self {
mode_maps: HashMap::new(),
}
}
pub fn insert(&mut self, mode: ModeKey, keymap: Arc<Keymap>) {
self.mode_maps.insert(mode, keymap);
}
/// Look up the root keymap for a given app mode.
pub fn get(&self, mode: &AppMode) -> Option<&Arc<Keymap>> {
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<Vec<Box<dyn Effect>>> {
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
}
}