This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/input.rs
T

819 lines
27 KiB
Rust

//! `InputKey` — canonical, hashable representation of one keyboard or mouse
//! event. Used as the key in the keymap table (so users can write
//! `"ctrl-w h"` or `"mouse:click(pane)"` in TOML and we match the same string
//! at runtime).
//!
//! Also home to [`ChordState`], the small state machine that holds keys when
//! they extend a known prefix (`g` waiting for `t` to make `g t`).
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crossterm::event::{
Event as CtEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
/// Canonical input event. `Char` carries modifier flags; specials get their
/// own variant. Mouse events carry a resolved `Region` so the keymap can bind
/// per-region (`mouse:click(pane)` vs `mouse:click(sidebar-row)`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputKey {
Char {
c: char,
ctrl: bool,
alt: bool,
shift: bool,
},
Esc,
Enter,
Tab,
/// Shift-Tab. crossterm reports it as its own key code, not a modified Tab.
BackTab,
Backspace,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
Paste(String),
Mouse {
kind: MouseKind,
region: Region,
col: u16,
row: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
/// Regions the renderer tags so the input router can bind mouse events to
/// specific UI surfaces. The render layer fills in the `RegionMap` (added in
/// NAP-280); for now only the enum is needed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
SidebarRow,
Pane,
Modal,
Unknown,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InputParseError {
#[error("malformed input key {0:?}")]
Malformed(String),
#[error("unknown mouse kind {0:?}")]
UnknownMouseKind(String),
#[error("unknown region {0:?}")]
UnknownRegion(String),
}
impl InputKey {
/// Construct a bare-character key (no modifiers).
pub fn char(c: char) -> Self {
Self::Char {
c,
ctrl: false,
alt: false,
shift: false,
}
}
/// Construct a `ctrl-<c>` key.
pub fn ctrl_char(c: char) -> Self {
Self::Char {
c,
ctrl: true,
alt: false,
shift: false,
}
}
/// Canonical-string form used by the keymap table. Round-trips with
/// [`InputKey::parse`].
pub fn canonical(&self) -> String {
match self {
InputKey::Char {
c,
ctrl,
alt,
shift,
} => {
let mut out = String::new();
if *ctrl {
out.push_str("ctrl-");
}
if *alt {
out.push_str("alt-");
}
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
out
}
InputKey::Esc => "esc".into(),
InputKey::Enter => "enter".into(),
InputKey::Tab => "tab".into(),
InputKey::BackTab => "backtab".into(),
InputKey::Backspace => "backspace".into(),
InputKey::Up => "up".into(),
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::PageUp => "pageup".into(),
InputKey::PageDown => "pagedown".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
InputKey::Paste(_) => "paste".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
let r = match region {
Region::SidebarRow => "sidebar-row",
Region::Pane => "pane",
Region::Modal => "modal",
Region::Unknown => "unknown",
};
format!("mouse:{k}({r})")
}
}
}
/// Parse a canonical string into an [`InputKey`]. Mouse events parse with
/// `col = row = 0`; in practice mouse keys are only constructed from real
/// events, but parsing is needed so [`Keymap::set`](crate::keymap::Keymap::set)
/// can validate keymap entries written in TOML.
pub fn parse(s: &str) -> Result<Self, InputParseError> {
if let Some(rest) = s.strip_prefix("mouse:") {
let (k, rest) = rest
.split_once('(')
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
let r = rest
.strip_suffix(')')
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
let kind = match k {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
};
let region = match r {
"sidebar-row" => Region::SidebarRow,
"pane" => Region::Pane,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
other => return Err(InputParseError::UnknownRegion(other.into())),
};
return Ok(InputKey::Mouse {
kind,
region,
col: 0,
row: 0,
});
}
match s {
"esc" => return Ok(InputKey::Esc),
"enter" => return Ok(InputKey::Enter),
"tab" => return Ok(InputKey::Tab),
"backtab" => return Ok(InputKey::BackTab),
"backspace" => return Ok(InputKey::Backspace),
"up" => return Ok(InputKey::Up),
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"pageup" => return Ok(InputKey::PageUp),
"pagedown" => return Ok(InputKey::PageDown),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
let mut ctrl = false;
let mut alt = false;
let mut shift = false;
let mut rest = s;
loop {
if let Some(r) = rest.strip_prefix("ctrl-") {
ctrl = true;
rest = r;
continue;
}
if let Some(r) = rest.strip_prefix("alt-") {
alt = true;
rest = r;
continue;
}
if let Some(r) = rest.strip_prefix("shift-") {
shift = true;
rest = r;
continue;
}
break;
}
let mut chars = rest.chars();
let c = chars
.next()
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
if chars.next().is_some() {
return Err(InputParseError::Malformed(s.into()));
}
Ok(InputKey::Char {
c,
ctrl,
alt,
shift,
})
}
}
/// Convert a raw `crossterm` event into a canonical [`InputKey`]. `region_for`
/// is the renderer's hit-test closure (resolves a screen coordinate to one
/// tagged region).
pub fn canonicalise<F>(ev: &CtEvent, region_for: F) -> Option<InputKey>
where
F: FnOnce(u16, u16) -> Region,
{
match ev {
CtEvent::Key(KeyEvent {
code, modifiers, ..
}) => {
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
let alt = modifiers.contains(KeyModifiers::ALT);
let shift = modifiers.contains(KeyModifiers::SHIFT);
Some(match code {
KeyCode::Char(c) => InputKey::Char {
c: *c,
ctrl,
alt,
shift,
},
KeyCode::Esc => InputKey::Esc,
KeyCode::Enter => InputKey::Enter,
KeyCode::Tab => InputKey::Tab,
KeyCode::BackTab => InputKey::BackTab,
KeyCode::Backspace => InputKey::Backspace,
KeyCode::Up => InputKey::Up,
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::PageUp => InputKey::PageUp,
KeyCode::PageDown => InputKey::PageDown,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
}
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
CtEvent::Mouse(MouseEvent {
kind, column, row, ..
}) => {
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
};
let region = region_for(*column, *row);
Some(InputKey::Mouse {
kind: mk,
region,
col: *column,
row: *row,
})
}
_ => None,
}
}
/// How long after a click a second click at the same cell counts as a
/// double-click. Matches the common desktop default.
pub const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(400);
/// Remembers the last click so a second one at the same cell can be promoted to
/// [`MouseKind::DblClick`].
///
/// Kept out of [`canonicalise`], which stays a pure function of one event.
#[derive(Debug, Default)]
pub struct ClickTracker {
last: Option<(u16, u16, Instant)>,
}
impl ClickTracker {
pub fn new() -> Self {
Self::default()
}
/// Upgrade a `Click` to a `DblClick` when it *immediately* follows another
/// click at the same cell inside [`DOUBLE_CLICK_WINDOW`].
///
/// Anything else passes through and clears the pending click: two clicks
/// with a drag or a keystroke between them are two clicks, however fast.
///
/// A promoted click also resets the tracker, so three rapid clicks are a
/// click then a double-click, not two double-clicks.
pub fn promote(&mut self, key: InputKey, now: Instant) -> InputKey {
let InputKey::Mouse {
kind: MouseKind::Click,
region,
col,
row,
} = key
else {
self.last = None;
return key;
};
let is_double = matches!(
self.last,
Some((c, r, at))
if c == col && r == row && now.duration_since(at) <= DOUBLE_CLICK_WINDOW
);
if is_double {
self.last = None;
return InputKey::Mouse {
kind: MouseKind::DblClick,
region,
col,
row,
};
}
self.last = Some((col, row, now));
key
}
}
/// Result of feeding one key into [`ChordState`]. `Pending` = held because the
/// current sequence is a strict prefix of some known binding; `Resolved` =
/// dispatch this canonical string against the keymap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChordResult {
Pending,
Resolved(String),
}
/// Tracks the partial-chord state ("the user has pressed `g`, waiting up to
/// `timeout` for the next key"). Per-cycle: when a key comes in, decide
/// whether to hold or resolve.
#[derive(Debug)]
pub struct ChordState {
held: Vec<String>,
last_press: Option<Instant>,
timeout: Duration,
/// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting
/// to be replayed as a fresh keystroke by the caller.
replay: Option<InputKey>,
}
impl ChordState {
pub fn new(timeout: Duration) -> Self {
Self {
held: Vec::new(),
last_press: None,
timeout,
replay: None,
}
}
/// Feed one canonical key.
///
/// * `Pending` — the held sequence is a strict prefix of at least one
/// binding; wait for the next key.
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
/// up in the keymap. The held buffer is cleared.
///
/// A held prefix that is itself directly bound (e.g. a leader key bound
/// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves
/// to that binding the moment the next key fails to extend it, rather
/// than swallowing the held key and losing its meaning — the new key
/// never belonged to the chord and is handed back via [`Self::take_replay`]
/// so the caller can process it as its own keystroke.
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
if let Some(t) = self.last_press {
if t.elapsed() > self.timeout {
self.held.clear();
}
}
let key_s = key.canonical();
let candidate = if self.held.is_empty() {
key_s.clone()
} else {
format!("{} {}", self.held.join(" "), key_s)
};
// A "strict prefix" check: is there a known binding that starts with
// `candidate + ' '`? If so, we should hold and wait for more.
let prefix_marker = format!("{candidate} ");
let is_prefix = known_bindings
.iter()
.any(|kb| kb.starts_with(&prefix_marker));
self.last_press = Some(Instant::now());
if is_prefix {
self.held.push(key_s);
return ChordResult::Pending;
}
if known_bindings.contains(&candidate) {
self.held.clear();
return ChordResult::Resolved(candidate);
}
if !self.held.is_empty() {
let held_str = self.held.join(" ");
self.held.clear();
if known_bindings.contains(&held_str) {
self.replay = Some(key.clone());
return ChordResult::Resolved(held_str);
}
}
ChordResult::Resolved(candidate)
}
/// Take the key bumped out of an abandoned chord by the last [`Self::feed`]
/// call, if any. The caller feeds it through again as a brand new
/// keystroke.
pub fn take_replay(&mut self) -> Option<InputKey> {
self.replay.take()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn click(col: u16, row: u16) -> InputKey {
InputKey::Mouse {
kind: MouseKind::Click,
region: Region::SidebarRow,
col,
row,
}
}
/// The shipped default binds `mouse:dblclick(sidebar-row)`. Nothing could
/// produce a DblClick, so that binding could never fire.
#[test]
fn a_second_click_at_the_same_cell_becomes_a_double_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
assert_eq!(t.promote(click(3, 4), t0), click(3, 4));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(100)),
InputKey::Mouse {
kind: MouseKind::DblClick,
region: Region::SidebarRow,
col: 3,
row: 4,
}
);
}
#[test]
fn a_slow_second_click_stays_a_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let late = t0 + DOUBLE_CLICK_WINDOW + Duration::from_millis(1);
assert_eq!(t.promote(click(3, 4), late), click(3, 4));
}
#[test]
fn a_second_click_elsewhere_stays_a_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
assert_eq!(
t.promote(click(9, 4), t0 + Duration::from_millis(10)),
click(9, 4)
);
}
/// Three fast clicks are click, double-click, click — not two doubles.
#[test]
fn a_promoted_click_resets_the_tracker() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(1, 1), t0);
let second = t.promote(click(1, 1), t0 + Duration::from_millis(50));
assert!(matches!(
second,
InputKey::Mouse {
kind: MouseKind::DblClick,
..
}
));
let third = t.promote(click(1, 1), t0 + Duration::from_millis(100));
assert_eq!(third, click(1, 1));
}
#[test]
fn non_click_events_pass_through_untouched() {
let mut t = ClickTracker::new();
assert_eq!(t.promote(InputKey::Enter, Instant::now()), InputKey::Enter);
}
/// A drag between two clicks means the user dragged, not double-clicked —
/// even at the same cell and well inside the window.
#[test]
fn a_drag_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let drag = InputKey::Mouse {
kind: MouseKind::Drag,
region: Region::SidebarRow,
col: 3,
row: 4,
};
assert_eq!(
t.promote(drag.clone(), t0 + Duration::from_millis(20)),
drag
);
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4),
"a drag must clear the pending click"
);
}
#[test]
fn a_keystroke_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
t.promote(InputKey::Enter, t0 + Duration::from_millis(20));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4)
);
}
/// A scroll also counts as an intervening event.
#[test]
fn a_scroll_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let scroll = InputKey::Mouse {
kind: MouseKind::ScrollUp,
region: Region::SidebarRow,
col: 3,
row: 4,
};
t.promote(scroll, t0 + Duration::from_millis(20));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4)
);
}
#[test]
fn back_tab_round_trips_through_the_canonical_form() {
assert_eq!(InputKey::BackTab.canonical(), "backtab");
assert_eq!(InputKey::parse("backtab").unwrap(), InputKey::BackTab);
}
/// The parser must not accept region names the renderer cannot produce.
#[test]
fn unproducible_region_names_are_rejected() {
assert!(InputKey::parse("mouse:click(sidebar)").is_err());
assert!(InputKey::parse("mouse:click(split-divider)").is_err());
assert!(InputKey::parse("mouse:click(sidebar-row)").is_ok());
}
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make every uppercase binding (the default `N`) unreachable from the
/// keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
"esc",
"enter",
"tab",
"backspace",
"up",
"down",
"left",
"right",
"home",
"end",
"pageup",
"pagedown",
"delete",
] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// A bracketed paste must arrive as one key carrying the whole payload,
/// not a burst of per-character keys.
#[test]
fn canonicalise_paste_event_carries_the_whole_payload() {
use crossterm::event::Event as CtEvent;
let ev = CtEvent::Paste("/srv/work/maestro".into());
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
}
#[test]
fn canonical_round_trips_mouse() {
let s = "mouse:click(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
fn parse_rejects_unknown_modifier() {
assert_eq!(
InputKey::parse("ctrl-glorb").unwrap_err(),
InputParseError::Malformed("ctrl-glorb".into()),
);
}
#[test]
fn parse_rejects_unknown_mouse_kind() {
assert_eq!(
InputKey::parse("mouse:zoom(pane)").unwrap_err(),
InputParseError::UnknownMouseKind("zoom".into()),
);
}
#[test]
fn parse_rejects_unknown_region() {
assert_eq!(
InputKey::parse("mouse:click(margin)").unwrap_err(),
InputParseError::UnknownRegion("margin".into()),
);
}
#[test]
fn canonicalise_key_event() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::ctrl_char('a'));
}
#[test]
fn canonicalise_mouse_event_uses_region_callback() {
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let ev = CtEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 5,
modifiers: KeyModifiers::NONE,
});
let k = canonicalise(&ev, |c, r| {
assert_eq!((c, r), (10, 5));
Region::Pane
})
.unwrap();
assert!(matches!(
k,
InputKey::Mouse {
kind: MouseKind::Click,
region: Region::Pane,
col: 10,
row: 5,
}
));
}
#[test]
fn chord_state_holds_known_prefix() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["g t".into()].into_iter().collect();
let r = chord.feed(&InputKey::char('g'), &known);
assert_eq!(r, ChordResult::Pending);
let r = chord.feed(&InputKey::char('t'), &known);
assert_eq!(r, ChordResult::Resolved("g t".into()));
}
#[test]
fn chord_state_drops_unknown_continuation() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["g t".into()].into_iter().collect();
chord.feed(&InputKey::char('g'), &known);
let r = chord.feed(&InputKey::char('x'), &known);
// 'g' was a prefix candidate, but 'g x' isn't a binding;
// resolver drops 'g' and treats 'x' as a fresh single-key resolve.
assert_eq!(r, ChordResult::Resolved("g x".into()));
}
/// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and
/// `ctrl-a ctrl-k`) must still fire its own binding when the next key
/// doesn't complete the chord — the held key's meaning isn't lost, and
/// the new key comes back via `take_replay` for the caller to process on
/// its own (#156).
#[test]
fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
let r = chord.feed(&InputKey::ctrl_char('a'), &known);
assert_eq!(r, ChordResult::Pending);
let r = chord.feed(&InputKey::char('j'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a".into()));
assert_eq!(chord.take_replay(), Some(InputKey::char('j')));
}
/// The full chord still resolves normally when the next key completes
/// it — no replay, no early fire of the shorter binding.
#[test]
fn a_completed_chord_off_a_bound_prefix_has_no_replay() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["ctrl-a".into(), "ctrl-a ctrl-k".into()]
.into_iter()
.collect();
chord.feed(&InputKey::ctrl_char('a'), &known);
let r = chord.feed(&InputKey::ctrl_char('k'), &known);
assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into()));
assert_eq!(chord.take_replay(), None);
}
#[test]
fn chord_state_lone_key_resolves_immediately() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["i".into(), "g t".into()].into_iter().collect();
let r = chord.feed(&InputKey::char('i'), &known);
// 'i' is a complete binding, not a strict prefix of anything → resolve.
assert_eq!(r, ChordResult::Resolved("i".into()));
}
}