Files
improvise/src/command/keymap.rs
Edward Langley 178983bcbf feat(ui): add new edge/jump commands and keymap
Introduced new commands: JumpToEdge (first/last row/col), PageScroll, and
OpenRecordRow. Updated command registry to use these commands and unified
key handling. Added format module for formatting functions. Updated main.rs
to include format module. Updated keymap to bind new commands and page
scroll.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
2026-04-07 09:16:25 -07:00

650 lines
24 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).
#[derive(Default)]
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.
/// For Char keys, if exact (key, mods) match fails, retries with NONE
/// modifiers since terminals vary in whether they send SHIFT for
/// uppercase/symbol characters.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
.get(&KeyPattern::Key(key, mods))
.or_else(|| {
// Retry Char keys without modifiers (shift is implicit in the char)
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
self.bindings
.get(&KeyPattern::Key(key, KeyModifiers::NONE))
} else {
None
}
})
.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);
}
/// 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;
// ── 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'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-col");
normal.bind(KeyCode::Home, none, "jump-first-col");
normal.bind(KeyCode::End, none, "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()]);
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".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(':'),
none,
"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('?'), none, "enter-mode", vec!["help".into()]);
// Panel toggles
normal.bind_args(
KeyCode::Char('F'),
none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('V'),
none,
"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 — i/a drill into aggregated cells, else edit
normal.bind(KeyCode::Char('i'), none, "edit-or-drill");
normal.bind(KeyCode::Char('a'), none, "edit-or-drill");
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'), none, "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'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history / add row
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
normal.bind(KeyCode::Char('o'), none, "open-record-row");
// Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
normal.bind(KeyCode::Char('P'), none, "toggle-prune-empty");
// Tile select
normal.bind(KeyCode::Char('T'), none, "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'), none, "save-and-quit");
normal.bind_prefix(KeyCode::Char('Z'), none, 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");
fp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
fp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
fp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
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, "filter-to-item");
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");
cp.bind(KeyCode::Char('d'), none, "delete-category-at-cursor");
cp.bind(KeyCode::Delete, none, "delete-category-at-cursor");
// C/F/V in panel modes: close panel (toggle-panel-and-focus sees focused=true)
cp.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
cp.bind_args(
KeyCode::Char('F'),
none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
cp.bind_args(
KeyCode::Char('V'),
none,
"toggle-panel-and-focus",
vec!["view".into()],
);
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");
vp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
vp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
vp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
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(KeyCode::Tab, none, "commit-and-advance-right");
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
}
}