Skip to main content

max / shop

Extract shop-xkb, and fix key repeat and DECCKM Key encoding moves out of the binary into a crate that depends on xkeysym and nothing else, so it is testable without a compositor, a seat, or a window. Keymap loading, modifier tracking and repeat timing stay with SCTK: a second libxkbcommon would be a second set of bugs about dead keys and layout switching. Key repeat never worked. get_keyboard without a loop handle compiles, runs, and simply never repeats, so holding a key typed one character and the repeat_key handler was dead code. Now on get_keyboard_with_repeat, and repeat_key is deliberately empty instead -- SCTK arms its timer on every press whether or not the compositor also sends its own repeat events, so a wl_keyboard v10 compositor would type every held key twice. DECCKM was unimplemented, so cursor keys always sent CSI A and arrows put [A in the buffer under vim or readline. Tracked now alongside DECKPAM; both change what the keyboard sends rather than what the screen shows, so the grid only records them. Also covers what the inline table did not: F1-F12, the application keypad, backtab by either keysym route, Ctrl and Alt forms of backspace and escape, Ctrl+Space as NUL, and modifier keycaps that must never type. A modified cursor key now drops back to the CSI form even in application mode, where the SS3 form has no room for the parameter.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 18:48 UTC
Signed with PGP, not checked
Commit: f2638bf4d0bc7896153e2f619e1d3b311a064949
Parent: 20c5668
7 files changed, +627 insertions, -82 deletions
M Cargo.lock +8
@@ -1125,6 +1125,7 @@
1125 1125 "shop-render",
1126 1126 "shop-vt",
1127 1127 "shop-wayland",
1128 + "shop-xkb",
1128 1129 "smithay-client-toolkit",
1129 1130 "toml",
1130 1131 "tracing",
@@ -1187,6 +1188,13 @@
1187 1188 "wayland-client",
1188 1189 ]
1189 1190
1191 + [[package]]
1192 + name = "shop-xkb"
1193 + version = "0.0.0"
1194 + dependencies = [
1195 + "xkeysym",
1196 + ]
1197 +
1190 1198 [[package]]
1191 1199 name = "simd-adler32"
1192 1200 version = "0.3.10"
M Cargo.toml +5
@@ -9,11 +9,16 @@
9 9 "crates/shop-grid",
10 10 "crates/shop-vt",
11 11 "crates/shop-bench",
12 + "crates/shop-xkb",
12 13 ]
13 14
14 15 [workspace.dependencies]
15 16 smithay-client-toolkit = { version = "0.21", features = ["system"] }
16 17 wayland-client = { version = "0.31", features = ["system"] }
18 + # Keysym names only. shop-xkb takes this rather than SCTK so the key-encoding
19 + # logic stays testable without a compositor; SCTK re-exports the same type, so
20 + # the binary passes its keysyms straight through.
21 + xkeysym = "0.2"
17 22 calloop = "0.14"
18 23 calloop-wayland-source = "0.4"
19 24 wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan", "metal"] }
@@ -21,6 +21,7 @@
21 21 shop-render = { path = "../shop-render" }
22 22 shop-pty = { path = "../shop-pty" }
23 23 shop-grid = { path = "../shop-grid" }
24 + shop-xkb = { path = "../shop-xkb" }
24 25 shop-vt = { path = "../shop-vt" }
25 26 kittygfx = { path = "../kitty-graphics" }
26 27 image = { workspace = true }
@@ -281,6 +281,11 @@
281 281 // paste rather than by typing, so it can decline to act on it. Reflected
282 282 // via [`Grid::bracketed_paste`]; wrapping the payload is the binary's job.
283 283 bracketed_paste: bool,
284 + // DECCKM (`CSI ? 1 h`) and DECKPAM (`ESC =`). Both change what the
285 + // KEYBOARD sends, not what the screen shows, so the grid only records
286 + // them — shop-xkb is what reads them.
287 + cursor_keys_application: bool,
288 + keypad_application: bool,
284 289 pending_title: Option<String>,
285 290 // Damage tracking — accumulated between take_damage() calls.
286 291 row_dirty: Vec<bool>,
@@ -324,6 +329,8 @@
324 329 cur_row_start: CUR_ROW_INVALID,
325 330 sync_update: false,
326 331 bracketed_paste: false,
332 + cursor_keys_application: false,
333 + keypad_application: false,
327 334 pending_title: None,
328 335 // Initial state: everything dirty so first render populates the
329 336 // per-row cache.
@@ -389,6 +396,19 @@
389 396 self.bracketed_paste
390 397 }
391 398
399 + /// DECCKM: cursor keys send `SS3 A` rather than `CSI A`.
400 + ///
401 + /// vim and readline both set it, and it is the difference between an
402 + /// arrow key moving the cursor and typing `[A` into the buffer.
403 + pub fn cursor_keys_application(&self) -> bool {
404 + self.cursor_keys_application
405 + }
406 +
407 + /// DECKPAM: the keypad sends function sequences rather than digits.
408 + pub fn keypad_application(&self) -> bool {
409 + self.keypad_application
410 + }
411 +
392 412 /// Consume any window title set by the shell via OSC 0/2 since the last
393 413 /// call. Binary polls after each `parser.advance` and forwards to
394 414 /// `xdg_window.set_title`.
@@ -1231,6 +1251,7 @@
1231 1251 // until `l` or the caller's timeout); `l` ends
1232 1252 // it. Grid just tracks the state — the binary
1233 1253 // is what actually defers the redraw.
1254 + 1 => self.cursor_keys_application = action == 'h',
1234 1255 2004 => self.bracketed_paste = action == 'h',
1235 1256 2026 => self.sync_update = action == 'h',
1236 1257 _ => {}
@@ -1247,6 +1268,10 @@
1247 1268 match byte {
1248 1269 b'7' => self.saved_main_cursor = self.cursor,
1249 1270 b'8' => self.cursor = self.saved_main_cursor,
1271 + // DECKPAM / DECKPNM. Application keypad is an ESC pair rather
1272 + // than a DECSET, for no reason beyond how DEC numbered things.
1273 + b'=' => self.keypad_application = true,
1274 + b'>' => self.keypad_application = false,
1250 1275 b'M' => {
1251 1276 // RI — reverse index
1252 1277 if self.cursor.row == self.scroll_top {
@@ -1623,6 +1648,27 @@
1623 1648 assert!(c.row < 2 && c.col < 4);
1624 1649 }
1625 1650
1651 + #[test]
1652 + fn decckm_toggles_cursor_key_mode() {
1653 + let mut g = Grid::new(10, 3);
1654 + assert!(!g.cursor_keys_application());
1655 + feed(&mut g, b"\x1b[?1h");
1656 + assert!(g.cursor_keys_application());
1657 + feed(&mut g, b"\x1b[?1l");
1658 + assert!(!g.cursor_keys_application());
1659 + }
1660 +
1661 + #[test]
1662 + fn deckpam_and_deckpnm_toggle_the_keypad() {
1663 + // An ESC pair rather than a DECSET, unlike every other mode here.
1664 + let mut g = Grid::new(10, 3);
1665 + assert!(!g.keypad_application());
1666 + feed(&mut g, b"\x1b=");
1667 + assert!(g.keypad_application());
1668 + feed(&mut g, b"\x1b>");
1669 + assert!(!g.keypad_application());
1670 + }
1671 +
1626 1672 #[test]
1627 1673 fn bracketed_paste_toggles_on_2004() {
1628 1674 let mut g = Grid::new(10, 3);
@@ -969,9 +969,22 @@
969 969 capability: Capability,
970 970 ) {
971 971 if capability == Capability::Keyboard && self.keyboard.is_none() {
972 - match self.chrome.seat.get_keyboard(qh, &seat, None) {
972 + // With repeat, which means handing SCTK the loop handle: it arms a
973 + // calloop timer per press at the compositor's advertised rate.
974 + // The plain `get_keyboard` compiles and runs and simply never
975 + // repeats, so holding a key types one character — which looks like
976 + // a stuck keyboard rather than a missing argument.
977 + let handle = self.loop_handle.clone();
978 + let repeat = self.chrome.seat.get_keyboard_with_repeat(
979 + qh,
980 + &seat,
981 + None,
982 + handle,
983 + Box::new(|app: &mut App, _kb, event| app.handle_key(&event)),
984 + );
985 + match repeat {
973 986 Ok(kb) => self.keyboard = Some(kb),
974 - Err(e) => warn!("get_keyboard: {e}"),
987 + Err(e) => warn!("get_keyboard_with_repeat: {e}"),
975 988 }
976 989 }
977 990 if capability == Capability::Pointer && self.pointer.is_none() {
@@ -1047,9 +1060,13 @@
1047 1060 _: &QueueHandle<Self>,
1048 1061 _: &wl_keyboard::WlKeyboard,
1049 1062 _: u32,
1050 - event: KeyEvent,
1063 + _: KeyEvent,
1051 1064 ) {
1052 - self.handle_key(&event);
1065 + // Deliberately empty. Repeats come from the calloop timer SCTK arms in
1066 + // `get_keyboard_with_repeat`, and that timer is armed on every press
1067 + // whether or not the compositor also sends its own repeat events. A
1068 + // wl_keyboard v10 compositor sends both, so doing the work here as
1069 + // well would type every held key twice.
1053 1070 }
1054 1071 fn release_key(
1055 1072 &mut self,
@@ -1575,93 +1592,45 @@
1575 1592 }
1576 1593 }
1577 1594
1595 + /// Turn a key press into bytes on the PTY.
1596 + ///
1597 + /// Shop's own bindings get first refusal; everything else goes to
1598 + /// [`shop_xkb::encode`], which needs the two DEC modes because the same
1599 + /// keycap sends different bytes depending on what the program asked for.
1578 1600 fn handle_key(&mut self, event: &KeyEvent) {
1579 1601 if self.handle_binding(event) {
1580 1602 return;
1581 1603 }
1582 - let bytes = key_to_bytes(event, self.modifiers);
1583 - if !bytes.is_empty()
1584 - && let Err(e) = self.pty.write(&bytes)
1585 - {
1604 + let modes = shop_xkb::Modes {
1605 + cursor_keys_application: self.grid.cursor_keys_application(),
1606 + keypad_application: self.grid.keypad_application(),
1607 + };
1608 + let bytes = shop_xkb::encode(
1609 + event.keysym,
1610 + event.utf8.as_deref().unwrap_or_default(),
1611 + xkb_mods(self.modifiers),
1612 + modes,
1613 + );
1614 + if bytes.is_empty() {
1615 + return;
1616 + }
1617 + if let Err(e) = self.pty.write(&bytes) {
1586 1618 warn!("pty write: {e}");
1587 1619 }
1588 1620 }
1589 1621 }
1590 1622
1591 - /// Translate a Wayland key event into the byte sequence the shell expects.
1592 - /// SCTK/xkbcommon already runs the keymap so `event.utf8` carries the printable
1593 - /// bytes (including Ctrl-modified chars like Ctrl+C -> "\x03"). Special keys
1594 - /// with no printable form get an xterm-style escape sequence, with modifier
1595 - /// numbers appended for editors that consume them (helix, vim, nvim).
1596 - fn key_to_bytes(event: &KeyEvent, mods: Modifiers) -> Vec<u8> {
1597 - let m = xterm_mod(mods);
1598 - let arrow = |letter: u8| xterm_arrow(letter, m);
1599 - let tilde = |code: u8| xterm_tilde(code, m);
1600 - match event.keysym {
1601 - Keysym::Return | Keysym::KP_Enter => return b"\r".to_vec(),
1602 - Keysym::BackSpace => return b"\x7f".to_vec(),
1603 - Keysym::Tab => return b"\t".to_vec(),
1604 - Keysym::Escape => return b"\x1b".to_vec(),
1605 - Keysym::Up => return arrow(b'A'),
1606 - Keysym::Down => return arrow(b'B'),
1607 - Keysym::Right => return arrow(b'C'),
1608 - Keysym::Left => return arrow(b'D'),
1609 - Keysym::Home => return arrow(b'H'),
1610 - Keysym::End => return arrow(b'F'),
1611 - Keysym::Page_Up => return tilde(5),
1612 - Keysym::Page_Down => return tilde(6),
1613 - Keysym::Insert => return tilde(2),
1614 - Keysym::Delete => return tilde(3),
1615 - _ => {}
1616 - }
1617 - let utf8 = event.utf8.as_deref().unwrap_or_default();
1618 - if utf8.is_empty() {
1619 - return Vec::new();
1620 - }
1621 - // xterm "Alt sends escape" convention: Alt+letter prepends ESC. Deferred
1622 - // dead-key handling: users with Compose/deadkey layouts may want this off
1623 - // via config once TOML lands.
1624 - if mods.alt {
1625 - let mut out = Vec::with_capacity(utf8.len() + 1);
1626 - out.push(0x1b);
1627 - out.extend_from_slice(utf8.as_bytes());
1628 - out
1629 - } else {
1630 - utf8.as_bytes().to_vec()
1631 - }
1632 - }
1633 -
1634 - /// xterm modifier number = 1 + Shift + 2*Alt + 4*Ctrl + 8*Meta.
1635 - fn xterm_mod(m: Modifiers) -> u8 {
1636 - let mut n: u8 = 0;
1637 - if m.shift {
1638 - n += 1;
1639 - }
1640 - if m.alt {
1641 - n += 2;
1642 - }
1643 - if m.ctrl {
1644 - n += 4;
1645 - }
1646 - if m.logo {
1647 - n += 8;
1648 - }
1649 - n + 1
1650 - }
1651 -
1652 - fn xterm_arrow(letter: u8, m: u8) -> Vec<u8> {
1653 - if m == 1 {
1654 - vec![0x1b, b'[', letter]
1655 - } else {
1656 - format!("\x1b[1;{m}{}", letter as char).into_bytes()
1657 - }
1658 - }
1659 -
1660 - fn xterm_tilde(code: u8, m: u8) -> Vec<u8> {
1661 - if m == 1 {
1662 - format!("\x1b[{code}~").into_bytes()
1663 - } else {
1664 - format!("\x1b[{code};{m}~").into_bytes()
1623 + /// SCTK's modifier set as shop-xkb's.
1624 + ///
1625 + /// Two structurally identical types, kept apart on purpose: shop-xkb does not
1626 + /// depend on the Wayland stack, and this three-line conversion is the whole
1627 + /// price of that.
1628 + fn xkb_mods(m: Modifiers) -> shop_xkb::Mods {
1629 + shop_xkb::Mods {
1630 + shift: m.shift,
1631 + alt: m.alt,
1632 + ctrl: m.ctrl,
1633 + logo: m.logo,
1665 1634 }
1666 1635 }
1667 1636
@@ -1,0 +1,16 @@
1 + [package]
2 + name = "shop-xkb"
3 + version = "0.0.0"
4 + description = "Key events to terminal byte sequences for shop"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + xkeysym.workspace = true
@@ -1,0 +1,528 @@
1 + //! Key events to the bytes a terminal program expects.
2 + //!
3 + //! The compositor hands over a keysym and, for anything printable, the UTF-8
4 + //! xkbcommon already produced for it. Turning that into what the program on
5 + //! the other end of the PTY is waiting for is the job here, and it is entirely
6 + //! a matter of convention: there is no standard, only what xterm does and what
7 + //! every terminfo entry has recorded about it since.
8 + //!
9 + //! shop advertises `xterm-256color`, so xterm is the contract. Where xterm is
10 + //! self-inconsistent this crate follows what the terminfo entry claims,
11 + //! because that is what programs actually read.
12 + //!
13 + //! # Why this is a crate
14 + //!
15 + //! No Wayland here, and deliberately: the encoding is a pure function of
16 + //! keysym, modifiers, and the two DEC modes, so it is testable without a
17 + //! compositor, a seat, or a window. [`xkeysym`] is the only dependency, and
18 + //! SCTK re-exports the same [`Keysym`] type, so the binary passes its keysyms
19 + //! straight through.
20 + //!
21 + //! Keymap loading, modifier tracking and key repeat are not here. SCTK already
22 + //! runs libxkbcommon for all three, and a second implementation would be a
23 + //! second set of bugs about dead keys and layout switching.
24 +
25 + use xkeysym::Keysym;
26 +
27 + /// Modifier state at the moment of the press.
28 + ///
29 + /// Named for what they do rather than for the keycaps: `logo` is what
30 + /// Wayland calls the Windows/Command key, and terminals call meta.
31 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
32 + pub struct Mods {
33 + pub shift: bool,
34 + pub alt: bool,
35 + pub ctrl: bool,
36 + pub logo: bool,
37 + }
38 +
39 + impl Mods {
40 + /// The xterm modifier number: `1 + shift + 2*alt + 4*ctrl + 8*meta`.
41 + ///
42 + /// 1 means "no modifiers", which is why the modified and unmodified forms
43 + /// of a key are different sequences rather than the same one with a
44 + /// parameter of zero.
45 + fn xterm_number(self) -> u8 {
46 + 1 + u8::from(self.shift)
47 + + 2 * u8::from(self.alt)
48 + + 4 * u8::from(self.ctrl)
49 + + 8 * u8::from(self.logo)
50 + }
51 +
52 + fn none(self) -> bool {
53 + self == Self::default()
54 + }
55 + }
56 +
57 + /// The DEC modes that change what a key sends.
58 + ///
59 + /// Both are set by the program, not the user, and both are the reason a key
60 + /// cannot be encoded from the event alone.
61 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
62 + pub struct Modes {
63 + /// DECCKM (`CSI ? 1 h`). Cursor keys send `SS3 A` instead of `CSI A`.
64 + /// vim and readline both turn this on, and a terminal that ignores it
65 + /// gives you `[A` in the buffer instead of moving the cursor.
66 + pub cursor_keys_application: bool,
67 + /// DECKPAM (`ESC =`). The keypad sends its own function sequences rather
68 + /// than digits.
69 + pub keypad_application: bool,
70 + }
71 +
72 + /// Encode one key press.
73 + ///
74 + /// `utf8` is what xkbcommon resolved the key to, if anything — already
75 + /// layout-aware and already carrying the control character for Ctrl+letter.
76 + /// Empty for keys with no printable form.
77 + ///
78 + /// Returns an empty vector for keys that send nothing: modifiers themselves,
79 + /// and anything the layout gave no text and this table no sequence.
80 + #[must_use]
81 + pub fn encode(keysym: Keysym, utf8: &str, mods: Mods, modes: Modes) -> Vec<u8> {
82 + if let Some(bytes) = encode_named(keysym, mods, modes) {
83 + return bytes;
84 + }
85 + if modes.keypad_application
86 + && let Some(bytes) = encode_keypad_application(keysym, mods)
87 + {
88 + return bytes;
89 + }
90 + encode_text(keysym, utf8, mods)
91 + }
92 +
93 + /// Keys whose sequence does not depend on the layout.
94 + fn encode_named(keysym: Keysym, mods: Mods, modes: Modes) -> Option<Vec<u8>> {
95 + let m = mods.xterm_number();
96 + // Cursor and editing keys. The application-mode form only applies
97 + // unmodified: xterm sends the CSI form the moment a modifier is involved,
98 + // because the parameter has nowhere to go in the SS3 form.
99 + let cursor = |final_byte: u8| -> Vec<u8> {
100 + if m == 1 {
101 + let introducer = if modes.cursor_keys_application {
102 + b'O'
103 + } else {
104 + b'['
105 + };
106 + vec![0x1b, introducer, final_byte]
107 + } else {
108 + format!("\x1b[1;{m}{}", final_byte as char).into_bytes()
109 + }
110 + };
111 + let tilde = |code: u8| -> Vec<u8> {
112 + if m == 1 {
113 + format!("\x1b[{code}~").into_bytes()
114 + } else {
115 + format!("\x1b[{code};{m}~").into_bytes()
116 + }
117 + };
118 +
119 + let bytes = match keysym {
120 + Keysym::Up => cursor(b'A'),
121 + Keysym::Down => cursor(b'B'),
122 + Keysym::Right => cursor(b'C'),
123 + Keysym::Left => cursor(b'D'),
124 + Keysym::Home => cursor(b'H'),
125 + Keysym::End => cursor(b'F'),
126 +
127 + Keysym::Insert => tilde(2),
128 + Keysym::Delete => tilde(3),
129 + Keysym::Page_Up => tilde(5),
130 + Keysym::Page_Down => tilde(6),
131 +
132 + // F1-F4 are SS3 keys unmodified and CSI keys otherwise, which is the
133 + // one place xterm's function-key table is not a straight run.
134 + Keysym::F1 => function_ss3(b'P', m),
135 + Keysym::F2 => function_ss3(b'Q', m),
136 + Keysym::F3 => function_ss3(b'R', m),
137 + Keysym::F4 => function_ss3(b'S', m),
138 + Keysym::F5 => tilde(15),
139 + // 16 is skipped by the convention, not by an oversight here.
140 + Keysym::F6 => tilde(17),
141 + Keysym::F7 => tilde(18),
142 + Keysym::F8 => tilde(19),
143 + Keysym::F9 => tilde(20),
144 + Keysym::F10 => tilde(21),
145 + // 22 skipped as well.
146 + Keysym::F11 => tilde(23),
147 + Keysym::F12 => tilde(24),
148 +
149 + // Shift+Tab. xkb reports it as its own keysym rather than as Tab with
150 + // a shift modifier, so it never reaches the text path.
151 + Keysym::ISO_Left_Tab => b"\x1b[Z".to_vec(),
152 + Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),
153 + Keysym::Tab if mods.alt => b"\x1b\t".to_vec(),
154 + Keysym::Tab => b"\t".to_vec(),
155 +
156 + Keysym::KP_Enter if modes.keypad_application => b"\x1bOM".to_vec(),
157 + Keysym::Return | Keysym::KP_Enter if mods.alt => b"\x1b\r".to_vec(),
158 + Keysym::Return | Keysym::KP_Enter => b"\r".to_vec(),
159 +
160 + // Backspace sends DEL, and Ctrl+Backspace sends BS. That looks
161 + // backwards written down and is what every terminfo entry says, so
162 + // readline's ^H binding is the one that has to be reached with Ctrl.
163 + Keysym::BackSpace if mods.ctrl => vec![0x08],
164 + Keysym::BackSpace if mods.alt => vec![0x1b, 0x7f],
165 + Keysym::BackSpace => vec![0x7f],
166 +
167 + Keysym::Escape if mods.alt => vec![0x1b, 0x1b],
168 + Keysym::Escape => vec![0x1b],
169 +
170 + // Ctrl+Space is NUL. xkbcommon hands back a plain space for it, so
171 + // without this the null byte a few programs still want never arrives.
172 + Keysym::space if mods.ctrl && !mods.alt => vec![0x00],
173 +
174 + _ => return None,
175 + };
176 + Some(bytes)
177 + }
178 +
179 + fn function_ss3(final_byte: u8, m: u8) -> Vec<u8> {
180 + if m == 1 {
181 + vec![0x1b, b'O', final_byte]
182 + } else {
183 + format!("\x1b[1;{m}{}", final_byte as char).into_bytes()
184 + }
185 + }
186 +
187 + /// The keypad, when the program has asked for its application form.
188 + ///
189 + /// Only reached with DECKPAM set; in numeric mode these keys fall through to
190 + /// the layout and type their digits.
191 + fn encode_keypad_application(keysym: Keysym, mods: Mods) -> Option<Vec<u8>> {
192 + // Modified keypad presses have no application form in xterm; they fall
193 + // back to the ordinary text path.
194 + if !mods.none() {
195 + return None;
196 + }
197 + let final_byte = match keysym {
198 + Keysym::KP_Space => b' ',
199 + Keysym::KP_Tab => b'I',
200 + Keysym::KP_Multiply => b'j',
201 + Keysym::KP_Add => b'k',
202 + Keysym::KP_Separator => b'l',
203 + Keysym::KP_Subtract => b'm',
204 + Keysym::KP_Decimal => b'n',
205 + Keysym::KP_Divide => b'o',
206 + Keysym::KP_0 => b'p',
207 + Keysym::KP_1 => b'q',
208 + Keysym::KP_2 => b'r',
209 + Keysym::KP_3 => b's',
210 + Keysym::KP_4 => b't',
211 + Keysym::KP_5 | Keysym::KP_Begin => b'u',
212 + Keysym::KP_6 => b'v',
213 + Keysym::KP_7 => b'w',
214 + Keysym::KP_8 => b'x',
215 + Keysym::KP_9 => b'y',
216 + Keysym::KP_Equal => b'X',
217 + _ => return None,
218 + };
219 + Some(vec![0x1b, b'O', final_byte])
220 + }
221 +
222 + /// Everything the layout resolved to text.
223 + fn encode_text(keysym: Keysym, utf8: &str, mods: Mods) -> Vec<u8> {
224 + if utf8.is_empty() {
225 + return Vec::new();
226 + }
227 + // A bare modifier press reports itself as a keysym with no text on most
228 + // layouts, but not all; drop it explicitly so a stray keycap does not type.
229 + if is_modifier(keysym) {
230 + return Vec::new();
231 + }
232 + if mods.alt {
233 + // xterm's "Alt sends escape". The alternative convention, setting the
234 + // high bit, cannot represent anything outside Latin-1 and loses to
235 + // UTF-8 the moment the layout is not English.
236 + let mut out = Vec::with_capacity(utf8.len() + 1);
237 + out.push(0x1b);
238 + out.extend_from_slice(utf8.as_bytes());
239 + return out;
240 + }
241 + utf8.as_bytes().to_vec()
242 + }
243 +
244 + fn is_modifier(keysym: Keysym) -> bool {
245 + matches!(
246 + keysym,
247 + Keysym::Shift_L
248 + | Keysym::Shift_R
249 + | Keysym::Control_L
250 + | Keysym::Control_R
251 + | Keysym::Alt_L
252 + | Keysym::Alt_R
253 + | Keysym::Super_L
254 + | Keysym::Super_R
255 + | Keysym::Meta_L
256 + | Keysym::Meta_R
257 + | Keysym::Hyper_L
258 + | Keysym::Hyper_R
259 + | Keysym::Caps_Lock
260 + | Keysym::Shift_Lock
261 + | Keysym::Num_Lock
262 + | Keysym::ISO_Level3_Shift
263 + | Keysym::ISO_Level5_Shift
264 + )
265 + }
266 +
267 + #[cfg(test)]
268 + mod tests {
269 + use super::*;
270 +
271 + const NONE: Mods = Mods {
272 + shift: false,
273 + alt: false,
274 + ctrl: false,
275 + logo: false,
276 + };
277 + const CTRL: Mods = Mods { ctrl: true, ..NONE };
278 + const SHIFT: Mods = Mods {
279 + shift: true,
280 + ..NONE
281 + };
282 + const ALT: Mods = Mods { alt: true, ..NONE };
283 +
284 + const NORMAL: Modes = Modes {
285 + cursor_keys_application: false,
286 + keypad_application: false,
287 + };
288 + const APP_CURSOR: Modes = Modes {
289 + cursor_keys_application: true,
290 + keypad_application: false,
291 + };
292 + const APP_KEYPAD: Modes = Modes {
293 + cursor_keys_application: false,
294 + keypad_application: true,
295 + };
296 +
297 + fn seq(keysym: Keysym, utf8: &str, mods: Mods, modes: Modes) -> String {
298 + String::from_utf8(encode(keysym, utf8, mods, modes)).unwrap()
299 + }
300 +
301 + // ---- modifier numbering --------------------------------------------
302 +
303 + #[test]
304 + fn the_unmodified_number_is_one_not_zero() {
305 + assert_eq!(NONE.xterm_number(), 1);
306 + }
307 +
308 + #[test]
309 + fn modifier_numbers_match_the_xterm_table() {
310 + assert_eq!(SHIFT.xterm_number(), 2);
311 + assert_eq!(ALT.xterm_number(), 3);
312 + assert_eq!(CTRL.xterm_number(), 5);
313 + assert_eq!(
314 + Mods {
315 + shift: true,
316 + ctrl: true,
317 + ..NONE
318 + }
319 + .xterm_number(),
320 + 6
321 + );
322 + assert_eq!(
323 + Mods {
324 + shift: true,
325 + alt: true,
326 + ctrl: true,
327 + logo: true
328 + }
329 + .xterm_number(),
330 + 16
331 + );
332 + }
333 +
334 + // ---- cursor keys ----------------------------------------------------
335 +
336 + #[test]
337 + fn cursor_keys_are_csi_by_default() {
338 + assert_eq!(seq(Keysym::Up, "", NONE, NORMAL), "\x1b[A");
339 + assert_eq!(seq(Keysym::Down, "", NONE, NORMAL), "\x1b[B");
340 + assert_eq!(seq(Keysym::Right, "", NONE, NORMAL), "\x1b[C");
341 + assert_eq!(seq(Keysym::Left, "", NONE, NORMAL), "\x1b[D");
342 + }
343 +
344 + #[test]
345 + fn application_mode_makes_cursor_keys_ss3() {
346 + assert_eq!(seq(Keysym::Up, "", NONE, APP_CURSOR), "\x1bOA");
347 + assert_eq!(seq(Keysym::Left, "", NONE, APP_CURSOR), "\x1bOD");
348 + }
349 +
350 + #[test]
351 + fn a_modified_cursor_key_is_csi_even_in_application_mode() {
352 + // The SS3 form has no room for the parameter, so xterm drops back to
353 + // CSI. A terminal that keeps SS3 here sends vim something it reads as
354 + // a bare Escape followed by garbage.
355 + assert_eq!(seq(Keysym::Up, "", CTRL, APP_CURSOR), "\x1b[1;5A");
356 + assert_eq!(seq(Keysym::Right, "", SHIFT, APP_CURSOR), "\x1b[1;2C");
357 + }
358 +
359 + #[test]
360 + fn home_and_end_ride_the_cursor_path() {
361 + assert_eq!(seq(Keysym::Home, "", NONE, NORMAL), "\x1b[H");
362 + assert_eq!(seq(Keysym::End, "", NONE, NORMAL), "\x1b[F");
363 + assert_eq!(seq(Keysym::Home, "", NONE, APP_CURSOR), "\x1bOH");
364 + assert_eq!(seq(Keysym::End, "", CTRL, NORMAL), "\x1b[1;5F");
365 + }
366 +
367 + // ---- editing keys ---------------------------------------------------
368 +
369 + #[test]
370 + fn editing_keys_use_the_tilde_form() {
371 + assert_eq!(seq(Keysym::Insert, "", NONE, NORMAL), "\x1b[2~");
372 + assert_eq!(seq(Keysym::Delete, "", NONE, NORMAL), "\x1b[3~");
373 + assert_eq!(seq(Keysym::Page_Up, "", NONE, NORMAL), "\x1b[5~");
374 + assert_eq!(seq(Keysym::Page_Down, "", NONE, NORMAL), "\x1b[6~");
375 + }
376 +
377 + #[test]
378 + fn a_modified_editing_key_carries_its_number() {
379 + assert_eq!(seq(Keysym::Delete, "", CTRL, NORMAL), "\x1b[3;5~");
380 + assert_eq!(seq(Keysym::Page_Up, "", SHIFT, NORMAL), "\x1b[5;2~");
381 + }
382 +
383 + // ---- function keys --------------------------------------------------
384 +
385 + #[test]
386 + fn the_first_four_function_keys_are_ss3() {
387 + assert_eq!(seq(Keysym::F1, "", NONE, NORMAL), "\x1bOP");
388 + assert_eq!(seq(Keysym::F2, "", NONE, NORMAL), "\x1bOQ");
389 + assert_eq!(seq(Keysym::F3, "", NONE, NORMAL), "\x1bOR");
390 + assert_eq!(seq(Keysym::F4, "", NONE, NORMAL), "\x1bOS");
391 + }
392 +
393 + #[test]
394 + fn modified_f1_to_f4_become_csi() {
395 + assert_eq!(seq(Keysym::F1, "", SHIFT, NORMAL), "\x1b[1;2P");
396 + assert_eq!(seq(Keysym::F4, "", CTRL, NORMAL), "\x1b[1;5S");
397 + }
398 +
399 + #[test]
400 + fn the_rest_are_tilde_keys_with_the_gaps_the_convention_has() {
401 + assert_eq!(seq(Keysym::F5, "", NONE, NORMAL), "\x1b[15~");
402 + assert_eq!(seq(Keysym::F6, "", NONE, NORMAL), "\x1b[17~");
403 + assert_eq!(seq(Keysym::F10, "", NONE, NORMAL), "\x1b[21~");
404 + assert_eq!(seq(Keysym::F11, "", NONE, NORMAL), "\x1b[23~");
405 + assert_eq!(seq(Keysym::F12, "", NONE, NORMAL), "\x1b[24~");
406 + }
407 +
408 + // ---- tab, return, backspace, escape ---------------------------------
409 +
410 + #[test]
411 + fn shift_tab_is_backtab_by_either_route() {
412 + // xkb usually reports its own keysym, but not every layout does.
413 + assert_eq!(seq(Keysym::ISO_Left_Tab, "", SHIFT, NORMAL), "\x1b[Z");
414 + assert_eq!(seq(Keysym::Tab, "\t", SHIFT, NORMAL), "\x1b[Z");
415 + }
416 +
417 + #[test]
418 + fn plain_tab_is_a_tab() {
419 + assert_eq!(seq(Keysym::Tab, "\t", NONE, NORMAL), "\t");
420 + }
421 +
422 + #[test]
423 + fn return_is_carriage_return_not_line_feed() {
424 + assert_eq!(seq(Keysym::Return, "\r", NONE, NORMAL), "\r");
425 + assert_eq!(seq(Keysym::KP_Enter, "", NONE, NORMAL), "\r");
426 + }
427 +
428 + #[test]
429 + fn keypad_enter_has_an_application_form() {
430 + assert_eq!(seq(Keysym::KP_Enter, "", NONE, APP_KEYPAD), "\x1bOM");
431 + }
432 +
433 + #[test]
434 + fn backspace_sends_del_and_ctrl_backspace_sends_bs() {
435 + assert_eq!(encode(Keysym::BackSpace, "", NONE, NORMAL), vec![0x7f]);
436 + assert_eq!(encode(Keysym::BackSpace, "", CTRL, NORMAL), vec![0x08]);
437 + assert_eq!(encode(Keysym::BackSpace, "", ALT, NORMAL), vec![0x1b, 0x7f]);
438 + }
439 +
440 + #[test]
441 + fn ctrl_space_is_a_null_byte() {
442 + // xkbcommon hands back a plain space here, so the table has to.
443 + assert_eq!(encode(Keysym::space, " ", CTRL, NORMAL), vec![0x00]);
444 + assert_eq!(encode(Keysym::space, " ", NONE, NORMAL), b" ".to_vec());
445 + }
446 +
447 + #[test]
448 + fn alt_escape_is_two_escapes() {
449 + assert_eq!(encode(Keysym::Escape, "", NONE, NORMAL), vec![0x1b]);
450 + assert_eq!(encode(Keysym::Escape, "", ALT, NORMAL), vec![0x1b, 0x1b]);
451 + }
452 +
453 + // ---- the keypad ------------------------------------------------------
454 +
455 + #[test]
456 + fn the_keypad_types_digits_in_numeric_mode() {
457 + assert_eq!(seq(Keysym::KP_1, "1", NONE, NORMAL), "1");
458 + assert_eq!(seq(Keysym::KP_Add, "+", NONE, NORMAL), "+");
459 + }
460 +
461 + #[test]
462 + fn the_keypad_has_its_own_sequences_in_application_mode() {
463 + assert_eq!(seq(Keysym::KP_0, "0", NONE, APP_KEYPAD), "\x1bOp");
464 + assert_eq!(seq(Keysym::KP_9, "9", NONE, APP_KEYPAD), "\x1bOy");
465 + assert_eq!(seq(Keysym::KP_Add, "+", NONE, APP_KEYPAD), "\x1bOk");
466 + assert_eq!(seq(Keysym::KP_Divide, "/", NONE, APP_KEYPAD), "\x1bOo");
467 + }
468 +
469 + #[test]
470 + fn the_centre_key_is_the_same_sequence_under_either_keysym() {
471 + // Num Lock decides whether the 5 key reports KP_5 or KP_Begin.
472 + assert_eq!(seq(Keysym::KP_5, "5", NONE, APP_KEYPAD), "\x1bOu");
473 + assert_eq!(seq(Keysym::KP_Begin, "", NONE, APP_KEYPAD), "\x1bOu");
474 + }
475 +
476 + #[test]
477 + fn a_modified_keypad_press_falls_back_to_its_text() {
478 + assert_eq!(seq(Keysym::KP_1, "1", CTRL, APP_KEYPAD), "1");
479 + }
480 +
481 + // ---- text ------------------------------------------------------------
482 +
483 + #[test]
484 + fn printable_text_passes_through() {
485 + assert_eq!(seq(Keysym::a, "a", NONE, NORMAL), "a");
486 + assert_eq!(seq(Keysym::A, "A", SHIFT, NORMAL), "A");
487 + }
488 +
489 + #[test]
490 + fn xkb_owns_the_control_characters() {
491 + // Ctrl+C arrives already folded to 0x03; recomputing it here would
492 + // mean disagreeing with the layout about what the key is.
493 + assert_eq!(encode(Keysym::c, "\x03", CTRL, NORMAL), vec![0x03]);
494 + }
495 +
496 + #[test]
497 + fn alt_prefixes_an_escape() {
498 + assert_eq!(seq(Keysym::b, "b", ALT, NORMAL), "\x1bb");
499 + }
500 +
Lines truncated