ci / rust (push) Successful in 8m45s
Three dogfood-driven UX fixes:
1. `AppState::attach_to_focused` now sets `mode = Mode::Insert` along with
`focus = Focus::Pane`, so pressing Enter on a sidebar row drops the user
straight into typing the remote shell. The leader (default `ctrl-a`)
returns to Normal — same surface as tmux's prefix key.
2. `Keymap::entries(mode)` exposes the live binding table; `App::dispatch
(Action::ShowHelp)` (and `App::new`) rebuild `HelpOverlay::lines` from
it. Users see the actual Normal + Insert bindings + the leader hint,
not just "? to close".
3. Visible blinking cursor in both surfaces that accept text:
- `Pane::render` calls `Frame::set_cursor_position` at the vt100 cursor
position when the pane is focused (and the screen hasn't hidden the
cursor itself). Terminals do the blink.
- `SpawnModal::render` places the cursor at the end of whichever text
field is currently focused (cwd / command / label). Daemon dropdown
and the Submit button still rely on the REVERSED style.
Workspace clippy + 85 unit tests passing.
171 lines
5.4 KiB
Rust
171 lines
5.4 KiB
Rust
//! `Keymap` — two-level `(mode → input → action)` table.
|
|
//!
|
|
//! Inputs and actions are validated against [`InputKey::parse`] and
|
|
//! [`Action::parse`] at `set` time, so a bad TOML entry is rejected at
|
|
//! TUI startup with a precise diagnostic (offending line in the caller's
|
|
//! report).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use crate::action::{Action, ActionParseError};
|
|
use crate::input::{InputKey, InputParseError};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum Mode {
|
|
Normal,
|
|
Insert,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum KeymapError {
|
|
#[error("bad input key: {0}")]
|
|
BadInput(#[from] InputParseError),
|
|
#[error("bad action: {0}")]
|
|
BadAction(#[from] ActionParseError),
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Keymap {
|
|
normal: HashMap<String, Action>,
|
|
insert: HashMap<String, Action>,
|
|
}
|
|
|
|
impl Keymap {
|
|
/// Bind a canonical input string to an action under a mode. Validates
|
|
/// every token of the input chord and the action name; either failure
|
|
/// surfaces as a [`KeymapError`].
|
|
pub fn set(&mut self, mode: Mode, input: &str, action: &str) -> Result<(), KeymapError> {
|
|
// Validate each whitespace-separated token of the chord.
|
|
for tok in input.split_whitespace() {
|
|
InputKey::parse(tok)?;
|
|
}
|
|
let action = Action::parse(action)?;
|
|
let table = self.table_mut(mode);
|
|
table.insert(input.to_string(), action);
|
|
Ok(())
|
|
}
|
|
|
|
/// Look up the action bound to a fully-resolved chord (post-[`ChordState`]
|
|
/// resolution). Returns `None` if nothing is bound.
|
|
pub fn resolve(&self, mode: Mode, canonical: &str) -> Option<Action> {
|
|
self.table(mode).get(canonical).cloned()
|
|
}
|
|
|
|
/// All bound chord strings for the given mode. [`ChordState`] uses this
|
|
/// to decide whether to hold a key as a chord prefix.
|
|
pub fn known_bindings(&self, mode: Mode) -> HashSet<String> {
|
|
self.table(mode).keys().cloned().collect()
|
|
}
|
|
|
|
/// Every `(canonical input, action)` pair currently bound for `mode`,
|
|
/// sorted lexicographically by input string. Used by the help overlay
|
|
/// so the rendering order is stable and discoverable.
|
|
pub fn entries(&self, mode: Mode) -> Vec<(String, Action)> {
|
|
let mut out: Vec<(String, Action)> = self
|
|
.table(mode)
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect();
|
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
out
|
|
}
|
|
|
|
fn table(&self, mode: Mode) -> &HashMap<String, Action> {
|
|
match mode {
|
|
Mode::Normal => &self.normal,
|
|
Mode::Insert => &self.insert,
|
|
}
|
|
}
|
|
|
|
fn table_mut(&mut self, mode: Mode) -> &mut HashMap<String, Action> {
|
|
match mode {
|
|
Mode::Normal => &mut self.normal,
|
|
Mode::Insert => &mut self.insert,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Direction;
|
|
|
|
fn build(entries: &[(Mode, &str, &str)]) -> Keymap {
|
|
let mut k = Keymap::default();
|
|
for (mode, input, action) in entries {
|
|
k.set(*mode, input, action).unwrap();
|
|
}
|
|
k
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_single_key() {
|
|
let k = build(&[(Mode::Normal, "i", "EnterInsert")]);
|
|
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
|
|
assert_eq!(k.resolve(Mode::Insert, "i"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_chord() {
|
|
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
|
|
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_argful_action() {
|
|
let k = build(&[(Mode::Normal, "ctrl-w h", "FocusPane(Left)")]);
|
|
assert_eq!(
|
|
k.resolve(Mode::Normal, "ctrl-w h"),
|
|
Some(Action::FocusPane(Direction::Left)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn known_bindings_sees_everything() {
|
|
let k = build(&[
|
|
(Mode::Normal, "g t", "NextTab"),
|
|
(Mode::Normal, "i", "EnterInsert"),
|
|
]);
|
|
let kb = k.known_bindings(Mode::Normal);
|
|
assert!(kb.contains("g t"));
|
|
assert!(kb.contains("i"));
|
|
assert_eq!(kb.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn modes_are_independent() {
|
|
let k = build(&[
|
|
(Mode::Normal, "i", "EnterInsert"),
|
|
(Mode::Insert, "ctrl-a", "LeaveInsert"),
|
|
]);
|
|
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
|
|
assert_eq!(k.resolve(Mode::Normal, "ctrl-a"), None);
|
|
assert_eq!(k.resolve(Mode::Insert, "ctrl-a"), Some(Action::LeaveInsert));
|
|
}
|
|
|
|
#[test]
|
|
fn reports_bad_input_at_load() {
|
|
let mut k = Keymap::default();
|
|
let err = k
|
|
.set(Mode::Normal, "ctrl-glorb", "EnterInsert")
|
|
.unwrap_err();
|
|
assert!(matches!(err, KeymapError::BadInput(_)), "got {err:?}");
|
|
assert!(format!("{err}").contains("ctrl-glorb"));
|
|
}
|
|
|
|
#[test]
|
|
fn reports_bad_action_at_load() {
|
|
let mut k = Keymap::default();
|
|
let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
|
|
assert!(matches!(err, KeymapError::BadAction(_)), "got {err:?}");
|
|
assert!(format!("{err}").contains("Frobnicate"));
|
|
}
|
|
|
|
#[test]
|
|
fn rebinding_replaces_previous() {
|
|
let mut k = build(&[(Mode::Normal, "i", "EnterInsert")]);
|
|
k.set(Mode::Normal, "i", "ShowHelp").unwrap();
|
|
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::ShowHelp));
|
|
}
|
|
}
|