diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/keymap.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/keymap.rs')
| -rw-r--r-- | src/buffer/keymap.rs | 392 |
1 files changed, 392 insertions, 0 deletions
diff --git a/src/buffer/keymap.rs b/src/buffer/keymap.rs new file mode 100644 index 0000000..61009c3 --- /dev/null +++ b/src/buffer/keymap.rs @@ -0,0 +1,392 @@ +//! Modal keymap resolution (MJB-HLR-015). +//! +//! Replaces the application template's resolver, which looked up single keys +//! first and otherwise accumulated a buffer cleared on every `Action::Tick` — +//! giving multi-key sequences a ~250 ms timeout at the default tick rate, so +//! `gg` failed if typed slowly. Here the set of proper prefixes is precomputed, +//! so resolution is exact and **time-independent** (MJB-LLR-154). +//! +//! A static table cannot express everything a modal editor needs: insert mode +//! must treat any unbound printable key as self-insert, and counts are typed as +//! ordinary digits. Both are handled around the table rather than in it, by +//! [`KeymapResult::Cancelled`] and by count accumulation. + +use std::collections::{HashMap, HashSet}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::command::Command; +use crate::config::{KeyBindings, Mode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeymapResult { + /// The keys so far are a prefix of at least one binding; wait for more. + Pending, + /// A binding matched, carrying any count typed before it. + Matched(Command, Option<usize>), + /// The keys match nothing. Carries what was accumulated so the caller can + /// apply a mode-specific fallback, such as insert-mode self-insert. + Cancelled(Vec<KeyEvent>), +} + +#[derive(Debug, Default)] +pub struct Keymap { + bindings: HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>, + /// MJB-LLR-150: every proper prefix of every bound sequence, per mode. + prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>>, + pending: Vec<KeyEvent>, + count: Option<usize>, +} + +impl Keymap { + /// MJB-LLR-150 + pub fn new(bindings: &KeyBindings) -> Self { + let mut prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>> = HashMap::new(); + + for (mode, map) in &bindings.0 { + let set = prefixes.entry(*mode).or_default(); + for keys in map.keys() { + for n in 1..keys.len() { + set.insert(keys[..n].to_vec()); + } + } + } + + Self { + bindings: bindings.0.clone(), + prefixes, + pending: Vec::new(), + count: None, + } + } + + pub fn pending(&self) -> &[KeyEvent] { + &self.pending + } + + pub fn count(&self) -> Option<usize> { + self.count + } + + /// Abandon any partial sequence and count, e.g. on a mode change. + pub fn reset(&mut self) { + self.pending.clear(); + self.count = None; + } + + /// Look up a single key without disturbing pending state. Used by `App` for + /// the `Global` map, which has no multi-key bindings (MJB-LLR-203). + pub fn lookup_single(&self, mode: Mode, key: KeyEvent) -> Option<Command> { + self.bindings.get(&mode)?.get(&vec![key]).copied() + } + + /// MJB-LLR-151..156: feed one key and resolve. + pub fn resolve(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { + // MJB-LLR-155: digits typed before a command form a count. Only while + // no sequence is pending, so `g` then `1` is not swallowed. + if self.pending.is_empty() + && matches!(mode, Mode::Normal | Mode::Select) + && let KeyCode::Char(c) = key.code + && c.is_ascii_digit() + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) + { + let digit = (c as u8 - b'0') as usize; + // A leading zero is not a count; it stays available as a binding. + if digit != 0 || self.count.is_some() { + self.count = Some(self.count.unwrap_or(0) * 10 + digit); + return KeymapResult::Pending; + } + } + + self.pending.push(key); + + // MJB-LLR-151 + if let Some(&cmd) = self.bindings.get(&mode).and_then(|m| m.get(&self.pending)) { + let count = self.count.take(); + self.pending.clear(); + return KeymapResult::Matched(cmd, count); + } + + // MJB-LLR-152 + if self + .prefixes + .get(&mode) + .is_some_and(|set| set.contains(&self.pending)) + { + return KeymapResult::Pending; + } + + // MJB-LLR-153 + let keys = std::mem::take(&mut self.pending); + self.count = None; + KeymapResult::Cancelled(keys) + } +} + +/// MJB-LLR-156: the insert-mode fallback — a bare printable character. +/// +/// Control and Alt are excluded so an unbound `Ctrl-x` is discarded rather than +/// inserting `x`. Shift is allowed: it is how capitals are typed. +pub fn self_insert_char(keys: &[KeyEvent]) -> Option<char> { + let [key] = keys else { + return None; + }; + let KeyCode::Char(c) = key.code else { + return None; + }; + if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) { + return None; + } + Some(c) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::parse_key_sequence; + + fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + fn keymap() -> Keymap { + let mut bindings = KeyBindings::default(); + let mut normal = HashMap::new(); + normal.insert(parse_key_sequence("<h>").unwrap(), Command::MoveCharLeft); + normal.insert(parse_key_sequence("<g><g>").unwrap(), Command::GotoFileStart); + normal.insert(parse_key_sequence("<g><e>").unwrap(), Command::GotoLastLine); + bindings.0.insert(Mode::Normal, normal); + + let mut insert = HashMap::new(); + insert.insert(parse_key_sequence("<esc>").unwrap(), Command::NormalMode); + bindings.0.insert(Mode::Insert, insert); + + Keymap::new(&bindings) + } + + /// MJB-LLR-150: every proper prefix is precomputed, and only proper + /// prefixes — a complete binding is not itself registered as a prefix, or + /// it would never resolve. + #[test] + fn mjb_llr_150_proper_prefixes_are_precomputed() { + let k = keymap(); + let set = k.prefixes.get(&Mode::Normal).expect("normal prefixes"); + + assert!( + set.contains(&parse_key_sequence("<g>").unwrap()), + "`g` is a proper prefix of `gg` and `ge`" + ); + assert!( + !set.contains(&parse_key_sequence("<g><g>").unwrap()), + "a complete binding must not be registered as a prefix" + ); + assert!( + !set.contains(&parse_key_sequence("<h>").unwrap()), + "a single-key binding has no proper prefix" + ); + } + + #[test] + fn mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences() { + let k = keymap(); + let set = k.prefixes.get(&Mode::Insert).expect("insert prefixes"); + assert!(set.is_empty(), "insert has only single-key bindings"); + } + + #[test] + fn mjb_llr_151_single_key_binding_matches_immediately() { + let mut k = keymap(); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, None) + ); + assert!(k.pending().is_empty(), "pending must be cleared"); + } + + /// MJB-LLR-152, MJB-LLR-154: `g` is a prefix, so it waits — indefinitely, + /// with no timer involved. This is the bug the old resolver had. + #[test] + fn mjb_llr_152_prefix_key_waits_for_more() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending); + assert_eq!(k.pending().len(), 1); + } + + #[test] + fn mjb_llr_151_two_key_sequence_resolves() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending); + assert_eq!( + k.resolve(Mode::Normal, key('g')), + KeymapResult::Matched(Command::GotoFileStart, None) + ); + } + + #[test] + fn mjb_llr_151_sequences_sharing_a_prefix_stay_distinct() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!( + k.resolve(Mode::Normal, key('e')), + KeymapResult::Matched(Command::GotoLastLine, None) + ); + } + + #[test] + fn mjb_llr_153_unknown_continuation_cancels() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + let got = k.resolve(Mode::Normal, key('z')); + match got { + KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('g'), key('z')]), + other => panic!("expected Cancelled, got {other:?}"), + } + assert!(k.pending().is_empty()); + } + + #[test] + fn mjb_llr_153_unbound_key_cancels_immediately() { + let mut k = keymap(); + match k.resolve(Mode::Normal, key('z')) { + KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('z')]), + other => panic!("expected Cancelled, got {other:?}"), + } + } + + /// MJB-LLR-154: no elapsed-time input exists, so a pending sequence + /// survives arbitrarily many unrelated resolutions in other modes. + #[test] + fn mjb_llr_154_pending_is_not_discarded_by_time() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!(k.pending().len(), 1); + // Nothing but another key can advance or clear it. + assert_eq!( + k.resolve(Mode::Normal, key('g')), + KeymapResult::Matched(Command::GotoFileStart, None) + ); + } + + #[test] + fn mjb_llr_155_count_accumulates_and_is_delivered() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('1')), KeymapResult::Pending); + assert_eq!(k.resolve(Mode::Normal, key('2')), KeymapResult::Pending); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, Some(12)) + ); + } + + #[test] + fn mjb_llr_155_count_is_consumed_once() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('3')); + k.resolve(Mode::Normal, key('h')); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, None), + "the count must not persist to the next command" + ); + } + + #[test] + fn mjb_llr_155_leading_zero_is_not_a_count() { + let mut k = keymap(); + // `0` with no count in progress falls through to binding lookup. + match k.resolve(Mode::Normal, key('0')) { + KeymapResult::Cancelled(_) => {} + other => panic!("expected Cancelled for unbound 0, got {other:?}"), + } + assert_eq!(k.count(), None); + } + + #[test] + fn mjb_llr_155_zero_extends_an_existing_count() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('1')); + k.resolve(Mode::Normal, key('0')); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, Some(10)) + ); + } + + #[test] + fn mjb_llr_155_digits_are_not_counts_in_insert_mode() { + let mut k = keymap(); + match k.resolve(Mode::Insert, key('5')) { + KeymapResult::Cancelled(keys) => { + assert_eq!(self_insert_char(&keys), Some('5'), "must type a 5"); + } + other => panic!("expected Cancelled, got {other:?}"), + } + } + + #[test] + fn mjb_llr_156_self_insert_accepts_plain_printables() { + assert_eq!(self_insert_char(&[key('a')]), Some('a')); + assert_eq!( + self_insert_char(&[KeyEvent::new( + KeyCode::Char('A'), + KeyModifiers::SHIFT + )]), + Some('A'), + "shift is how capitals are typed" + ); + } + + #[test] + fn mjb_llr_156_self_insert_rejects_control_and_alt() { + assert_eq!(self_insert_char(&[ctrl('x')]), None); + assert_eq!( + self_insert_char(&[KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)]), + None + ); + } + + #[test] + fn mjb_llr_156_self_insert_rejects_non_char_and_sequences() { + assert_eq!( + self_insert_char(&[KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())]), + None + ); + assert_eq!( + self_insert_char(&[key('a'), key('b')]), + None, + "only a single key can self-insert" + ); + } + + #[test] + fn mjb_llr_203_lookup_single_does_not_disturb_pending() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!(k.lookup_single(Mode::Normal, key('h')), Some(Command::MoveCharLeft)); + assert_eq!(k.pending().len(), 1, "global lookup must be side-effect free"); + } + + #[test] + fn reset_clears_pending_and_count() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('3')); + k.resolve(Mode::Normal, key('g')); + k.reset(); + assert!(k.pending().is_empty()); + assert_eq!(k.count(), None); + } + + #[test] + fn unknown_mode_cancels_rather_than_panicking() { + let mut k = keymap(); + match k.resolve(Mode::Command, key('x')) { + KeymapResult::Cancelled(_) => {} + other => panic!("expected Cancelled for an unmapped mode, got {other:?}"), + } + } +} |
