Files
improvise/src/command/keymap.rs
Edward Langley 35946afc91 refactor(command): pre-resolve cell key and grid dimensions in CmdContext
Refactor command system to pre-resolve cell key and grid dimensions
in CmdContext, eliminating repeated GridLayout construction.

Key changes:
- Add cell_key, row_count, col_count to CmdContext
- Replace generic CmdRegistry::register with
  register/register_pure/register_nullary
- Cell commands (clear-cell, yank, paste) now take explicit CellKey
- Update keymap dispatch to use new interactive() method
- Special-case "search" buffer in SetBuffer effect
- Update tests to populate new context fields

This reduces code duplication and makes command execution more
efficient by computing layout once at context creation time.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 12:33:11 -07:00

514 lines
21 KiB
Rust

use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyModifiers};
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use super::cmd::{self, CmdContext, CmdRegistry};
// `cmd` module imported for `default_registry()` in default_keymaps()
/// 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),
/// Matches any Char key (for text-entry modes).
AnyChar,
/// Matches any key at all (lowest priority fallback).
Any,
}
/// Identifies which mode a binding applies to.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey {
Normal,
Help,
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
Editing,
FormulaEdit,
CategoryAdd,
ItemAdd,
ExportPrompt,
CommandMode,
SearchMode,
ImportWizard,
}
impl ModeKey {
pub fn from_app_mode(mode: &AppMode, search_mode: bool) -> Option<Self> {
match mode {
AppMode::Normal if search_mode => Some(ModeKey::SearchMode),
AppMode::Normal => Some(ModeKey::Normal),
AppMode::Help => Some(ModeKey::Help),
AppMode::FormulaPanel => Some(ModeKey::FormulaPanel),
AppMode::CategoryPanel => Some(ModeKey::CategoryPanel),
AppMode::ViewPanel => Some(ModeKey::ViewPanel),
AppMode::TileSelect => Some(ModeKey::TileSelect),
AppMode::Editing { .. } => Some(ModeKey::Editing),
AppMode::FormulaEdit { .. } => Some(ModeKey::FormulaEdit),
AppMode::CategoryAdd { .. } => Some(ModeKey::CategoryAdd),
AppMode::ItemAdd { .. } => Some(ModeKey::ItemAdd),
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
_ => None,
}
}
}
/// What a key binding resolves to.
#[derive(Debug, Clone)]
pub enum Binding {
/// A command name + arguments, looked up in the registry at dispatch time.
Cmd {
name: &'static str,
args: Vec<String>,
},
/// A prefix sub-keymap (Emacs-style).
Prefix(Arc<Keymap>),
}
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
pub struct Keymap {
bindings: HashMap<KeyPattern, Binding>,
}
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 {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
/// Bind a key to a command name (no args).
pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, name: &'static str) {
self.bindings.insert(
KeyPattern::Key(key, mods),
Binding::Cmd {
name,
args: vec![],
},
);
}
/// Bind a key to a command name with arguments.
pub fn bind_args(
&mut self,
key: KeyCode,
mods: KeyModifiers,
name: &'static str,
args: Vec<String>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Cmd { name, args });
}
/// Bind a prefix key that activates a sub-keymap.
pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
}
/// Bind a catch-all for any Char key.
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
self.bindings
.insert(KeyPattern::AnyChar, Binding::Cmd { name, args });
}
/// Bind a catch-all for any key at all.
pub fn bind_any(&mut self, name: &'static str) {
self.bindings.insert(
KeyPattern::Any,
Binding::Cmd {
name,
args: vec![],
},
);
}
/// Look up the binding for a key.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
.get(&KeyPattern::Key(key, mods))
.or_else(|| {
if matches!(key, KeyCode::Char(_)) {
self.bindings.get(&KeyPattern::AnyChar)
} else {
None
}
})
.or_else(|| self.bindings.get(&KeyPattern::Any))
}
/// Dispatch a key: look up binding, resolve through registry, return effects.
pub fn dispatch(
&self,
registry: &CmdRegistry,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let binding = self.lookup(key, mods)?;
match binding {
Binding::Cmd { name, args } => {
let cmd = registry.interactive(name, args, ctx).ok()?;
Some(cmd.execute(ctx))
}
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.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 + owns the command registry.
pub struct KeymapSet {
mode_maps: HashMap<ModeKey, Arc<Keymap>>,
registry: CmdRegistry,
}
impl KeymapSet {
pub fn new(registry: CmdRegistry) -> Self {
Self {
mode_maps: HashMap::new(),
registry,
}
}
pub fn insert(&mut self, mode: ModeKey, keymap: Arc<Keymap>) {
self.mode_maps.insert(mode, keymap);
}
pub fn registry(&self) -> &CmdRegistry {
&self.registry
}
/// 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 mode_key = ModeKey::from_app_mode(ctx.mode, ctx.search_mode)?;
let keymap = self.mode_maps.get(&mode_key)?;
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Dispatch against a specific keymap (for transient/prefix keymaps).
pub fn dispatch_transient(
&self,
keymap: &Keymap,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Build the default keymap set with all bindings.
pub fn default_keymaps() -> Self {
let registry = cmd::default_registry();
let mut set = Self::new(registry);
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
let shift = KeyModifiers::SHIFT;
// ── Normal mode ──────────────────────────────────────────────────
let mut normal = Keymap::new();
// Navigation
for (key, dr, dc) in [
(KeyCode::Up, -1, 0),
(KeyCode::Down, 1, 0),
(KeyCode::Left, 0, -1),
(KeyCode::Right, 0, 1),
] {
normal.bind_args(key, none, "move-selection", vec![dr.to_string(), dc.to_string()]);
}
for (ch, dr, dc) in [('k', -1, 0), ('j', 1, 0), ('h', 0, -1), ('l', 0, 1)] {
normal.bind_args(
KeyCode::Char(ch),
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
// Jump to boundaries
normal.bind(KeyCode::Char('G'), shift, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), shift, "jump-last-col");
// Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
// Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell");
normal.bind(KeyCode::Char('p'), none, "paste");
// View
normal.bind(KeyCode::Char('t'), none, "transpose");
// Mode changes
normal.bind(KeyCode::Char('q'), ctrl, "force-quit");
normal.bind_args(
KeyCode::Char(':'),
shift,
"enter-mode",
vec!["command".into()],
);
normal.bind(KeyCode::Char('/'), none, "search");
normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_args(KeyCode::F(1), none, "enter-mode", vec!["help".into()]);
normal.bind_args(KeyCode::Char('?'), shift, "enter-mode", vec!["help".into()]);
// Panel toggles
normal.bind_args(
KeyCode::Char('F'),
shift,
"toggle-panel-and-focus",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('C'),
shift,
"toggle-panel-and-focus",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('V'),
shift,
"toggle-panel-and-focus",
vec!["view".into()],
);
normal.bind_args(
KeyCode::Char('f'),
ctrl,
"toggle-panel-visibility",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('c'),
ctrl,
"toggle-panel-visibility",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('v'),
ctrl,
"toggle-panel-visibility",
vec!["view".into()],
);
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Editing entry
normal.bind(KeyCode::Char('i'), none, "enter-edit-mode");
normal.bind(KeyCode::Char('a'), none, "enter-edit-mode");
normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
// Search / category add
normal.bind_args(KeyCode::Char('n'), none, "search-navigate", vec!["forward".into()]);
normal.bind(KeyCode::Char('N'), shift, "search-or-category-add");
// Page navigation
normal.bind(KeyCode::Char(']'), none, "page-next");
normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), shift, "hide-selected-row-item");
// Tile select
normal.bind(KeyCode::Char('T'), shift, "enter-tile-select");
normal.bind(KeyCode::Left, ctrl, "enter-tile-select");
normal.bind(KeyCode::Right, ctrl, "enter-tile-select");
normal.bind(KeyCode::Up, ctrl, "enter-tile-select");
normal.bind(KeyCode::Down, ctrl, "enter-tile-select");
// Prefix keys
let mut g_map = Keymap::new();
g_map.bind(KeyCode::Char('g'), none, "jump-first-row");
g_map.bind(KeyCode::Char('z'), none, "toggle-col-group-under-cursor");
normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map));
let mut y_map = Keymap::new();
y_map.bind(KeyCode::Char('y'), none, "yank");
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
let mut z_map = Keymap::new();
z_map.bind(KeyCode::Char('Z'), shift, "save-and-quit");
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_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
help.bind_args(KeyCode::Char('q'), none, "enter-mode", vec!["normal".into()]);
set.insert(ModeKey::Help, Arc::new(help));
// ── Formula panel ────────────────────────────────────────────────
let mut fp = Keymap::new();
fp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
fp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
fp.bind_args(key, none, "move-panel-cursor", vec!["formula".into(), "-1".into()]);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
fp.bind_args(key, none, "move-panel-cursor", vec!["formula".into(), "1".into()]);
}
fp.bind(KeyCode::Char('a'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('n'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel ───────────────────────────────────────────────
let mut cp = Keymap::new();
cp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
cp.bind_args(key, none, "move-panel-cursor", vec!["category".into(), "-1".into()]);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
cp.bind_args(key, none, "move-panel-cursor", vec!["category".into(), "1".into()]);
}
cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
cp.bind_args(
KeyCode::Char('n'),
none,
"enter-mode",
vec!["category-add".into()],
);
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
// ── View panel ───────────────────────────────────────────────────
let mut vp = Keymap::new();
vp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
vp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
vp.bind_args(key, none, "move-panel-cursor", vec!["view".into(), "-1".into()]);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
vp.bind_args(key, none, "move-panel-cursor", vec!["view".into(), "1".into()]);
}
vp.bind(KeyCode::Enter, none, "switch-view-at-cursor");
vp.bind(KeyCode::Char('n'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::new();
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_args(KeyCode::Char('h'), none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_args(KeyCode::Right, none, "move-tile-cursor", vec!["1".into()]);
ts.bind_args(KeyCode::Char('l'), none, "move-tile-cursor", vec!["1".into()]);
ts.bind(KeyCode::Enter, none, "cycle-axis-for-tile");
ts.bind(KeyCode::Char(' '), none, "cycle-axis-for-tile");
ts.bind_args(KeyCode::Char('r'), none, "set-axis-for-tile", vec!["row".into()]);
ts.bind_args(KeyCode::Char('c'), none, "set-axis-for-tile", vec!["column".into()]);
ts.bind_args(KeyCode::Char('p'), none, "set-axis-for-tile", vec!["page".into()]);
ts.bind_args(KeyCode::Char('n'), none, "set-axis-for-tile", vec!["none".into()]);
set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Editing mode ─────────────────────────────────────────────────
let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind(KeyCode::Enter, none, "commit-cell-edit");
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed));
// ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new();
fe.bind_args(KeyCode::Esc, none, "enter-mode", vec!["formula-panel".into()]);
fe.bind(KeyCode::Enter, none, "commit-formula");
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
fe.bind_any_char("append-char", vec!["formula".into()]);
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new();
ca.bind_args(KeyCode::Esc, none, "enter-mode", vec!["category-panel".into()]);
ca.bind(KeyCode::Enter, none, "commit-category-add");
ca.bind(KeyCode::Tab, none, "commit-category-add");
ca.bind_args(KeyCode::Backspace, none, "pop-char", vec!["category".into()]);
ca.bind_any_char("append-char", vec!["category".into()]);
set.insert(ModeKey::CategoryAdd, Arc::new(ca));
// ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new();
ia.bind_args(KeyCode::Esc, none, "enter-mode", vec!["category-panel".into()]);
ia.bind(KeyCode::Enter, none, "commit-item-add");
ia.bind(KeyCode::Tab, none, "commit-item-add");
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
ia.bind_any_char("append-char", vec!["item".into()]);
set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new();
ep.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ep.bind(KeyCode::Enter, none, "commit-export");
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
ep.bind_any_char("append-char", vec!["export".into()]);
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new();
cm.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cm.bind(KeyCode::Enter, none, "execute-command");
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char("append-char", vec!["command".into()]);
set.insert(ModeKey::CommandMode, Arc::new(cm));
// ── Search mode ──────────────────────────────────────────────────
let mut sm = Keymap::new();
sm.bind(KeyCode::Esc, none, "exit-search-mode");
sm.bind(KeyCode::Enter, none, "exit-search-mode");
sm.bind(KeyCode::Backspace, none, "search-pop-char");
sm.bind_any_char("search-append-char", vec![]);
set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard ────────────────────────────────────────────────
let mut wiz = Keymap::new();
wiz.bind_any("handle-wizard-key");
set.insert(ModeKey::ImportWizard, Arc::new(wiz));
set
}
}