refactor(command): decouple keymap bindings from command implementations

Refactor the keymap system to use string-based command names instead of
concrete command struct instantiations. This introduces a Binding enum that
can represent either a command lookup (name + args) or a prefix sub-keymap.

Key changes:
- Keymap now stores Binding enum instead of Arc<dyn Cmd>
- dispatch() accepts CmdRegistry to resolve commands at runtime
- Added bind_args() for commands with arguments
- KeymapSet now owns the command registry
- Removed PrefixKey struct, inlined its logic
- Updated all default keymap bindings to use string names

This enables more flexible command configuration and easier testing.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-04 11:02:00 -07:00
parent a45390b7a9
commit 649d80cb35
3 changed files with 267 additions and 358 deletions

View File

@ -1909,6 +1909,10 @@ pub fn default_registry() -> CmdRegistry {
"view-panel" => AppMode::ViewPanel, "view-panel" => AppMode::ViewPanel,
"tile-select" => AppMode::TileSelect, "tile-select" => AppMode::TileSelect,
"command" => AppMode::CommandMode { buffer: String::new() }, "command" => AppMode::CommandMode { buffer: String::new() },
"category-add" => AppMode::CategoryAdd { buffer: String::new() },
"editing" => AppMode::Editing { buffer: String::new() },
"formula-edit" => AppMode::FormulaEdit { buffer: String::new() },
"export-prompt" => AppMode::ExportPrompt { buffer: String::new() },
other => return Err(format!("Unknown mode: {other}")), other => return Err(format!("Unknown mode: {other}")),
}; };
Ok(Box::new(EnterMode(mode))) Ok(Box::new(EnterMode(mode)))

View File

@ -6,24 +6,22 @@ use crossterm::event::{KeyCode, KeyModifiers};
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::ui::effect::Effect; use crate::ui::effect::Effect;
use crate::view::Axis;
use super::cmd::{self, Cmd, CmdContext}; 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. /// A key pattern that can be matched against a KeyEvent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyPattern { pub enum KeyPattern {
/// Single key with modifiers /// Single key with modifiers
Key(KeyCode, KeyModifiers), Key(KeyCode, KeyModifiers),
/// Matches any Char key (for text-entry modes). The actual char /// Matches any Char key (for text-entry modes).
/// is available in CmdContext::key_code.
AnyChar, AnyChar,
/// Matches any key at all (lowest priority fallback). /// Matches any key at all (lowest priority fallback).
Any, Any,
} }
/// Identifies which mode a binding applies to. /// Identifies which mode a binding applies to.
/// Uses discriminant matching — mode data (buffers, etc.) is ignored.
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey { pub enum ModeKey {
Normal, Normal,
@ -64,14 +62,21 @@ impl ModeKey {
} }
} }
/// A keymap maps key patterns to commands. /// What a key binding resolves to.
/// #[derive(Debug, Clone)]
/// Keymaps are stored as `Arc<Keymap>` so they can be cheaply shared. pub enum Binding {
/// A prefix key binding stores a sub-keymap as a `PrefixKey` command, /// A command name + arguments, looked up in the registry at dispatch time.
/// which when executed sets the sub-keymap as the transient keymap Cmd {
/// (Emacs-style prefix dispatch). 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 { pub struct Keymap {
bindings: HashMap<KeyPattern, Arc<dyn Cmd>>, bindings: HashMap<KeyPattern, Binding>,
} }
impl fmt::Debug for Keymap { impl fmt::Debug for Keymap {
@ -89,21 +94,54 @@ impl Keymap {
} }
} }
pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, cmd: Arc<dyn Cmd>) { /// Bind a key to a command name (no args).
self.bindings.insert(KeyPattern::Key(key, mods), cmd); pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, name: &'static str) {
self.bindings.insert(
KeyPattern::Key(key, mods),
Binding::Cmd {
name,
args: vec![],
},
);
} }
/// Convenience: bind a concrete Cmd value (wraps in Arc). /// Bind a key to a command name with arguments.
pub fn bind_cmd(&mut self, key: KeyCode, mods: KeyModifiers, cmd: impl Cmd + 'static) { pub fn bind_args(
self.bind(key, mods, Arc::new(cmd)); &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. /// Bind a prefix key that activates a sub-keymap.
pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) { pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) {
self.bind(key, mods, Arc::new(PrefixKey(sub))); self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
} }
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&dyn Cmd> { /// 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 self.bindings
.get(&KeyPattern::Key(key, mods)) .get(&KeyPattern::Key(key, mods))
.or_else(|| { .or_else(|| {
@ -114,37 +152,24 @@ impl Keymap {
} }
}) })
.or_else(|| self.bindings.get(&KeyPattern::Any)) .or_else(|| self.bindings.get(&KeyPattern::Any))
.map(|c| c.as_ref())
} }
/// Bind a catch-all for any Char key (used for text-entry modes). /// Dispatch a key: look up binding, resolve through registry, return effects.
pub fn bind_any_char(&mut self, cmd: impl Cmd + 'static) {
self.bindings.insert(KeyPattern::AnyChar, Arc::new(cmd));
}
/// Execute a keymap lookup and return effects, or None if no binding.
pub fn dispatch( pub fn dispatch(
&self, &self,
registry: &CmdRegistry,
ctx: &CmdContext, ctx: &CmdContext,
key: KeyCode, key: KeyCode,
mods: KeyModifiers, mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> { ) -> Option<Vec<Box<dyn Effect>>> {
let cmd = self.lookup(key, mods)?; let binding = self.lookup(key, mods)?;
match binding {
Binding::Cmd { name, args } => {
let cmd = registry.parse(name, args).ok()?;
Some(cmd.execute(ctx)) Some(cmd.execute(ctx))
} }
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
} }
/// A prefix key command that activates a sub-keymap as transient.
#[derive(Debug, Clone)]
pub struct PrefixKey(pub Arc<Keymap>);
impl Cmd for PrefixKey {
fn name(&self) -> &str {
"prefix-key"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(SetTransientKeymap(self.0.clone()))]
} }
} }
@ -158,15 +183,17 @@ impl Effect for SetTransientKeymap {
} }
} }
/// Maps modes to their root keymaps. /// Maps modes to their root keymaps + owns the command registry.
pub struct KeymapSet { pub struct KeymapSet {
mode_maps: HashMap<ModeKey, Arc<Keymap>>, mode_maps: HashMap<ModeKey, Arc<Keymap>>,
registry: CmdRegistry,
} }
impl KeymapSet { impl KeymapSet {
pub fn new() -> Self { pub fn new(registry: CmdRegistry) -> Self {
Self { Self {
mode_maps: HashMap::new(), mode_maps: HashMap::new(),
registry,
} }
} }
@ -174,10 +201,8 @@ impl KeymapSet {
self.mode_maps.insert(mode, keymap); self.mode_maps.insert(mode, keymap);
} }
/// Look up the root keymap for a given app mode. pub fn registry(&self) -> &CmdRegistry {
pub fn get(&self, mode: &AppMode, search_mode: bool) -> Option<&Arc<Keymap>> { &self.registry
let mode_key = ModeKey::from_app_mode(mode, search_mode)?;
self.mode_maps.get(&mode_key)
} }
/// Dispatch a key event: returns effects if a binding matched. /// Dispatch a key event: returns effects if a binding matched.
@ -187,421 +212,300 @@ impl KeymapSet {
key: KeyCode, key: KeyCode,
mods: KeyModifiers, mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> { ) -> Option<Vec<Box<dyn Effect>>> {
let keymap = self.get(ctx.mode, ctx.search_mode)?; let mode_key = ModeKey::from_app_mode(ctx.mode, ctx.search_mode)?;
keymap.dispatch(ctx, key, mods) let keymap = self.mode_maps.get(&mode_key)?;
keymap.dispatch(&self.registry, ctx, key, mods)
} }
/// Build the default keymap set with all vim-style bindings. /// Dispatch against a specific keymap (for transient/prefix keymaps).
pub fn default_keymaps() -> Self { pub fn dispatch_transient(
let mut set = Self::new(); &self,
keymap: &Keymap,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
keymap.dispatch(&self.registry, ctx, key, mods)
}
// ── Normal mode ────────────────────────────────────────────────── /// Build the default keymap set with all bindings.
let mut normal = Keymap::new(); pub fn default_keymaps() -> Self {
let registry = cmd::default_registry();
let mut set = Self::new(registry);
let none = KeyModifiers::NONE; let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL; let ctrl = KeyModifiers::CONTROL;
let shift = KeyModifiers::SHIFT; let shift = KeyModifiers::SHIFT;
// ── Normal mode ──────────────────────────────────────────────────
let mut normal = Keymap::new();
// Navigation // Navigation
normal.bind_cmd(KeyCode::Up, none, cmd::MoveSelection { dr: -1, dc: 0 }); for (key, dr, dc) in [
normal.bind_cmd(KeyCode::Down, none, cmd::MoveSelection { dr: 1, dc: 0 }); (KeyCode::Up, -1, 0),
normal.bind_cmd(KeyCode::Left, none, cmd::MoveSelection { dr: 0, dc: -1 }); (KeyCode::Down, 1, 0),
normal.bind_cmd(KeyCode::Right, none, cmd::MoveSelection { dr: 0, dc: 1 }); (KeyCode::Left, 0, -1),
normal.bind_cmd( (KeyCode::Right, 0, 1),
KeyCode::Char('k'), ] {
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, none,
cmd::MoveSelection { dr: -1, dc: 0 }, "move-selection",
); vec![dr.to_string(), dc.to_string()],
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 // Jump to boundaries
normal.bind_cmd(KeyCode::Char('G'), shift, cmd::JumpToLastRow); normal.bind(KeyCode::Char('G'), shift, "jump-last-row");
normal.bind_cmd(KeyCode::Char('0'), none, cmd::JumpToFirstCol); normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind_cmd(KeyCode::Char('$'), shift, cmd::JumpToLastCol); normal.bind(KeyCode::Char('$'), shift, "jump-last-col");
// Scroll // Scroll
normal.bind_cmd(KeyCode::Char('d'), ctrl, cmd::ScrollRows(5)); normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_cmd(KeyCode::Char('u'), ctrl, cmd::ScrollRows(-5)); normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
// Cell operations // Cell operations
normal.bind_cmd(KeyCode::Char('x'), none, cmd::ClearSelectedCell); normal.bind(KeyCode::Char('x'), none, "clear-selected-cell");
normal.bind_cmd(KeyCode::Char('p'), none, cmd::PasteCell); normal.bind(KeyCode::Char('p'), none, "paste");
// View // View
normal.bind_cmd(KeyCode::Char('t'), none, cmd::TransposeAxes); normal.bind(KeyCode::Char('t'), none, "transpose");
// Mode changes // Mode changes
normal.bind_cmd(KeyCode::Char('q'), ctrl, cmd::ForceQuit); normal.bind(KeyCode::Char('q'), ctrl, "force-quit");
normal.bind_cmd( normal.bind_args(
KeyCode::Char(':'), KeyCode::Char(':'),
shift, shift,
cmd::EnterMode(AppMode::CommandMode { "enter-mode",
buffer: String::new(), vec!["command".into()],
}),
); );
normal.bind_cmd(KeyCode::Char('/'), none, cmd::EnterSearchMode); normal.bind(KeyCode::Char('/'), none, "search");
normal.bind_cmd(KeyCode::Char('s'), ctrl, cmd::SaveCmd); normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind_cmd(KeyCode::F(1), none, cmd::EnterMode(AppMode::Help)); normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_cmd(KeyCode::Char('?'), shift, cmd::EnterMode(AppMode::Help)); 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 (uppercase = toggle + focus) // Panel toggles
normal.bind_cmd( normal.bind_args(
KeyCode::Char('F'), KeyCode::Char('F'),
shift, shift,
cmd::TogglePanelAndFocus(crate::ui::effect::Panel::Formula), "toggle-panel-and-focus",
vec!["formula".into()],
); );
normal.bind_cmd( normal.bind_args(
KeyCode::Char('C'), KeyCode::Char('C'),
shift, shift,
cmd::TogglePanelAndFocus(crate::ui::effect::Panel::Category), "toggle-panel-and-focus",
vec!["category".into()],
); );
normal.bind_cmd( normal.bind_args(
KeyCode::Char('V'), KeyCode::Char('V'),
shift, shift,
cmd::TogglePanelAndFocus(crate::ui::effect::Panel::View), "toggle-panel-and-focus",
vec!["view".into()],
); );
normal.bind_args(
// Legacy Ctrl+ panel toggles (visibility only)
normal.bind_cmd(
KeyCode::Char('f'), KeyCode::Char('f'),
ctrl, ctrl,
cmd::TogglePanelVisibility(crate::ui::effect::Panel::Formula), "toggle-panel-visibility",
vec!["formula".into()],
); );
normal.bind_cmd( normal.bind_args(
KeyCode::Char('c'), KeyCode::Char('c'),
ctrl, ctrl,
cmd::TogglePanelVisibility(crate::ui::effect::Panel::Category), "toggle-panel-visibility",
vec!["category".into()],
); );
normal.bind_cmd( normal.bind_args(
KeyCode::Char('v'), KeyCode::Char('v'),
ctrl, ctrl,
cmd::TogglePanelVisibility(crate::ui::effect::Panel::View), "toggle-panel-visibility",
vec!["view".into()],
); );
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Tab cycles open panels
normal.bind_cmd(KeyCode::Tab, none, cmd::CyclePanelFocus);
// Editing entry // Editing entry
normal.bind_cmd(KeyCode::Char('i'), none, cmd::EnterEditMode); normal.bind(KeyCode::Char('i'), none, "enter-edit-mode");
normal.bind_cmd(KeyCode::Char('a'), none, cmd::EnterEditMode); normal.bind(KeyCode::Char('a'), none, "enter-edit-mode");
normal.bind_cmd(KeyCode::Enter, none, cmd::EnterAdvance); normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind_cmd(KeyCode::Char('e'), ctrl, cmd::EnterExportPrompt); normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
// Search / category add // Search / category add
normal.bind_cmd(KeyCode::Char('n'), none, cmd::SearchNavigate(true)); normal.bind_args(KeyCode::Char('n'), none, "search-navigate", vec!["forward".into()]);
normal.bind_cmd(KeyCode::Char('N'), shift, cmd::SearchOrCategoryAdd); normal.bind(KeyCode::Char('N'), shift, "search-or-category-add");
// Page navigation // Page navigation
normal.bind_cmd(KeyCode::Char(']'), none, cmd::PageNext); normal.bind(KeyCode::Char(']'), none, "page-next");
normal.bind_cmd(KeyCode::Char('['), none, cmd::PagePrev); normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide // Group / hide
normal.bind_cmd(KeyCode::Char('z'), none, cmd::ToggleGroupUnderCursor); normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind_cmd(KeyCode::Char('H'), shift, cmd::HideSelectedRowItem); normal.bind(KeyCode::Char('H'), shift, "hide-selected-row-item");
// Tile select // Tile select
normal.bind_cmd(KeyCode::Char('T'), shift, cmd::EnterTileSelect); normal.bind(KeyCode::Char('T'), shift, "enter-tile-select");
normal.bind_cmd(KeyCode::Left, ctrl, cmd::EnterTileSelect); normal.bind(KeyCode::Left, ctrl, "enter-tile-select");
normal.bind_cmd(KeyCode::Right, ctrl, cmd::EnterTileSelect); normal.bind(KeyCode::Right, ctrl, "enter-tile-select");
normal.bind_cmd(KeyCode::Up, ctrl, cmd::EnterTileSelect); normal.bind(KeyCode::Up, ctrl, "enter-tile-select");
normal.bind_cmd(KeyCode::Down, ctrl, cmd::EnterTileSelect); normal.bind(KeyCode::Down, ctrl, "enter-tile-select");
// ── Prefix keys (Emacs-style sub-keymaps) ──────────────────────── // Prefix keys
// g-prefix
let mut g_map = Keymap::new(); let mut g_map = Keymap::new();
g_map.bind_cmd(KeyCode::Char('g'), none, cmd::JumpToFirstRow); g_map.bind(KeyCode::Char('g'), none, "jump-first-row");
g_map.bind_cmd(KeyCode::Char('z'), none, cmd::ToggleColGroupUnderCursor); g_map.bind(KeyCode::Char('z'), none, "toggle-col-group-under-cursor");
normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map)); normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map));
// y-prefix
let mut y_map = Keymap::new(); let mut y_map = Keymap::new();
y_map.bind_cmd(KeyCode::Char('y'), none, cmd::YankCell); y_map.bind(KeyCode::Char('y'), none, "yank");
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map)); normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
// Z-prefix
let mut z_map = Keymap::new(); let mut z_map = Keymap::new();
z_map.bind_cmd(KeyCode::Char('Z'), shift, cmd::SaveAndQuit); z_map.bind(KeyCode::Char('Z'), shift, "save-and-quit");
normal.bind_prefix(KeyCode::Char('Z'), shift, Arc::new(z_map)); normal.bind_prefix(KeyCode::Char('Z'), shift, Arc::new(z_map));
set.insert(ModeKey::Normal, Arc::new(normal)); set.insert(ModeKey::Normal, Arc::new(normal));
// ── Help mode ──────────────────────────────────────────────────── // ── Help mode ────────────────────────────────────────────────────
let mut help = Keymap::new(); let mut help = Keymap::new();
help.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); help.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
help.bind_cmd(KeyCode::Char('q'), none, cmd::EnterMode(AppMode::Normal)); help.bind_args(KeyCode::Char('q'), none, "enter-mode", vec!["normal".into()]);
set.insert(ModeKey::Help, Arc::new(help)); set.insert(ModeKey::Help, Arc::new(help));
// ── Formula panel mode ─────────────────────────────────────────── // ── Formula panel ────────────────────────────────────────────────
let mut fp = Keymap::new(); let mut fp = Keymap::new();
fp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); fp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
fp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); fp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
fp.bind_cmd( for key in [KeyCode::Up, KeyCode::Char('k')] {
KeyCode::Up, fp.bind_args(key, none, "move-panel-cursor", vec!["formula".into(), "-1".into()]);
none, }
cmd::MovePanelCursor { for key in [KeyCode::Down, KeyCode::Char('j')] {
panel: crate::ui::effect::Panel::Formula, fp.bind_args(key, none, "move-panel-cursor", vec!["formula".into(), "1".into()]);
delta: -1, }
}, fp.bind(KeyCode::Char('a'), none, "enter-formula-edit");
); fp.bind(KeyCode::Char('n'), none, "enter-formula-edit");
fp.bind_cmd( fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
KeyCode::Char('k'), fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
none, fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Formula,
delta: -1,
},
);
fp.bind_cmd(
KeyCode::Down,
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Formula,
delta: 1,
},
);
fp.bind_cmd(
KeyCode::Char('j'),
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Formula,
delta: 1,
},
);
fp.bind_cmd(KeyCode::Char('a'), none, cmd::EnterFormulaEdit);
fp.bind_cmd(KeyCode::Char('n'), none, cmd::EnterFormulaEdit);
fp.bind_cmd(KeyCode::Char('o'), none, cmd::EnterFormulaEdit);
fp.bind_cmd(KeyCode::Char('d'), none, cmd::DeleteFormulaAtCursor);
fp.bind_cmd(KeyCode::Delete, none, cmd::DeleteFormulaAtCursor);
set.insert(ModeKey::FormulaPanel, Arc::new(fp)); set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel mode ────────────────────────────────────────── // ── Category panel ───────────────────────────────────────────────
let mut cp = Keymap::new(); let mut cp = Keymap::new();
cp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); cp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); cp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
cp.bind_cmd( for key in [KeyCode::Up, KeyCode::Char('k')] {
KeyCode::Up, cp.bind_args(key, none, "move-panel-cursor", vec!["category".into(), "-1".into()]);
none, }
cmd::MovePanelCursor { for key in [KeyCode::Down, KeyCode::Char('j')] {
panel: crate::ui::effect::Panel::Category, cp.bind_args(key, none, "move-panel-cursor", vec!["category".into(), "1".into()]);
delta: -1, }
}, cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
); cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
cp.bind_cmd( cp.bind_args(
KeyCode::Char('k'),
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Category,
delta: -1,
},
);
cp.bind_cmd(
KeyCode::Down,
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Category,
delta: 1,
},
);
cp.bind_cmd(
KeyCode::Char('j'),
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::Category,
delta: 1,
},
);
cp.bind_cmd(KeyCode::Enter, none, cmd::CycleAxisAtCursor);
cp.bind_cmd(KeyCode::Char(' '), none, cmd::CycleAxisAtCursor);
cp.bind_cmd(
KeyCode::Char('n'), KeyCode::Char('n'),
none, none,
cmd::EnterMode(AppMode::CategoryAdd { "enter-mode",
buffer: String::new(), vec!["category-add".into()],
}),
); );
cp.bind_cmd(KeyCode::Char('a'), none, cmd::OpenItemAddAtCursor); cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
cp.bind_cmd(KeyCode::Char('o'), none, cmd::OpenItemAddAtCursor); cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
set.insert(ModeKey::CategoryPanel, Arc::new(cp)); set.insert(ModeKey::CategoryPanel, Arc::new(cp));
// ── View panel mode ────────────────────────────────────────────── // ── View panel ───────────────────────────────────────────────────
let mut vp = Keymap::new(); let mut vp = Keymap::new();
vp.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); vp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
vp.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); vp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
vp.bind_cmd( for key in [KeyCode::Up, KeyCode::Char('k')] {
KeyCode::Up, vp.bind_args(key, none, "move-panel-cursor", vec!["view".into(), "-1".into()]);
none, }
cmd::MovePanelCursor { for key in [KeyCode::Down, KeyCode::Char('j')] {
panel: crate::ui::effect::Panel::View, vp.bind_args(key, none, "move-panel-cursor", vec!["view".into(), "1".into()]);
delta: -1, }
}, vp.bind(KeyCode::Enter, none, "switch-view-at-cursor");
); vp.bind(KeyCode::Char('n'), none, "create-and-switch-view");
vp.bind_cmd( vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
KeyCode::Char('k'), vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
none, vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::View,
delta: -1,
},
);
vp.bind_cmd(
KeyCode::Down,
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::View,
delta: 1,
},
);
vp.bind_cmd(
KeyCode::Char('j'),
none,
cmd::MovePanelCursor {
panel: crate::ui::effect::Panel::View,
delta: 1,
},
);
vp.bind_cmd(KeyCode::Enter, none, cmd::SwitchViewAtCursor);
vp.bind_cmd(KeyCode::Char('n'), none, cmd::CreateAndSwitchView);
vp.bind_cmd(KeyCode::Char('o'), none, cmd::CreateAndSwitchView);
vp.bind_cmd(KeyCode::Char('d'), none, cmd::DeleteViewAtCursor);
vp.bind_cmd(KeyCode::Delete, none, cmd::DeleteViewAtCursor);
set.insert(ModeKey::ViewPanel, Arc::new(vp)); set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select mode ───────────────────────────────────────────── // ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::new(); let mut ts = Keymap::new();
ts.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_cmd(KeyCode::Tab, none, cmd::EnterMode(AppMode::Normal)); ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_cmd(KeyCode::Left, none, cmd::MoveTileCursor(-1)); ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_cmd(KeyCode::Char('h'), none, cmd::MoveTileCursor(-1)); ts.bind_args(KeyCode::Char('h'), none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_cmd(KeyCode::Right, none, cmd::MoveTileCursor(1)); ts.bind_args(KeyCode::Right, none, "move-tile-cursor", vec!["1".into()]);
ts.bind_cmd(KeyCode::Char('l'), none, cmd::MoveTileCursor(1)); ts.bind_args(KeyCode::Char('l'), none, "move-tile-cursor", vec!["1".into()]);
ts.bind_cmd(KeyCode::Enter, none, cmd::CycleAxisForTile); ts.bind(KeyCode::Enter, none, "cycle-axis-for-tile");
ts.bind_cmd(KeyCode::Char(' '), none, cmd::CycleAxisForTile); ts.bind(KeyCode::Char(' '), none, "cycle-axis-for-tile");
ts.bind_cmd(KeyCode::Char('r'), none, cmd::SetAxisForTile(Axis::Row)); ts.bind_args(KeyCode::Char('r'), none, "set-axis-for-tile", vec!["row".into()]);
ts.bind_cmd(KeyCode::Char('c'), none, cmd::SetAxisForTile(Axis::Column)); ts.bind_args(KeyCode::Char('c'), none, "set-axis-for-tile", vec!["column".into()]);
ts.bind_cmd(KeyCode::Char('p'), none, cmd::SetAxisForTile(Axis::Page)); ts.bind_args(KeyCode::Char('p'), none, "set-axis-for-tile", vec!["page".into()]);
ts.bind_cmd(KeyCode::Char('n'), none, cmd::SetAxisForTile(Axis::None)); ts.bind_args(KeyCode::Char('n'), none, "set-axis-for-tile", vec!["none".into()]);
set.insert(ModeKey::TileSelect, Arc::new(ts)); set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Editing mode ───────────────────────────────────────────────── // ── Editing mode ─────────────────────────────────────────────────
let mut ed = Keymap::new(); let mut ed = Keymap::new();
ed.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind_cmd(KeyCode::Enter, none, cmd::CommitCellEdit); ed.bind(KeyCode::Enter, none, "commit-cell-edit");
ed.bind_cmd( ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
KeyCode::Backspace, ed.bind_any_char("append-char", vec!["edit".into()]);
none,
cmd::PopChar {
buffer: "edit".to_string(),
},
);
ed.bind_any_char(cmd::AppendChar {
buffer: "edit".to_string(),
});
set.insert(ModeKey::Editing, Arc::new(ed)); set.insert(ModeKey::Editing, Arc::new(ed));
// ── Formula edit mode ──────────────────────────────────────────── // ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new(); let mut fe = Keymap::new();
fe.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::FormulaPanel)); fe.bind_args(KeyCode::Esc, none, "enter-mode", vec!["formula-panel".into()]);
fe.bind_cmd(KeyCode::Enter, none, cmd::CommitFormula); fe.bind(KeyCode::Enter, none, "commit-formula");
fe.bind_cmd( fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
KeyCode::Backspace, fe.bind_any_char("append-char", vec!["formula".into()]);
none,
cmd::PopChar {
buffer: "formula".to_string(),
},
);
fe.bind_any_char(cmd::AppendChar {
buffer: "formula".to_string(),
});
set.insert(ModeKey::FormulaEdit, Arc::new(fe)); set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add mode ──────────────────────────────────────────── // ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new(); let mut ca = Keymap::new();
ca.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::CategoryPanel)); ca.bind_args(KeyCode::Esc, none, "enter-mode", vec!["category-panel".into()]);
ca.bind_cmd(KeyCode::Enter, none, cmd::CommitCategoryAdd); ca.bind(KeyCode::Enter, none, "commit-category-add");
ca.bind_cmd(KeyCode::Tab, none, cmd::CommitCategoryAdd); ca.bind(KeyCode::Tab, none, "commit-category-add");
ca.bind_cmd( ca.bind_args(KeyCode::Backspace, none, "pop-char", vec!["category".into()]);
KeyCode::Backspace, ca.bind_any_char("append-char", vec!["category".into()]);
none,
cmd::PopChar {
buffer: "category".to_string(),
},
);
ca.bind_any_char(cmd::AppendChar {
buffer: "category".to_string(),
});
set.insert(ModeKey::CategoryAdd, Arc::new(ca)); set.insert(ModeKey::CategoryAdd, Arc::new(ca));
// ── Item add mode ──────────────────────────────────────────────── // ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new(); let mut ia = Keymap::new();
ia.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::CategoryPanel)); ia.bind_args(KeyCode::Esc, none, "enter-mode", vec!["category-panel".into()]);
ia.bind_cmd(KeyCode::Enter, none, cmd::CommitItemAdd); ia.bind(KeyCode::Enter, none, "commit-item-add");
ia.bind_cmd(KeyCode::Tab, none, cmd::CommitItemAdd); ia.bind(KeyCode::Tab, none, "commit-item-add");
ia.bind_cmd( ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
KeyCode::Backspace, ia.bind_any_char("append-char", vec!["item".into()]);
none,
cmd::PopChar {
buffer: "item".to_string(),
},
);
ia.bind_any_char(cmd::AppendChar {
buffer: "item".to_string(),
});
set.insert(ModeKey::ItemAdd, Arc::new(ia)); set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt mode ─────────────────────────────────────────── // ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new(); let mut ep = Keymap::new();
ep.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); ep.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ep.bind_cmd(KeyCode::Enter, none, cmd::CommitExport); ep.bind(KeyCode::Enter, none, "commit-export");
ep.bind_cmd( ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
KeyCode::Backspace, ep.bind_any_char("append-char", vec!["export".into()]);
none,
cmd::PopChar {
buffer: "export".to_string(),
},
);
ep.bind_any_char(cmd::AppendChar {
buffer: "export".to_string(),
});
set.insert(ModeKey::ExportPrompt, Arc::new(ep)); set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ───────────────────────────────────────────────── // ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new(); let mut cm = Keymap::new();
cm.bind_cmd(KeyCode::Esc, none, cmd::EnterMode(AppMode::Normal)); cm.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cm.bind_cmd(KeyCode::Enter, none, cmd::ExecuteCommand); cm.bind(KeyCode::Enter, none, "execute-command");
cm.bind_cmd(KeyCode::Backspace, none, cmd::CommandModeBackspace); cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char(cmd::AppendChar { cm.bind_any_char("append-char", vec!["command".into()]);
buffer: "command".to_string(),
});
set.insert(ModeKey::CommandMode, Arc::new(cm)); set.insert(ModeKey::CommandMode, Arc::new(cm));
// ── Search mode ────────────────────────────────────────────────── // ── Search mode ──────────────────────────────────────────────────
let mut sm = Keymap::new(); let mut sm = Keymap::new();
sm.bind_cmd(KeyCode::Esc, none, cmd::ExitSearchMode); sm.bind(KeyCode::Esc, none, "exit-search-mode");
sm.bind_cmd(KeyCode::Enter, none, cmd::ExitSearchMode); sm.bind(KeyCode::Enter, none, "exit-search-mode");
sm.bind_cmd(KeyCode::Backspace, none, cmd::SearchPopChar); sm.bind(KeyCode::Backspace, none, "search-pop-char");
sm.bind_any_char(cmd::SearchAppendChar); sm.bind_any_char("search-append-char", vec![]);
set.insert(ModeKey::SearchMode, Arc::new(sm)); set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard mode ──────────────────────────────────────────── // ── Import wizard ────────────────────────────────────────────────
let mut wiz = Keymap::new(); let mut wiz = Keymap::new();
// All keys are dispatched to the wizard effect, which handles wiz.bind_any("handle-wizard-key");
// step-specific behavior internally.
wiz.bindings
.insert(KeyPattern::Any, Arc::new(cmd::HandleWizardKey));
set.insert(ModeKey::ImportWizard, Arc::new(wiz)); set.insert(ModeKey::ImportWizard, Arc::new(wiz));
set set

View File

@ -141,7 +141,8 @@ impl App {
if let Some(transient) = self.transient_keymap.take() { if let Some(transient) = self.transient_keymap.take() {
let effects = { let effects = {
let ctx = self.cmd_context(key.code, key.modifiers); let ctx = self.cmd_context(key.code, key.modifiers);
transient.dispatch(&ctx, key.code, key.modifiers) self.keymap_set
.dispatch_transient(&transient, &ctx, key.code, key.modifiers)
}; };
if let Some(effects) = effects { if let Some(effects) = effects {
self.apply_effects(effects); self.apply_effects(effects);