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}; /// Format a KeyCode as a short human-readable label for which-key display. fn format_key_label(code: &KeyCode) -> String { match code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "Enter".to_string(), KeyCode::Esc => "Esc".to_string(), KeyCode::Tab => "Tab".to_string(), KeyCode::BackTab => "S-Tab".to_string(), KeyCode::Backspace => "BS".to_string(), KeyCode::Delete => "Del".to_string(), KeyCode::Left => "←".to_string(), KeyCode::Right => "→".to_string(), KeyCode::Up => "↑".to_string(), KeyCode::Down => "↓".to_string(), KeyCode::Home => "Home".to_string(), KeyCode::End => "End".to_string(), KeyCode::PageUp => "PgUp".to_string(), KeyCode::PageDown => "PgDn".to_string(), KeyCode::F(n) => format!("F{n}"), _ => format!("{code:?}"), } } // `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, RecordsNormal, RecordsEditing, } impl ModeKey { pub fn from_app_mode(mode: &AppMode, search_mode: bool) -> Option { 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), AppMode::RecordsNormal => Some(ModeKey::RecordsNormal), AppMode::RecordsEditing { .. } => Some(ModeKey::RecordsEditing), _ => 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, }, /// A prefix sub-keymap (Emacs-style). Prefix(Arc), /// A sequence of commands executed in order, concatenating their effects. Sequence(Vec<(&'static str, Vec)>), } /// A keymap maps key patterns to bindings (command names or prefix sub-keymaps). /// Supports Emacs-style parent keymap inheritance: if a key is not found in this /// keymap, lookup falls through to the parent. #[derive(Default)] pub struct Keymap { bindings: HashMap, parent: Option>, } 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(), parent: None, } } /// Create a keymap that inherits from a parent. Keys not found here /// fall through to the parent (Emacs-style keymap inheritance). pub fn with_parent(parent: Arc) -> Self { Self { bindings: HashMap::new(), parent: Some(parent), } } /// 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, ) { 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) { self.bindings .insert(KeyPattern::Key(key, mods), Binding::Prefix(sub)); } /// Bind a key to a sequence of commands (executed in order). pub fn bind_seq( &mut self, key: KeyCode, mods: KeyModifiers, steps: Vec<(&'static str, Vec)>, ) { self.bindings .insert(KeyPattern::Key(key, mods), Binding::Sequence(steps)); } /// Bind a catch-all for any Char key. pub fn bind_any_char(&mut self, name: &'static str, args: Vec) { 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![] }); } /// Return human-readable hints for all concrete bindings in this keymap. /// Used by the which-key popup to show available completions after a prefix key. pub fn binding_hints(&self) -> Vec<(String, &'static str)> { let mut hints: Vec<(String, &'static str)> = self .bindings .iter() .filter_map(|(pattern, binding)| { let label = match pattern { KeyPattern::Key(code, _) => format_key_label(code), KeyPattern::AnyChar | KeyPattern::Any => return None, }; let name = match binding { Binding::Cmd { name, .. } => *name, Binding::Prefix(_) => return None, Binding::Sequence(steps) => { if let Some((name, _)) = steps.first() { *name } else { return None; } } }; Some((label, name)) }) .collect(); hints.sort_by(|a, b| a.0.cmp(&b.0)); hints } /// Look up the binding for a key in this keymap's own bindings. fn lookup_local(&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)) } /// Look up the binding for a key, falling through to parent keymaps. pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> { self.lookup_local(key, mods) .or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods))) } /// Dispatch a key: look up binding, resolve through registry, return effects. pub fn dispatch( &self, registry: &CmdRegistry, ctx: &CmdContext, key: KeyCode, mods: KeyModifiers, ) -> Option>> { 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()))]), Binding::Sequence(steps) => { let mut effects: Vec> = Vec::new(); for (name, args) in steps { let cmd = registry.interactive(name, args, ctx).ok()?; effects.extend(cmd.execute(ctx)); } Some(effects) } } } } /// 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 + owns the command registry. pub struct KeymapSet { mode_maps: HashMap>, registry: CmdRegistry, } impl KeymapSet { pub fn new(registry: CmdRegistry) -> Self { Self { mode_maps: HashMap::new(), registry, } } pub fn registry(&self) -> &CmdRegistry { &self.registry } pub fn insert(&mut self, mode: ModeKey, keymap: Arc) { 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>> { 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>> { 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 normal.bind(KeyCode::Char('>'), none, "drill-into-cell"); normal.bind(KeyCode::Char('<'), none, "view-back"); // 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, "wq"); normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map)); let normal = Arc::new(normal); set.insert(ModeKey::Normal, normal.clone()); // ── Records normal mode (inherits from normal) ──────────────────── let mut rn = Keymap::with_parent(normal); rn.bind_seq( KeyCode::Char('o'), none, vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])], ); set.insert(ModeKey::RecordsNormal, Arc::new(rn)); // ── 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()], ); // Allow entering command mode from Help (important on empty-model launch) help.bind_args( KeyCode::Char(':'), none, "enter-mode", vec!["command".into()], ); // Page navigation help.bind(KeyCode::Right, none, "help-page-next"); help.bind(KeyCode::Char('l'), none, "help-page-next"); help.bind(KeyCode::Char('n'), none, "help-page-next"); help.bind(KeyCode::Tab, none, "help-page-next"); help.bind(KeyCode::Left, none, "help-page-prev"); help.bind(KeyCode::Char('h'), none, "help-page-prev"); help.bind(KeyCode::Char('p'), none, "help-page-prev"); help.bind(KeyCode::BackTab, none, "help-page-prev"); 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()], ); fp.bind_args( KeyCode::Char(':'), none, "enter-mode", vec!["command".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()], ); cp.bind_args( KeyCode::Char(':'), none, "enter-mode", vec!["command".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()], ); vp.bind_args( KeyCode::Char(':'), none, "enter-mode", vec!["command".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()], ); ts.bind_args( KeyCode::Char(':'), none, "enter-mode", vec!["command".into()], ); set.insert(ModeKey::TileSelect, Arc::new(ts)); // ── Editing mode ───────────────────────────────────────────────── let mut ed = Keymap::new(); ed.bind_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["edit".into()]), ("enter-mode", vec!["normal".into()]), ], ); ed.bind_seq( KeyCode::Enter, none, vec![ ("commit-cell-edit", vec![]), ("clear-buffer", vec!["edit".into()]), ], ); ed.bind_seq( KeyCode::Tab, none, vec![ ("commit-and-advance-right", vec![]), ("clear-buffer", vec!["edit".into()]), ], ); ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]); ed.bind_any_char("append-char", vec!["edit".into()]); let ed = Arc::new(ed); set.insert(ModeKey::Editing, ed.clone()); // ── Records editing mode (inherits from editing) ────────────────── let mut re = Keymap::with_parent(ed); re.bind_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["edit".into()]), ("enter-mode", vec!["records-normal".into()]), ], ); set.insert(ModeKey::RecordsEditing, Arc::new(re)); // ── Formula edit ───────────────────────────────────────────────── let mut fe = Keymap::new(); fe.bind_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["formula".into()]), ("enter-mode", vec!["formula-panel".into()]), ], ); fe.bind_seq( KeyCode::Enter, none, vec![ ("commit-formula", vec![]), ("clear-buffer", vec!["formula".into()]), ], ); 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_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["category".into()]), ("enter-mode", vec!["category-panel".into()]), ], ); ca.bind_seq( KeyCode::Enter, none, vec![ ("commit-category-add", vec![]), ("clear-buffer", vec!["category".into()]), ], ); ca.bind_seq( KeyCode::Tab, none, vec![ ("commit-category-add", vec![]), ("clear-buffer", vec!["category".into()]), ], ); 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_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["item".into()]), ("enter-mode", vec!["category-panel".into()]), ], ); ia.bind_seq( KeyCode::Enter, none, vec![ ("commit-item-add", vec![]), ("clear-buffer", vec!["item".into()]), ], ); ia.bind_seq( KeyCode::Tab, none, vec![ ("commit-item-add", vec![]), ("clear-buffer", vec!["item".into()]), ], ); 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_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["export".into()]), ("enter-mode", vec!["normal".into()]), ], ); ep.bind_seq( KeyCode::Enter, none, vec![ ("commit-export", vec![]), ("clear-buffer", vec!["export".into()]), ], ); 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_seq( KeyCode::Esc, none, vec![ ("clear-buffer", vec!["command".into()]), ("enter-mode", vec!["normal".into()]), ], ); cm.bind_seq( KeyCode::Enter, none, vec![ ("execute-command", vec![]), ("clear-buffer", vec!["command".into()]), ], ); 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_args(KeyCode::Backspace, none, "pop-char", vec!["search".into()]); sm.bind_any_char("append-char", vec!["search".into()]); 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 } } #[cfg(test)] mod tests { use super::*; // ── Keymap::lookup fallback chain ────────────────────────────────── #[test] fn lookup_exact_match() { let mut km = Keymap::new(); km.bind(KeyCode::Char('a'), KeyModifiers::NONE, "cmd-a"); let b = km.lookup(KeyCode::Char('a'), KeyModifiers::NONE); assert!(matches!(b, Some(Binding::Cmd { name: "cmd-a", .. }))); } #[test] fn lookup_exact_with_ctrl() { let mut km = Keymap::new(); km.bind(KeyCode::Char('s'), KeyModifiers::CONTROL, "save"); let b = km.lookup(KeyCode::Char('s'), KeyModifiers::CONTROL); assert!(matches!(b, Some(Binding::Cmd { name: "save", .. }))); } #[test] fn lookup_char_falls_back_to_none_mods() { // Bind 'A' with NONE. Lookup with SHIFT should still match because // terminals vary in whether they send SHIFT for uppercase chars. let mut km = Keymap::new(); km.bind(KeyCode::Char('A'), KeyModifiers::NONE, "cmd-A"); let b = km.lookup(KeyCode::Char('A'), KeyModifiers::SHIFT); assert!(matches!(b, Some(Binding::Cmd { name: "cmd-A", .. }))); } #[test] fn lookup_non_char_does_not_fall_back_to_none_mods() { // Arrow keys with CTRL should NOT fall back to NONE when no match let mut km = Keymap::new(); km.bind(KeyCode::Up, KeyModifiers::NONE, "move-up"); let b = km.lookup(KeyCode::Up, KeyModifiers::CONTROL); // Should NOT match — no AnyChar or Any fallback registered assert!(b.is_none()); } #[test] fn lookup_char_falls_to_any_char() { let mut km = Keymap::new(); km.bind_any_char("append-char", vec![]); let b = km.lookup(KeyCode::Char('z'), KeyModifiers::NONE); assert!(matches!( b, Some(Binding::Cmd { name: "append-char", .. }) )); } #[test] fn lookup_non_char_skips_any_char() { // AnyChar should only match Char keys, not Enter/Esc/arrows let mut km = Keymap::new(); km.bind_any_char("append-char", vec![]); let b = km.lookup(KeyCode::Enter, KeyModifiers::NONE); assert!(b.is_none()); } #[test] fn lookup_any_matches_everything() { let mut km = Keymap::new(); km.bind_any("catchall"); let b = km.lookup(KeyCode::F(12), KeyModifiers::NONE); assert!(matches!( b, Some(Binding::Cmd { name: "catchall", .. }) )); } #[test] fn lookup_exact_takes_priority_over_any_char() { let mut km = Keymap::new(); km.bind(KeyCode::Char('n'), KeyModifiers::NONE, "specific"); km.bind_any_char("generic", vec![]); let b = km.lookup(KeyCode::Char('n'), KeyModifiers::NONE); assert!(matches!( b, Some(Binding::Cmd { name: "specific", .. }) )); } #[test] fn lookup_any_char_takes_priority_over_any() { let mut km = Keymap::new(); km.bind_any_char("char-catch", vec![]); km.bind_any("total-catch"); let b = km.lookup(KeyCode::Char('x'), KeyModifiers::NONE); assert!(matches!( b, Some(Binding::Cmd { name: "char-catch", .. }) )); } #[test] fn lookup_non_char_falls_to_any_not_any_char() { let mut km = Keymap::new(); km.bind_any_char("char-catch", vec![]); km.bind_any("total-catch"); let b = km.lookup(KeyCode::Esc, KeyModifiers::NONE); assert!(matches!( b, Some(Binding::Cmd { name: "total-catch", .. }) )); } #[test] fn lookup_ctrl_char_with_only_none_binding_falls_through() { // Ctrl+S bound; plain 's' should NOT match Ctrl+S let mut km = Keymap::new(); km.bind(KeyCode::Char('s'), KeyModifiers::CONTROL, "save"); let b = km.lookup(KeyCode::Char('s'), KeyModifiers::NONE); assert!(b.is_none()); } // ── ModeKey::from_app_mode ───────────────────────────────────────── #[test] fn mode_key_normal_no_search() { let mk = ModeKey::from_app_mode(&AppMode::Normal, false); assert_eq!(mk, Some(ModeKey::Normal)); } #[test] fn mode_key_normal_with_search_overrides() { let mk = ModeKey::from_app_mode(&AppMode::Normal, true); assert_eq!(mk, Some(ModeKey::SearchMode)); } #[test] fn mode_key_help() { let mk = ModeKey::from_app_mode(&AppMode::Help, false); assert_eq!(mk, Some(ModeKey::Help)); } #[test] fn mode_key_quit_returns_none() { let mk = ModeKey::from_app_mode(&AppMode::Quit, false); assert_eq!(mk, None); } // ── Prefix bindings ──────────────────────────────────────────────── #[test] fn lookup_returns_prefix_binding() { let mut km = Keymap::new(); let sub = Arc::new(Keymap::new()); km.bind_prefix(KeyCode::Char('g'), KeyModifiers::NONE, sub); let b = km.lookup(KeyCode::Char('g'), KeyModifiers::NONE); assert!(matches!(b, Some(Binding::Prefix(_)))); } #[test] fn lookup_returns_sequence_binding() { let mut km = Keymap::new(); km.bind_seq( KeyCode::Char('o'), KeyModifiers::NONE, vec![("step1", vec![]), ("step2", vec![])], ); let b = km.lookup(KeyCode::Char('o'), KeyModifiers::NONE); assert!(matches!(b, Some(Binding::Sequence(steps)) if steps.len() == 2)); } // ── default_keymaps smoke tests ──────────────────────────────────── #[test] fn default_keymaps_has_all_modes() { let ks = KeymapSet::default_keymaps(); let expected_modes = vec![ ModeKey::Normal, ModeKey::Help, ModeKey::FormulaPanel, ModeKey::CategoryPanel, ModeKey::ViewPanel, ModeKey::TileSelect, ModeKey::Editing, ModeKey::FormulaEdit, ModeKey::CategoryAdd, ModeKey::ItemAdd, ModeKey::ExportPrompt, ModeKey::CommandMode, ModeKey::SearchMode, ModeKey::ImportWizard, ModeKey::RecordsNormal, ModeKey::RecordsEditing, ]; for mode in &expected_modes { assert!( ks.mode_maps.contains_key(mode), "Missing keymap for mode {:?}", mode ); } } #[test] fn normal_mode_has_basic_movement() { let ks = KeymapSet::default_keymaps(); let normal = ks.mode_maps.get(&ModeKey::Normal).unwrap(); // hjkl should be bound for key in ['h', 'j', 'k', 'l'] { assert!( normal .lookup(KeyCode::Char(key), KeyModifiers::NONE) .is_some(), "Normal mode missing binding for '{}'", key ); } // Arrow keys for key in [KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right] { assert!( normal.lookup(key, KeyModifiers::NONE).is_some(), "Normal mode missing binding for {:?}", key ); } } #[test] fn editing_mode_has_any_char_and_esc() { let ks = KeymapSet::default_keymaps(); let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap(); // Should have AnyChar for text input assert!( editing .lookup(KeyCode::Char('z'), KeyModifiers::NONE) .is_some() ); // Should have Esc to exit assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some()); } #[test] fn search_mode_has_any_char_and_esc() { let ks = KeymapSet::default_keymaps(); let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap(); assert!( search .lookup(KeyCode::Char('a'), KeyModifiers::NONE) .is_some() ); assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some()); } #[test] fn import_wizard_has_any_catchall() { let ks = KeymapSet::default_keymaps(); let wiz = ks.mode_maps.get(&ModeKey::ImportWizard).unwrap(); // Should catch any key at all assert!(wiz.lookup(KeyCode::F(5), KeyModifiers::NONE).is_some()); } }