diff --git a/src/command/keymap.rs b/src/command/keymap.rs index cc7bc31..fd8ce84 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -479,6 +479,22 @@ impl KeymapSet { "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 ──────────────────────────────────────────────── @@ -524,6 +540,12 @@ impl KeymapSet { "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 ─────────────────────────────────────────────── @@ -577,6 +599,12 @@ impl KeymapSet { "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 ─────────────────────────────────────────────────── @@ -622,6 +650,12 @@ impl KeymapSet { "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 ────────────────────────────────────────────────── @@ -668,6 +702,12 @@ impl KeymapSet { "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 ───────────────────────────────────────────────── @@ -757,3 +797,272 @@ impl KeymapSet { 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, + ]; + 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()); + } +}