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}