Initial commit

This commit is contained in:
2026-03-25 09:32:02 +01:00
commit 23ce1b7ee2
77 changed files with 21169 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
[package]
name = "brittle-keymap"
version = "0.1.0"
edition = "2021"

View 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";

View 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.
}
}

View 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
View 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
View 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
View 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
}
);
}
}