aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/keymap.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/keymap.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
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.rs392
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:?}"),
+ }
+ }
+}