Skip to main content

kimun_notes/keys/
reachability.rs

1//! Whether a chord can reach the app on *this* terminal.
2//!
3//! A terminal without the kitty keyboard protocol does not send keys; it sends
4//! bytes. `Ctrl` plus a letter is one byte in `0x01..=0x1A`, and several of
5//! those bytes were spoken for by real keys decades before anyone wanted them
6//! as chords: `0x09` is Tab, `0x0D` is Enter, `0x1B` is Escape. Press `Ctrl+I`
7//! on such a terminal and what arrives is Tab — identical, no side channel, no
8//! timing tell. Shift fares no better: a `Ctrl+Shift+letter` chord transmits
9//! without the shift bit, arriving as the plain `Ctrl+letter`.
10//!
11//! This module answers, for one [`KeyCombo`] and one terminal, which of three
12//! things happens. It is pure and takes the terminal's traits as data, so the
13//! answer can be asserted in a test rather than discovered by pressing keys —
14//! which is the point: [`crate::settings`] uses it to keep the *default* keymap
15//! free of chords that cannot arrive, and nothing about that check needs a
16//! terminal.
17//!
18//! Every rule here is two small tables: the ASCII control byte a chord packs
19//! to (`control_byte`) and what crossterm's legacy decoder
20//! (`event::sys::unix::parse`) makes of that byte (`legacy_decode`). Where it
21//! resolves a byte to a named key before it reaches the `Ctrl`+letter range —
22//! `\t`, `\r`, `\x1B`, `\x7F` each have their own arm — that named key is
23//! what wins, and the chord is what loses.
24
25use super::KeyBindings;
26use super::action_shortcuts::ActionShortcuts;
27use super::key_combo::{KeyCombo, KeyModifiers};
28use super::key_strike::KeyStrike;
29
30/// What this terminal does to keys. Both facts are session-scoped and known at
31/// startup; neither is a user preference.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct TerminalKeys {
34    /// The kitty keyboard-enhancement flags went out, so keys arrive as keys
35    /// and none of the byte collisions below exist.
36    pub enhanced: bool,
37    /// `0x08` is being delivered as Backspace (see `app::ctrl_h`), so the
38    /// `Ctrl+H` chord never arrives at all.
39    pub ctrl_h_is_backspace: bool,
40}
41
42impl TerminalKeys {
43    /// The terminal the default keymap is held to: no protocol, so every byte
44    /// collision applies.
45    ///
46    /// `Ctrl+H` is left as a chord rather than made pessimistic here, because
47    /// the rewrite is conditional on one user's tty erase character rather
48    /// than on terminals in general (see `app::ctrl_h`). Holding the defaults
49    /// to it would demand `FocusSidebar` give up `Ctrl+H` for everyone to
50    /// serve the few — which is the trade the `ctrl_h` setting exists to let
51    /// those users make for themselves.
52    pub const LEGACY: Self = Self {
53        enhanced: false,
54        ctrl_h_is_backspace: false,
55    };
56
57    /// The traits of a live session.
58    ///
59    /// `kitty_pushed` is whether the enhancement flags went out
60    /// (`terminal::TerminalSession::keyboard_enhanced`). It is not the whole
61    /// answer: on Windows crossterm never asks the terminal — its
62    /// `supports_keyboard_enhancement` is a hard-coded `Ok(false)` — yet the
63    /// console API reports keys rather than bytes, so none of the collisions
64    /// this module tracks exist there (see `app::ctrl_h::tty_erase_char`).
65    /// Folding that in here, rather than at each caller, keeps the startup
66    /// notice and `kimun doctor` agreeing on the `enhanced` axis for the same
67    /// terminal: both derive it as `cfg!(windows) || kitty_pushed`.
68    ///
69    /// `ctrl_h_is_backspace` carries no such guarantee — it is a plain
70    /// pass-through, and the two callers deliberately pass different values
71    /// (see the comment at the `app/mod.rs` call site). This constructor
72    /// cannot make them agree on that axis; it only relays what each caller
73    /// decides.
74    pub fn detected(kitty_pushed: bool, ctrl_h_is_backspace: bool) -> Self {
75        Self {
76            enhanced: cfg!(windows) || kitty_pushed,
77            ctrl_h_is_backspace,
78        }
79    }
80}
81
82/// What becomes of a chord on the way in.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Reach {
85    /// Arrives as itself. The binding works.
86    Ok,
87    /// Arrives as a *different* combo. Pressing the chord fires whatever that
88    /// one is bound to, and the binding is dead — silently, which is what
89    /// makes this worth a test.
90    Shadowed(KeyCombo),
91    /// Never arrives. The terminal has no distinct encoding for it, so the
92    /// binding is dead but nothing else fires either.
93    Untransmitted,
94}
95
96impl Reach {
97    pub fn is_ok(self) -> bool {
98        self == Reach::Ok
99    }
100}
101
102/// The fate as a phrase, without the chord — `arrives`, `arrives as <Tab>`,
103/// `not sent by this terminal`. `kimun doctor`, the startup log line and the
104/// settings invariant test all print it, and `docs/.../cli.md` quotes
105/// doctor's output, so the words live here and nowhere else.
106impl std::fmt::Display for Reach {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Reach::Ok => f.write_str("arrives"),
110            Reach::Shadowed(by) => write!(f, "arrives as {by}"),
111            Reach::Untransmitted => f.write_str("not sent by this terminal"),
112        }
113    }
114}
115
116/// The byte a `Ctrl` chord on `key` packs to on a legacy terminal, or `None`
117/// for a key most terminals send no distinct code for. Letters and the
118/// punctuation that shares their column are the only keys with a byte.
119///
120/// The rule is `& 0x1F` of the teletype-shifted symbol historically wired to
121/// each key — not the US-keyboard shifted symbol: `2`→`@`, `3`→`[`, `4`→`\`,
122/// `5`→`]`, `6`→`^`, `7`→`_`, `8`→DEL. `ascii & 0x1F` happens to agree for the
123/// letters and for `[`/`\`/`]`/Space, but not for the digits or `-`/`/`
124/// (`b'-' & 0x1F` is `0x0D`, Enter — not the `0x1F` this table assigns it).
125fn control_byte(key: KeyStrike) -> Option<u8> {
126    use KeyStrike::*;
127    let byte = match key {
128        // This arithmetic depends on `KeyStrike` (key_strike.rs) declaring
129        // KeyA..KeyZ contiguously and in that order, so it lines up with
130        // `LETTERS` below — nothing in key_strike.rs enforces that.
131        // `key_combo.rs`'s `is_letter_chord` (its own KeyA..=KeyZ range
132        // check) and `is_valid_binding` (which calls `is_letter_chord`, and
133        // separately range-checks Digit0..=Digit9 the same way) share the
134        // assumption. `letters_round_trip_through_both_control_tables` below
135        // is what actually checks it.
136        k if (KeyA..=KeyZ).contains(&k) => (k as u8) - (KeyA as u8) + 0x01,
137        Space | Digit2 => 0x00,
138        Digit3 | BracketLeft => 0x1B,
139        Digit4 | Backslash => 0x1C,
140        Digit5 | BracketRight => 0x1D,
141        Digit6 => 0x1E,
142        Digit7 | Minus | Slash => 0x1F,
143        Digit8 => 0x7F,
144        // `use KeyStrike::*` above brings `KeyStrike::None` into scope, which
145        // would otherwise shadow the prelude's `Option::None` here.
146        _ => return Option::None,
147    };
148    Some(byte)
149}
150
151/// What crossterm's legacy decoder (`event::sys::unix::parse`, 0.29) makes
152/// of one control byte: `(ctrl, key)`. Mirrors its arms in order — the named
153/// keys first, then the two `Ctrl` ranges — so a crossterm bump has one
154/// table to re-check.
155fn legacy_decode(byte: u8) -> (bool, KeyStrike) {
156    use KeyStrike::*;
157    match byte {
158        0x09 => (false, Tab),
159        0x0D => (false, Enter),
160        0x1B => (false, Escape),
161        0x7F => (false, Backspace),
162        0x00 => (true, Space),
163        // No `0x0A => Enter` arm, and that is correct: crossterm's own
164        // `b'\n' => Enter` arm is guarded by `!is_raw_mode_enabled()`, and
165        // this TUI is always in raw mode. So 0x0A falls through to the
166        // Ctrl+letter range just below and arrives as Ctrl+J (`NewJournal`
167        // in the default keymap) — adding the arm to "complete" this table
168        // would silently break that binding instead.
169        0x01..=0x1A => (true, LETTERS[usize::from(byte - 0x01)]),
170        0x1C..=0x1F => (
171            true,
172            [Digit4, Digit5, Digit6, Digit7][usize::from(byte - 0x1C)],
173        ),
174        // Every byte `control_byte` can produce is matched above.
175        _ => unreachable!("not a control byte: {byte:#04x}"),
176    }
177}
178
179const LETTERS: [KeyStrike; 26] = {
180    use KeyStrike::*;
181    [
182        KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, KeyO,
183        KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ,
184    ]
185};
186
187/// What happens to `combo` on a terminal with these traits.
188pub fn reach(combo: KeyCombo, keys: TerminalKeys) -> Reach {
189    // Checked before the protocol, because this one is not the terminal's
190    // doing: `app::ctrl_h` rewrites `Ctrl+H` at kimün's own input seam, after
191    // the terminal has spoken, and an explicit `ctrl_h = "backspace"` is
192    // obeyed even where the protocol keeps the two keys apart. So the chord
193    // is gone under that setting on *any* terminal — and a diagnostic that
194    // said otherwise would contradict the session it describes.
195    //
196    // Exactly the bare chord: `CtrlHPolicy::apply` matches `CONTROL` alone,
197    // so `Ctrl+Shift+H` and `Ctrl+Alt+H` are untouched.
198    let bare_ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
199    if keys.ctrl_h_is_backspace && combo == bare_ctrl_h {
200        return Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace));
201    }
202    // Past here every collision is an artefact of packing chords into single
203    // bytes, which the protocol does away with.
204    if keys.enhanced {
205        return Reach::Ok;
206    }
207    // Only Ctrl collapses a chord into a control byte. `Alt` is sent as an
208    // Esc prefix followed by the unmodified key, which keeps the key intact;
209    // bare F-keys have their own escape sequences.
210    if !combo.modifiers.is_ctrl() {
211        return Reach::Ok;
212    }
213
214    // Pack the chord into its byte, decode the byte the way crossterm does,
215    // and compare. The byte carries no shift bit and no Ctrl once it is
216    // decoded as a named key, so a shadow keeps only Alt — the Esc prefix is
217    // sent separately and survives independently.
218    let Some(byte) = control_byte(combo.key) else {
219        // Ctrl plus punctuation or a remaining digit: most terminals send no
220        // distinct code for these at all. `Ctrl+,` is the one the default
221        // keymap cares about, which is why `OpenPreferences` leads with F4.
222        return Reach::Untransmitted;
223    };
224    let (ctrl, key) = legacy_decode(byte);
225    if ctrl && key == combo.key && !combo.modifiers.is_shift() {
226        return Reach::Ok;
227    }
228    let mut modifiers = KeyModifiers::new();
229    if combo.modifiers.is_alt() {
230        modifiers = modifiers.and_alt();
231    }
232    if ctrl {
233        modifiers = modifiers.and_ctrl();
234    }
235    Reach::Shadowed(KeyCombo::new(modifiers, key))
236}
237
238/// An action with no chord this terminal can send, and what becomes of each
239/// chord it does have.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct Unreachable {
242    pub action: ActionShortcuts,
243    /// What becomes of each chord this action does have — never `Reach::Ok`.
244    /// `unreachable_actions` below only builds an `Unreachable` for an action
245    /// once it has confirmed `!combos.iter().any(|c| reach(*c, keys).is_ok())`,
246    /// so by construction none of the pairs stored here can be the `Ok` case;
247    /// each one is already known to be `Shadowed` or `Untransmitted`.
248    pub combos: Vec<(KeyCombo, Reach)>,
249}
250
251/// Every action in `bindings` that cannot be reached at all on this terminal,
252/// ordered by action name so output is stable between runs.
253///
254/// Empty for the default keymap on any terminal with `ctrl_h_is_backspace:
255/// false` — an invariant test in [`crate::settings`] holds it to that (it
256/// scans against [`TerminalKeys::LEGACY`], which sets that field `false`). So
257/// a non-empty answer usually means the user's own `[key_bindings]` picked a
258/// chord their terminal cannot deliver, which is worth telling them about
259/// precisely because nothing else would: the chord simply does nothing, or
260/// quietly fires whatever shadows it.
261///
262/// The one exception is the Ctrl-H rewrite: under `ctrl_h_is_backspace: true`
263/// the *default* keymap is not empty either, because `FocusSidebar`'s only
264/// chord is the bare `Ctrl+H` the rewrite shadows with Backspace —
265/// `the_rewrite_strands_exactly_focus_sidebar_in_the_default_keymap` below
266/// proves it. That case is kimün's own doing rather than a user mistake, and
267/// `notice_text` in `app/mod.rs` words it accordingly.
268///
269/// Actions the user has deliberately *unbound* never appear: they hold no
270/// combos, and only bound actions are listed at all.
271pub fn unreachable_actions(bindings: &KeyBindings, keys: TerminalKeys) -> Vec<Unreachable> {
272    let mut found: Vec<Unreachable> = bindings
273        .to_hashmap()
274        .into_iter()
275        .filter(|(_, combos)| !combos.iter().any(|c| reach(*c, keys).is_ok()))
276        .map(|(action, combos)| Unreachable {
277            combos: combos.into_iter().map(|c| (c, reach(c, keys))).collect(),
278            action,
279        })
280        .collect();
281    found.sort_by_key(|u| u.action.to_string());
282    found
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn ctrl(key: KeyStrike) -> KeyCombo {
290        KeyCombo::new(KeyModifiers::new().and_ctrl(), key)
291    }
292
293    fn ctrl_shift(key: KeyStrike) -> KeyCombo {
294        KeyCombo::new(KeyModifiers::new().and_ctrl().and_shift(), key)
295    }
296
297    /// The protocol is the one cure: it reports keys, not bytes.
298    #[test]
299    fn the_kitty_protocol_makes_everything_reachable() {
300        let enhanced = TerminalKeys {
301            enhanced: true,
302            ctrl_h_is_backspace: false,
303        };
304        for combo in [
305            ctrl(KeyStrike::KeyI),
306            ctrl(KeyStrike::KeyM),
307            ctrl(KeyStrike::Comma),
308            ctrl_shift(KeyStrike::KeyL),
309            ctrl(KeyStrike::BracketLeft),
310        ] {
311            assert_eq!(reach(combo, enhanced), Reach::Ok, "{combo}");
312        }
313    }
314
315    /// The three bytes crossterm hands to a named key before it considers the
316    /// Ctrl+letter range — verified against its `parse_event`.
317    #[test]
318    fn named_keys_win_their_bytes() {
319        assert_eq!(
320            reach(ctrl(KeyStrike::KeyI), TerminalKeys::LEGACY),
321            Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
322        );
323        assert_eq!(
324            reach(ctrl(KeyStrike::KeyM), TerminalKeys::LEGACY),
325            Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Enter))
326        );
327        assert_eq!(
328            reach(ctrl(KeyStrike::BracketLeft), TerminalKeys::LEGACY),
329            Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Escape))
330        );
331    }
332
333    /// Shift never reaches the app on a legacy terminal, so the chord arrives
334    /// as its unshifted twin — and fires whatever *that* is bound to.
335    #[test]
336    fn shift_is_lost_from_a_ctrl_chord() {
337        assert_eq!(
338            reach(ctrl_shift(KeyStrike::KeyL), TerminalKeys::LEGACY),
339            Reach::Shadowed(ctrl(KeyStrike::KeyL))
340        );
341        // The collision wins over the shift rule: the byte is 0x09 either way.
342        assert_eq!(
343            reach(ctrl_shift(KeyStrike::KeyI), TerminalKeys::LEGACY),
344            Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
345        );
346    }
347
348    /// Ctrl+H's reachability is the one answer that depends on policy rather
349    /// than on the terminal alone.
350    #[test]
351    fn ctrl_h_follows_the_session_policy() {
352        assert_eq!(
353            reach(ctrl(KeyStrike::KeyH), TerminalKeys::LEGACY),
354            Reach::Ok
355        );
356        let backspace = Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace));
357        for enhanced in [false, true] {
358            let rewritten = TerminalKeys {
359                enhanced,
360                ctrl_h_is_backspace: true,
361            };
362            assert_eq!(
363                reach(ctrl(KeyStrike::KeyH), rewritten),
364                backspace,
365                "the rewrite is kimün's own, not the terminal's (enhanced={enhanced})"
366            );
367            // Only the *bare* chord is rewritten. On a protocol terminal
368            // Ctrl+Shift+H is its own event and survives; on a legacy one it
369            // still loses its shift bit to the ordinary rule, landing on the
370            // chord that the rewrite then claims — reported one hop at a
371            // time, since chaining would assert more than is known.
372            assert_eq!(
373                reach(ctrl_shift(KeyStrike::KeyH), rewritten),
374                if enhanced {
375                    Reach::Ok
376                } else {
377                    Reach::Shadowed(ctrl(KeyStrike::KeyH))
378                }
379            );
380        }
381    }
382
383    /// `0x1C..=0x1F` arrive as Ctrl+4..7, so the digits are the reachable
384    /// half of that alias and the punctuation is not.
385    #[test]
386    fn the_control_digits_alias_punctuation() {
387        assert_eq!(
388            reach(ctrl(KeyStrike::Backslash), TerminalKeys::LEGACY),
389            Reach::Shadowed(ctrl(KeyStrike::Digit4))
390        );
391        assert_eq!(
392            reach(ctrl(KeyStrike::BracketRight), TerminalKeys::LEGACY),
393            Reach::Shadowed(ctrl(KeyStrike::Digit5))
394        );
395        for digit in [KeyStrike::Digit4, KeyStrike::Digit7] {
396            assert_eq!(reach(ctrl(digit), TerminalKeys::LEGACY), Reach::Ok);
397        }
398        assert_eq!(
399            reach(ctrl(KeyStrike::Digit1), TerminalKeys::LEGACY),
400            Reach::Untransmitted
401        );
402        assert_eq!(
403            reach(ctrl(KeyStrike::Comma), TerminalKeys::LEGACY),
404            Reach::Untransmitted
405        );
406    }
407
408    /// Alt is an Esc prefix, which leaves the key itself untouched — so the
409    /// byte collisions are a Ctrl problem and F-keys are never affected.
410    #[test]
411    fn alt_chords_and_fkeys_always_arrive() {
412        for combo in [
413            KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::KeyI),
414            KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::KeyH),
415            KeyCombo::new(KeyModifiers::new(), KeyStrike::F4),
416        ] {
417            assert_eq!(reach(combo, TerminalKeys::LEGACY), Reach::Ok, "{combo}");
418        }
419    }
420
421    /// Ctrl+Alt still packs a control byte, so the collision holds — and the
422    /// Esc prefix survives into the shadow, because it is sent separately.
423    #[test]
424    fn a_ctrl_alt_collision_keeps_its_esc_prefix() {
425        let combo = KeyCombo::new(KeyModifiers::new().and_ctrl().and_alt(), KeyStrike::KeyI);
426        assert_eq!(
427            reach(combo, TerminalKeys::LEGACY),
428            Reach::Shadowed(KeyCombo::new(KeyModifiers::new().and_alt(), KeyStrike::Tab))
429        );
430    }
431
432    /// The scan the startup warning and `kimun doctor` both run.
433    #[test]
434    fn the_scan_finds_an_action_with_no_usable_chord() {
435        let mut kb = KeyBindings::empty();
436        kb.batch_add()
437            .with_ctrl()
438            // Only chord, and it is Tab's byte: unreachable.
439            .add(KeyStrike::KeyI, ActionShortcuts::QuickNote)
440            // Unreachable, but this action has a second chord below.
441            .add(KeyStrike::KeyM, ActionShortcuts::Quit)
442            .add(KeyStrike::KeyQ, ActionShortcuts::Quit);
443
444        let found = unreachable_actions(&kb, TerminalKeys::LEGACY);
445        assert_eq!(found.len(), 1, "only QuickNote is stranded: {found:?}");
446        assert_eq!(found[0].action, ActionShortcuts::QuickNote);
447        assert_eq!(
448            found[0].combos,
449            vec![(
450                ctrl(KeyStrike::KeyI),
451                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab))
452            )]
453        );
454    }
455
456    /// The protocol resolves every collision, so nothing is ever stranded on
457    /// a terminal that speaks it.
458    #[test]
459    fn the_scan_finds_nothing_under_the_kitty_protocol() {
460        let mut kb = KeyBindings::empty();
461        kb.batch_add()
462            .with_ctrl()
463            .add(KeyStrike::KeyI, ActionShortcuts::QuickNote);
464        let enhanced = TerminalKeys {
465            enhanced: true,
466            ctrl_h_is_backspace: false,
467        };
468        assert!(unreachable_actions(&kb, enhanced).is_empty());
469    }
470
471    /// The default keymap must never trip the warning — otherwise it fires
472    /// for every user on a legacy terminal, which is a nag, not a warning.
473    #[test]
474    fn the_scan_is_silent_for_the_default_keymap() {
475        let kb = crate::settings::AppSettings::default().key_bindings;
476        for keys in [
477            TerminalKeys::LEGACY,
478            TerminalKeys {
479                enhanced: true,
480                ctrl_h_is_backspace: false,
481            },
482        ] {
483            let found = unreachable_actions(&kb, keys);
484            assert!(found.is_empty(), "{keys:?} stranded {found:?}");
485        }
486    }
487
488    /// The ordinary case, so the rules above read as exceptions rather than
489    /// the norm.
490    #[test]
491    fn an_ordinary_ctrl_letter_arrives() {
492        for key in [KeyStrike::KeyB, KeyStrike::KeyJ, KeyStrike::KeyQ] {
493            assert_eq!(reach(ctrl(key), TerminalKeys::LEGACY), Reach::Ok);
494        }
495    }
496
497    /// The Windows console reports keys, not bytes: crossterm's
498    /// `supports_keyboard_enhancement` is a hard-coded `Ok(false)` there, but
499    /// Ctrl+I and Tab still arrive as different events. The session
500    /// constructor is the one place that knows this.
501    #[test]
502    fn a_live_session_is_enhanced_on_windows_without_the_protocol() {
503        let keys = TerminalKeys::detected(false, false);
504        assert_eq!(keys.enhanced, cfg!(windows));
505        assert!(!keys.ctrl_h_is_backspace);
506        // The protocol answer is honoured everywhere.
507        assert!(TerminalKeys::detected(true, true).enhanced);
508        assert!(TerminalKeys::detected(true, true).ctrl_h_is_backspace);
509    }
510
511    /// The bytes the hand-written table used to miss. Each one is a real
512    /// keypress that crossterm's legacy decoder resolves to *something*, so
513    /// "never arrives" was wrong — the chord fires another key.
514    #[test]
515    fn every_control_byte_is_accounted_for() {
516        let plain = |key| KeyCombo::new(KeyModifiers::new(), key);
517        // 0x00: Ctrl+2 and Ctrl+Space share a byte; crossterm names it Ctrl+Space.
518        assert_eq!(
519            reach(ctrl(KeyStrike::Space), TerminalKeys::LEGACY),
520            Reach::Ok
521        );
522        assert_eq!(
523            reach(ctrl(KeyStrike::Digit2), TerminalKeys::LEGACY),
524            Reach::Shadowed(ctrl(KeyStrike::Space))
525        );
526        // 0x1B: Ctrl+3 is Esc, like Ctrl+[.
527        assert_eq!(
528            reach(ctrl(KeyStrike::Digit3), TerminalKeys::LEGACY),
529            Reach::Shadowed(plain(KeyStrike::Escape))
530        );
531        // 0x7F: Ctrl+8 is the *other* Backspace byte — the same class of
532        // shadow as the Ctrl+H bug, and it deletes a character.
533        assert_eq!(
534            reach(ctrl(KeyStrike::Digit8), TerminalKeys::LEGACY),
535            Reach::Shadowed(plain(KeyStrike::Backspace))
536        );
537        // 0x1E / 0x1F: Ctrl+6 arrives as itself; Ctrl+- and Ctrl+/ as Ctrl+7.
538        assert_eq!(
539            reach(ctrl(KeyStrike::Digit6), TerminalKeys::LEGACY),
540            Reach::Ok
541        );
542        for key in [KeyStrike::Minus, KeyStrike::Slash] {
543            assert_eq!(
544                reach(ctrl(key), TerminalKeys::LEGACY),
545                Reach::Shadowed(ctrl(KeyStrike::Digit7)),
546                "{key}"
547            );
548        }
549        // And the keys that genuinely have no control byte stay untransmitted.
550        for key in [
551            KeyStrike::Digit0,
552            KeyStrike::Digit1,
553            KeyStrike::Digit9,
554            KeyStrike::Comma,
555            KeyStrike::Period,
556            KeyStrike::Equal,
557            KeyStrike::Semicolon,
558            KeyStrike::Quote,
559            KeyStrike::Backquote,
560        ] {
561            assert_eq!(
562                reach(ctrl(key), TerminalKeys::LEGACY),
563                Reach::Untransmitted,
564                "{key}"
565            );
566        }
567    }
568
569    /// Locks `LETTERS`, `control_byte`'s arithmetic and `legacy_decode`'s
570    /// `0x01..=0x1A` arm to each other and to the `KeyStrike` discriminants —
571    /// the invariant `control_byte`'s doc comment states but that nothing
572    /// else in this file checks. Covers the boundaries KeyA -> 0x01 and
573    /// KeyZ -> 0x1A, which the hand-picked B/J/Q cases in
574    /// `an_ordinary_ctrl_letter_arrives` do not reach. An insertion into
575    /// `KeyStrike` between two letters (`KeyStrike` is declared
576    /// alphabetically, so a non-letter variant can land mid-run) would shift
577    /// this arithmetic out of step with `LETTERS` and fail here first.
578    #[test]
579    fn letters_round_trip_through_both_control_tables() {
580        for (i, k) in LETTERS.iter().enumerate() {
581            let byte = i as u8 + 1;
582            assert_eq!(control_byte(*k), Some(byte), "{k:?} -> control_byte");
583            // 0x09 (I) and 0x0D (M) are intercepted by legacy_decode's
584            // named-key arms (Tab, Enter) before the Ctrl+letter range —
585            // `named_keys_win_their_bytes` already covers that half of the
586            // story. Every other byte in range round-trips to its own
587            // letter, which is the part nothing else here checks.
588            if byte != 0x09 && byte != 0x0D {
589                assert_eq!(
590                    legacy_decode(byte),
591                    (true, *k),
592                    "{byte:#04x} -> legacy_decode"
593                );
594            }
595        }
596    }
597
598    /// Under the rewrite the default keymap *does* strand one action —
599    /// `FocusSidebar`, whose only chord is Ctrl+H. The scan reports it; the
600    /// callers word it as a `ctrl_h` decision rather than a user mistake.
601    #[test]
602    fn the_rewrite_strands_exactly_focus_sidebar_in_the_default_keymap() {
603        let kb = crate::settings::AppSettings::default().key_bindings;
604        let keys = TerminalKeys {
605            enhanced: false,
606            ctrl_h_is_backspace: true,
607        };
608        let found = unreachable_actions(&kb, keys);
609        assert_eq!(found.len(), 1, "{found:?}");
610        assert_eq!(found[0].action, ActionShortcuts::FocusSidebar);
611        assert_eq!(
612            found[0].combos,
613            vec![(
614                ctrl(KeyStrike::KeyH),
615                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace))
616            )]
617        );
618    }
619
620    /// Three callers print a chord's fate — doctor, the startup log line and
621    /// the default-keymap invariant test — and they had three wordings. One
622    /// `Display`, so the docs' doctor sample stays the wording everywhere.
623    #[test]
624    fn a_fate_has_one_wording() {
625        assert_eq!(Reach::Ok.to_string(), "arrives");
626        assert_eq!(
627            Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab)).to_string(),
628            "arrives as <Tab>"
629        );
630        assert_eq!(
631            Reach::Untransmitted.to_string(),
632            "not sent by this terminal"
633        );
634    }
635}