refactor(command): update keymaps for command mode and help navigation

Update keymaps to allow entering command mode via ':' in various panels and
add help page navigation.

- Added ':' command binding to Help, FormulaPanel, CategoryPanel,
  ViewPanel, and TileSelect modes.
- Added navigation bindings (Right/Left, l/h, n/p, Tab/BackTab) to the Help
  keymap.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
Edward Langley
2026-04-08 22:27:37 -07:00
parent d49bcf0060
commit 2b1f42d8bf

View File

@ -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());
}
}