keymap_core/legacy.rs
1//! How a chord fares under the legacy 7-bit terminal encoding.
2//!
3//! Terminals that do not implement an enhanced keyboard protocol (the kitty
4//! keyboard protocol, xterm's `modifyOtherKeys`, …) encode keys with the old
5//! C0 control-code scheme, which cannot carry every chord a modern keyboard can
6//! produce. The classic surprises:
7//!
8//! - **`ctrl+shift+<letter>` is the same byte as `ctrl+<letter>`.** A control
9//! code is `letter & 0x1f` with no bit for Shift, so `ctrl+shift+s` and
10//! `ctrl+s` are *indistinguishable* — a binding to one silently fires on the
11//! other. (This is why terminal Vim/Emacs avoid `Ctrl+Shift+*` and lean on
12//! prefix/leader sequences instead — see `keymap-seq`.)
13//! - **`ctrl+i` ≡ `tab`, `ctrl+m` ≡ `enter`, `ctrl+[` ≡ `esc`.** Same C0 byte.
14//! - **`super`/`cmd` has no legacy encoding at all** — a legacy terminal cannot
15//! deliver it.
16//!
17//! [`KeyInput::legacy_form`] reports this *structural* fact from the chord
18//! alone — no terminal measurement needed, so it is stable and CI-testable
19//! today. It deliberately does **not** answer "will this key reach the app on
20//! my terminal": Option composing a glyph (`Option+s` → `ß`), the OS eating
21//! `Cmd`, or the `alt`-vs-`Esc` timing ambiguity are environment-dependent
22//! *reachability* questions for the later capability-aware layer. Treat
23//! `legacy_form` as the lower bound (what is lost *no matter the terminal*); the
24//! reachability layer refines it with empirical per-terminal data.
25
26use crate::{Key, KeyInput, Keymap, Modifiers};
27
28/// How a [`KeyInput`] survives the legacy 7-bit C0 terminal encoding,
29/// independent of any enhanced protocol. See the [module docs](self).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LegacyForm {
32 /// Encodable and distinguishable as-is under legacy C0.
33 Representable,
34 /// Loses information under legacy C0 and becomes indistinguishable from this
35 /// other chord (e.g. `ctrl+shift+s` → `ctrl+s`, `ctrl+i` → `tab`). A binding
36 /// to the original silently behaves like the contained chord on legacy
37 /// terminals — bind that chord (or use a `keymap-seq` sequence) instead.
38 CollapsesTo(KeyInput),
39 /// Has no legacy C0 encoding at all (any chord holding `super`/`cmd`); a
40 /// legacy terminal cannot deliver it.
41 Unrepresentable,
42}
43
44impl KeyInput {
45 /// Reports how this chord fares under the legacy 7-bit C0 terminal encoding
46 /// (see [`LegacyForm`] and the [module docs](self)).
47 ///
48 /// This is a pure, terminal-independent structural fact, not a reachability
49 /// verdict. Only the cases that are *certain* regardless of terminal are
50 /// reported as [`CollapsesTo`](LegacyForm::CollapsesTo) /
51 /// [`Unrepresentable`](LegacyForm::Unrepresentable); everything else is
52 /// [`Representable`](LegacyForm::Representable), which means "has a legacy C0
53 /// encoding", not "guaranteed to arrive on every terminal".
54 #[must_use]
55 pub fn legacy_form(&self) -> LegacyForm {
56 // `super`/`cmd` cannot be encoded in legacy C0 under any circumstance.
57 if self.modifiers().contains(Modifiers::SUPER) {
58 return LegacyForm::Unrepresentable;
59 }
60 let collapsed = collapse(self.key(), self.modifiers());
61 if collapsed == *self {
62 LegacyForm::Representable
63 } else {
64 LegacyForm::CollapsesTo(collapsed)
65 }
66 }
67}
68
69/// The chord a legacy C0 terminal actually delivers for `(key, mods)`, for the
70/// cases that are certain. Returns the input unchanged when nothing is lost.
71fn collapse(key: Key, mods: Modifiers) -> KeyInput {
72 if mods.contains(Modifiers::CTRL) {
73 if let Key::Char(c) = key {
74 // `ctrl+i`/`ctrl+m`/`ctrl+[` are the same C0 byte as the named keys.
75 let aliased = match c.to_ascii_lowercase() {
76 'i' => Some(Key::Tab),
77 'm' => Some(Key::Enter),
78 '[' => Some(Key::Esc),
79 _ => None,
80 };
81 if let Some(named) = aliased {
82 // Ctrl and Shift are both absorbed into the single C0 byte; any
83 // Alt (Meta / ESC-prefix) is orthogonal and survives.
84 let rest = mods
85 .difference(Modifiers::CTRL)
86 .difference(Modifiers::SHIFT);
87 return KeyInput::new(named, rest);
88 }
89 // `ctrl+shift+<letter>` sends the same byte as `ctrl+<letter>`.
90 if mods.contains(Modifiers::SHIFT) {
91 return KeyInput::new(key, mods.difference(Modifiers::SHIFT));
92 }
93 }
94 }
95 KeyInput::new(key, mods)
96}
97
98/// A bound chord that will not survive a legacy 7-bit (C0) terminal, reported by
99/// [`legacy_lints`]. The variants mirror the two lossy [`LegacyForm`] cases (a
100/// [`Representable`](LegacyForm::Representable) chord produces no lint).
101///
102/// Chords are rendered in canonical form (`KeyInput`'s `Display`), matching how
103/// the config layer names chords in its warnings. This is `#[non_exhaustive]`:
104/// the empirical capability-aware layer may add environment-dependent lint
105/// categories (e.g. "may not be delivered on this terminal") additively.
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum LegacyLint {
109 /// The chord holds `super`/`cmd`: it has no legacy C0 encoding at all, so a
110 /// legacy terminal can never deliver it. See
111 /// [`Unrepresentable`](LegacyForm::Unrepresentable).
112 Unrepresentable {
113 /// The bound chord, in canonical form (e.g. `"super+s"`).
114 chord: String,
115 },
116 /// On a legacy C0 terminal the chord is indistinguishable from another, so a
117 /// binding to it also fires on `collapses_to` (e.g. `ctrl+shift+s` ≡
118 /// `ctrl+s`, `ctrl+i` ≡ `tab`). See [`CollapsesTo`](LegacyForm::CollapsesTo).
119 CollapsesTo {
120 /// The bound chord, in canonical form.
121 chord: String,
122 /// The chord it is indistinguishable from on a legacy terminal, in
123 /// canonical form.
124 collapses_to: String,
125 },
126}
127
128/// Reports every bound chord in `keymap` that will not survive a legacy 7-bit
129/// (C0) terminal, derived purely from [`KeyInput::legacy_form`] — a structural
130/// fact needing no terminal measurement, so this is stable and CI-testable.
131///
132/// This is **opt-in**: nothing computes it unless you call it, and it is wholly
133/// independent of any config-layer warning surface (`keymap-config`'s
134/// `BuildOutput::warnings`), so a caller gating on `warnings.is_empty()` is
135/// unaffected. Pass `out.global()` (or any one layer) after a config load, or any
136/// `Keymap` you built by hand.
137///
138/// The result is sorted by chord (canonical form); [`Keymap::iter`] is itself
139/// unordered, so sorting gives stable, human-readable output. Covers
140/// single-chord bindings only — multi-key sequences are a planned follow-up
141/// (gated on iteration over `SequenceKeymap`).
142#[must_use]
143pub fn legacy_lints<A>(keymap: &Keymap<A>) -> Vec<LegacyLint> {
144 let mut lints: Vec<LegacyLint> = keymap
145 .iter()
146 .filter_map(|(input, _)| match input.legacy_form() {
147 LegacyForm::Representable => None,
148 LegacyForm::Unrepresentable => Some(LegacyLint::Unrepresentable {
149 chord: input.to_string(),
150 }),
151 LegacyForm::CollapsesTo(target) => Some(LegacyLint::CollapsesTo {
152 chord: input.to_string(),
153 collapses_to: target.to_string(),
154 }),
155 })
156 .collect();
157 // Each KeyInput key is unique, so the chord is a total, stable sort key.
158 lints.sort_by(|a, b| lint_chord(a).cmp(lint_chord(b)));
159 lints
160}
161
162/// The canonical chord string a [`LegacyLint`] is about (its sort key).
163fn lint_chord(lint: &LegacyLint) -> &str {
164 match lint {
165 LegacyLint::Unrepresentable { chord } | LegacyLint::CollapsesTo { chord, .. } => chord,
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::{Key, KeyInput, Modifiers};
173
174 fn ctrl(c: char) -> KeyInput {
175 KeyInput::new(Key::Char(c), Modifiers::CTRL)
176 }
177
178 fn ctrl_shift(c: char) -> KeyInput {
179 KeyInput::new(Key::Char(c), Modifiers::CTRL | Modifiers::SHIFT)
180 }
181
182 #[test]
183 fn super_chords_are_unrepresentable() {
184 let cmd_s = KeyInput::new(Key::Char('s'), Modifiers::SUPER);
185 assert_eq!(cmd_s.legacy_form(), LegacyForm::Unrepresentable);
186 // Super dominates even when the rest would otherwise collapse.
187 let cmd_shift_s = KeyInput::new(Key::Char('s'), Modifiers::SUPER | Modifiers::SHIFT);
188 assert_eq!(cmd_shift_s.legacy_form(), LegacyForm::Unrepresentable);
189 }
190
191 #[test]
192 fn ctrl_shift_letter_collapses_to_ctrl_letter() {
193 assert_eq!(
194 ctrl_shift('s').legacy_form(),
195 LegacyForm::CollapsesTo(ctrl('s'))
196 );
197 }
198
199 #[test]
200 fn ctrl_i_m_bracket_alias_named_keys() {
201 assert_eq!(
202 ctrl('i').legacy_form(),
203 LegacyForm::CollapsesTo(KeyInput::new(Key::Tab, Modifiers::NONE))
204 );
205 assert_eq!(
206 ctrl('m').legacy_form(),
207 LegacyForm::CollapsesTo(KeyInput::new(Key::Enter, Modifiers::NONE))
208 );
209 assert_eq!(
210 ctrl('[').legacy_form(),
211 LegacyForm::CollapsesTo(KeyInput::new(Key::Esc, Modifiers::NONE))
212 );
213 // ctrl+shift+i still lands on Tab (shift and ctrl both absorbed).
214 assert_eq!(
215 ctrl_shift('i').legacy_form(),
216 LegacyForm::CollapsesTo(KeyInput::new(Key::Tab, Modifiers::NONE))
217 );
218 }
219
220 #[test]
221 fn plain_and_ctrl_letter_are_representable() {
222 assert_eq!(
223 KeyInput::new(Key::Char('a'), Modifiers::NONE).legacy_form(),
224 LegacyForm::Representable
225 );
226 // ctrl+s (no shift) is the canonical C0 byte itself.
227 assert_eq!(ctrl('s').legacy_form(), LegacyForm::Representable);
228 }
229
230 #[test]
231 fn named_function_and_arrow_keys_are_representable() {
232 for key in [
233 Key::Tab,
234 Key::Enter,
235 Key::Esc,
236 Key::Up,
237 Key::F(1),
238 Key::Home,
239 ] {
240 assert_eq!(
241 KeyInput::new(key, Modifiers::NONE).legacy_form(),
242 LegacyForm::Representable,
243 "{key:?} should be representable"
244 );
245 }
246 // shift+tab is a distinct legacy sequence (BackTab), still representable.
247 assert_eq!(
248 KeyInput::new(Key::Tab, Modifiers::SHIFT).legacy_form(),
249 LegacyForm::Representable
250 );
251 }
252
253 fn lint_map(chords: &[KeyInput]) -> Keymap<u8> {
254 let mut map = Keymap::new();
255 for (i, &c) in chords.iter().enumerate() {
256 map.bind(c, u8::try_from(i).unwrap());
257 }
258 map
259 }
260
261 #[test]
262 fn legacy_lints_empty_map_is_empty() {
263 assert_eq!(legacy_lints(&Keymap::<u8>::new()), vec![]);
264 }
265
266 #[test]
267 fn legacy_lints_classify_each_arm_with_canonical_strings() {
268 // ctrl+s = Representable (no lint); cmd+s = Unrepresentable;
269 // ctrl+shift+s = CollapsesTo ctrl+s; ctrl+i = CollapsesTo tab.
270 let map = lint_map(&[
271 KeyInput::new(Key::Char('s'), Modifiers::CTRL),
272 KeyInput::new(Key::Char('s'), Modifiers::SUPER),
273 KeyInput::new(Key::Char('s'), Modifiers::CTRL | Modifiers::SHIFT),
274 ctrl('i'),
275 ]);
276 // Sorted by chord: "ctrl+i", "ctrl+shift+s", "super+s" ("ctrl+s" omitted).
277 assert_eq!(
278 legacy_lints(&map),
279 vec![
280 LegacyLint::CollapsesTo {
281 chord: "ctrl+i".to_string(),
282 collapses_to: "tab".to_string(),
283 },
284 LegacyLint::CollapsesTo {
285 chord: "ctrl+shift+s".to_string(),
286 collapses_to: "ctrl+s".to_string(),
287 },
288 LegacyLint::Unrepresentable {
289 chord: "super+s".to_string(),
290 },
291 ]
292 );
293 }
294
295 #[test]
296 fn legacy_lints_super_dominates_over_shift_collapse() {
297 // cmd+shift+s holds super, so it is Unrepresentable, not a CollapsesTo —
298 // the precedence must survive the lint mapping.
299 let map = lint_map(&[KeyInput::new(
300 Key::Char('s'),
301 Modifiers::SUPER | Modifiers::SHIFT,
302 )]);
303 assert_eq!(
304 legacy_lints(&map),
305 vec![LegacyLint::Unrepresentable {
306 chord: "shift+super+s".to_string(),
307 }]
308 );
309 }
310
311 #[test]
312 fn legacy_lints_omit_representable_and_count_matches() {
313 // Three representable + two not: only the two are reported (no-drop,
314 // no-spurious count, independent of legacy_form's per-chord rule).
315 let map = lint_map(&[
316 KeyInput::new(Key::Char('a'), Modifiers::CTRL), // representable
317 KeyInput::new(Key::Up, Modifiers::NONE), // representable
318 KeyInput::new(Key::F(1), Modifiers::NONE), // representable
319 KeyInput::new(Key::Char('1'), Modifiers::SUPER), // unrepresentable
320 ctrl('m'), // collapses to enter
321 ]);
322 assert_eq!(legacy_lints(&map).len(), 2);
323 }
324
325 #[test]
326 fn legacy_lints_collapses_to_target_is_canonical_and_representable() {
327 // Every reported collapse target must parse back and itself be
328 // Representable — guards the string rendering of renamed targets.
329 let map = lint_map(&[ctrl('i'), ctrl('m'), ctrl('['), ctrl_shift('s')]);
330 for lint in legacy_lints(&map) {
331 if let LegacyLint::CollapsesTo { collapses_to, .. } = lint {
332 let parsed: KeyInput = collapses_to.parse().expect("target parses");
333 assert_eq!(parsed.legacy_form(), LegacyForm::Representable);
334 }
335 }
336 }
337
338 #[test]
339 fn collapse_target_is_itself_a_stable_legacy_form() {
340 // The law: whatever a chord collapses to must itself be Representable
341 // (the collapsed form is what the terminal actually delivers).
342 for chord in [
343 ctrl_shift('s'),
344 ctrl('i'),
345 ctrl('m'),
346 ctrl('['),
347 ctrl_shift('i'),
348 ] {
349 if let LegacyForm::CollapsesTo(target) = chord.legacy_form() {
350 assert_eq!(
351 target.legacy_form(),
352 LegacyForm::Representable,
353 "{chord:?} collapsed to a non-stable form {target:?}"
354 );
355 } else {
356 panic!("{chord:?} expected to collapse");
357 }
358 }
359 }
360}