use super::KeyBindings;
use super::action_shortcuts::ActionShortcuts;
use super::key_combo::{KeyCombo, KeyModifiers};
use super::key_strike::KeyStrike;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalKeys {
pub enhanced: bool,
pub ctrl_h_is_backspace: bool,
}
impl TerminalKeys {
pub const LEGACY: Self = Self {
enhanced: false,
ctrl_h_is_backspace: false,
};
pub fn detected(kitty_pushed: bool, ctrl_h_is_backspace: bool) -> Self {
Self {
enhanced: cfg!(windows) || kitty_pushed,
ctrl_h_is_backspace,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reach {
Ok,
Shadowed(KeyCombo),
Untransmitted,
}
impl Reach {
pub fn is_ok(self) -> bool {
self == Reach::Ok
}
}
impl std::fmt::Display for Reach {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Reach::Ok => f.write_str("arrives"),
Reach::Shadowed(by) => write!(f, "arrives as {by}"),
Reach::Untransmitted => f.write_str("not sent by this terminal"),
}
}
}
fn control_byte(key: KeyStrike) -> Option<u8> {
use KeyStrike::*;
let byte = match key {
k if (KeyA..=KeyZ).contains(&k) => (k as u8) - (KeyA as u8) + 0x01,
Space | Digit2 => 0x00,
Digit3 | BracketLeft => 0x1B,
Digit4 | Backslash => 0x1C,
Digit5 | BracketRight => 0x1D,
Digit6 => 0x1E,
Digit7 | Minus | Slash => 0x1F,
Digit8 => 0x7F,
_ => return Option::None,
};
Some(byte)
}
fn legacy_decode(byte: u8) -> (bool, KeyStrike) {
use KeyStrike::*;
match byte {
0x09 => (false, Tab),
0x0D => (false, Enter),
0x1B => (false, Escape),
0x7F => (false, Backspace),
0x00 => (true, Space),
0x01..=0x1A => (true, LETTERS[usize::from(byte - 0x01)]),
0x1C..=0x1F => (
true,
[Digit4, Digit5, Digit6, Digit7][usize::from(byte - 0x1C)],
),
_ => unreachable!("not a control byte: {byte:#04x}"),
}
}
const LETTERS: [KeyStrike; 26] = {
use KeyStrike::*;
[
KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, KeyO,
KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ,
]
};
pub fn reach(combo: KeyCombo, keys: TerminalKeys) -> Reach {
let bare_ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
if keys.ctrl_h_is_backspace && combo == bare_ctrl_h {
return Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace));
}
if keys.enhanced {
return Reach::Ok;
}
if !combo.modifiers.is_ctrl() {
return Reach::Ok;
}
let Some(byte) = control_byte(combo.key) else {
return Reach::Untransmitted;
};
let (ctrl, key) = legacy_decode(byte);
if ctrl && key == combo.key && !combo.modifiers.is_shift() {
return Reach::Ok;
}
let mut modifiers = KeyModifiers::new();
if combo.modifiers.is_alt() {
modifiers = modifiers.and_alt();
}
if ctrl {
modifiers = modifiers.and_ctrl();
}
Reach::Shadowed(KeyCombo::new(modifiers, key))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unreachable {
pub action: ActionShortcuts,
pub combos: Vec<(KeyCombo, Reach)>,
}
pub fn unreachable_actions(bindings: &KeyBindings, keys: TerminalKeys) -> Vec<Unreachable> {
let mut found: Vec<Unreachable> = bindings
.to_hashmap()
.into_iter()
.filter(|(_, combos)| !combos.iter().any(|c| reach(*c, keys).is_ok()))
.map(|(action, combos)| Unreachable {
combos: combos.into_iter().map(|c| (c, reach(c, keys))).collect(),
action,
})
.collect();
found.sort_by_key(|u| u.action.to_string());
found
}
#[cfg(test)]
mod tests {
use super::*;
fn ctrl(key: KeyStrike) -> KeyCombo {
KeyCombo::new(KeyModifiers::new().and_ctrl(), key)
}
fn ctrl_shift(key: KeyStrike) -> KeyCombo {
KeyCombo::new(KeyModifiers::new().and_ctrl().and_shift(), key)
}
#[test]
fn the_kitty_protocol_makes_everything_reachable() {
let enhanced = TerminalKeys {
enhanced: true,
ctrl_h_is_backspace: false,
};
for combo in [
ctrl(KeyStrike::KeyI),
ctrl(KeyStrike::KeyM),
ctrl(KeyStrike::Comma),
ctrl_shift(KeyStrike::KeyL),
ctrl(KeyStrike::BracketLeft),
] {
assert_eq!(reach(combo, enhanced), Reach::Ok, "{combo}");
}
}
#[test]
fn named_keys_win_their_bytes() {
assert_eq!(
reach(ctrl(KeyStrike::KeyI), TerminalKeys::LEGACY),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
);
assert_eq!(
reach(ctrl(KeyStrike::KeyM), TerminalKeys::LEGACY),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Enter))
);
assert_eq!(
reach(ctrl(KeyStrike::BracketLeft), TerminalKeys::LEGACY),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Escape))
);
}
#[test]
fn shift_is_lost_from_a_ctrl_chord() {
assert_eq!(
reach(ctrl_shift(KeyStrike::KeyL), TerminalKeys::LEGACY),
Reach::Shadowed(ctrl(KeyStrike::KeyL))
);
assert_eq!(
reach(ctrl_shift(KeyStrike::KeyI), TerminalKeys::LEGACY),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
);
}
#[test]
fn ctrl_h_follows_the_session_policy() {
assert_eq!(
reach(ctrl(KeyStrike::KeyH), TerminalKeys::LEGACY),
Reach::Ok
);
let backspace = Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace));
for enhanced in [false, true] {
let rewritten = TerminalKeys {
enhanced,
ctrl_h_is_backspace: true,
};
assert_eq!(
reach(ctrl(KeyStrike::KeyH), rewritten),
backspace,
"the rewrite is kimün's own, not the terminal's (enhanced={enhanced})"
);
assert_eq!(
reach(ctrl_shift(KeyStrike::KeyH), rewritten),
if enhanced {
Reach::Ok
} else {
Reach::Shadowed(ctrl(KeyStrike::KeyH))
}
);
}
}
#[test]
fn the_control_digits_alias_punctuation() {
assert_eq!(
reach(ctrl(KeyStrike::Backslash), TerminalKeys::LEGACY),
Reach::Shadowed(ctrl(KeyStrike::Digit4))
);
assert_eq!(
reach(ctrl(KeyStrike::BracketRight), TerminalKeys::LEGACY),
Reach::Shadowed(ctrl(KeyStrike::Digit5))
);
for digit in [KeyStrike::Digit4, KeyStrike::Digit7] {
assert_eq!(reach(ctrl(digit), TerminalKeys::LEGACY), Reach::Ok);
}
assert_eq!(
reach(ctrl(KeyStrike::Digit1), TerminalKeys::LEGACY),
Reach::Untransmitted
);
assert_eq!(
reach(ctrl(KeyStrike::Comma), TerminalKeys::LEGACY),
Reach::Untransmitted
);
}
#[test]
fn alt_chords_and_fkeys_always_arrive() {
for combo in [
KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::KeyI),
KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::KeyH),
KeyCombo::new(KeyModifiers::new(), KeyStrike::F4),
] {
assert_eq!(reach(combo, TerminalKeys::LEGACY), Reach::Ok, "{combo}");
}
}
#[test]
fn a_ctrl_alt_collision_keeps_its_esc_prefix() {
let combo = KeyCombo::new(KeyModifiers::new().and_ctrl().and_alt(), KeyStrike::KeyI);
assert_eq!(
reach(combo, TerminalKeys::LEGACY),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::Tab))
);
}
#[test]
fn the_scan_finds_an_action_with_no_usable_chord() {
let mut kb = KeyBindings::empty();
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyI, ActionShortcuts::QuickNote)
.add(KeyStrike::KeyM, ActionShortcuts::Quit)
.add(KeyStrike::KeyQ, ActionShortcuts::Quit);
let found = unreachable_actions(&kb, TerminalKeys::LEGACY);
assert_eq!(found.len(), 1, "only QuickNote is stranded: {found:?}");
assert_eq!(found[0].action, ActionShortcuts::QuickNote);
assert_eq!(
found[0].combos,
vec![(
ctrl(KeyStrike::KeyI),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
)]
);
}
#[test]
fn the_scan_finds_nothing_under_the_kitty_protocol() {
let mut kb = KeyBindings::empty();
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyI, ActionShortcuts::QuickNote);
let enhanced = TerminalKeys {
enhanced: true,
ctrl_h_is_backspace: false,
};
assert!(unreachable_actions(&kb, enhanced).is_empty());
}
#[test]
fn the_scan_is_silent_for_the_default_keymap() {
let kb = crate::settings::AppSettings::default().key_bindings;
for keys in [
TerminalKeys::LEGACY,
TerminalKeys {
enhanced: true,
ctrl_h_is_backspace: false,
},
] {
let found = unreachable_actions(&kb, keys);
assert!(found.is_empty(), "{keys:?} stranded {found:?}");
}
}
#[test]
fn an_ordinary_ctrl_letter_arrives() {
for key in [KeyStrike::KeyB, KeyStrike::KeyJ, KeyStrike::KeyQ] {
assert_eq!(reach(ctrl(key), TerminalKeys::LEGACY), Reach::Ok);
}
}
#[test]
fn a_live_session_is_enhanced_on_windows_without_the_protocol() {
let keys = TerminalKeys::detected(false, false);
assert_eq!(keys.enhanced, cfg!(windows));
assert!(!keys.ctrl_h_is_backspace);
assert!(TerminalKeys::detected(true, true).enhanced);
assert!(TerminalKeys::detected(true, true).ctrl_h_is_backspace);
}
#[test]
fn every_control_byte_is_accounted_for() {
let plain = |key| KeyCombo::new(KeyModifiers::new(), key);
assert_eq!(
reach(ctrl(KeyStrike::Space), TerminalKeys::LEGACY),
Reach::Ok
);
assert_eq!(
reach(ctrl(KeyStrike::Digit2), TerminalKeys::LEGACY),
Reach::Shadowed(ctrl(KeyStrike::Space))
);
assert_eq!(
reach(ctrl(KeyStrike::Digit3), TerminalKeys::LEGACY),
Reach::Shadowed(plain(KeyStrike::Escape))
);
assert_eq!(
reach(ctrl(KeyStrike::Digit8), TerminalKeys::LEGACY),
Reach::Shadowed(plain(KeyStrike::Backspace))
);
assert_eq!(
reach(ctrl(KeyStrike::Digit6), TerminalKeys::LEGACY),
Reach::Ok
);
for key in [KeyStrike::Minus, KeyStrike::Slash] {
assert_eq!(
reach(ctrl(key), TerminalKeys::LEGACY),
Reach::Shadowed(ctrl(KeyStrike::Digit7)),
"{key}"
);
}
for key in [
KeyStrike::Digit0,
KeyStrike::Digit1,
KeyStrike::Digit9,
KeyStrike::Comma,
KeyStrike::Period,
KeyStrike::Equal,
KeyStrike::Semicolon,
KeyStrike::Quote,
KeyStrike::Backquote,
] {
assert_eq!(
reach(ctrl(key), TerminalKeys::LEGACY),
Reach::Untransmitted,
"{key}"
);
}
}
#[test]
fn letters_round_trip_through_both_control_tables() {
for (i, k) in LETTERS.iter().enumerate() {
let byte = i as u8 + 1;
assert_eq!(control_byte(*k), Some(byte), "{k:?} -> control_byte");
if byte != 0x09 && byte != 0x0D {
assert_eq!(
legacy_decode(byte),
(true, *k),
"{byte:#04x} -> legacy_decode"
);
}
}
}
#[test]
fn the_rewrite_strands_exactly_focus_sidebar_in_the_default_keymap() {
let kb = crate::settings::AppSettings::default().key_bindings;
let keys = TerminalKeys {
enhanced: false,
ctrl_h_is_backspace: true,
};
let found = unreachable_actions(&kb, keys);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].action, ActionShortcuts::FocusSidebar);
assert_eq!(
found[0].combos,
vec![(
ctrl(KeyStrike::KeyH),
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace))
)]
);
}
#[test]
fn a_fate_has_one_wording() {
assert_eq!(Reach::Ok.to_string(), "arrives");
assert_eq!(
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab)).to_string(),
"arrives as <Tab>"
);
assert_eq!(
Reach::Untransmitted.to_string(),
"not sent by this terminal"
);
}
}