termesh_core/input.rs
1//! Backend-agnostic keyboard input. The `app` crate translates crossterm events
2//! into these types so the keymap (`config`) never depends on the terminal backend.
3use core::fmt;
4
5/// A logical key, independent of any terminal library.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum Key {
8 Char(char),
9 Enter,
10 Esc,
11 Tab,
12 BackTab,
13 Backspace,
14 Delete,
15 Up,
16 Down,
17 Left,
18 Right,
19 Home,
20 End,
21 PageUp,
22 PageDown,
23 F(u8),
24}
25
26/// Modifier state for a chord.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
28pub struct Mods {
29 pub ctrl: bool,
30 pub alt: bool,
31 pub shift: bool,
32}
33
34impl Mods {
35 pub const NONE: Mods = Mods { ctrl: false, alt: false, shift: false };
36 pub const CTRL: Mods = Mods { ctrl: true, alt: false, shift: false };
37 pub const CTRL_SHIFT: Mods = Mods { ctrl: true, alt: false, shift: true };
38 pub const ALT: Mods = Mods { ctrl: false, alt: true, shift: false };
39 pub const SHIFT: Mods = Mods { ctrl: false, alt: false, shift: true };
40 pub fn is_none(self) -> bool {
41 !self.ctrl && !self.alt && !self.shift
42 }
43}
44
45/// Where a binding applies.
46///
47/// Phase 02 got away with a focus check inside the command handler, because nothing
48/// competed for the arrow keys. The editor is the second consumer — `Down` means "next
49/// tree row" in the explorer and "next line" in a buffer — so the choice belongs in
50/// resolution, where one chord can mean different things in different panes, rather than
51/// in a growing pile of `if focus != ...` guards.
52///
53/// Resolution tries the focused context first and falls back to [`KeyContext::Global`],
54/// so `Ctrl+S` keeps working everywhere while `Enter` is free to differ.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
56pub enum KeyContext {
57 /// Applies regardless of focus.
58 Global,
59 /// Only while the file explorer has focus.
60 Project,
61 /// Only while a buffer has focus.
62 Editor,
63 /// Only while the agent pane has focus and a session exists.
64 Agent,
65 /// Only while terminal copy mode is active. Normal terminal input bypasses the
66 /// keymap and is encoded directly for the PTY (ADR-0008 §3).
67 Terminal,
68}
69
70/// A key plus its modifiers — the unit a keymap binds to a [`crate::Command`].
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub struct KeyChord {
73 pub key: Key,
74 pub mods: Mods,
75}
76
77impl KeyChord {
78 pub const fn new(key: Key, mods: Mods) -> Self {
79 Self { key, mods }
80 }
81 pub const fn plain(key: Key) -> Self {
82 Self { key, mods: Mods::NONE }
83 }
84 pub const fn ctrl(key: Key) -> Self {
85 Self { key, mods: Mods::CTRL }
86 }
87 pub const fn ctrl_shift(key: Key) -> Self {
88 Self { key, mods: Mods::CTRL_SHIFT }
89 }
90 pub const fn alt(key: Key) -> Self {
91 Self { key, mods: Mods::ALT }
92 }
93 pub const fn shift(key: Key) -> Self {
94 Self { key, mods: Mods::SHIFT }
95 }
96}
97
98impl KeyChord {
99 /// Whether a legacy terminal can even deliver this chord distinguishably.
100 ///
101 /// Without the kitty keyboard protocol — which we do not enable — `Ctrl+<key>` is
102 /// sent as the control byte `key & 0x1f`, and several of those bytes are already
103 /// spoken for by named keys or by each other:
104 ///
105 /// | chord | byte | indistinguishable from |
106 /// |--------------|------|------------------------------|
107 /// | `Ctrl+I` | 0x09 | `Tab` |
108 /// | `Ctrl+M` | 0x0D | `Enter` |
109 /// | `Ctrl+J` | 0x0A | `Enter` (line feed) |
110 /// | `Ctrl+H` | 0x08 | `Backspace` |
111 /// | `Ctrl+[` | 0x1B | `Esc` |
112 /// | ``Ctrl+` `` | 0x00 | `Ctrl+@`, `Ctrl+Space` (NUL) |
113 /// | `Ctrl+@` | 0x00 | ``Ctrl+` ``, `Ctrl+Space` |
114 /// | `Ctrl+Space` | 0x00 | ``Ctrl+` ``, `Ctrl+@` |
115 /// | `Ctrl+Shift+letter` | same control byte as `Ctrl+letter` | shift is lost |
116 ///
117 /// The NUL family is worse than merely ambiguous: most emulators decline to send
118 /// anything at all for ``Ctrl+` ``, and macOS claims `Ctrl+Space` for input-source
119 /// switching before any terminal sees it. Neither is reachable in practice.
120 ///
121 /// Binding one of these does not fail loudly — it silently does whatever the *other*
122 /// key is bound to, or nothing, which is indistinguishable from the feature being
123 /// broken. So the default keymap is tested against this rather than trusted.
124 pub fn is_terminal_ambiguous(&self) -> bool {
125 if !self.mods.ctrl || self.mods.alt {
126 return false;
127 }
128 if self.mods.shift && matches!(self.key, Key::Char(_)) {
129 return true;
130 }
131 matches!(self.key, Key::Char('i' | 'm' | 'j' | 'h' | '[' | '`' | '@' | ' '))
132 }
133}
134
135impl fmt::Display for Key {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 match self {
138 Key::Char(' ') => write!(f, "Space"),
139 Key::Char(c) => write!(f, "{}", c.to_ascii_uppercase()),
140 Key::Enter => write!(f, "Enter"),
141 Key::Esc => write!(f, "Esc"),
142 Key::Tab => write!(f, "Tab"),
143 Key::BackTab => write!(f, "Shift+Tab"),
144 Key::Backspace => write!(f, "Backspace"),
145 Key::Delete => write!(f, "Del"),
146 Key::Up => write!(f, "\u{2191}"),
147 Key::Down => write!(f, "\u{2193}"),
148 Key::Left => write!(f, "\u{2190}"),
149 Key::Right => write!(f, "\u{2192}"),
150 Key::Home => write!(f, "Home"),
151 Key::End => write!(f, "End"),
152 Key::PageUp => write!(f, "PgUp"),
153 Key::PageDown => write!(f, "PgDn"),
154 Key::F(n) => write!(f, "F{n}"),
155 }
156 }
157}
158
159impl fmt::Display for KeyChord {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 if self.mods.ctrl {
162 write!(f, "Ctrl+")?;
163 }
164 if self.mods.alt {
165 write!(f, "Alt+")?;
166 }
167 if self.mods.shift && !matches!(self.key, Key::BackTab) {
168 write!(f, "Shift+")?;
169 }
170 write!(f, "{}", self.key)
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn legacy_terminals_cannot_distinguish_ctrl_shift_letters_from_ctrl_letters() {
180 for letter in ['a', 'f', 'p', 'z'] {
181 assert!(KeyChord::ctrl_shift(Key::Char(letter)).is_terminal_ambiguous());
182 }
183 assert!(!KeyChord::plain(Key::F(9)).is_terminal_ambiguous());
184 assert!(!KeyChord::plain(Key::F(10)).is_terminal_ambiguous());
185 }
186}