Skip to main content

hjkl_engine/
input.rs

1//! Backend-agnostic key input types used by the vim engine.
2//!
3//! Phase 8 of the hjkl-buffer migration replaced `tui_textarea::Input`
4//! / `tui_textarea::Key` with these in-crate equivalents so the
5//! editor can drop the `tui-textarea` dependency entirely.
6
7/// A key code, mirroring the subset of [`crossterm::event::KeyCode`]
8/// the vim engine actually consumes. `Null` is the conventional
9/// sentinel for "no input" (matching the previous `tui_textarea::Key`
10/// shape) so call sites can early-return on unsupported keys.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
12pub enum Key {
13    Char(char),
14    Backspace,
15    Enter,
16    Left,
17    Right,
18    Up,
19    Down,
20    Tab,
21    Delete,
22    Home,
23    End,
24    PageUp,
25    PageDown,
26    Esc,
27    #[default]
28    Null,
29}
30
31/// A key press with modifier flags. The vim engine reads modifiers
32/// directly off this struct (e.g. `input.ctrl && input.key == Key::Char('d')`).
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct Input {
35    pub key: Key,
36    pub ctrl: bool,
37    pub alt: bool,
38    pub shift: bool,
39}
40
41/// Serialize a captured macro into vim's keystroke notation
42/// (`<Esc>`, `<C-d>`, `<lt>`, etc.) so it can live as plain text in a
43/// register slot. Used when `q{reg}` finishes recording.
44pub fn encode_macro(inputs: &[Input]) -> String {
45    let mut out = String::new();
46    for input in inputs {
47        match input.key {
48            Key::Char(c) if input.ctrl => {
49                out.push_str("<C-");
50                out.push(c);
51                out.push('>');
52            }
53            Key::Char(c) if input.alt => {
54                out.push_str("<M-");
55                out.push(c);
56                out.push('>');
57            }
58            Key::Char('<') => out.push_str("<lt>"),
59            Key::Char(c) => out.push(c),
60            Key::Esc => out.push_str("<Esc>"),
61            Key::Enter => out.push_str("<CR>"),
62            Key::Backspace => out.push_str("<BS>"),
63            Key::Tab => out.push_str("<Tab>"),
64            Key::Up => out.push_str("<Up>"),
65            Key::Down => out.push_str("<Down>"),
66            Key::Left => out.push_str("<Left>"),
67            Key::Right => out.push_str("<Right>"),
68            Key::Delete => out.push_str("<Del>"),
69            Key::Home => out.push_str("<Home>"),
70            Key::End => out.push_str("<End>"),
71            Key::PageUp => out.push_str("<PageUp>"),
72            Key::PageDown => out.push_str("<PageDown>"),
73            Key::Null => {}
74        }
75    }
76    out
77}
78
79/// Reverse of [`encode_macro`] — parse the textual form back into
80/// `Input` events for replay. Unknown `<…>` tags are dropped silently
81/// so the caller can roundtrip text the user pasted into a register
82/// without erroring out on partial matches.
83pub fn decode_macro(s: &str) -> Vec<Input> {
84    let mut out = Vec::new();
85    let mut chars = s.chars().peekable();
86    while let Some(c) = chars.next() {
87        if c != '<' {
88            out.push(Input {
89                key: Key::Char(c),
90                ..Input::default()
91            });
92            continue;
93        }
94        let mut tag = String::new();
95        let mut closed = false;
96        for ch in chars.by_ref() {
97            if ch == '>' {
98                closed = true;
99                break;
100            }
101            tag.push(ch);
102        }
103        if !closed {
104            // Stray `<` with no `>` — emit the literal so we don't
105            // silently drop user text.
106            out.push(Input {
107                key: Key::Char('<'),
108                ..Input::default()
109            });
110            for ch in tag.chars() {
111                out.push(Input {
112                    key: Key::Char(ch),
113                    ..Input::default()
114                });
115            }
116            continue;
117        }
118        let input = match tag.as_str() {
119            "Esc" => Input {
120                key: Key::Esc,
121                ..Input::default()
122            },
123            "CR" => Input {
124                key: Key::Enter,
125                ..Input::default()
126            },
127            "BS" => Input {
128                key: Key::Backspace,
129                ..Input::default()
130            },
131            "Tab" => Input {
132                key: Key::Tab,
133                ..Input::default()
134            },
135            "Up" => Input {
136                key: Key::Up,
137                ..Input::default()
138            },
139            "Down" => Input {
140                key: Key::Down,
141                ..Input::default()
142            },
143            "Left" => Input {
144                key: Key::Left,
145                ..Input::default()
146            },
147            "Right" => Input {
148                key: Key::Right,
149                ..Input::default()
150            },
151            "Del" => Input {
152                key: Key::Delete,
153                ..Input::default()
154            },
155            "Home" => Input {
156                key: Key::Home,
157                ..Input::default()
158            },
159            "End" => Input {
160                key: Key::End,
161                ..Input::default()
162            },
163            "PageUp" => Input {
164                key: Key::PageUp,
165                ..Input::default()
166            },
167            "PageDown" => Input {
168                key: Key::PageDown,
169                ..Input::default()
170            },
171            "lt" => Input {
172                key: Key::Char('<'),
173                ..Input::default()
174            },
175            t if t.starts_with("C-") => {
176                let Some(ch) = t.chars().nth(2) else {
177                    continue;
178                };
179                Input {
180                    key: Key::Char(ch),
181                    ctrl: true,
182                    ..Input::default()
183                }
184            }
185            t if t.starts_with("M-") => {
186                let Some(ch) = t.chars().nth(2) else {
187                    continue;
188                };
189                Input {
190                    key: Key::Char(ch),
191                    alt: true,
192                    ..Input::default()
193                }
194            }
195            _ => continue,
196        };
197        out.push(input);
198    }
199    out
200}
201
202/// Decode a [`crate::types::Input`] (alias: [`crate::PlannedInput`]) to the
203/// engine-internal [`Input`] type.
204///
205/// Returns `None` for variants the legacy FSM does not dispatch
206/// (`Mouse`, `Paste`, `FocusGained`, `FocusLost`, `Resize`) and for any
207/// special-key variant that maps to [`Key::Null`] (e.g. `SpecialKey::Insert`,
208/// `SpecialKey::F(_)`).
209///
210/// Phase 6.6g.1: extracted from `Editor::feed_input` so that both the
211/// engine-internal path and `hjkl_vim::feed_input` share the same decode
212/// without duplication.
213pub fn from_planned(planned: crate::types::Input) -> Option<Input> {
214    use crate::types::{Input as PlannedInput, SpecialKey};
215    let (key, mods) = match planned {
216        PlannedInput::Char(c, m) => (Key::Char(c), m),
217        PlannedInput::Key(k, m) => {
218            let key = match k {
219                SpecialKey::Esc => Key::Esc,
220                SpecialKey::Enter => Key::Enter,
221                SpecialKey::Backspace => Key::Backspace,
222                SpecialKey::Tab => Key::Tab,
223                // Engine's internal `Key` doesn't model BackTab as a
224                // distinct variant — fall through to the FSM as
225                // shift+Tab, matching crossterm semantics.
226                SpecialKey::BackTab => Key::Tab,
227                SpecialKey::Up => Key::Up,
228                SpecialKey::Down => Key::Down,
229                SpecialKey::Left => Key::Left,
230                SpecialKey::Right => Key::Right,
231                SpecialKey::Home => Key::Home,
232                SpecialKey::End => Key::End,
233                SpecialKey::PageUp => Key::PageUp,
234                SpecialKey::PageDown => Key::PageDown,
235                // Engine's `Key` has no Insert / F(n) — drop to Null
236                // (FSM ignores it) which matches the crossterm path
237                // (`crossterm_to_input` mapped these to Null too).
238                SpecialKey::Insert => Key::Null,
239                SpecialKey::Delete => Key::Delete,
240                SpecialKey::F(_) => Key::Null,
241            };
242            let m = if matches!(k, SpecialKey::BackTab) {
243                crate::types::Modifiers { shift: true, ..m }
244            } else {
245                m
246            };
247            (key, m)
248        }
249        // Variants the legacy FSM doesn't consume yet.
250        PlannedInput::Mouse(_)
251        | PlannedInput::Paste(_)
252        | PlannedInput::FocusGained
253        | PlannedInput::FocusLost
254        | PlannedInput::Resize(_, _) => return None,
255    };
256    if key == Key::Null {
257        return None;
258    }
259    Some(Input {
260        key,
261        ctrl: mods.ctrl,
262        alt: mods.alt,
263        shift: mods.shift,
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn roundtrip_simple_chars() {
273        let keys = vec![
274            Input {
275                key: Key::Char('h'),
276                ..Input::default()
277            },
278            Input {
279                key: Key::Char('i'),
280                ..Input::default()
281            },
282        ];
283        let text = encode_macro(&keys);
284        assert_eq!(text, "hi");
285        assert_eq!(decode_macro(&text), keys);
286    }
287
288    #[test]
289    fn roundtrip_with_special_keys_and_ctrl() {
290        let keys = vec![
291            Input {
292                key: Key::Char('i'),
293                ..Input::default()
294            },
295            Input {
296                key: Key::Char('X'),
297                ..Input::default()
298            },
299            Input {
300                key: Key::Esc,
301                ..Input::default()
302            },
303            Input {
304                key: Key::Char('d'),
305                ctrl: true,
306                ..Input::default()
307            },
308        ];
309        let text = encode_macro(&keys);
310        assert_eq!(text, "iX<Esc><C-d>");
311        assert_eq!(decode_macro(&text), keys);
312    }
313
314    #[test]
315    fn roundtrip_literal_lt() {
316        let keys = vec![Input {
317            key: Key::Char('<'),
318            ..Input::default()
319        }];
320        let text = encode_macro(&keys);
321        assert_eq!(text, "<lt>");
322        assert_eq!(decode_macro(&text), keys);
323    }
324}