Skip to main content

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}