Skip to main content

kimun_notes/app/
ctrl_h.rs

1//! What a bare `0x08` byte from the terminal means.
2//!
3//! Outside the kitty keyboard protocol a terminal has one byte for two keys.
4//! `0x08` is what the Ctrl-H chord sends, and it is also what the Backspace
5//! *key* sends on a terminal whose keytab (or `stty erase ^H`) is set to the
6//! older of the two conventions — the other, and the common one today, being
7//! `0x7F`. crossterm's legacy decoder breaks the tie one way: `0x7F` is the
8//! only spelling of `KeyCode::Backspace`, while every byte in `0x01..=0x1A`
9//! becomes that letter plus `CONTROL`. So on such a terminal Backspace
10//! arrives as Ctrl-H and fires whatever chord Ctrl-H is bound to instead of
11//! deleting a character.
12//!
13//! There is no side channel to tell the two apart — same byte, no timing
14//! tell, nothing in the escape stream. Both keys cannot work at once, so this
15//! module decides once at startup and rewrites the event at the input seam,
16//! leaving everything downstream — the shortcut tier, the editor backends — to
17//! see one unambiguous key.
18//!
19//! Where the terminal *can* tell them apart nothing is rewritten and both keys
20//! work: under the kitty protocol (pushed in `terminal::TerminalSession` when
21//! the terminal answers the capability query) the chord arrives as
22//! `CSI 104;5u` and the key keeps `0x7F`. That is why
23//! [`CtrlHPolicy::resolve`] takes whether those flags went out: the ambiguity
24//! this module exists for is a property of the *session*, not of the user's
25//! taste, and `auto` should not spend a working chord on a terminal that never
26//! had the problem.
27
28use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
29
30use crate::settings::CtrlHSetting;
31
32/// The resolved rule, consulted per key event.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum CtrlHPolicy {
35    /// Leave `Ctrl+h` alone: the chord's binding fires, and a Backspace key
36    /// that sends `0x08` cannot delete.
37    #[default]
38    Chord,
39    /// Rewrite `Ctrl+h` to `Backspace`: the key deletes, and the chord is
40    /// unreachable (rebind the action if you need it).
41    Backspace,
42}
43
44impl CtrlHPolicy {
45    /// Decide the rule for this session.
46    ///
47    /// `keyboard_enhanced` is whether the kitty keyboard-enhancement flags
48    /// were pushed — when they were, the two keys are already distinct and
49    /// `Auto` keeps the chord. The explicit settings are unconditional: a user
50    /// who names one has said what they want the byte to mean, and a rule that
51    /// quietly did nothing on some terminals would be worse than one that is
52    /// simply obeyed.
53    pub fn resolve(setting: CtrlHSetting, keyboard_enhanced: bool) -> Self {
54        Self::resolve_with(setting, keyboard_enhanced, tty_erase_is_bs())
55    }
56
57    /// [`Self::resolve`] with the tty probe supplied rather than performed.
58    ///
59    /// Two callers need this. `kimun doctor` already knows the erase
60    /// character and must not read it a second time — it declines to read it
61    /// at all when its output is redirected, and a hidden probe inside here
62    /// would contradict the disclaimer it prints. And the `Auto` branches are
63    /// only testable at all once the probe is an argument: called for real,
64    /// it reads the test runner's own stdin.
65    pub fn resolve_with(
66        setting: CtrlHSetting,
67        keyboard_enhanced: bool,
68        tty_erase_is_bs: bool,
69    ) -> Self {
70        let policy = match setting {
71            CtrlHSetting::Chord => Self::Chord,
72            CtrlHSetting::Backspace => Self::Backspace,
73            CtrlHSetting::Auto if keyboard_enhanced => Self::Chord,
74            CtrlHSetting::Auto if tty_erase_is_bs => Self::Backspace,
75            CtrlHSetting::Auto => Self::Chord,
76        };
77        tracing::debug!(
78            "ctrl_h: setting={setting:?} keyboard_enhanced={keyboard_enhanced} \
79             tty_erase_is_bs={tty_erase_is_bs} -> {policy:?}"
80        );
81        policy
82    }
83
84    /// Whether losing the `Ctrl+H` chord is worth telling the user about.
85    ///
86    /// Only when they did not ask for it. `ctrl_h = "backspace"` is a choice
87    /// made against a documented consequence — the setting's own
88    /// documentation says the chord becomes unreachable — so reporting it
89    /// every launch says nothing they do not know, and a warning that fires
90    /// on a working, chosen setup is one people learn to ignore. Under `auto`
91    /// the erase-character probe can reach the same policy without anyone
92    /// choosing it, and there it is news.
93    pub fn loss_is_worth_reporting(self, setting: CtrlHSetting) -> bool {
94        self == Self::Backspace && setting != CtrlHSetting::Backspace
95    }
96
97    /// `key`, with `Ctrl+h` rewritten to `Backspace` when that is the rule.
98    ///
99    /// The modifier test is exact, so `Ctrl+Shift+H` and `Ctrl+Alt+H` — chords
100    /// a terminal spells differently, and which no Backspace key sends — pass
101    /// through untouched. `kind` and `state` are carried over: the rewrite
102    /// changes which key this is, not whether it is a press.
103    pub fn apply(self, key: KeyEvent) -> KeyEvent {
104        if self == Self::Backspace
105            && key.code == KeyCode::Char('h')
106            && key.modifiers == KeyModifiers::CONTROL
107        {
108            KeyEvent {
109                code: KeyCode::Backspace,
110                modifiers: KeyModifiers::NONE,
111                ..key
112            }
113        } else {
114            key
115        }
116    }
117}
118
119/// This tty's erase character, or `None` when it cannot be read (not a tty,
120/// or not a platform with `termios`).
121///
122/// `0x08` here is the one signal available about which convention the user's
123/// terminal is set up for: a `^H` Backspace key and `stty erase ^H` are halves
124/// of the same old-school setup, and a terminal emulator is not obliged to
125/// agree with the line discipline — so this catches the correlated case and no
126/// more. Raw mode does not touch `c_cc[VERASE]`, so the answer is the same
127/// before or after `enable_raw_mode`, which is what lets `kimun doctor` read
128/// it outside the TUI.
129///
130/// Asked of the descriptor crossterm reads keys from: stdin when it is a
131/// terminal, `/dev/tty` otherwise (its `tty_fd`). A launcher that redirects
132/// stdin still gets an interactive TUI, and probing fd 0 there would answer
133/// `None` for the very user the probe exists for.
134#[cfg(unix)]
135pub fn tty_erase_char() -> Option<u8> {
136    use std::io::IsTerminal;
137    use std::os::fd::AsFd;
138
139    let stdin = std::io::stdin();
140    if stdin.is_terminal() {
141        return erase_char_of(stdin.as_fd());
142    }
143    let tty = std::fs::File::open("/dev/tty").ok()?;
144    erase_char_of(tty.as_fd())
145}
146
147/// `VERASE` of one descriptor, or `None` when it is not a terminal.
148#[cfg(unix)]
149fn erase_char_of(fd: std::os::fd::BorrowedFd<'_>) -> Option<u8> {
150    use rustix::termios::{SpecialCodeIndex, tcgetattr};
151    let termios = tcgetattr(fd).ok()?;
152    Some(termios.special_codes[SpecialCodeIndex::VERASE])
153}
154
155/// Windows has no `termios`, and no ambiguity to resolve: the console API
156/// reports the Backspace key and the Ctrl-H chord as different events however
157/// the terminal is configured.
158#[cfg(not(unix))]
159pub fn tty_erase_char() -> Option<u8> {
160    None
161}
162
163fn tty_erase_is_bs() -> bool {
164    tty_erase_char() == Some(0x08)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn ctrl_h() -> KeyEvent {
172        KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)
173    }
174
175    /// The kitty protocol makes the keys distinct, so `Auto` must not spend
176    /// the chord — even when the tty's erase character says otherwise.
177    #[test]
178    fn auto_keeps_the_chord_under_the_kitty_protocol() {
179        assert_eq!(
180            CtrlHPolicy::resolve_with(CtrlHSetting::Auto, true, true),
181            CtrlHPolicy::Chord
182        );
183    }
184
185    /// Both explicit settings are obeyed on every terminal: naming one is the
186    /// escape hatch for a session whose ambiguity `Auto` guessed wrong.
187    #[test]
188    fn explicit_settings_ignore_the_terminal() {
189        for enhanced in [false, true] {
190            for erase_is_bs in [false, true] {
191                assert_eq!(
192                    CtrlHPolicy::resolve_with(CtrlHSetting::Backspace, enhanced, erase_is_bs),
193                    CtrlHPolicy::Backspace,
194                    "explicit `backspace` must hold with enhanced={enhanced} erase_is_bs={erase_is_bs}"
195                );
196                assert_eq!(
197                    CtrlHPolicy::resolve_with(CtrlHSetting::Chord, enhanced, erase_is_bs),
198                    CtrlHPolicy::Chord,
199                    "explicit `chord` must hold with enhanced={enhanced} erase_is_bs={erase_is_bs}"
200                );
201            }
202        }
203    }
204
205    /// The `Auto` decision table, now that the probe is an argument. These
206    /// four rows are the whole of what `auto` means.
207    #[test]
208    fn auto_reads_the_terminal() {
209        use CtrlHPolicy::{Backspace, Chord};
210        for (enhanced, erase_is_bs, expected) in [
211            (true, false, Chord),
212            (true, true, Chord),
213            (false, false, Chord),
214            (false, true, Backspace),
215        ] {
216            assert_eq!(
217                CtrlHPolicy::resolve_with(CtrlHSetting::Auto, enhanced, erase_is_bs),
218                expected,
219                "enhanced={enhanced} erase_is_bs={erase_is_bs}"
220            );
221        }
222    }
223
224    /// The rule that keeps the startup warning quiet for the people who
225    /// chose this, and talkative for the people who did not.
226    #[test]
227    fn only_an_unasked_for_loss_is_reported() {
228        assert!(
229            CtrlHPolicy::Backspace.loss_is_worth_reporting(CtrlHSetting::Auto),
230            "auto resolved it by probe — the user never chose it"
231        );
232        assert!(
233            !CtrlHPolicy::Backspace.loss_is_worth_reporting(CtrlHSetting::Backspace),
234            "they asked for exactly this"
235        );
236        // Nothing is lost under the chord policy, so there is nothing to say
237        // whatever the setting was.
238        for setting in [
239            CtrlHSetting::Auto,
240            CtrlHSetting::Backspace,
241            CtrlHSetting::Chord,
242        ] {
243            assert!(!CtrlHPolicy::Chord.loss_is_worth_reporting(setting));
244        }
245    }
246
247    #[test]
248    fn rewrites_ctrl_h_to_backspace() {
249        let key = CtrlHPolicy::Backspace.apply(ctrl_h());
250        assert_eq!(key.code, KeyCode::Backspace);
251        assert_eq!(key.modifiers, KeyModifiers::NONE);
252    }
253
254    #[test]
255    fn chord_policy_rewrites_nothing() {
256        assert_eq!(CtrlHPolicy::Chord.apply(ctrl_h()), ctrl_h());
257    }
258
259    /// Only the bare chord collides with `0x08`. A shifted or alt-ed Ctrl-H is
260    /// a chord of its own and must survive the rewrite.
261    #[test]
262    fn leaves_neighbouring_chords_alone() {
263        for modifiers in [
264            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
265            KeyModifiers::CONTROL | KeyModifiers::ALT,
266            KeyModifiers::NONE,
267            KeyModifiers::ALT,
268        ] {
269            let key = KeyEvent::new(KeyCode::Char('h'), modifiers);
270            assert_eq!(
271                CtrlHPolicy::Backspace.apply(key),
272                key,
273                "{modifiers:?}+h is not the byte 0x08"
274            );
275        }
276    }
277
278    /// A real Backspace is already a Backspace — the rewrite must be
279    /// idempotent rather than turn it into something else.
280    #[test]
281    fn leaves_backspace_alone() {
282        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
283        assert_eq!(CtrlHPolicy::Backspace.apply(key), key);
284    }
285
286    /// The probe answers `None` for a descriptor that is not a terminal
287    /// rather than reading garbage — `/dev/null` is the classic redirected
288    /// stdin, and the reason the probe now looks past stdin at all.
289    #[cfg(unix)]
290    #[test]
291    fn a_non_tty_descriptor_has_no_erase_char() {
292        use std::os::fd::AsFd;
293        let null = std::fs::File::open("/dev/null").unwrap();
294        assert_eq!(erase_char_of(null.as_fd()), None);
295    }
296}