use keymap_config::to_toml;
use keymap_core::{
BreakReason, Key, KeyInput, Keymap, LegacyForm, Modifiers, RebindVerdict, validate_rebind,
};
use keymap_seq::SequenceKeymap;
use keymap_term::{DecodeMode, Decoded, decode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
CursorDown,
Save,
Split,
}
impl Action {
fn name(self) -> &'static str {
match self {
Action::CursorDown => "cursor_down",
Action::Save => "save",
Action::Split => "split",
}
}
}
fn main() {
let reserved = [
KeyInput::new(Key::Esc, Modifiers::NONE),
KeyInput::new(Key::Char('c'), Modifiers::CTRL),
];
let mut global = Keymap::new();
global.bind(
KeyInput::new(Key::Char('j'), Modifiers::NONE),
Action::CursorDown,
);
global.bind(
KeyInput::new(Key::Char('x'), Modifiers::CTRL),
Action::Split,
);
println!("== capture: what the terminal actually delivers ==");
capture_demo();
println!("\n== validate before mutate: the escape hatch must survive ==");
let cs = capture(&[0x13], DecodeMode::Baseline).expect("ctrl+s decodes to a key");
let layers = [&global];
report(
"rebind Save ->",
cs,
&validate_rebind(&layers, 0, cs, &reserved),
);
let esc = capture(&[0x1b], DecodeMode::Baseline).expect("esc decodes to a key");
report(
"rebind Save ->",
esc,
&validate_rebind(&layers, 0, esc, &reserved),
);
let j = capture(&[0x6a], DecodeMode::Baseline).expect("'j' decodes to a key");
report(
"rebind Split ->",
j,
&validate_rebind(&layers, 0, j, &reserved),
);
println!("\n== composed resolution: an upper layer cannot shadow reserved ==");
let overlay: Keymap<Action> = Keymap::new();
let two = [&overlay, &global];
report(
"rebind Save into overlay ->",
esc,
&validate_rebind(&two, 0, esc, &reserved),
);
println!("\n== legacy collapse: ctrl+i ≡ tab on a baseline terminal ==");
let tab_reserved = [KeyInput::new(Key::Tab, Modifiers::NONE)];
let ctrl_i = KeyInput::new(Key::Char('i'), Modifiers::CTRL);
report(
"rebind Save ->",
ctrl_i,
&validate_rebind(&layers, 0, ctrl_i, &tab_reserved),
);
println!("\n== persist: serialize for the caller to write ==");
if let RebindVerdict::Allowed { .. } = validate_rebind(&layers, 0, cs, &reserved) {
global.bind(cs, Action::Save);
}
let toml = to_toml(&global, &SequenceKeymap::<Action>::new(), |a| {
Some(a.name())
});
print!("{toml}");
println!(
"// Write this to the user's *config directory* (a trusted path), where the\n\
// reserved guard above is advisory. Pointing a rebind at a PTY-writable\n\
// per-project file instead crosses a trust boundary: there the guard must\n\
// be a hard error (see docs/ROADMAP.md). Round-trip is semantic, not\n\
// byte-exact — comments, ordering, and `shift+a`-style spellings are lost."
);
}
fn capture(bytes: &[u8], mode: DecodeMode) -> Option<KeyInput> {
match decode(bytes, mode) {
Decoded::Key { input, .. } => Some(input),
_ => None,
}
}
fn capture_demo() {
show("alt+a (bytes c3 a5)", &[0xc3, 0xa5]);
show("ctrl+i (byte 09)", &[0x09]);
show("truncated CSI (bytes 1b 5b)", &[0x1b, 0x5b]);
show("invalid byte (ff)", &[0xff]);
}
fn show(label: &str, bytes: &[u8]) {
match capture(bytes, DecodeMode::Baseline) {
Some(input) => println!(" {label} -> captured {input}"),
None => match decode(bytes, DecodeMode::Baseline) {
Decoded::Incomplete => println!(" {label} -> incomplete, keep reading"),
_ => println!(" {label} -> not a key, rejected"),
},
}
}
fn report(what: &str, proposed: KeyInput, verdict: &RebindVerdict<'_, Action>) {
match verdict {
RebindVerdict::BreaksReserved { reserved, reason } => {
let why = match reason {
BreakReason::DirectSteal => "would steal this reserved key",
BreakReason::LegacyCollapse => "collapses onto a reserved key on legacy terminals",
_ => "breaks a reserved key",
};
println!(" {what} {proposed}: REFUSED — {why} ({reserved})");
}
RebindVerdict::Allowed { shadows, legacy } => {
let mut notes = Vec::new();
if let Some(prev) = shadows {
notes.push(format!("overrides {}", prev.name()));
}
match legacy {
LegacyForm::Representable => {}
LegacyForm::CollapsesTo(t) => notes.push(format!("legacy: indistinct from {t}")),
LegacyForm::Unrepresentable => {
notes.push("legacy: not deliverable on a C0 terminal".to_string());
}
}
let suffix = if notes.is_empty() {
String::new()
} else {
format!(" ({})", notes.join("; "))
};
println!(" {what} {proposed}: allowed{suffix}");
}
_ => println!(" {what} {proposed}: (unrecognised verdict)"),
}
}