Skip to main content

lattice_host/
keymap_terminal.rs

1//! Keystroke → ANSI byte encoder for Terminal-Insert mode.
2//!
3//! Terminal-mode T2.a (2026-05-25): the minimum table that lets a
4//! user type at a shell prompt and run commands:
5//!
6//! - printable chars (ASCII + UTF-8) → their bytes.
7//! - `Enter` → `\r`.
8//! - `Tab` → `\t`; `Shift-Tab` → `\x1b[Z` (backtab CSI Z).
9//! - `Backspace` → `\x7f` (DEL — what xterm sends by default).
10//! - `Esc` → `\x1b`.
11//! - `Ctrl-a..Ctrl-z` → `\x01..\x1a`.
12//! - `Ctrl-[` / `Ctrl-\` / `Ctrl-]` / `Ctrl-^` / `Ctrl-_` /
13//!   `Ctrl-Space` / `Ctrl-@` → their canonical bytes per the
14//!   VT/xterm table.
15//!
16//! Terminal-mode T2.b (2026-05-25): full encoder.
17//! - Arrows / Home / End / PgUp / PgDn / Insert / Delete →
18//!   CSI sequences (DECCKM-OFF; the application-cursor-keys
19//!   variant lands when the alacritty_terminal swap tracks the
20//!   mode bit).
21//! - F1–F4 → SS3 `ESC O P/Q/R/S`.
22//! - F5–F12 → CSI `ESC [ <n> ~`.
23//! - Alt + key → ESC-prefix encoding (`\x1b` then the key's
24//!   own bytes).
25//! - Shift / Ctrl on arrows / F-keys use the xterm
26//!   modifyOtherKeys-style `;<n>` parameter.
27//!
28//! DECCKM (application-cursor-keys) tracking is deferred to T2.c
29//! once `lattice-terminal::reader` swaps to alacritty_terminal,
30//! which surfaces the mode bit per terminal. The encoder takes
31//! `cursor_keys_application_mode: bool` so the wiring is
32//! one-call-site when the substrate lands; today the only caller
33//! passes `false` (xterm-default cursor keys).
34//!
35//! Lives here in `lattice-host` because [`crate::chord::KeyChord`]
36//! is host-owned and the substrate crate (`lattice-terminal`)
37//! can't depend on the host without a cycle. If `KeyChord` ever
38//! moves down to `lattice-core` this module can relocate to
39//! `lattice-terminal::encode` without changing the surface.
40
41use crate::chord::{KeyChord, KeyKind, SpecialKey};
42
43/// Encode a single key chord into PTY-stdin bytes. Returns `None`
44/// for chords with no Terminal-Insert meaning yet (modifier-only
45/// releases, unmapped F-keys beyond F12). Callers treat `None`
46/// as a no-op rather than a translation error.
47///
48/// Backwards-compat wrapper over [`key_to_ansi_with_mode`] that
49/// pins DECCKM to `false` (xterm-default normal cursor keys).
50/// Production callers route through this; tests opting into the
51/// application-cursor-keys variant call the lower-level helper.
52pub fn key_to_ansi(chord: &KeyChord) -> Option<Vec<u8>> {
53    key_to_ansi_with_mode(chord, false)
54}
55
56/// Lower-level encoder with explicit cursor-key mode. When
57/// `cursor_keys_application_mode` is `true`, bare arrow keys
58/// encode as `ESC O <letter>` (SS3) instead of `ESC [ <letter>`
59/// (CSI). The rest of the table is independent of the mode bit.
60pub fn key_to_ansi_with_mode(
61    chord: &KeyChord,
62    cursor_keys_application_mode: bool,
63) -> Option<Vec<u8>> {
64    let mods = chord.mods;
65
66    // Alt-prefix: ESC-prefix encoding. Compute the no-Alt payload
67    // via a recursive call with the Alt bit cleared and splice
68    // `\x1b` in front. Captures Alt-arrow (yields
69    // `ESC ESC [ A`, bash/zsh's "argument word-back" idiom) and
70    // Alt-letter (`ESC <c>`, the "meta key" convention).
71    if mods.alt() {
72        let stripped = KeyChord {
73            key: chord.key,
74            mods: mods.without(crate::chord::KeyMods::ALT),
75        };
76        return key_to_ansi_with_mode(&stripped, cursor_keys_application_mode).map(|mut bytes| {
77            let mut out = Vec::with_capacity(1 + bytes.len());
78            out.push(0x1b);
79            out.append(&mut bytes);
80            out
81        });
82    }
83
84    // Ctrl-bearing first: `Ctrl-letter` is the densest part of
85    // the table; short-circuit before the bare-char fall-through.
86    // Capital ASCII letters yield the same control byte as
87    // lower-case (Ctrl-A == Ctrl-a == \x01); xterm matches this
88    // even on Caps-Lock.
89    if mods.ctrl()
90        && let KeyKind::Char(c) = chord.key
91        && let Some(b) = ctrl_char_byte(c)
92    {
93        return Some(vec![b]);
94    }
95
96    match chord.key {
97        KeyKind::Char(c) => {
98            // Bare printable + Shift'd printables both serialise
99            // to the upstream byte (the chord layer already
100            // reflects shift state in `c`).
101            let mut buf = [0u8; 4];
102            let s = c.encode_utf8(&mut buf);
103            Some(s.as_bytes().to_vec())
104        }
105        KeyKind::Special(SpecialKey::Enter) => Some(vec![b'\r']),
106        KeyKind::Special(SpecialKey::Tab) => {
107            if mods.shift() {
108                Some(b"\x1b[Z".to_vec())
109            } else {
110                Some(vec![b'\t'])
111            }
112        }
113        KeyKind::Special(SpecialKey::Backspace) => Some(vec![0x7f]),
114        KeyKind::Special(SpecialKey::Esc) => Some(vec![0x1b]),
115        // Space arrives as `Special::Space` only when modifiers
116        // are present (the chord layer keeps bare space as
117        // `Char(' ')`). Encode as a literal space byte; modifier
118        // combinations like Ctrl-Space hit the `ctrl_char_byte`
119        // table above before reaching this arm.
120        KeyKind::Special(SpecialKey::Space) => Some(vec![b' ']),
121        // ---- Arrows + Home/End — cursor-key family.
122        KeyKind::Special(SpecialKey::Up) => {
123            Some(cursor_key(b'A', mods, cursor_keys_application_mode))
124        }
125        KeyKind::Special(SpecialKey::Down) => {
126            Some(cursor_key(b'B', mods, cursor_keys_application_mode))
127        }
128        KeyKind::Special(SpecialKey::Right) => {
129            Some(cursor_key(b'C', mods, cursor_keys_application_mode))
130        }
131        KeyKind::Special(SpecialKey::Left) => {
132            Some(cursor_key(b'D', mods, cursor_keys_application_mode))
133        }
134        KeyKind::Special(SpecialKey::Home) => {
135            Some(cursor_key(b'H', mods, cursor_keys_application_mode))
136        }
137        KeyKind::Special(SpecialKey::End) => {
138            Some(cursor_key(b'F', mods, cursor_keys_application_mode))
139        }
140        // ---- Tilde-terminated family.
141        KeyKind::Special(SpecialKey::PageUp) => Some(tilde_key(5, mods)),
142        KeyKind::Special(SpecialKey::PageDown) => Some(tilde_key(6, mods)),
143        KeyKind::Special(SpecialKey::Insert) => Some(tilde_key(2, mods)),
144        KeyKind::Special(SpecialKey::Delete) => Some(tilde_key(3, mods)),
145        // ---- Function keys: SS3 for F1–F4, tilde form for F5+.
146        KeyKind::Special(SpecialKey::F(n)) => fn_key(n, mods),
147    }
148}
149
150/// Build the CSI / SS3 encoding for an arrow or Home/End key.
151/// Bare in app-mode: `ESC O <letter>`. Bare in normal mode:
152/// `ESC [ <letter>`. Modified (any of shift / ctrl / alt-in-
153/// inner-call): `ESC [ 1 ; <mod> <letter>` — modifiers always
154/// stay on the CSI variant per xterm.
155fn cursor_key(letter: u8, mods: crate::chord::KeyMods, app_mode: bool) -> Vec<u8> {
156    let mod_param = modifier_param(mods);
157    if mod_param == 1 {
158        if app_mode {
159            vec![0x1b, b'O', letter]
160        } else {
161            vec![0x1b, b'[', letter]
162        }
163    } else {
164        let mut out = Vec::with_capacity(8);
165        out.extend_from_slice(b"\x1b[1;");
166        out.extend_from_slice(mod_param.to_string().as_bytes());
167        out.push(letter);
168        out
169    }
170}
171
172/// Build a `ESC [ <n> ~` (or `ESC [ <n> ; <mod> ~` when
173/// modified) encoding for keys that follow the tilde-terminator
174/// convention: Insert (2), Delete (3), PageUp (5), PageDown (6),
175/// and F5+ function keys.
176fn tilde_key(n: u16, mods: crate::chord::KeyMods) -> Vec<u8> {
177    let mod_param = modifier_param(mods);
178    let mut out = Vec::with_capacity(8);
179    out.extend_from_slice(b"\x1b[");
180    out.extend_from_slice(n.to_string().as_bytes());
181    if mod_param != 1 {
182        out.push(b';');
183        out.extend_from_slice(mod_param.to_string().as_bytes());
184    }
185    out.push(b'~');
186    out
187}
188
189/// Encode F1–F12 per the xterm convention: F1–F4 use SS3
190/// (`ESC O P/Q/R/S`), F5–F12 use the tilde form with explicit
191/// numeric parameters (15, 17–21, 23–24). Higher F-keys
192/// (F13–F24) ride the same scheme but are rarely needed; the
193/// match below stops at F12 and returns `None` for the rest so
194/// upstream sees the omission instead of fabricated bytes.
195fn fn_key(n: u8, mods: crate::chord::KeyMods) -> Option<Vec<u8>> {
196    let mod_param = modifier_param(mods);
197    // F1–F4: SS3 form. Modified variants fall back to the CSI
198    // shape `ESC [ 1 ; <mod> P/Q/R/S` per xterm.
199    let ss3_letter = match n {
200        1 => Some(b'P'),
201        2 => Some(b'Q'),
202        3 => Some(b'R'),
203        4 => Some(b'S'),
204        _ => None,
205    };
206    if let Some(letter) = ss3_letter {
207        if mod_param == 1 {
208            return Some(vec![0x1b, b'O', letter]);
209        }
210        let mut out = Vec::with_capacity(8);
211        out.extend_from_slice(b"\x1b[1;");
212        out.extend_from_slice(mod_param.to_string().as_bytes());
213        out.push(letter);
214        return Some(out);
215    }
216    // F5–F12 → tilde form with xterm's canonical numeric ids.
217    // The non-sequential gap at 16 and 22 matches the spec.
218    let param = match n {
219        5 => 15,
220        6 => 17,
221        7 => 18,
222        8 => 19,
223        9 => 20,
224        10 => 21,
225        11 => 23,
226        12 => 24,
227        _ => return None,
228    };
229    Some(tilde_key(param, mods))
230}
231
232/// xterm modifier-parameter encoding: `1` = none, `2` = Shift,
233/// `3` = Alt, `5` = Ctrl, `4` = Shift+Alt, `6` = Shift+Ctrl,
234/// `7` = Ctrl+Alt, `8` = Shift+Ctrl+Alt. Built from the bitmap
235/// `(shift << 0) | (alt << 1) | (ctrl << 2) + 1`. The Alt bit
236/// is never observed here in practice — `key_to_ansi_with_mode`
237/// strips Alt at its entry and re-prefixes with `\x1b` — but the
238/// bit stays in the table so callers that bypass the wrapper
239/// still get the right xterm parameter.
240fn modifier_param(mods: crate::chord::KeyMods) -> u8 {
241    let mut bits: u8 = 0;
242    if mods.shift() {
243        bits |= 0b001;
244    }
245    if mods.alt() {
246        bits |= 0b010;
247    }
248    if mods.ctrl() {
249        bits |= 0b100;
250    }
251    bits + 1
252}
253
254/// Map a printable ASCII character to its Ctrl-modified byte. Per
255/// the VT/xterm table:
256///
257/// | Char           | Byte    |
258/// |----------------|---------|
259/// | `a..z` / `A..Z`| `01..1a`|
260/// | `[`            | `1b`    |
261/// | `\`            | `1c`    |
262/// | `]`            | `1d`    |
263/// | `^` / `~`      | `1e`    |
264/// | `_` / `?`      | `1f`    |
265/// | ` ` / `@`      | `00`    |
266///
267/// Returns `None` for chars with no Ctrl-mapping (digits, most
268/// punctuation); callers fall through to the bare byte.
269fn ctrl_char_byte(c: char) -> Option<u8> {
270    match c {
271        'a'..='z' => Some(c as u8 - b'a' + 1),
272        'A'..='Z' => Some(c as u8 - b'A' + 1),
273        '[' => Some(0x1b),
274        '\\' => Some(0x1c),
275        ']' => Some(0x1d),
276        '^' | '~' => Some(0x1e),
277        '_' | '?' => Some(0x1f),
278        ' ' | '@' => Some(0x00),
279        _ => None,
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::chord::KeyMods;
287
288    fn bare(key: KeyKind) -> KeyChord {
289        KeyChord {
290            key,
291            mods: KeyMods::NONE,
292        }
293    }
294
295    fn ctrl(c: char) -> KeyChord {
296        KeyChord::ctrl(c)
297    }
298
299    #[test]
300    fn printable_ascii_passes_through_as_one_byte() {
301        assert_eq!(key_to_ansi(&bare(KeyKind::Char('a'))), Some(b"a".to_vec()));
302        assert_eq!(key_to_ansi(&bare(KeyKind::Char('Z'))), Some(b"Z".to_vec()));
303        assert_eq!(key_to_ansi(&bare(KeyKind::Char('0'))), Some(b"0".to_vec()));
304        assert_eq!(key_to_ansi(&bare(KeyKind::Char(' '))), Some(b" ".to_vec()));
305        assert_eq!(key_to_ansi(&bare(KeyKind::Char('-'))), Some(b"-".to_vec()));
306    }
307
308    #[test]
309    fn enter_tab_backspace_esc_map_to_canonical_bytes() {
310        assert_eq!(
311            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Enter))),
312            Some(vec![b'\r']),
313        );
314        assert_eq!(
315            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Tab))),
316            Some(vec![b'\t']),
317        );
318        assert_eq!(
319            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Backspace))),
320            Some(vec![0x7f]),
321        );
322        assert_eq!(
323            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Esc))),
324            Some(vec![0x1b]),
325        );
326    }
327
328    #[test]
329    fn ctrl_letters_map_to_low_control_bytes() {
330        assert_eq!(key_to_ansi(&ctrl('a')), Some(vec![0x01]));
331        assert_eq!(key_to_ansi(&ctrl('c')), Some(vec![0x03])); // SIGINT
332        assert_eq!(key_to_ansi(&ctrl('d')), Some(vec![0x04])); // EOF
333        assert_eq!(key_to_ansi(&ctrl('w')), Some(vec![0x17])); // WERASE
334        assert_eq!(key_to_ansi(&ctrl('z')), Some(vec![0x1a])); // SIGTSTP
335        // Case-insensitive: Ctrl-A == Ctrl-a.
336        assert_eq!(key_to_ansi(&ctrl('A')), Some(vec![0x01]));
337    }
338
339    #[test]
340    fn ctrl_punctuation_maps_per_xterm_table() {
341        assert_eq!(key_to_ansi(&ctrl('[')), Some(vec![0x1b])); // ESC
342        assert_eq!(key_to_ansi(&ctrl('\\')), Some(vec![0x1c]));
343        assert_eq!(key_to_ansi(&ctrl(']')), Some(vec![0x1d]));
344        assert_eq!(key_to_ansi(&ctrl('^')), Some(vec![0x1e]));
345        assert_eq!(key_to_ansi(&ctrl('_')), Some(vec![0x1f]));
346        assert_eq!(key_to_ansi(&ctrl(' ')), Some(vec![0x00]));
347    }
348
349    #[test]
350    fn shift_tab_becomes_backtab_csi_z() {
351        let backtab = KeyChord {
352            key: KeyKind::Special(SpecialKey::Tab),
353            mods: KeyMods::SHIFT,
354        };
355        assert_eq!(key_to_ansi(&backtab), Some(b"\x1b[Z".to_vec()));
356    }
357
358    // ---- T2.b (2026-05-25) — full encoder coverage ----
359
360    fn special(k: SpecialKey, mods: KeyMods) -> KeyChord {
361        KeyChord {
362            key: KeyKind::Special(k),
363            mods,
364        }
365    }
366
367    #[test]
368    fn bare_arrows_emit_csi_letter_in_normal_mode() {
369        assert_eq!(
370            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Up))),
371            Some(b"\x1b[A".to_vec()),
372        );
373        assert_eq!(
374            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Down))),
375            Some(b"\x1b[B".to_vec()),
376        );
377        assert_eq!(
378            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Right))),
379            Some(b"\x1b[C".to_vec()),
380        );
381        assert_eq!(
382            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Left))),
383            Some(b"\x1b[D".to_vec()),
384        );
385    }
386
387    #[test]
388    fn bare_arrows_emit_ss3_letter_in_application_mode() {
389        // DECCKM-on (program issued `ESC [ ? 1 h`): bare arrows
390        // flip to SS3. Modifiers stay on CSI.
391        assert_eq!(
392            key_to_ansi_with_mode(&bare(KeyKind::Special(SpecialKey::Up)), true),
393            Some(b"\x1bOA".to_vec()),
394        );
395        assert_eq!(
396            key_to_ansi_with_mode(&bare(KeyKind::Special(SpecialKey::Left)), true),
397            Some(b"\x1bOD".to_vec()),
398        );
399    }
400
401    #[test]
402    fn shifted_arrows_use_xterm_modifier_param() {
403        assert_eq!(
404            key_to_ansi(&special(SpecialKey::Up, KeyMods::SHIFT)),
405            Some(b"\x1b[1;2A".to_vec()),
406        );
407        assert_eq!(
408            key_to_ansi(&special(SpecialKey::Right, KeyMods::CTRL)),
409            Some(b"\x1b[1;5C".to_vec()),
410        );
411        // Ctrl+Shift = parameter `6` (bits 0b101 + 1).
412        assert_eq!(
413            key_to_ansi(&special(SpecialKey::Down, KeyMods::CTRL | KeyMods::SHIFT,)),
414            Some(b"\x1b[1;6B".to_vec()),
415        );
416    }
417
418    #[test]
419    fn home_end_follow_cursor_key_family() {
420        assert_eq!(
421            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Home))),
422            Some(b"\x1b[H".to_vec()),
423        );
424        assert_eq!(
425            key_to_ansi(&bare(KeyKind::Special(SpecialKey::End))),
426            Some(b"\x1b[F".to_vec()),
427        );
428    }
429
430    #[test]
431    fn insert_delete_page_keys_emit_tilde_form() {
432        assert_eq!(
433            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Insert))),
434            Some(b"\x1b[2~".to_vec()),
435        );
436        assert_eq!(
437            key_to_ansi(&bare(KeyKind::Special(SpecialKey::Delete))),
438            Some(b"\x1b[3~".to_vec()),
439        );
440        assert_eq!(
441            key_to_ansi(&bare(KeyKind::Special(SpecialKey::PageUp))),
442            Some(b"\x1b[5~".to_vec()),
443        );
444        assert_eq!(
445            key_to_ansi(&bare(KeyKind::Special(SpecialKey::PageDown))),
446            Some(b"\x1b[6~".to_vec()),
447        );
448    }
449
450    #[test]
451    fn modified_tilde_keys_carry_xterm_param() {
452        assert_eq!(
453            key_to_ansi(&special(SpecialKey::Delete, KeyMods::SHIFT)),
454            Some(b"\x1b[3;2~".to_vec()),
455        );
456        assert_eq!(
457            key_to_ansi(&special(SpecialKey::PageUp, KeyMods::CTRL)),
458            Some(b"\x1b[5;5~".to_vec()),
459        );
460    }
461
462    #[test]
463    fn f1_to_f4_use_ss3_form() {
464        for (n, letter) in [(1u8, b'P'), (2, b'Q'), (3, b'R'), (4, b'S')] {
465            assert_eq!(
466                key_to_ansi(&bare(KeyKind::Special(SpecialKey::F(n)))),
467                Some(vec![0x1b, b'O', letter]),
468                "F{n} should emit ESC O {}",
469                letter as char,
470            );
471        }
472    }
473
474    #[test]
475    fn f5_to_f12_use_tilde_form_with_xterm_ids() {
476        let cases = [
477            (5u8, 15u16),
478            (6, 17),
479            (7, 18),
480            (8, 19),
481            (9, 20),
482            (10, 21),
483            (11, 23),
484            (12, 24),
485        ];
486        for (n, param) in cases {
487            let expected = format!("\x1b[{param}~");
488            assert_eq!(
489                key_to_ansi(&bare(KeyKind::Special(SpecialKey::F(n)))),
490                Some(expected.into_bytes()),
491                "F{n} should emit ESC [ {param} ~",
492            );
493        }
494    }
495
496    #[test]
497    fn unmapped_fn_returns_none() {
498        // F13+ aren't in the T2.b table; the encoder rejects them
499        // rather than fabricating bytes.
500        assert_eq!(
501            key_to_ansi(&bare(KeyKind::Special(SpecialKey::F(13)))),
502            None,
503        );
504        assert_eq!(key_to_ansi(&bare(KeyKind::Special(SpecialKey::F(0)))), None,);
505    }
506
507    #[test]
508    fn alt_letter_uses_esc_prefix_meta_convention() {
509        // Alt-x → ESC x (the "meta key" convention; readline reads
510        // this as M-x). Letters take the upstream byte; the Alt
511        // bit clears before recursion.
512        let alt_x = KeyChord {
513            key: KeyKind::Char('x'),
514            mods: KeyMods::ALT,
515        };
516        assert_eq!(key_to_ansi(&alt_x), Some(b"\x1bx".to_vec()));
517    }
518
519    #[test]
520    fn alt_arrow_emits_double_esc_csi_idiom() {
521        // Alt-Left: bash/zsh's "word backward" — `ESC ESC [ D`.
522        let alt_left = special(SpecialKey::Left, KeyMods::ALT);
523        assert_eq!(key_to_ansi(&alt_left), Some(b"\x1b\x1b[D".to_vec()));
524    }
525}