use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn modifier_param(mods: KeyModifiers) -> u8 {
let mut value = 1;
if mods.contains(KeyModifiers::SHIFT) {
value += 1;
}
if mods.contains(KeyModifiers::ALT) {
value += 2;
}
if mods.contains(KeyModifiers::CONTROL) {
value += 4;
}
value
}
fn is_plain(mods: KeyModifiers) -> bool {
modifier_param(mods) == 1
}
fn csi(final_byte: u8, mods: KeyModifiers) -> Vec<u8> {
if is_plain(mods) {
vec![0x1b, b'[', final_byte]
} else {
format!("\x1b[1;{}{}", modifier_param(mods), final_byte as char).into_bytes()
}
}
fn tilde(number: u8, mods: KeyModifiers) -> Vec<u8> {
if is_plain(mods) {
format!("\x1b[{number}~").into_bytes()
} else {
format!("\x1b[{};{}~", number, modifier_param(mods)).into_bytes()
}
}
fn control_byte(c: char) -> Option<u8> {
match c {
'a'..='z' => Some(c as u8 - b'a' + 1),
'A'..='Z' => Some(c as u8 - b'A' + 1),
' ' | '@' => Some(0x00),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' | '/' => Some(0x1f),
'4'..='7' => Some(0x1c + (c as u8 - b'4')),
'?' => Some(0x7f),
_ => None,
}
}
fn alt_prefixed(byte: u8, mods: KeyModifiers) -> Vec<u8> {
if mods.contains(KeyModifiers::ALT) {
vec![0x1b, byte]
} else {
vec![byte]
}
}
pub fn encode(event: KeyEvent) -> Option<Vec<u8>> {
use ratatui::crossterm::event::KeyEventKind;
if event.kind == KeyEventKind::Release {
return None;
}
let mods = event.modifiers;
let bytes = match event.code {
KeyCode::Char(c) => {
if mods.contains(KeyModifiers::CONTROL) {
let byte = control_byte(c)?;
if mods.contains(KeyModifiers::ALT) {
vec![0x1b, byte]
} else {
vec![byte]
}
} else if mods.contains(KeyModifiers::ALT) {
let mut out = vec![0x1b];
out.extend_from_slice(c.to_string().as_bytes());
out
} else {
c.to_string().into_bytes()
}
}
KeyCode::Enter => {
if mods.intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL) {
format!("\x1b[13;{}u", modifier_param(mods)).into_bytes()
} else {
alt_prefixed(b'\r', mods)
}
}
KeyCode::Tab => alt_prefixed(b'\t', mods),
KeyCode::BackTab => vec![0x1b, b'[', b'Z'],
KeyCode::Backspace if mods.contains(KeyModifiers::CONTROL) => alt_prefixed(0x08, mods),
KeyCode::Backspace => alt_prefixed(0x7f, mods),
KeyCode::Esc => alt_prefixed(0x1b, mods),
KeyCode::Up => csi(b'A', mods),
KeyCode::Down => csi(b'B', mods),
KeyCode::Right => csi(b'C', mods),
KeyCode::Left => csi(b'D', mods),
KeyCode::End => csi(b'F', mods),
KeyCode::Home => csi(b'H', mods),
KeyCode::Insert => tilde(2, mods),
KeyCode::Delete => tilde(3, mods),
KeyCode::PageUp => tilde(5, mods),
KeyCode::PageDown => tilde(6, mods),
KeyCode::F(n @ 1..=4) => {
let final_byte = b'P' + (n - 1);
if is_plain(mods) {
vec![0x1b, b'O', final_byte]
} else {
format!("\x1b[1;{}{}", modifier_param(mods), final_byte as char).into_bytes()
}
}
KeyCode::F(n) => {
let number = match n {
5 => 15,
6..=10 => 17 + (n - 6),
11 => 23,
12 => 24,
_ => return None,
};
tilde(number, mods)
}
KeyCode::Null => vec![0x00],
_ => return None,
};
Some(bytes)
}
pub fn to_hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
pub fn send_keys_command(pane: &str, event: KeyEvent) -> Option<String> {
let bytes = encode(event)?;
if bytes.is_empty() {
return None;
}
Some(format!("send-keys -t {} -H {}", pane, to_hex(&bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEventKind, KeyEventState};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn with(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn enc(code: KeyCode) -> Vec<u8> {
encode(key(code)).expect("should encode")
}
fn enc_mod(code: KeyCode, mods: KeyModifiers) -> Vec<u8> {
encode(with(code, mods)).expect("should encode")
}
#[test]
fn plain_characters_are_their_utf8() {
assert_eq!(enc(KeyCode::Char('a')), b"a");
assert_eq!(enc(KeyCode::Char('Z')), b"Z");
assert_eq!(enc(KeyCode::Char('£')), "£".as_bytes());
assert_eq!(enc(KeyCode::Char('🦀')), "🦀".as_bytes());
}
#[test]
fn enter_sends_carriage_return_not_newline() {
assert_eq!(enc(KeyCode::Enter), b"\r");
}
#[test]
fn backspace_sends_del_not_backspace() {
assert_eq!(enc(KeyCode::Backspace), &[0x7f]);
}
#[test]
fn control_letters_map_to_control_bytes() {
let ctrl = KeyModifiers::CONTROL;
assert_eq!(enc_mod(KeyCode::Char('a'), ctrl), &[0x01]);
assert_eq!(enc_mod(KeyCode::Char('c'), ctrl), &[0x03]);
assert_eq!(enc_mod(KeyCode::Char('d'), ctrl), &[0x04]);
assert_eq!(enc_mod(KeyCode::Char('z'), ctrl), &[0x1a]);
assert_eq!(enc_mod(KeyCode::Char('C'), ctrl), &[0x03]);
}
#[test]
fn control_punctuation_is_handled() {
let ctrl = KeyModifiers::CONTROL;
assert_eq!(enc_mod(KeyCode::Char(' '), ctrl), &[0x00]);
assert_eq!(
enc_mod(KeyCode::Char('['), ctrl),
&[0x1b],
"Ctrl-[ is Escape"
);
assert_eq!(enc_mod(KeyCode::Char('\\'), ctrl), &[0x1c]);
assert_eq!(enc_mod(KeyCode::Char(']'), ctrl), &[0x1d]);
assert_eq!(enc_mod(KeyCode::Char('_'), ctrl), &[0x1f]);
}
#[test]
fn control_on_a_key_with_no_control_byte_produces_nothing() {
assert_eq!(
encode(with(KeyCode::Char('1'), KeyModifiers::CONTROL)),
None
);
}
#[test]
fn the_control_bytes_a_legacy_terminal_actually_sends_are_encoded() {
let ctrl = KeyModifiers::CONTROL;
assert_eq!(enc_mod(KeyCode::Char('4'), ctrl), &[0x1c], "ctrl+\\");
assert_eq!(enc_mod(KeyCode::Char('5'), ctrl), &[0x1d], "ctrl+]");
assert_eq!(enc_mod(KeyCode::Char('6'), ctrl), &[0x1e], "ctrl+^");
assert_eq!(enc_mod(KeyCode::Char('7'), ctrl), &[0x1f], "ctrl+/");
assert_eq!(enc_mod(KeyCode::Char('/'), ctrl), &[0x1f]);
assert_eq!(enc_mod(KeyCode::Char('?'), ctrl), &[0x7f], "ctrl+? is DEL");
}
#[test]
fn alt_survives_on_the_named_keys() {
let alt = KeyModifiers::ALT;
assert_eq!(enc_mod(KeyCode::Backspace, alt), &[0x1b, 0x7f]);
assert_eq!(enc_mod(KeyCode::Enter, alt), &[0x1b, b'\r']);
assert_eq!(enc_mod(KeyCode::Tab, alt), &[0x1b, b'\t']);
assert_eq!(enc_mod(KeyCode::Esc, alt), &[0x1b, 0x1b]);
}
#[test]
fn shift_enter_is_a_new_line_not_a_submit() {
assert_eq!(enc_mod(KeyCode::Enter, KeyModifiers::SHIFT), b"\x1b[13;2u");
assert_ne!(
enc_mod(KeyCode::Enter, KeyModifiers::SHIFT),
enc(KeyCode::Enter),
"shift+enter must not be indistinguishable from enter"
);
assert_ne!(
enc_mod(KeyCode::Enter, KeyModifiers::CONTROL),
enc(KeyCode::Enter)
);
}
#[test]
fn ctrl_backspace_is_distinct_from_backspace() {
assert_ne!(
enc_mod(KeyCode::Backspace, KeyModifiers::CONTROL),
enc(KeyCode::Backspace),
"delete-word must not be delete-character"
);
}
#[test]
fn alt_prefixes_escape() {
assert_eq!(enc_mod(KeyCode::Char('b'), KeyModifiers::ALT), b"\x1bb");
assert_eq!(
enc_mod(
KeyCode::Char('c'),
KeyModifiers::ALT | KeyModifiers::CONTROL
),
&[0x1b, 0x03],
"alt-ctrl-c is escape then the control byte"
);
}
#[test]
fn arrows_are_csi_sequences() {
assert_eq!(enc(KeyCode::Up), b"\x1b[A");
assert_eq!(enc(KeyCode::Down), b"\x1b[B");
assert_eq!(enc(KeyCode::Right), b"\x1b[C");
assert_eq!(enc(KeyCode::Left), b"\x1b[D");
assert_eq!(enc(KeyCode::Home), b"\x1b[H");
assert_eq!(enc(KeyCode::End), b"\x1b[F");
}
#[test]
fn modified_arrows_carry_a_modifier_parameter() {
assert_eq!(enc_mod(KeyCode::Up, KeyModifiers::SHIFT), b"\x1b[1;2A");
assert_eq!(enc_mod(KeyCode::Up, KeyModifiers::ALT), b"\x1b[1;3A");
assert_eq!(enc_mod(KeyCode::Up, KeyModifiers::CONTROL), b"\x1b[1;5A");
assert_eq!(
enc_mod(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
b"\x1b[1;6D"
);
assert_eq!(
enc_mod(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
),
b"\x1b[1;8C"
);
}
#[test]
fn edit_keys_are_tilde_sequences() {
assert_eq!(enc(KeyCode::Insert), b"\x1b[2~");
assert_eq!(enc(KeyCode::Delete), b"\x1b[3~");
assert_eq!(enc(KeyCode::PageUp), b"\x1b[5~");
assert_eq!(enc(KeyCode::PageDown), b"\x1b[6~");
assert_eq!(
enc_mod(KeyCode::Delete, KeyModifiers::CONTROL),
b"\x1b[3;5~"
);
}
#[test]
fn function_keys_split_between_ss3_and_csi() {
assert_eq!(enc(KeyCode::F(1)), b"\x1bOP");
assert_eq!(enc(KeyCode::F(4)), b"\x1bOS");
assert_eq!(enc(KeyCode::F(5)), b"\x1b[15~");
assert_eq!(enc(KeyCode::F(6)), b"\x1b[17~");
assert_eq!(enc(KeyCode::F(10)), b"\x1b[21~");
assert_eq!(enc(KeyCode::F(12)), b"\x1b[24~");
assert_eq!(enc_mod(KeyCode::F(1), KeyModifiers::SHIFT), b"\x1b[1;2P");
}
#[test]
fn there_is_no_f13() {
assert_eq!(encode(key(KeyCode::F(13))), None);
}
#[test]
fn tab_and_backtab_differ() {
assert_eq!(enc(KeyCode::Tab), b"\t");
assert_eq!(enc(KeyCode::BackTab), b"\x1b[Z");
}
#[test]
fn key_releases_produce_nothing() {
let release = KeyEvent::new_with_kind_and_state(
KeyCode::Char('a'),
KeyModifiers::NONE,
KeyEventKind::Release,
KeyEventState::NONE,
);
assert_eq!(encode(release), None);
let repeat = KeyEvent::new_with_kind_and_state(
KeyCode::Char('a'),
KeyModifiers::NONE,
KeyEventKind::Repeat,
KeyEventState::NONE,
);
assert_eq!(encode(repeat), Some(b"a".to_vec()), "repeats do type");
}
#[test]
fn hex_encoding_is_what_tmux_expects() {
assert_eq!(to_hex(&[0x1b, b'[', b'A']), "1b 5b 41");
assert_eq!(to_hex(&[0x00, 0x0f]), "00 0f");
}
#[test]
fn builds_a_complete_send_keys_command() {
let cmd = send_keys_command("%3", key(KeyCode::Up)).unwrap();
assert_eq!(cmd, "send-keys -t %3 -H 1b 5b 41");
}
#[test]
fn keys_with_no_encoding_yield_no_command() {
assert_eq!(send_keys_command("%0", key(KeyCode::F(13))), None);
}
}