749 lines
26 KiB
Rust
749 lines
26 KiB
Rust
// keymap_stack.rs --- Scoped keymaps + per-key resolver.
|
|
|
|
//! Scope ordering and the per-key dispatcher state machine.
|
|
//!
|
|
//! The Pmacs editor maintains three categories of keymaps:
|
|
//!
|
|
//! 1. **Buffer-local**: bindings that apply only when a specific
|
|
//! buffer is the active one. Most-specific scope.
|
|
//! 2. **Mode**: bindings that apply when the active buffer's major mode
|
|
//! matches the mode keymap.
|
|
//! 3. **Global**: the universal fallback. Last-resort scope.
|
|
//!
|
|
//! [`KeymapStack::resolve`] walks them in order and returns the
|
|
//! most-specific [`Resolution`] for the given chord sequence. Scopes
|
|
//! that are partial-match (Pending) cooperate: if buffer-local is
|
|
//! Unbound but global has a Pending prefix, the resolver returns
|
|
//! Pending so the dispatcher waits for more chords.
|
|
//!
|
|
//! [`KeyDispatcher`] carries the in-flight prefix between key events.
|
|
//! Dispatch returns one of [`Action::Run`] (a complete binding fired),
|
|
//! [`Action::Pending`] (more keys expected), or [`Action::Unbound`]
|
|
//! (the sequence dead-ended; reset).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use crate::buffer::BufferId;
|
|
use crate::key::{Chord, Sequence, display_sequence};
|
|
use crate::keymap_tree::{Binding, Keymap, KeymapError, Resolution};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope identifier
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The scope a binding lives in. Reported by `pmacs.describe.key` so
|
|
/// users can see *why* their key resolved the way it did.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum Scope {
|
|
/// Buffer-local --- specific to one [`BufferId`].
|
|
Buffer(BufferId),
|
|
/// Mode-active --- the active major mode's keymap.
|
|
Mode(String),
|
|
/// The global fallback.
|
|
Global,
|
|
}
|
|
|
|
impl Scope {
|
|
/// Render as a stable user-facing string.
|
|
#[must_use]
|
|
pub fn render(&self) -> String {
|
|
match self {
|
|
Self::Buffer(_) => "buffer".to_owned(),
|
|
Self::Mode(name) => format!("mode:{name}"),
|
|
Self::Global => "global".to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolved binding (binding + which scope)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A complete resolution: the binding plus the scope that produced it.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ResolvedBinding {
|
|
/// The binding metadata (command + source).
|
|
pub binding: Binding,
|
|
/// Which scope held the binding.
|
|
pub scope: Scope,
|
|
}
|
|
|
|
/// Result of [`KeymapStack::resolve`].
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum StackResolution {
|
|
/// At least one scope returned a complete binding; the
|
|
/// most-specific one wins.
|
|
Bound(ResolvedBinding),
|
|
/// No scope had a complete match, but at least one returned
|
|
/// [`Resolution::Pending`] for this prefix.
|
|
Pending,
|
|
/// No scope has any match (complete or partial).
|
|
Unbound,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// KeymapStack
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The complete keymap state for an editor session.
|
|
#[derive(Default)]
|
|
pub struct KeymapStack {
|
|
/// The global keymap (always consulted last).
|
|
pub global: Keymap,
|
|
/// Per-mode keymaps. Registration order is preserved by `Vec`; resolution
|
|
/// follows the borrowed mode names supplied to [`Self::resolve`].
|
|
pub modes: Vec<(String, Keymap)>,
|
|
/// Per-buffer keymaps. Buffer-local always beats mode and global.
|
|
pub buffers: HashMap<BufferId, Keymap>,
|
|
}
|
|
|
|
impl KeymapStack {
|
|
/// An empty stack.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Bind into the global keymap.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError`]s from the underlying [`Keymap::bind`].
|
|
pub fn bind_global(
|
|
&mut self,
|
|
sequence: &[Chord],
|
|
command: impl Into<String>,
|
|
source: crate::command::SourceLocation,
|
|
) -> Result<(), KeymapError> {
|
|
self.global.bind(sequence, command, source)
|
|
}
|
|
|
|
/// Bind into a buffer-local keymap, creating it if it doesn't exist.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError`]s from the underlying [`Keymap::bind`].
|
|
pub fn bind_buffer(
|
|
&mut self,
|
|
buffer: BufferId,
|
|
sequence: &[Chord],
|
|
command: impl Into<String>,
|
|
source: crate::command::SourceLocation,
|
|
) -> Result<(), KeymapError> {
|
|
self.buffers
|
|
.entry(buffer)
|
|
.or_default()
|
|
.bind(sequence, command, source)
|
|
}
|
|
|
|
/// Bind into a mode keymap, creating the mode entry if needed.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError`]s from the underlying [`Keymap::bind`].
|
|
pub fn bind_mode(
|
|
&mut self,
|
|
mode: &str,
|
|
sequence: &[Chord],
|
|
command: impl Into<String>,
|
|
source: crate::command::SourceLocation,
|
|
) -> Result<(), KeymapError> {
|
|
if !self.modes.iter().any(|(n, _)| n == mode) {
|
|
self.modes.push((mode.to_owned(), Keymap::default()));
|
|
}
|
|
let map = &mut self
|
|
.modes
|
|
.iter_mut()
|
|
.find(|(n, _)| n == mode)
|
|
.expect("just ensured present")
|
|
.1;
|
|
map.bind(sequence, command, source)
|
|
}
|
|
|
|
/// Unbind from the global keymap.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError::NotBound`] if the sequence wasn't
|
|
/// bound globally.
|
|
pub fn unbind_global(&mut self, sequence: &[Chord]) -> Result<Binding, KeymapError> {
|
|
self.global.unbind(sequence)
|
|
}
|
|
|
|
/// Unbind a buffer-local sequence.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError::NotBound`] if the sequence isn't bound
|
|
/// in the buffer's local map (or if the buffer has no local map).
|
|
pub fn unbind_buffer(
|
|
&mut self,
|
|
buffer: BufferId,
|
|
sequence: &[Chord],
|
|
) -> Result<Binding, KeymapError> {
|
|
let map = self
|
|
.buffers
|
|
.get_mut(&buffer)
|
|
.ok_or_else(|| KeymapError::NotBound {
|
|
sequence: display_sequence(sequence),
|
|
})?;
|
|
let removed = map.unbind(sequence)?;
|
|
if map.is_empty() {
|
|
self.buffers.remove(&buffer);
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Drop every buffer-local binding for a buffer that just left
|
|
/// the registry.
|
|
pub fn remove_buffer(&mut self, buffer: BufferId) -> bool {
|
|
self.buffers.remove(&buffer).is_some()
|
|
}
|
|
|
|
/// Unbind a sequence from a mode keymap.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Surfaces [`KeymapError::NotBound`] if the mode doesn't exist
|
|
/// or the sequence isn't bound there.
|
|
pub fn unbind_mode(&mut self, mode: &str, sequence: &[Chord]) -> Result<Binding, KeymapError> {
|
|
let idx = self
|
|
.modes
|
|
.iter()
|
|
.position(|(n, _)| n == mode)
|
|
.ok_or_else(|| KeymapError::NotBound {
|
|
sequence: display_sequence(sequence),
|
|
})?;
|
|
let removed = self.modes[idx].1.unbind(sequence)?;
|
|
if self.modes[idx].1.is_empty() {
|
|
self.modes.remove(idx);
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Resolve `sequence` in scope priority order.
|
|
///
|
|
/// `active_buffer` is the [`BufferId`] currently in focus (if any).
|
|
/// `active_modes` borrows active mode names in priority order; the first
|
|
/// match in that order wins among modes.
|
|
///
|
|
/// Resolution semantics: the resolver returns the *most-specific*
|
|
/// complete binding it finds. If no scope has a complete match
|
|
/// but at least one has a partial (Pending) match, returns
|
|
/// [`StackResolution::Pending`].
|
|
#[must_use]
|
|
pub fn resolve(
|
|
&self,
|
|
sequence: &[Chord],
|
|
active_buffer: Option<BufferId>,
|
|
active_modes: &[&str],
|
|
) -> StackResolution {
|
|
let mut any_pending = false;
|
|
|
|
// 1) Buffer-local --- highest priority.
|
|
if let Some(id) = active_buffer
|
|
&& let Some(map) = self.buffers.get(&id)
|
|
{
|
|
match map.lookup(sequence) {
|
|
Resolution::Bound(b) => {
|
|
return StackResolution::Bound(ResolvedBinding {
|
|
binding: b,
|
|
scope: Scope::Buffer(id),
|
|
});
|
|
}
|
|
Resolution::Pending => any_pending = true,
|
|
Resolution::Unbound => {}
|
|
}
|
|
}
|
|
|
|
// 2) Modes --- ordered by `active_modes`.
|
|
for mode_name in active_modes {
|
|
if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == *mode_name) {
|
|
match map.lookup(sequence) {
|
|
Resolution::Bound(b) => {
|
|
return StackResolution::Bound(ResolvedBinding {
|
|
binding: b,
|
|
scope: Scope::Mode((*mode_name).to_owned()),
|
|
});
|
|
}
|
|
Resolution::Pending => any_pending = true,
|
|
Resolution::Unbound => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Global --- fallback.
|
|
match self.global.lookup(sequence) {
|
|
Resolution::Bound(b) => StackResolution::Bound(ResolvedBinding {
|
|
binding: b,
|
|
scope: Scope::Global,
|
|
}),
|
|
Resolution::Pending => StackResolution::Pending,
|
|
Resolution::Unbound => {
|
|
if any_pending {
|
|
StackResolution::Pending
|
|
} else {
|
|
StackResolution::Unbound
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Iterate over every (scope, sequence, binding) triple. Used by
|
|
/// `pmacs.describe.command` to populate `key_bindings` and by
|
|
/// `pmacs.keymap.list`.
|
|
pub fn iter_all(&self) -> Vec<(Scope, Sequence, Binding)> {
|
|
let mut out = Vec::new();
|
|
for (id, map) in &self.buffers {
|
|
for (seq, b) in map.iter() {
|
|
out.push((Scope::Buffer(*id), seq, b));
|
|
}
|
|
}
|
|
for (name, map) in &self.modes {
|
|
for (seq, b) in map.iter() {
|
|
out.push((Scope::Mode(name.clone()), seq, b));
|
|
}
|
|
}
|
|
for (seq, b) in self.global.iter() {
|
|
out.push((Scope::Global, seq, b));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// KeyDispatcher: the multi-key state machine
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What the dispatcher decided to do with the latest key.
|
|
#[derive(Clone, Debug)]
|
|
pub enum Action {
|
|
/// Run a command. The full sequence that fired is included for
|
|
/// `describe-key`-style logging.
|
|
Run {
|
|
/// The command name to invoke.
|
|
command: String,
|
|
/// The complete sequence that fired (just-typed chords).
|
|
sequence: Sequence,
|
|
/// Where the binding came from.
|
|
scope: Scope,
|
|
},
|
|
/// More keys expected; the dispatcher is waiting on a longer
|
|
/// prefix. Includes a copy of the sequence so the UI can echo it
|
|
/// (e.g. "C-x -" in the modeline).
|
|
Pending {
|
|
/// Chord sequence accumulated so far.
|
|
sequence: Sequence,
|
|
},
|
|
/// No binding for this sequence. The accumulated prefix is
|
|
/// returned so callers can report it; the dispatcher's internal
|
|
/// state is now reset.
|
|
Unbound {
|
|
/// The dead-end sequence.
|
|
sequence: Sequence,
|
|
},
|
|
}
|
|
|
|
/// Per-session multi-key dispatch state machine.
|
|
#[derive(Default)]
|
|
pub struct KeyDispatcher {
|
|
pending: Sequence,
|
|
}
|
|
|
|
impl KeyDispatcher {
|
|
/// An empty dispatcher with no pending prefix.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Currently-pending prefix (immutable view).
|
|
#[must_use]
|
|
pub fn pending(&self) -> &[Chord] {
|
|
&self.pending
|
|
}
|
|
|
|
/// Reset the dispatcher's pending prefix.
|
|
pub fn reset(&mut self) {
|
|
self.pending.clear();
|
|
}
|
|
|
|
/// Consume one chord and return the resulting [`Action`].
|
|
pub fn dispatch(
|
|
&mut self,
|
|
chord: Chord,
|
|
stack: &KeymapStack,
|
|
active_buffer: Option<BufferId>,
|
|
active_modes: &[&str],
|
|
) -> Action {
|
|
self.pending.push(chord);
|
|
match stack.resolve(&self.pending, active_buffer, active_modes) {
|
|
StackResolution::Bound(rb) => {
|
|
let sequence = std::mem::take(&mut self.pending);
|
|
Action::Run {
|
|
command: rb.binding.command,
|
|
sequence,
|
|
scope: rb.scope,
|
|
}
|
|
}
|
|
StackResolution::Pending => Action::Pending {
|
|
sequence: self.pending.clone(),
|
|
},
|
|
StackResolution::Unbound => {
|
|
let sequence = std::mem::take(&mut self.pending);
|
|
Action::Unbound { sequence }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::SourceLocation;
|
|
use crate::key::parse_sequence;
|
|
|
|
fn src(line: i32) -> SourceLocation {
|
|
SourceLocation {
|
|
file: "test.lua".into(),
|
|
line,
|
|
}
|
|
}
|
|
|
|
fn seq(s: &str) -> Sequence {
|
|
parse_sequence(s).unwrap()
|
|
}
|
|
|
|
fn buf() -> BufferId {
|
|
BufferId::next()
|
|
}
|
|
|
|
#[test]
|
|
fn global_resolves() {
|
|
let mut s = KeymapStack::new();
|
|
s.bind_global(&seq("C-s"), "save", src(1)).unwrap();
|
|
let r = s.resolve(&seq("C-s"), None, &[]);
|
|
match r {
|
|
StackResolution::Bound(rb) => {
|
|
assert_eq!(rb.binding.command, "save");
|
|
assert_eq!(rb.scope, Scope::Global);
|
|
}
|
|
other => panic!("expected Bound, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn buffer_local_overrides_global() {
|
|
let mut s = KeymapStack::new();
|
|
let id = buf();
|
|
s.bind_global(&seq("C-s"), "global.save", src(1)).unwrap();
|
|
s.bind_buffer(id, &seq("C-s"), "buffer.save", src(2))
|
|
.unwrap();
|
|
match s.resolve(&seq("C-s"), Some(id), &[]) {
|
|
StackResolution::Bound(rb) => {
|
|
assert_eq!(rb.binding.command, "buffer.save");
|
|
assert_eq!(rb.scope, Scope::Buffer(id));
|
|
}
|
|
other => panic!("expected buffer-local Bound, got {other:?}"),
|
|
}
|
|
// Different buffer: falls through to global.
|
|
match s.resolve(&seq("C-s"), Some(buf()), &[]) {
|
|
StackResolution::Bound(rb) => {
|
|
assert_eq!(rb.binding.command, "global.save");
|
|
assert_eq!(rb.scope, Scope::Global);
|
|
}
|
|
other => panic!("expected Global Bound, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mode_overrides_global_but_not_buffer() {
|
|
let mut s = KeymapStack::new();
|
|
let id = buf();
|
|
s.bind_global(&seq("C-s"), "global.save", src(1)).unwrap();
|
|
s.bind_mode("normal", &seq("C-s"), "mode.save", src(2))
|
|
.unwrap();
|
|
s.bind_buffer(id, &seq("C-s"), "buffer.save", src(3))
|
|
.unwrap();
|
|
// Buffer wins.
|
|
match s.resolve(&seq("C-s"), Some(id), &["normal"]) {
|
|
StackResolution::Bound(rb) => assert_eq!(rb.binding.command, "buffer.save"),
|
|
other => panic!("got {other:?}"),
|
|
}
|
|
// No buffer: mode wins.
|
|
match s.resolve(&seq("C-s"), None, &["normal"]) {
|
|
StackResolution::Bound(rb) => {
|
|
assert_eq!(rb.binding.command, "mode.save");
|
|
assert_eq!(rb.scope, Scope::Mode("normal".into()));
|
|
}
|
|
other => panic!("got {other:?}"),
|
|
}
|
|
// No mode: global wins.
|
|
match s.resolve(&seq("C-s"), None, &[]) {
|
|
StackResolution::Bound(rb) => assert_eq!(rb.binding.command, "global.save"),
|
|
other => panic!("got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pending_in_global_yields_pending_overall() {
|
|
let mut s = KeymapStack::new();
|
|
s.bind_global(&seq("C-x C-s"), "save", src(1)).unwrap();
|
|
assert_eq!(s.resolve(&seq("C-x"), None, &[]), StackResolution::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn unbound_when_no_scope_matches() {
|
|
let s = KeymapStack::new();
|
|
assert_eq!(s.resolve(&seq("C-q"), None, &[]), StackResolution::Unbound);
|
|
}
|
|
|
|
#[test]
|
|
fn pending_in_one_scope_propagates_when_others_unbound() {
|
|
// If buffer-local has a pending prefix and global has nothing,
|
|
// the overall result is Pending.
|
|
let mut s = KeymapStack::new();
|
|
let id = buf();
|
|
s.bind_buffer(id, &seq("C-x C-s"), "buf.save", src(1))
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.resolve(&seq("C-x"), Some(id), &[]),
|
|
StackResolution::Pending
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatcher_state_machine_runs_on_complete_sequence() {
|
|
let mut s = KeymapStack::new();
|
|
s.bind_global(&seq("C-x C-s"), "save", src(1)).unwrap();
|
|
let mut d = KeyDispatcher::new();
|
|
let chords = seq("C-x C-s");
|
|
|
|
// First chord: pending.
|
|
match d.dispatch(chords[0], &s, None, &[]) {
|
|
Action::Pending { sequence } => assert_eq!(sequence.len(), 1),
|
|
other => panic!("expected Pending, got {other:?}"),
|
|
}
|
|
// Second chord: fires the command.
|
|
match d.dispatch(chords[1], &s, None, &[]) {
|
|
Action::Run {
|
|
command, sequence, ..
|
|
} => {
|
|
assert_eq!(command, "save");
|
|
assert_eq!(sequence.len(), 2);
|
|
}
|
|
other => panic!("expected Run, got {other:?}"),
|
|
}
|
|
// Pending is reset.
|
|
assert!(d.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dispatcher_resets_on_unbound_sequence() {
|
|
let mut s = KeymapStack::new();
|
|
s.bind_global(&seq("C-x C-s"), "save", src(1)).unwrap();
|
|
let mut d = KeyDispatcher::new();
|
|
let cx = parse_sequence("C-x").unwrap()[0];
|
|
let cq = parse_sequence("C-q").unwrap()[0];
|
|
|
|
let _ = d.dispatch(cx, &s, None, &[]);
|
|
match d.dispatch(cq, &s, None, &[]) {
|
|
Action::Unbound { sequence } => {
|
|
assert_eq!(sequence.len(), 2);
|
|
assert_eq!(sequence[0], cx);
|
|
assert_eq!(sequence[1], cq);
|
|
}
|
|
other => panic!("expected Unbound, got {other:?}"),
|
|
}
|
|
assert!(d.pending().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unbind_buffer_prunes_empty_map() {
|
|
let mut s = KeymapStack::new();
|
|
let id = buf();
|
|
s.bind_buffer(id, &seq("C-s"), "save", src(1)).unwrap();
|
|
s.unbind_buffer(id, &seq("C-s")).unwrap();
|
|
assert!(!s.buffers.contains_key(&id));
|
|
}
|
|
|
|
#[test]
|
|
fn unbind_mode_prunes_empty_map() {
|
|
let mut s = KeymapStack::new();
|
|
s.bind_mode("normal", &seq("C-s"), "save", src(1)).unwrap();
|
|
s.unbind_mode("normal", &seq("C-s")).unwrap();
|
|
assert!(!s.modes.iter().any(|(n, _)| n == "normal"));
|
|
}
|
|
|
|
#[test]
|
|
fn iter_all_visits_every_binding_with_scope() {
|
|
let mut s = KeymapStack::new();
|
|
let id = buf();
|
|
s.bind_global(&seq("C-q"), "quit", src(1)).unwrap();
|
|
s.bind_mode("normal", &seq("C-s"), "save", src(2)).unwrap();
|
|
s.bind_buffer(id, &seq("C-z"), "undo", src(3)).unwrap();
|
|
let mut got: Vec<String> = s
|
|
.iter_all()
|
|
.into_iter()
|
|
.map(|(scope, seq, b)| {
|
|
format!(
|
|
"{}:{} -> {}",
|
|
scope.render(),
|
|
display_sequence(&seq),
|
|
b.command
|
|
)
|
|
})
|
|
.collect();
|
|
got.sort();
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
"buffer:C-z -> undo",
|
|
"global:C-q -> quit",
|
|
"mode:normal:C-s -> save",
|
|
]
|
|
);
|
|
}
|
|
|
|
// ---- proptest: dispatcher invariants -----------------------------------
|
|
//
|
|
// The auto-repeat regression we shipped earlier was a chord-event
|
|
// edge case the unit tests didn't cover. These properties throw
|
|
// random chord soup at the dispatcher under a plausible keymap
|
|
// and check the universal invariants the dispatcher promises.
|
|
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
use proptest::prelude::*;
|
|
|
|
fn small_chord_strategy() -> impl Strategy<Value = Chord> {
|
|
// Restrict to a small alphabet so prefix interactions actually
|
|
// collide instead of every chord being unique. ASCII letter +
|
|
// optional Control modifier is the realistic dispatcher input.
|
|
(any::<u8>(), any::<bool>()).prop_map(|(b, ctrl)| {
|
|
let ch = (b'a' + (b % 8)) as char;
|
|
let modifiers = if ctrl {
|
|
KeyModifiers::CONTROL
|
|
} else {
|
|
KeyModifiers::NONE
|
|
};
|
|
Chord::new(KeyCode::Char(ch), modifiers)
|
|
})
|
|
}
|
|
|
|
fn small_keymap() -> KeymapStack {
|
|
// A keymap with both single-chord bindings and multi-chord
|
|
// prefixes, so dispatching random sequences exercises every
|
|
// arm of the resolver.
|
|
let mut s = KeymapStack::new();
|
|
s.bind_global(&seq("C-a"), "go-line-start", src(1)).unwrap();
|
|
s.bind_global(&seq("C-e"), "go-line-end", src(2)).unwrap();
|
|
s.bind_global(&seq("C-x C-s"), "save", src(3)).unwrap();
|
|
s.bind_global(&seq("C-x C-c"), "quit", src(4)).unwrap();
|
|
s.bind_global(&seq("C-x b"), "switch-buffer", src(5))
|
|
.unwrap();
|
|
s.bind_global(&seq("C-c C-c"), "compile", src(6)).unwrap();
|
|
s
|
|
}
|
|
|
|
proptest! {
|
|
/// Universal invariant: after each dispatch, `pending` is
|
|
/// either empty (the last action was Run or Unbound) or
|
|
/// non-empty (the action was Pending). Never panics.
|
|
#[test]
|
|
fn pending_state_matches_last_action(chords in proptest::collection::vec(small_chord_strategy(), 1..30)) {
|
|
let stack = small_keymap();
|
|
let mut d = KeyDispatcher::new();
|
|
for chord in chords {
|
|
let action = d.dispatch(chord, &stack, None, &[]);
|
|
match action {
|
|
Action::Run { .. } | Action::Unbound { .. } => {
|
|
prop_assert!(d.pending().is_empty(), "pending should be empty after Run/Unbound");
|
|
}
|
|
Action::Pending { ref sequence } => {
|
|
prop_assert!(!d.pending().is_empty(), "pending must be non-empty after Pending");
|
|
prop_assert_eq!(d.pending(), sequence.as_slice(), "echoed sequence must match dispatcher pending");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A bound single-chord sequence always fires on first
|
|
/// dispatch when nothing else is pending.
|
|
#[test]
|
|
fn single_chord_binding_fires_immediately(_unused in 0..10u8) {
|
|
let stack = small_keymap();
|
|
let mut d = KeyDispatcher::new();
|
|
let chord = seq("C-a")[0];
|
|
let action = d.dispatch(chord, &stack, None, &[]);
|
|
match action {
|
|
Action::Run { command, .. } => prop_assert_eq!(command, "go-line-start".to_string()),
|
|
other => prop_assert!(false, "expected Run, got {:?}", other),
|
|
}
|
|
prop_assert!(d.pending().is_empty());
|
|
}
|
|
|
|
/// Pending depth never exceeds the longest binding's length.
|
|
/// This caught the auto-repeat regression in spirit:
|
|
/// pending should not grow without bound.
|
|
#[test]
|
|
fn pending_never_exceeds_max_binding_length(chords in proptest::collection::vec(small_chord_strategy(), 1..30)) {
|
|
// Longest binding in `small_keymap` is 2 chords.
|
|
const MAX_BINDING_LEN: usize = 2;
|
|
let stack = small_keymap();
|
|
let mut d = KeyDispatcher::new();
|
|
for chord in chords {
|
|
d.dispatch(chord, &stack, None, &[]);
|
|
prop_assert!(
|
|
d.pending().len() <= MAX_BINDING_LEN,
|
|
"pending depth {} exceeded max binding length {MAX_BINDING_LEN}",
|
|
d.pending().len()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Forming a known multi-chord binding by typing each chord
|
|
/// in sequence resolves to that binding on the final chord.
|
|
#[test]
|
|
fn typing_a_bound_sequence_produces_run(_unused in 0..10u8) {
|
|
let stack = small_keymap();
|
|
let target = seq("C-x C-s");
|
|
let mut d = KeyDispatcher::new();
|
|
// First chord: Pending (C-x is a known prefix).
|
|
let a = d.dispatch(target[0], &stack, None, &[]);
|
|
let is_pending = matches!(a, Action::Pending { .. });
|
|
prop_assert!(is_pending, "C-x should be Pending");
|
|
// Second chord: Run "save".
|
|
let a = d.dispatch(target[1], &stack, None, &[]);
|
|
match a {
|
|
Action::Run { command, .. } => prop_assert_eq!(command, "save".to_string()),
|
|
_ => prop_assert!(false, "expected Run save"),
|
|
}
|
|
prop_assert!(d.pending().is_empty());
|
|
}
|
|
|
|
/// Unrelated chord while a prefix is pending: the dispatcher
|
|
/// reports Unbound for the accumulated sequence and resets
|
|
/// state. This is what saves the user from a stuck prefix.
|
|
#[test]
|
|
fn unrelated_chord_after_prefix_reports_unbound_and_resets(byte in 0u8..6) {
|
|
let stack = small_keymap();
|
|
let mut d = KeyDispatcher::new();
|
|
// Start a known prefix.
|
|
let pending = d.dispatch(seq("C-x")[0], &stack, None, &[]);
|
|
let is_pending = matches!(pending, Action::Pending { .. });
|
|
prop_assert!(is_pending);
|
|
// A chord that doesn't extend C-x: pick `q` (bound nowhere
|
|
// in the keymap, including not after C-x).
|
|
let unbound_chord = Chord::new(KeyCode::Char((b'q' + byte) as char), KeyModifiers::NONE);
|
|
let action = d.dispatch(unbound_chord, &stack, None, &[]);
|
|
let is_unbound = matches!(action, Action::Unbound { .. });
|
|
prop_assert!(is_unbound, "expected Unbound");
|
|
prop_assert!(d.pending().is_empty());
|
|
}
|
|
}
|
|
}
|