//! Key events to the bytes a terminal program expects. //! //! The compositor hands over a keysym and, for anything printable, the UTF-8 //! xkbcommon already produced for it. Turning that into what the program on //! the other end of the PTY is waiting for is the job here, and it is entirely //! a matter of convention: there is no standard, only what xterm does and what //! every terminfo entry has recorded about it since. //! //! shop advertises `xterm-256color`, so xterm is the contract. Where xterm is //! self-inconsistent this crate follows what the terminfo entry claims, //! because that is what programs actually read. //! //! # Why this is a crate //! //! No Wayland here, and deliberately: the encoding is a pure function of //! keysym, modifiers, and the two DEC modes, so it is testable without a //! compositor, a seat, or a window. [`xkeysym`] is the only dependency, and //! SCTK re-exports the same [`Keysym`] type, so the binary passes its keysyms //! straight through. //! //! Keymap loading, modifier tracking and key repeat are not here. SCTK already //! runs libxkbcommon for all three, and a second implementation would be a //! second set of bugs about dead keys and layout switching. use xkeysym::Keysym; /// Modifier state at the moment of the press. /// /// Named for what they do rather than for the keycaps: `logo` is what /// Wayland calls the Windows/Command key, and terminals call meta. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct Mods { pub shift: bool, pub alt: bool, pub ctrl: bool, pub logo: bool, } impl Mods { /// The xterm modifier number: `1 + shift + 2*alt + 4*ctrl + 8*meta`. /// /// 1 means "no modifiers", which is why the modified and unmodified forms /// of a key are different sequences rather than the same one with a /// parameter of zero. fn xterm_number(self) -> u8 { 1 + u8::from(self.shift) + 2 * u8::from(self.alt) + 4 * u8::from(self.ctrl) + 8 * u8::from(self.logo) } fn none(self) -> bool { self == Self::default() } } /// The DEC modes that change what a key sends. /// /// Both are set by the program, not the user, and both are the reason a key /// cannot be encoded from the event alone. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct Modes { /// DECCKM (`CSI ? 1 h`). Cursor keys send `SS3 A` instead of `CSI A`. /// vim and readline both turn this on, and a terminal that ignores it /// gives you `[A` in the buffer instead of moving the cursor. pub cursor_keys_application: bool, /// DECKPAM (`ESC =`). The keypad sends its own function sequences rather /// than digits. pub keypad_application: bool, } /// Encode one key press. /// /// `utf8` is what xkbcommon resolved the key to, if anything — already /// layout-aware and already carrying the control character for Ctrl+letter. /// Empty for keys with no printable form. /// /// Returns an empty vector for keys that send nothing: modifiers themselves, /// and anything the layout gave no text and this table no sequence. #[must_use] pub fn encode(keysym: Keysym, utf8: &str, mods: Mods, modes: Modes) -> Vec { if let Some(bytes) = encode_named(keysym, mods, modes) { return bytes; } if modes.keypad_application && let Some(bytes) = encode_keypad_application(keysym, mods) { return bytes; } encode_text(keysym, utf8, mods) } /// Keys whose sequence does not depend on the layout. fn encode_named(keysym: Keysym, mods: Mods, modes: Modes) -> Option> { let m = mods.xterm_number(); // Cursor and editing keys. The application-mode form only applies // unmodified: xterm sends the CSI form the moment a modifier is involved, // because the parameter has nowhere to go in the SS3 form. let cursor = |final_byte: u8| -> Vec { if m == 1 { let introducer = if modes.cursor_keys_application { b'O' } else { b'[' }; vec![0x1b, introducer, final_byte] } else { format!("\x1b[1;{m}{}", final_byte as char).into_bytes() } }; let tilde = |code: u8| -> Vec { if m == 1 { format!("\x1b[{code}~").into_bytes() } else { format!("\x1b[{code};{m}~").into_bytes() } }; let bytes = match keysym { Keysym::Up => cursor(b'A'), Keysym::Down => cursor(b'B'), Keysym::Right => cursor(b'C'), Keysym::Left => cursor(b'D'), Keysym::Home => cursor(b'H'), Keysym::End => cursor(b'F'), Keysym::Insert => tilde(2), Keysym::Delete => tilde(3), Keysym::Page_Up => tilde(5), Keysym::Page_Down => tilde(6), // F1-F4 are SS3 keys unmodified and CSI keys otherwise, which is the // one place xterm's function-key table is not a straight run. Keysym::F1 => function_ss3(b'P', m), Keysym::F2 => function_ss3(b'Q', m), Keysym::F3 => function_ss3(b'R', m), Keysym::F4 => function_ss3(b'S', m), Keysym::F5 => tilde(15), // 16 is skipped by the convention, not by an oversight here. Keysym::F6 => tilde(17), Keysym::F7 => tilde(18), Keysym::F8 => tilde(19), Keysym::F9 => tilde(20), Keysym::F10 => tilde(21), // 22 skipped as well. Keysym::F11 => tilde(23), Keysym::F12 => tilde(24), // Shift+Tab. xkb reports it as its own keysym rather than as Tab with // a shift modifier, so it never reaches the text path. Keysym::ISO_Left_Tab => b"\x1b[Z".to_vec(), Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(), Keysym::Tab if mods.alt => b"\x1b\t".to_vec(), Keysym::Tab => b"\t".to_vec(), Keysym::KP_Enter if modes.keypad_application => b"\x1bOM".to_vec(), Keysym::Return | Keysym::KP_Enter if mods.alt => b"\x1b\r".to_vec(), Keysym::Return | Keysym::KP_Enter => b"\r".to_vec(), // Backspace sends DEL, and Ctrl+Backspace sends BS. That looks // backwards written down and is what every terminfo entry says, so // readline's ^H binding is the one that has to be reached with Ctrl. Keysym::BackSpace if mods.ctrl => vec![0x08], Keysym::BackSpace if mods.alt => vec![0x1b, 0x7f], Keysym::BackSpace => vec![0x7f], Keysym::Escape if mods.alt => vec![0x1b, 0x1b], Keysym::Escape => vec![0x1b], // Ctrl+Space is NUL. xkbcommon hands back a plain space for it, so // without this the null byte a few programs still want never arrives. Keysym::space if mods.ctrl && !mods.alt => vec![0x00], _ => return None, }; Some(bytes) } fn function_ss3(final_byte: u8, m: u8) -> Vec { if m == 1 { vec![0x1b, b'O', final_byte] } else { format!("\x1b[1;{m}{}", final_byte as char).into_bytes() } } /// The keypad, when the program has asked for its application form. /// /// Only reached with DECKPAM set; in numeric mode these keys fall through to /// the layout and type their digits. fn encode_keypad_application(keysym: Keysym, mods: Mods) -> Option> { // Modified keypad presses have no application form in xterm; they fall // back to the ordinary text path. if !mods.none() { return None; } let final_byte = match keysym { Keysym::KP_Space => b' ', Keysym::KP_Tab => b'I', Keysym::KP_Multiply => b'j', Keysym::KP_Add => b'k', Keysym::KP_Separator => b'l', Keysym::KP_Subtract => b'm', Keysym::KP_Decimal => b'n', Keysym::KP_Divide => b'o', Keysym::KP_0 => b'p', Keysym::KP_1 => b'q', Keysym::KP_2 => b'r', Keysym::KP_3 => b's', Keysym::KP_4 => b't', Keysym::KP_5 | Keysym::KP_Begin => b'u', Keysym::KP_6 => b'v', Keysym::KP_7 => b'w', Keysym::KP_8 => b'x', Keysym::KP_9 => b'y', Keysym::KP_Equal => b'X', _ => return None, }; Some(vec![0x1b, b'O', final_byte]) } /// Everything the layout resolved to text. fn encode_text(keysym: Keysym, utf8: &str, mods: Mods) -> Vec { if utf8.is_empty() { return Vec::new(); } // A bare modifier press reports itself as a keysym with no text on most // layouts, but not all; drop it explicitly so a stray keycap does not type. if is_modifier(keysym) { return Vec::new(); } if mods.alt { // xterm's "Alt sends escape". The alternative convention, setting the // high bit, cannot represent anything outside Latin-1 and loses to // UTF-8 the moment the layout is not English. let mut out = Vec::with_capacity(utf8.len() + 1); out.push(0x1b); out.extend_from_slice(utf8.as_bytes()); return out; } utf8.as_bytes().to_vec() } fn is_modifier(keysym: Keysym) -> bool { matches!( keysym, Keysym::Shift_L | Keysym::Shift_R | Keysym::Control_L | Keysym::Control_R | Keysym::Alt_L | Keysym::Alt_R | Keysym::Super_L | Keysym::Super_R | Keysym::Meta_L | Keysym::Meta_R | Keysym::Hyper_L | Keysym::Hyper_R | Keysym::Caps_Lock | Keysym::Shift_Lock | Keysym::Num_Lock | Keysym::ISO_Level3_Shift | Keysym::ISO_Level5_Shift ) } #[cfg(test)] mod tests { use super::*; const NONE: Mods = Mods { shift: false, alt: false, ctrl: false, logo: false, }; const CTRL: Mods = Mods { ctrl: true, ..NONE }; const SHIFT: Mods = Mods { shift: true, ..NONE }; const ALT: Mods = Mods { alt: true, ..NONE }; const NORMAL: Modes = Modes { cursor_keys_application: false, keypad_application: false, }; const APP_CURSOR: Modes = Modes { cursor_keys_application: true, keypad_application: false, }; const APP_KEYPAD: Modes = Modes { cursor_keys_application: false, keypad_application: true, }; fn seq(keysym: Keysym, utf8: &str, mods: Mods, modes: Modes) -> String { String::from_utf8(encode(keysym, utf8, mods, modes)).unwrap() } // ---- modifier numbering -------------------------------------------- #[test] fn the_unmodified_number_is_one_not_zero() { assert_eq!(NONE.xterm_number(), 1); } #[test] fn modifier_numbers_match_the_xterm_table() { assert_eq!(SHIFT.xterm_number(), 2); assert_eq!(ALT.xterm_number(), 3); assert_eq!(CTRL.xterm_number(), 5); assert_eq!( Mods { shift: true, ctrl: true, ..NONE } .xterm_number(), 6 ); assert_eq!( Mods { shift: true, alt: true, ctrl: true, logo: true } .xterm_number(), 16 ); } // ---- cursor keys ---------------------------------------------------- #[test] fn cursor_keys_are_csi_by_default() { assert_eq!(seq(Keysym::Up, "", NONE, NORMAL), "\x1b[A"); assert_eq!(seq(Keysym::Down, "", NONE, NORMAL), "\x1b[B"); assert_eq!(seq(Keysym::Right, "", NONE, NORMAL), "\x1b[C"); assert_eq!(seq(Keysym::Left, "", NONE, NORMAL), "\x1b[D"); } #[test] fn application_mode_makes_cursor_keys_ss3() { assert_eq!(seq(Keysym::Up, "", NONE, APP_CURSOR), "\x1bOA"); assert_eq!(seq(Keysym::Left, "", NONE, APP_CURSOR), "\x1bOD"); } #[test] fn a_modified_cursor_key_is_csi_even_in_application_mode() { // The SS3 form has no room for the parameter, so xterm drops back to // CSI. A terminal that keeps SS3 here sends vim something it reads as // a bare Escape followed by garbage. assert_eq!(seq(Keysym::Up, "", CTRL, APP_CURSOR), "\x1b[1;5A"); assert_eq!(seq(Keysym::Right, "", SHIFT, APP_CURSOR), "\x1b[1;2C"); } #[test] fn home_and_end_ride_the_cursor_path() { assert_eq!(seq(Keysym::Home, "", NONE, NORMAL), "\x1b[H"); assert_eq!(seq(Keysym::End, "", NONE, NORMAL), "\x1b[F"); assert_eq!(seq(Keysym::Home, "", NONE, APP_CURSOR), "\x1bOH"); assert_eq!(seq(Keysym::End, "", CTRL, NORMAL), "\x1b[1;5F"); } // ---- editing keys --------------------------------------------------- #[test] fn editing_keys_use_the_tilde_form() { assert_eq!(seq(Keysym::Insert, "", NONE, NORMAL), "\x1b[2~"); assert_eq!(seq(Keysym::Delete, "", NONE, NORMAL), "\x1b[3~"); assert_eq!(seq(Keysym::Page_Up, "", NONE, NORMAL), "\x1b[5~"); assert_eq!(seq(Keysym::Page_Down, "", NONE, NORMAL), "\x1b[6~"); } #[test] fn a_modified_editing_key_carries_its_number() { assert_eq!(seq(Keysym::Delete, "", CTRL, NORMAL), "\x1b[3;5~"); assert_eq!(seq(Keysym::Page_Up, "", SHIFT, NORMAL), "\x1b[5;2~"); } // ---- function keys -------------------------------------------------- #[test] fn the_first_four_function_keys_are_ss3() { assert_eq!(seq(Keysym::F1, "", NONE, NORMAL), "\x1bOP"); assert_eq!(seq(Keysym::F2, "", NONE, NORMAL), "\x1bOQ"); assert_eq!(seq(Keysym::F3, "", NONE, NORMAL), "\x1bOR"); assert_eq!(seq(Keysym::F4, "", NONE, NORMAL), "\x1bOS"); } #[test] fn modified_f1_to_f4_become_csi() { assert_eq!(seq(Keysym::F1, "", SHIFT, NORMAL), "\x1b[1;2P"); assert_eq!(seq(Keysym::F4, "", CTRL, NORMAL), "\x1b[1;5S"); } #[test] fn the_rest_are_tilde_keys_with_the_gaps_the_convention_has() { assert_eq!(seq(Keysym::F5, "", NONE, NORMAL), "\x1b[15~"); assert_eq!(seq(Keysym::F6, "", NONE, NORMAL), "\x1b[17~"); assert_eq!(seq(Keysym::F10, "", NONE, NORMAL), "\x1b[21~"); assert_eq!(seq(Keysym::F11, "", NONE, NORMAL), "\x1b[23~"); assert_eq!(seq(Keysym::F12, "", NONE, NORMAL), "\x1b[24~"); } // ---- tab, return, backspace, escape --------------------------------- #[test] fn shift_tab_is_backtab_by_either_route() { // xkb usually reports its own keysym, but not every layout does. assert_eq!(seq(Keysym::ISO_Left_Tab, "", SHIFT, NORMAL), "\x1b[Z"); assert_eq!(seq(Keysym::Tab, "\t", SHIFT, NORMAL), "\x1b[Z"); } #[test] fn plain_tab_is_a_tab() { assert_eq!(seq(Keysym::Tab, "\t", NONE, NORMAL), "\t"); } #[test] fn return_is_carriage_return_not_line_feed() { assert_eq!(seq(Keysym::Return, "\r", NONE, NORMAL), "\r"); assert_eq!(seq(Keysym::KP_Enter, "", NONE, NORMAL), "\r"); } #[test] fn keypad_enter_has_an_application_form() { assert_eq!(seq(Keysym::KP_Enter, "", NONE, APP_KEYPAD), "\x1bOM"); } #[test] fn backspace_sends_del_and_ctrl_backspace_sends_bs() { assert_eq!(encode(Keysym::BackSpace, "", NONE, NORMAL), vec![0x7f]); assert_eq!(encode(Keysym::BackSpace, "", CTRL, NORMAL), vec![0x08]); assert_eq!(encode(Keysym::BackSpace, "", ALT, NORMAL), vec![0x1b, 0x7f]); } #[test] fn ctrl_space_is_a_null_byte() { // xkbcommon hands back a plain space here, so the table has to. assert_eq!(encode(Keysym::space, " ", CTRL, NORMAL), vec![0x00]); assert_eq!(encode(Keysym::space, " ", NONE, NORMAL), b" ".to_vec()); } #[test] fn alt_escape_is_two_escapes() { assert_eq!(encode(Keysym::Escape, "", NONE, NORMAL), vec![0x1b]); assert_eq!(encode(Keysym::Escape, "", ALT, NORMAL), vec![0x1b, 0x1b]); } // ---- the keypad ------------------------------------------------------ #[test] fn the_keypad_types_digits_in_numeric_mode() { assert_eq!(seq(Keysym::KP_1, "1", NONE, NORMAL), "1"); assert_eq!(seq(Keysym::KP_Add, "+", NONE, NORMAL), "+"); } #[test] fn the_keypad_has_its_own_sequences_in_application_mode() { assert_eq!(seq(Keysym::KP_0, "0", NONE, APP_KEYPAD), "\x1bOp"); assert_eq!(seq(Keysym::KP_9, "9", NONE, APP_KEYPAD), "\x1bOy"); assert_eq!(seq(Keysym::KP_Add, "+", NONE, APP_KEYPAD), "\x1bOk"); assert_eq!(seq(Keysym::KP_Divide, "/", NONE, APP_KEYPAD), "\x1bOo"); } #[test] fn the_centre_key_is_the_same_sequence_under_either_keysym() { // Num Lock decides whether the 5 key reports KP_5 or KP_Begin. assert_eq!(seq(Keysym::KP_5, "5", NONE, APP_KEYPAD), "\x1bOu"); assert_eq!(seq(Keysym::KP_Begin, "", NONE, APP_KEYPAD), "\x1bOu"); } #[test] fn a_modified_keypad_press_falls_back_to_its_text() { assert_eq!(seq(Keysym::KP_1, "1", CTRL, APP_KEYPAD), "1"); } // ---- text ------------------------------------------------------------ #[test] fn printable_text_passes_through() { assert_eq!(seq(Keysym::a, "a", NONE, NORMAL), "a"); assert_eq!(seq(Keysym::A, "A", SHIFT, NORMAL), "A"); } #[test] fn xkb_owns_the_control_characters() { // Ctrl+C arrives already folded to 0x03; recomputing it here would // mean disagreeing with the layout about what the key is. assert_eq!(encode(Keysym::c, "\x03", CTRL, NORMAL), vec![0x03]); } #[test] fn alt_prefixes_an_escape() { assert_eq!(seq(Keysym::b, "b", ALT, NORMAL), "\x1bb"); } #[test] fn a_non_ascii_layout_survives_alt() { assert_eq!(seq(Keysym::adiaeresis, "ä", ALT, NORMAL), "\x1bä"); } #[test] fn keys_with_no_text_and_no_sequence_send_nothing() { assert!(encode(Keysym::Menu, "", NONE, NORMAL).is_empty()); assert!(encode(Keysym::Print, "", NONE, NORMAL).is_empty()); } #[test] fn modifier_keys_never_type() { for keysym in [ Keysym::Shift_L, Keysym::Control_R, Keysym::Alt_L, Keysym::Super_L, Keysym::Caps_Lock, Keysym::ISO_Level3_Shift, ] { assert!( encode(keysym, " ", NONE, NORMAL).is_empty(), "{keysym:?} typed something" ); } } }