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:
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user