Implement a new "Records" mode for data entry. - Add `RecordsNormal` and `RecordsEditing` to `AppMode` and `ModeKey` . - `DataStore` now uses `IndexMap` and supports `sort_by_key()` to ensure deterministic row order. - `ToggleRecordsMode` command now sorts data and switches to `RecordsNormal` . - `EnterEditMode` command now respects records editing variants. - `RecordsNormal` mode includes a new `o` keybinding to add a record row. - `RecordsEditing` mode inherits from `Editing` and adds an `Esc` binding to return to `RecordsNormal` . - Added `SortData` effect to trigger data sorting. - Updated UI to display "RECORDS" and "RECORDS INSERT" mode names and styles. - Updated keymaps, command registry, and view navigation to support these new modes. - Added comprehensive tests for records mode behavior, including sorting and boundary conditions for Tab/Enter. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
1201 lines
41 KiB
Rust
1201 lines
41 KiB
Rust
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<Self> {
|
|
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<String>,
|
|
},
|
|
/// A prefix sub-keymap (Emacs-style).
|
|
Prefix(Arc<Keymap>),
|
|
/// A sequence of commands executed in order, concatenating their effects.
|
|
Sequence(Vec<(&'static str, Vec<String>)>),
|
|
}
|
|
|
|
/// 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<KeyPattern, Binding>,
|
|
parent: Option<Arc<Keymap>>,
|
|
}
|
|
|
|
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<Keymap>) -> 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<String>,
|
|
) {
|
|
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<Keymap>) {
|
|
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<String>)>,
|
|
) {
|
|
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<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![] });
|
|
}
|
|
|
|
/// 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<Vec<Box<dyn Effect>>> {
|
|
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<Box<dyn Effect>> = 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<Keymap>);
|
|
|
|
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<ModeKey, Arc<Keymap>>,
|
|
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<Keymap>) {
|
|
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<Vec<Box<dyn Effect>>> {
|
|
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<Vec<Box<dyn Effect>>> {
|
|
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());
|
|
}
|
|
}
|