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}