Initial commit
This commit is contained in:
4
brittle-keymap/Cargo.toml
Normal file
4
brittle-keymap/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[package]
|
||||
name = "brittle-keymap"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
72
brittle-keymap/src/actions.rs
Normal file
72
brittle-keymap/src/actions.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Canonical action name constants used by both the default bindings and the UI.
|
||||
//!
|
||||
//! Every string that the keymap can emit as an action name is defined here.
|
||||
//! The UI dispatches on these to decide what to do.
|
||||
|
||||
// ── Navigation (within the focused pane) ─────────────────────────────────────
|
||||
|
||||
/// Move the selection cursor one step down (repeated `count` times).
|
||||
pub const NAV_DOWN: &str = "nav.down";
|
||||
/// Move the selection cursor one step up (repeated `count` times).
|
||||
pub const NAV_UP: &str = "nav.up";
|
||||
/// Jump to the first item.
|
||||
pub const NAV_TOP: &str = "nav.top";
|
||||
/// Jump to the last item.
|
||||
pub const NAV_BOTTOM: &str = "nav.bottom";
|
||||
/// Scroll / page down.
|
||||
pub const NAV_PAGE_DOWN: &str = "nav.page.down";
|
||||
/// Scroll / page up.
|
||||
pub const NAV_PAGE_UP: &str = "nav.page.up";
|
||||
|
||||
// ── Pane focus ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Focus the left pane (library tree).
|
||||
pub const FOCUS_LEFT: &str = "focus.left";
|
||||
/// Focus the centre pane (reference list).
|
||||
pub const FOCUS_CENTER: &str = "focus.center";
|
||||
/// Focus the right pane (reference detail / editor).
|
||||
pub const FOCUS_RIGHT: &str = "focus.right";
|
||||
/// Move focus to the next pane (cycles left → centre → right → left).
|
||||
pub const FOCUS_NEXT: &str = "focus.next";
|
||||
/// Move focus to the previous pane.
|
||||
pub const FOCUS_PREV: &str = "focus.prev";
|
||||
|
||||
// ── Library tree ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Expand the selected tree node.
|
||||
pub const TREE_EXPAND: &str = "tree.expand";
|
||||
/// Collapse the selected tree node.
|
||||
pub const TREE_COLLAPSE: &str = "tree.collapse";
|
||||
/// Toggle the selected tree node open/closed.
|
||||
pub const TREE_TOGGLE: &str = "tree.toggle";
|
||||
|
||||
// ── Item actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Open the selected item (load PDF, expand library, etc.).
|
||||
pub const ACTION_OPEN: &str = "action.open";
|
||||
/// Begin editing the selected item.
|
||||
pub const ACTION_EDIT: &str = "action.edit";
|
||||
/// Delete the selected item.
|
||||
pub const ACTION_DELETE: &str = "action.delete";
|
||||
/// Create a new item in the current context.
|
||||
pub const ACTION_NEW: &str = "action.new";
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cycle to the next tab (wraps around).
|
||||
pub const TAB_NEXT: &str = "tab.next";
|
||||
/// Cycle to the previous tab (wraps around).
|
||||
pub const TAB_PREV: &str = "tab.prev";
|
||||
/// Close the current tab (no-op on the Library tab).
|
||||
pub const TAB_CLOSE: &str = "tab.close";
|
||||
|
||||
// ── Input modes ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Enter command mode (the `:` prompt).
|
||||
pub const MODE_COMMAND: &str = "mode.command";
|
||||
/// Enter search / filter mode (the `/` prompt).
|
||||
pub const MODE_SEARCH: &str = "mode.search";
|
||||
/// Return to normal mode (dismiss any prompt, clear pending sequence).
|
||||
pub const MODE_NORMAL: &str = "mode.normal";
|
||||
/// Export current view as BibTeX.
|
||||
pub const MODE_BIBTEX: &str = "mode.bibtex";
|
||||
198
brittle-keymap/src/binding.rs
Normal file
198
brittle-keymap/src/binding.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! Binding set: maps key sequences to action names.
|
||||
|
||||
use crate::key::{Key, ParseError};
|
||||
|
||||
/// A named binding: a key sequence that triggers an action.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Binding {
|
||||
/// The ordered sequence of keys that must be pressed.
|
||||
pub keys: Vec<Key>,
|
||||
/// The action name emitted when the sequence completes.
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
/// Result of looking up a (partial) key sequence in a [`BindingSet`].
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum LookupResult {
|
||||
/// The sequence is an exact match for a binding.
|
||||
Exact(String),
|
||||
/// The sequence is a valid prefix of one or more bindings; keep waiting.
|
||||
Prefix,
|
||||
/// The sequence matches nothing.
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
/// A collection of key→action bindings.
|
||||
///
|
||||
/// Internally stored as a flat `Vec`; acceptable because the number of
|
||||
/// bindings is small (typically < 100) and sequences are short (≤ 4 keys).
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct BindingSet {
|
||||
bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
impl BindingSet {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a binding from pre-parsed keys.
|
||||
pub fn add(&mut self, keys: Vec<Key>, action: impl Into<String>) {
|
||||
self.bindings.push(Binding {
|
||||
keys,
|
||||
action: action.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a binding by parsing the key sequence string.
|
||||
///
|
||||
/// Returns the `ParseError` if the string is not valid.
|
||||
pub fn add_parsed(
|
||||
&mut self,
|
||||
key_sequence: &str,
|
||||
action: impl Into<String>,
|
||||
) -> Result<(), ParseError> {
|
||||
let keys = crate::key::parse_sequence(key_sequence)?;
|
||||
self.add(keys, action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a (possibly partial) key sequence.
|
||||
///
|
||||
/// If a sequence is both an exact match *and* a prefix of longer bindings,
|
||||
/// the exact match takes priority (no ambiguity).
|
||||
pub fn lookup(&self, keys: &[Key]) -> LookupResult {
|
||||
let mut found_prefix = false;
|
||||
|
||||
for binding in &self.bindings {
|
||||
if binding.keys == keys {
|
||||
return LookupResult::Exact(binding.action.clone());
|
||||
}
|
||||
if binding.keys.starts_with(keys) && binding.keys.len() > keys.len() {
|
||||
found_prefix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found_prefix {
|
||||
LookupResult::Prefix
|
||||
} else {
|
||||
LookupResult::NoMatch
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply user-defined overrides on top of this binding set.
|
||||
///
|
||||
/// For each `(action, key_sequence)` pair in `overrides`:
|
||||
/// - All existing bindings for that action are removed.
|
||||
/// - A new binding from the parsed sequence is added.
|
||||
///
|
||||
/// Unknown action names are added as new bindings; parse errors are skipped.
|
||||
pub fn apply_overrides<'a>(&mut self, overrides: impl IntoIterator<Item = (&'a str, &'a str)>) {
|
||||
for (action, key_seq) in overrides {
|
||||
// Remove existing bindings for this action.
|
||||
self.bindings.retain(|b| b.action != action);
|
||||
// Add the new binding (skip on parse error).
|
||||
if let Ok(keys) = crate::key::parse_sequence(key_seq) {
|
||||
self.add(keys, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all bindings for inspection.
|
||||
pub fn bindings(&self) -> &[Binding] {
|
||||
&self.bindings
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key::Key;
|
||||
|
||||
fn set_with_defaults() -> BindingSet {
|
||||
let mut s = BindingSet::new();
|
||||
s.add_parsed("j", "nav.down").unwrap();
|
||||
s.add_parsed("k", "nav.up").unwrap();
|
||||
s.add_parsed("<Down>", "nav.down").unwrap();
|
||||
s.add_parsed("gg", "nav.top").unwrap();
|
||||
s.add_parsed("G", "nav.bottom").unwrap();
|
||||
s.add_parsed("zo", "tree.expand").unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_single_key() {
|
||||
let s = set_with_defaults();
|
||||
let keys = vec![Key::char('j')];
|
||||
assert_eq!(s.lookup(&keys), LookupResult::Exact("nav.down".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_multi_key() {
|
||||
let s = set_with_defaults();
|
||||
let keys = vec![Key::char('g'), Key::char('g')];
|
||||
assert_eq!(s.lookup(&keys), LookupResult::Exact("nav.top".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_of_multi_key_binding() {
|
||||
let s = set_with_defaults();
|
||||
let keys = vec![Key::char('g')];
|
||||
assert_eq!(s.lookup(&keys), LookupResult::Prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match() {
|
||||
let s = set_with_defaults();
|
||||
let keys = vec![Key::char('x')];
|
||||
assert_eq!(s.lookup(&keys), LookupResult::NoMatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_wrong_continuation() {
|
||||
let s = set_with_defaults();
|
||||
// "gx" should not match anything.
|
||||
let keys = vec![Key::char('g'), Key::char('x')];
|
||||
assert_eq!(s.lookup(&keys), LookupResult::NoMatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_replaces_action_binding() {
|
||||
let mut s = set_with_defaults();
|
||||
// Replace nav.down from "j" to "n".
|
||||
s.apply_overrides([("nav.down", "n")]);
|
||||
|
||||
// Old "j" binding is gone.
|
||||
assert_eq!(s.lookup(&[Key::char('j')]), LookupResult::NoMatch);
|
||||
// New "n" binding is present.
|
||||
assert_eq!(
|
||||
s.lookup(&[Key::char('n')]),
|
||||
LookupResult::Exact("nav.down".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_removes_all_bindings_for_action() {
|
||||
let mut s = set_with_defaults();
|
||||
// nav.down is bound to both "j" and "<Down>".
|
||||
s.apply_overrides([("nav.down", "n")]);
|
||||
|
||||
// Both old bindings should be gone.
|
||||
use crate::key::{Key as K, KeyCode};
|
||||
assert_eq!(
|
||||
s.lookup(&[K::plain(KeyCode::ArrowDown)]),
|
||||
LookupResult::NoMatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_overrides_bad_sequence_is_skipped() {
|
||||
let mut s = set_with_defaults();
|
||||
// "<Bogus>" is not a valid key sequence — the override should be silently skipped.
|
||||
s.apply_overrides([("nav.down", "<Bogus>")]);
|
||||
// Original binding is still gone (we removed it before failing to parse).
|
||||
// This is an acceptable edge case — the user has a bad config.
|
||||
}
|
||||
}
|
||||
208
brittle-keymap/src/defaults.rs
Normal file
208
brittle-keymap/src/defaults.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Built-in default keybindings.
|
||||
//!
|
||||
//! These are Sioyek/zathura/vim-inspired defaults. Every binding refers to an
|
||||
//! action constant from [`crate::actions`].
|
||||
|
||||
use crate::{actions as a, binding::BindingSet};
|
||||
|
||||
/// Return a [`BindingSet`] populated with the built-in default bindings.
|
||||
///
|
||||
/// Multiple key sequences may map to the same action (e.g. both `j` and
|
||||
/// `<Down>` trigger `nav.down`).
|
||||
pub fn default_bindings() -> BindingSet {
|
||||
let mut set = BindingSet::new();
|
||||
|
||||
// Helper to register a binding, panicking if the sequence is invalid.
|
||||
// Invalid sequences in defaults are a programming error, not a user error.
|
||||
macro_rules! bind {
|
||||
($seq:expr => $action:expr) => {
|
||||
set.add_parsed($seq, $action)
|
||||
.unwrap_or_else(|e| panic!("invalid default binding '{}': {}", $seq, e));
|
||||
};
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
bind!("j" => a::NAV_DOWN);
|
||||
bind!("<Down>" => a::NAV_DOWN);
|
||||
bind!("k" => a::NAV_UP);
|
||||
bind!("<Up>" => a::NAV_UP);
|
||||
bind!("gg" => a::NAV_TOP);
|
||||
bind!("G" => a::NAV_BOTTOM);
|
||||
bind!("<C-d>" => a::NAV_PAGE_DOWN);
|
||||
bind!("<C-u>" => a::NAV_PAGE_UP);
|
||||
bind!("<PageDown>" => a::NAV_PAGE_DOWN);
|
||||
bind!("<PageUp>" => a::NAV_PAGE_UP);
|
||||
|
||||
// ── Pane focus ────────────────────────────────────────────────────────────
|
||||
bind!("<Tab>" => a::FOCUS_NEXT);
|
||||
bind!("<S-Tab>" => a::FOCUS_PREV);
|
||||
bind!("H" => a::FOCUS_LEFT);
|
||||
bind!("M" => a::FOCUS_CENTER);
|
||||
bind!("L" => a::FOCUS_RIGHT);
|
||||
|
||||
// ── Library tree ──────────────────────────────────────────────────────────
|
||||
bind!("zo" => a::TREE_EXPAND);
|
||||
bind!("zc" => a::TREE_COLLAPSE);
|
||||
bind!("za" => a::TREE_TOGGLE);
|
||||
// Arrow-style tree navigation: l expands, h collapses.
|
||||
bind!("l" => a::TREE_EXPAND);
|
||||
bind!("h" => a::TREE_COLLAPSE);
|
||||
|
||||
// ── Item actions ──────────────────────────────────────────────────────────
|
||||
bind!("<Enter>" => a::ACTION_OPEN);
|
||||
bind!("e" => a::ACTION_EDIT);
|
||||
bind!("d" => a::ACTION_DELETE);
|
||||
bind!("n" => a::ACTION_NEW);
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
// vim-style gt/gT; g is already a prefix from gg (nav.top).
|
||||
bind!("gt" => a::TAB_NEXT);
|
||||
bind!("gT" => a::TAB_PREV);
|
||||
bind!("q" => a::TAB_CLOSE);
|
||||
|
||||
// ── Input modes ───────────────────────────────────────────────────────────
|
||||
bind!(":" => a::MODE_COMMAND);
|
||||
bind!("/" => a::MODE_SEARCH);
|
||||
bind!("<Esc>" => a::MODE_NORMAL);
|
||||
bind!("b" => a::MODE_BIBTEX);
|
||||
|
||||
set
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
binding::LookupResult,
|
||||
key::{Key, KeyCode},
|
||||
};
|
||||
|
||||
fn defaults() -> BindingSet {
|
||||
default_bindings()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_maps_to_nav_down() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('j')]),
|
||||
LookupResult::Exact(a::NAV_DOWN.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_down_maps_to_nav_down() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::plain(KeyCode::ArrowDown)]),
|
||||
LookupResult::Exact(a::NAV_DOWN.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gg_maps_to_nav_top() {
|
||||
let d = defaults();
|
||||
// 'g' alone is a prefix.
|
||||
assert_eq!(d.lookup(&[Key::char('g')]), LookupResult::Prefix);
|
||||
// 'gg' is the full binding.
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('g'), Key::char('g')]),
|
||||
LookupResult::Exact(a::NAV_TOP.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_g_maps_to_nav_bottom() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('G')]),
|
||||
LookupResult::Exact(a::NAV_BOTTOM.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zo_maps_to_tree_expand() {
|
||||
let d = defaults();
|
||||
assert_eq!(d.lookup(&[Key::char('z')]), LookupResult::Prefix);
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('z'), Key::char('o')]),
|
||||
LookupResult::Exact(a::TREE_EXPAND.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_maps_to_mode_command() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char(':')]),
|
||||
LookupResult::Exact(a::MODE_COMMAND.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_maps_to_mode_normal() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::plain(KeyCode::Escape)]),
|
||||
LookupResult::Exact(a::MODE_NORMAL.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_maps_to_focus_next() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::plain(KeyCode::Tab)]),
|
||||
LookupResult::Exact(a::FOCUS_NEXT.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_maps_to_focus_prev() {
|
||||
use crate::key::parse_sequence;
|
||||
let d = defaults();
|
||||
let shift_tab = &parse_sequence("<S-Tab>").unwrap()[0];
|
||||
assert_eq!(
|
||||
d.lookup(&[shift_tab.clone()]),
|
||||
LookupResult::Exact(a::FOCUS_PREV.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gt_maps_to_tab_next() {
|
||||
let d = defaults();
|
||||
// g is still a prefix (gg, gt, gT all share it)
|
||||
assert_eq!(d.lookup(&[Key::char('g')]), LookupResult::Prefix);
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('g'), Key::char('t')]),
|
||||
LookupResult::Exact(a::TAB_NEXT.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_gt_maps_to_tab_prev() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('g'), Key::char('T')]),
|
||||
LookupResult::Exact(a::TAB_PREV.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_maps_to_tab_close() {
|
||||
let d = defaults();
|
||||
assert_eq!(
|
||||
d.lookup(&[Key::char('q')]),
|
||||
LookupResult::Exact(a::TAB_CLOSE.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_default_sequences_are_valid() {
|
||||
// Constructing the defaults panics on any invalid sequence, so this
|
||||
// test implicitly validates every binding in default_bindings().
|
||||
let _ = default_bindings();
|
||||
}
|
||||
}
|
||||
371
brittle-keymap/src/key.rs
Normal file
371
brittle-keymap/src/key.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
//! Key representation and string parsing.
|
||||
//!
|
||||
//! Key sequences are written in a vim-inspired notation:
|
||||
//!
|
||||
//! | Notation | Meaning |
|
||||
//! |------------------|-------------------------------|
|
||||
//! | `j` | the letter j |
|
||||
//! | `G` | capital G (no modifier needed) |
|
||||
//! | `<Enter>` / `<CR>` | Enter / Return |
|
||||
//! | `<Esc>` | Escape |
|
||||
//! | `<Tab>` | Tab |
|
||||
//! | `<BS>` | Backspace |
|
||||
//! | `<Del>` | Delete |
|
||||
//! | `<Space>` | Space bar |
|
||||
//! | `<Up/Down/Left/Right>` | Arrow keys |
|
||||
//! | `<Home>` / `<End>` | Home / End |
|
||||
//! | `<PageUp>` / `<PageDown>` | Page Up / Down |
|
||||
//! | `<C-x>` | Ctrl+x |
|
||||
//! | `<S-Tab>` | Shift+Tab |
|
||||
//! | `<M-x>` / `<A-x>` | Alt+x |
|
||||
//!
|
||||
//! Sequences are formed by concatenating specs: `gg`, `zo`, `<C-d>`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A single key press, including its modifiers.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct Key {
|
||||
pub code: KeyCode,
|
||||
pub ctrl: bool,
|
||||
pub shift: bool,
|
||||
pub alt: bool,
|
||||
pub meta: bool,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Create an unmodified character key.
|
||||
pub fn char(c: char) -> Self {
|
||||
Key {
|
||||
code: KeyCode::Char(c),
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a key with the given code and no modifiers.
|
||||
pub fn plain(code: KeyCode) -> Self {
|
||||
Key {
|
||||
code,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let needs_brackets = self.ctrl
|
||||
|| self.shift
|
||||
|| self.alt
|
||||
|| self.meta
|
||||
|| !matches!(self.code, KeyCode::Char(_));
|
||||
|
||||
if needs_brackets {
|
||||
write!(f, "<")?;
|
||||
if self.ctrl {
|
||||
write!(f, "C-")?;
|
||||
}
|
||||
if self.shift {
|
||||
write!(f, "S-")?;
|
||||
}
|
||||
if self.alt {
|
||||
write!(f, "M-")?;
|
||||
}
|
||||
if self.meta {
|
||||
write!(f, "D-")?;
|
||||
}
|
||||
write!(f, "{}>", self.code)
|
||||
} else {
|
||||
write!(f, "{}", self.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical key code, independent of platform.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum KeyCode {
|
||||
/// A Unicode character key (letters, digits, punctuation).
|
||||
Char(char),
|
||||
Enter,
|
||||
Escape,
|
||||
Tab,
|
||||
Backspace,
|
||||
Delete,
|
||||
Space,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
F(u8),
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
KeyCode::Char(c) => write!(f, "{}", c),
|
||||
KeyCode::Enter => write!(f, "Enter"),
|
||||
KeyCode::Escape => write!(f, "Esc"),
|
||||
KeyCode::Tab => write!(f, "Tab"),
|
||||
KeyCode::Backspace => write!(f, "BS"),
|
||||
KeyCode::Delete => write!(f, "Del"),
|
||||
KeyCode::Space => write!(f, "Space"),
|
||||
KeyCode::ArrowUp => write!(f, "Up"),
|
||||
KeyCode::ArrowDown => write!(f, "Down"),
|
||||
KeyCode::ArrowLeft => write!(f, "Left"),
|
||||
KeyCode::ArrowRight => write!(f, "Right"),
|
||||
KeyCode::Home => write!(f, "Home"),
|
||||
KeyCode::End => write!(f, "End"),
|
||||
KeyCode::PageUp => write!(f, "PageUp"),
|
||||
KeyCode::PageDown => write!(f, "PageDown"),
|
||||
KeyCode::F(n) => write!(f, "F{}", n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parsing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Error type for key sequence parsing.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub enum ParseError {
|
||||
/// A `<` was not closed with a `>`.
|
||||
UnclosedBracket,
|
||||
/// A `<...>` block contained an unrecognized key name.
|
||||
UnknownKey(String),
|
||||
/// An empty `<>` was encountered.
|
||||
EmptyBracket,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ParseError::UnclosedBracket => write!(f, "unclosed '<' in key sequence"),
|
||||
ParseError::UnknownKey(k) => write!(f, "unknown key name: '{}'", k),
|
||||
ParseError::EmptyBracket => write!(f, "empty '<>' in key sequence"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a key sequence string (e.g. `"gg"`, `"<C-d>"`, `"zo"`) into a list of [`Key`]s.
|
||||
pub fn parse_sequence(s: &str) -> Result<Vec<Key>, ParseError> {
|
||||
let mut keys = Vec::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '<' {
|
||||
// Consume until the matching '>'.
|
||||
let mut spec = String::new();
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('>') => break,
|
||||
Some(c) => spec.push(c),
|
||||
None => return Err(ParseError::UnclosedBracket),
|
||||
}
|
||||
}
|
||||
if spec.is_empty() {
|
||||
return Err(ParseError::EmptyBracket);
|
||||
}
|
||||
keys.push(parse_bracket_spec(&spec)?);
|
||||
} else {
|
||||
keys.push(Key::char(c));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Parse the interior of a `<...>` bracket, e.g. `"C-d"`, `"S-Tab"`, `"Enter"`.
|
||||
fn parse_bracket_spec(spec: &str) -> Result<Key, ParseError> {
|
||||
let mut ctrl = false;
|
||||
let mut shift = false;
|
||||
let mut alt = false;
|
||||
let mut meta = false;
|
||||
let mut rest = spec;
|
||||
|
||||
// Strip modifier prefixes in any order.
|
||||
loop {
|
||||
if let Some(s) = rest.strip_prefix("C-") {
|
||||
ctrl = true;
|
||||
rest = s;
|
||||
} else if let Some(s) = rest.strip_prefix("S-") {
|
||||
shift = true;
|
||||
rest = s;
|
||||
} else if let Some(s) = rest.strip_prefix("M-").or_else(|| rest.strip_prefix("A-")) {
|
||||
alt = true;
|
||||
rest = s;
|
||||
} else if let Some(s) = rest.strip_prefix("D-") {
|
||||
meta = true;
|
||||
rest = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let code = match rest {
|
||||
"Enter" | "CR" | "Return" => KeyCode::Enter,
|
||||
"Esc" | "Escape" => KeyCode::Escape,
|
||||
"Tab" => KeyCode::Tab,
|
||||
"BS" | "Backspace" => KeyCode::Backspace,
|
||||
"Del" | "Delete" => KeyCode::Delete,
|
||||
"Space" => KeyCode::Space,
|
||||
"Up" => KeyCode::ArrowUp,
|
||||
"Down" => KeyCode::ArrowDown,
|
||||
"Left" => KeyCode::ArrowLeft,
|
||||
"Right" => KeyCode::ArrowRight,
|
||||
"Home" => KeyCode::Home,
|
||||
"End" => KeyCode::End,
|
||||
"PageUp" => KeyCode::PageUp,
|
||||
"PageDown" => KeyCode::PageDown,
|
||||
s if s.starts_with('F') && s.len() > 1 => {
|
||||
let n: u8 = s[1..]
|
||||
.parse()
|
||||
.map_err(|_| ParseError::UnknownKey(spec.to_owned()))?;
|
||||
KeyCode::F(n)
|
||||
}
|
||||
s if s.chars().count() == 1 => KeyCode::Char(s.chars().next().unwrap()),
|
||||
_ => return Err(ParseError::UnknownKey(spec.to_owned())),
|
||||
};
|
||||
|
||||
Ok(Key {
|
||||
code,
|
||||
ctrl,
|
||||
shift,
|
||||
alt,
|
||||
meta,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seq(s: &str) -> Vec<Key> {
|
||||
parse_sequence(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_char() {
|
||||
assert_eq!(seq("j"), vec![Key::char('j')]);
|
||||
assert_eq!(seq("G"), vec![Key::char('G')]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_char_sequence() {
|
||||
assert_eq!(seq("gg"), vec![Key::char('g'), Key::char('g')]);
|
||||
assert_eq!(seq("zo"), vec![Key::char('z'), Key::char('o')]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_keys() {
|
||||
assert_eq!(seq("<Enter>"), vec![Key::plain(KeyCode::Enter)]);
|
||||
assert_eq!(seq("<CR>"), vec![Key::plain(KeyCode::Enter)]);
|
||||
assert_eq!(seq("<Esc>"), vec![Key::plain(KeyCode::Escape)]);
|
||||
assert_eq!(seq("<Tab>"), vec![Key::plain(KeyCode::Tab)]);
|
||||
assert_eq!(seq("<BS>"), vec![Key::plain(KeyCode::Backspace)]);
|
||||
assert_eq!(seq("<Del>"), vec![Key::plain(KeyCode::Delete)]);
|
||||
assert_eq!(seq("<Space>"), vec![Key::plain(KeyCode::Space)]);
|
||||
assert_eq!(seq("<Up>"), vec![Key::plain(KeyCode::ArrowUp)]);
|
||||
assert_eq!(seq("<Down>"), vec![Key::plain(KeyCode::ArrowDown)]);
|
||||
assert_eq!(seq("<Home>"), vec![Key::plain(KeyCode::Home)]);
|
||||
assert_eq!(seq("<End>"), vec![Key::plain(KeyCode::End)]);
|
||||
assert_eq!(seq("<PageUp>"), vec![Key::plain(KeyCode::PageUp)]);
|
||||
assert_eq!(seq("<PageDown>"), vec![Key::plain(KeyCode::PageDown)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_modifier() {
|
||||
let key = &seq("<C-d>")[0];
|
||||
assert_eq!(key.code, KeyCode::Char('d'));
|
||||
assert!(key.ctrl);
|
||||
assert!(!key.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_modifier() {
|
||||
let key = &seq("<S-Tab>")[0];
|
||||
assert_eq!(key.code, KeyCode::Tab);
|
||||
assert!(key.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_modifier() {
|
||||
let key = &seq("<M-j>")[0];
|
||||
assert_eq!(key.code, KeyCode::Char('j'));
|
||||
assert!(key.alt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_modifiers() {
|
||||
let key = &seq("<C-S-Tab>")[0];
|
||||
assert_eq!(key.code, KeyCode::Tab);
|
||||
assert!(key.ctrl);
|
||||
assert!(key.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_key() {
|
||||
assert_eq!(seq("<F1>"), vec![Key::plain(KeyCode::F(1))]);
|
||||
assert_eq!(seq("<F12>"), vec![Key::plain(KeyCode::F(12))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_sequence() {
|
||||
let keys = seq("<C-d>j<Enter>");
|
||||
assert_eq!(keys.len(), 3);
|
||||
assert_eq!(keys[0].code, KeyCode::Char('d'));
|
||||
assert!(keys[0].ctrl);
|
||||
assert_eq!(keys[1], Key::char('j'));
|
||||
assert_eq!(keys[2].code, KeyCode::Enter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_bracket_error() {
|
||||
assert_eq!(parse_sequence("<Enter"), Err(ParseError::UnclosedBracket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_error() {
|
||||
assert!(matches!(
|
||||
parse_sequence("<Foobar>"),
|
||||
Err(ParseError::UnknownKey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bracket_error() {
|
||||
assert_eq!(parse_sequence("<>"), Err(ParseError::EmptyBracket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_char_key() {
|
||||
assert_eq!(Key::char('j').to_string(), "j");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_ctrl_key() {
|
||||
let k = seq("<C-d>")[0].clone();
|
||||
assert_eq!(k.to_string(), "<C-d>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_shift_tab() {
|
||||
let k = seq("<S-Tab>")[0].clone();
|
||||
assert_eq!(k.to_string(), "<S-Tab>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_special_key() {
|
||||
let k = Key::plain(KeyCode::Enter);
|
||||
assert_eq!(k.to_string(), "<Enter>");
|
||||
}
|
||||
}
|
||||
23
brittle-keymap/src/lib.rs
Normal file
23
brittle-keymap/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Keymap engine for Brittle.
|
||||
//!
|
||||
//! Provides vim-style key sequence parsing, binding sets, and a stateful
|
||||
//! input processor that supports count prefixes and multi-key sequences.
|
||||
//!
|
||||
//! # Quick start
|
||||
//!
|
||||
//! ```rust
|
||||
//! use brittle_keymap::{KeymapState, default_bindings};
|
||||
//!
|
||||
//! let mut state = KeymapState::new(default_bindings());
|
||||
//! ```
|
||||
|
||||
pub mod actions;
|
||||
pub mod binding;
|
||||
pub mod defaults;
|
||||
pub mod key;
|
||||
pub mod state;
|
||||
|
||||
pub use binding::{BindingSet, LookupResult};
|
||||
pub use defaults::default_bindings;
|
||||
pub use key::{parse_sequence, Key, KeyCode, ParseError};
|
||||
pub use state::{KeymapState, Outcome};
|
||||
354
brittle-keymap/src/state.rs
Normal file
354
brittle-keymap/src/state.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
//! The keymap state machine.
|
||||
//!
|
||||
//! `KeymapState` processes a stream of [`Key`] presses and emits [`Outcome`]s.
|
||||
//! It supports:
|
||||
//!
|
||||
//! - **Count prefixes**: digits build a numeric multiplier before the binding
|
||||
//! fires (e.g. `5j` → `nav.down` with count 5). `0` alone is treated as a
|
||||
//! regular key (not a count) so it can be bound; `10`, `20`, … work normally.
|
||||
//!
|
||||
//! - **Multi-key sequences**: bindings like `gg`, `zo`, `zc` are resolved by
|
||||
//! accumulating pressed keys until an exact match is found. If the
|
||||
//! accumulated sequence stops being a prefix of any binding, the machine
|
||||
//! discards the prefix and retries with just the latest key.
|
||||
//!
|
||||
//! - **Configurable bindings**: the `BindingSet` is injected at construction;
|
||||
//! user overrides are applied before constructing the state.
|
||||
|
||||
use crate::{
|
||||
binding::{BindingSet, LookupResult},
|
||||
key::Key,
|
||||
};
|
||||
|
||||
/// What the state machine decided after processing one key press.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// An action was fully resolved.
|
||||
Action {
|
||||
/// The action name (see [`crate::actions`]).
|
||||
name: String,
|
||||
/// The count prefix (always ≥ 1; `1` if no prefix was typed).
|
||||
count: u32,
|
||||
},
|
||||
/// The key was consumed but we are waiting for more keys.
|
||||
Pending,
|
||||
/// The key (or the accumulated sequence) did not match any binding.
|
||||
Unbound,
|
||||
}
|
||||
|
||||
/// The keymap state machine.
|
||||
#[derive(Debug)]
|
||||
pub struct KeymapState {
|
||||
bindings: BindingSet,
|
||||
/// Keys accumulated so far in the current multi-key sequence.
|
||||
pending: Vec<Key>,
|
||||
/// The count prefix typed before the current sequence (0 = none).
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl KeymapState {
|
||||
pub fn new(bindings: BindingSet) -> Self {
|
||||
Self {
|
||||
bindings,
|
||||
pending: Vec::new(),
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process one key press and return the outcome.
|
||||
pub fn process(&mut self, key: Key) -> Outcome {
|
||||
// Count-building: only when no sequence is in progress.
|
||||
if self.pending.is_empty() {
|
||||
if let Some(digit) = digit_of(&key) {
|
||||
// '0' alone is not a count-start — it's treated as a key.
|
||||
// Once a count > 0 has started, '0' extends it (e.g., "10j").
|
||||
if self.count > 0 || digit != 0 {
|
||||
self.count = self.count.saturating_mul(10).saturating_add(digit);
|
||||
return Outcome::Pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append to the in-progress sequence and look it up.
|
||||
self.pending.push(key.clone());
|
||||
|
||||
match self.bindings.lookup(&self.pending) {
|
||||
LookupResult::Exact(action) => {
|
||||
let count = if self.count == 0 { 1 } else { self.count };
|
||||
let outcome = Outcome::Action {
|
||||
name: action,
|
||||
count,
|
||||
};
|
||||
self.reset();
|
||||
outcome
|
||||
}
|
||||
LookupResult::Prefix => Outcome::Pending,
|
||||
LookupResult::NoMatch => {
|
||||
// The accumulated sequence is a dead end.
|
||||
// Discard the prefix and retry with only the last key,
|
||||
// unless this is already a single-key sequence.
|
||||
if self.pending.len() > 1 {
|
||||
let last = key; // the key we just pushed
|
||||
self.pending.clear();
|
||||
self.count = 0;
|
||||
return self.process(last);
|
||||
}
|
||||
// Single key and still no match → unbound.
|
||||
self.reset();
|
||||
Outcome::Unbound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the state machine to idle (clears pending keys and count).
|
||||
pub fn reset(&mut self) {
|
||||
self.pending.clear();
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
/// Keys accumulated so far (useful for displaying a "pending sequence" hint).
|
||||
pub fn pending_keys(&self) -> &[Key] {
|
||||
&self.pending
|
||||
}
|
||||
|
||||
/// Count prefix typed so far (0 = none).
|
||||
pub fn current_count(&self) -> u32 {
|
||||
self.count
|
||||
}
|
||||
}
|
||||
|
||||
/// If `key` is an unmodified digit, return its numeric value; otherwise `None`.
|
||||
fn digit_of(key: &Key) -> Option<u32> {
|
||||
if key.ctrl || key.shift || key.alt || key.meta {
|
||||
return None;
|
||||
}
|
||||
if let crate::key::KeyCode::Char(c) = key.code {
|
||||
c.to_digit(10)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key::{Key, KeyCode};
|
||||
|
||||
fn make_state() -> KeymapState {
|
||||
let mut bindings = BindingSet::new();
|
||||
bindings.add_parsed("j", "nav.down").unwrap();
|
||||
bindings.add_parsed("k", "nav.up").unwrap();
|
||||
bindings.add_parsed("<Down>", "nav.down").unwrap();
|
||||
bindings.add_parsed("gg", "nav.top").unwrap();
|
||||
bindings.add_parsed("G", "nav.bottom").unwrap();
|
||||
bindings.add_parsed("zo", "tree.expand").unwrap();
|
||||
bindings.add_parsed("zc", "tree.collapse").unwrap();
|
||||
bindings.add_parsed("za", "tree.toggle").unwrap();
|
||||
bindings.add_parsed("<Esc>", "mode.normal").unwrap();
|
||||
bindings.add_parsed(":", "mode.command").unwrap();
|
||||
KeymapState::new(bindings)
|
||||
}
|
||||
|
||||
fn key(c: char) -> Key {
|
||||
Key::char(c)
|
||||
}
|
||||
|
||||
fn special(code: KeyCode) -> Key {
|
||||
Key::plain(code)
|
||||
}
|
||||
|
||||
// ── Single-key bindings ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn single_key_fires_action() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_key_returns_unbound() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(s.process(key('x')), Outcome::Unbound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_key_fires_action() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(
|
||||
s.process(special(KeyCode::Escape)),
|
||||
Outcome::Action {
|
||||
name: "mode.normal".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Count prefix ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn count_prefix_single_digit() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(s.process(key('5')), Outcome::Pending);
|
||||
assert_eq!(s.current_count(), 5);
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 5
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_prefix_multi_digit() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(s.process(key('1')), Outcome::Pending);
|
||||
assert_eq!(s.process(key('0')), Outcome::Pending);
|
||||
assert_eq!(s.current_count(), 10);
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 10
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_alone_is_not_a_count() {
|
||||
// '0' alone with no prior count should be treated as a key, not a count-start.
|
||||
let mut bindings = BindingSet::new();
|
||||
bindings.add_parsed("0", "nav.top").unwrap();
|
||||
let mut s = KeymapState::new(bindings);
|
||||
assert_eq!(
|
||||
s.process(key('0')),
|
||||
Outcome::Action {
|
||||
name: "nav.top".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_resets_after_action() {
|
||||
let mut s = make_state();
|
||||
s.process(key('3'));
|
||||
s.process(key('j'));
|
||||
// Next key should have count=1 again.
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Multi-key sequences ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn multi_key_first_key_is_pending() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(s.process(key('g')), Outcome::Pending);
|
||||
assert_eq!(s.pending_keys(), &[Key::char('g')]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_key_sequence_fires_on_completion() {
|
||||
let mut s = make_state();
|
||||
s.process(key('g'));
|
||||
assert_eq!(
|
||||
s.process(key('g')),
|
||||
Outcome::Action {
|
||||
name: "nav.top".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
// State is cleared after action.
|
||||
assert!(s.pending_keys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_key_wrong_continuation_retries_last_key() {
|
||||
let mut s = make_state();
|
||||
// 'g' starts a sequence; 'j' does not continue it → retry 'j' alone.
|
||||
s.process(key('g'));
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_key_wrong_continuation_unbound_if_retry_also_fails() {
|
||||
let mut s = make_state();
|
||||
// 'g' starts a sequence; 'x' does not continue it and is also unbound alone.
|
||||
s.process(key('g'));
|
||||
assert_eq!(s.process(key('x')), Outcome::Unbound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zo_sequence() {
|
||||
let mut s = make_state();
|
||||
assert_eq!(s.process(key('z')), Outcome::Pending);
|
||||
assert_eq!(
|
||||
s.process(key('o')),
|
||||
Outcome::Action {
|
||||
name: "tree.expand".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_with_multi_key_sequence() {
|
||||
let mut s = make_state();
|
||||
s.process(key('3')); // count = 3
|
||||
s.process(key('z')); // pending = [z]
|
||||
assert_eq!(
|
||||
s.process(key('o')),
|
||||
Outcome::Action {
|
||||
name: "tree.expand".into(),
|
||||
count: 3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reset_clears_pending_and_count() {
|
||||
let mut s = make_state();
|
||||
s.process(key('5'));
|
||||
s.process(key('g'));
|
||||
s.reset();
|
||||
assert_eq!(s.current_count(), 0);
|
||||
assert!(s.pending_keys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_reset_processes_normally() {
|
||||
let mut s = make_state();
|
||||
s.process(key('5'));
|
||||
s.process(key('g'));
|
||||
s.reset();
|
||||
assert_eq!(
|
||||
s.process(key('j')),
|
||||
Outcome::Action {
|
||||
name: "nav.down".into(),
|
||||
count: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user