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/// The longest tag body [`decode_macro`] recognises (`PageDown`). A `>`
80/// further away than this cannot close a known tag, so the search for it
81/// stops here and decoding stays linear in the input.
82const LONGEST_TAG: usize = "PageDown".len();
83
84/// Reverse of [`encode_macro`] — parse the textual form back into
85/// `Input` events for replay. A `<` that does not open a known `<…>` tag is
86/// a literal `<`, and scanning resumes right after it, as vim's notation
87/// parser does: `<<Esc>` is `<` then Escape, and an unknown `<xyz>` replays
88/// as its literal characters, so pasted register text round-trips without
89/// silently dropping user content.
90pub fn decode_macro(s: &str) -> Vec<Input> {
91    let chars: Vec<char> = s.chars().collect();
92    let mut out = Vec::new();
93    let mut i = 0;
94    while i < chars.len() {
95        let c = chars[i];
96        i += 1;
97        if c == '<' {
98            let rest = &chars[i..];
99            let close = rest.iter().take(LONGEST_TAG + 1).position(|&ch| ch == '>');
100            if let Some(len) = close
101                && let Some(input) = key_from_tag(&rest[..len].iter().collect::<String>())
102            {
103                out.push(input);
104                i += len + 1;
105                continue;
106            }
107        }
108        out.push(Input {
109            key: Key::Char(c),
110            ..Input::default()
111        });
112    }
113    out
114}
115
116/// The key a `<tag>` body names, or `None` when it names none.
117fn key_from_tag(tag: &str) -> Option<Input> {
118    let key = match tag {
119        "Esc" => Key::Esc,
120        "CR" => Key::Enter,
121        "BS" => Key::Backspace,
122        "Tab" => Key::Tab,
123        "Space" => Key::Char(' '),
124        "Up" => Key::Up,
125        "Down" => Key::Down,
126        "Left" => Key::Left,
127        "Right" => Key::Right,
128        "Del" => Key::Delete,
129        "Home" => Key::Home,
130        "End" => Key::End,
131        "PageUp" => Key::PageUp,
132        "PageDown" => Key::PageDown,
133        "lt" => Key::Char('<'),
134        t => {
135            let mut parts = t.chars();
136            let (modifier, dash, ch) = (parts.next()?, parts.next()?, parts.next()?);
137            if dash != '-' || parts.next().is_some() {
138                return None;
139            }
140            return match modifier {
141                'C' => Some(Input {
142                    key: Key::Char(ch),
143                    ctrl: true,
144                    ..Input::default()
145                }),
146                'M' => Some(Input {
147                    key: Key::Char(ch),
148                    alt: true,
149                    ..Input::default()
150                }),
151                _ => None,
152            };
153        }
154    };
155    Some(Input {
156        key,
157        ..Input::default()
158    })
159}
160
161/// Decode a [`crate::types::Input`] (alias: [`crate::PlannedInput`]) to the
162/// engine-internal [`Input`] type.
163///
164/// Returns `None` for variants the legacy FSM does not dispatch
165/// (`Mouse`, `Paste`, `FocusGained`, `FocusLost`, `Resize`) and for any
166/// special-key variant that maps to [`Key::Null`] (e.g. `SpecialKey::Insert`,
167/// `SpecialKey::F(_)`).
168///
169/// Phase 6.6g.1: extracted from `Editor::feed_input` so that both the
170/// engine-internal path and `hjkl_vim::feed_input` share the same decode
171/// without duplication.
172pub fn from_planned(planned: crate::types::Input) -> Option<Input> {
173    use crate::types::{Input as PlannedInput, SpecialKey};
174    let (key, mods) = match planned {
175        PlannedInput::Char(c, m) => (Key::Char(c), m),
176        PlannedInput::Key(k, m) => {
177            let key = match k {
178                SpecialKey::Esc => Key::Esc,
179                SpecialKey::Enter => Key::Enter,
180                SpecialKey::Backspace => Key::Backspace,
181                SpecialKey::Tab => Key::Tab,
182                // Engine's internal `Key` doesn't model BackTab as a
183                // distinct variant — fall through to the FSM as
184                // shift+Tab, matching crossterm semantics.
185                SpecialKey::BackTab => Key::Tab,
186                SpecialKey::Up => Key::Up,
187                SpecialKey::Down => Key::Down,
188                SpecialKey::Left => Key::Left,
189                SpecialKey::Right => Key::Right,
190                SpecialKey::Home => Key::Home,
191                SpecialKey::End => Key::End,
192                SpecialKey::PageUp => Key::PageUp,
193                SpecialKey::PageDown => Key::PageDown,
194                // Engine's `Key` has no Insert / F(n) — drop to Null
195                // (FSM ignores it) which matches the crossterm path
196                // (`crossterm_to_input` mapped these to Null too).
197                SpecialKey::Insert => Key::Null,
198                SpecialKey::Delete => Key::Delete,
199                SpecialKey::F(_) => Key::Null,
200            };
201            let m = if matches!(k, SpecialKey::BackTab) {
202                crate::types::Modifiers { shift: true, ..m }
203            } else {
204                m
205            };
206            (key, m)
207        }
208        // Variants the legacy FSM doesn't consume yet.
209        PlannedInput::Mouse(_)
210        | PlannedInput::Paste(_)
211        | PlannedInput::FocusGained
212        | PlannedInput::FocusLost
213        | PlannedInput::Resize(_, _) => return None,
214    };
215    if key == Key::Null {
216        return None;
217    }
218    Some(Input {
219        key,
220        ctrl: mods.ctrl,
221        alt: mods.alt,
222        shift: mods.shift,
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn roundtrip_simple_chars() {
232        let keys = vec![
233            Input {
234                key: Key::Char('h'),
235                ..Input::default()
236            },
237            Input {
238                key: Key::Char('i'),
239                ..Input::default()
240            },
241        ];
242        let text = encode_macro(&keys);
243        assert_eq!(text, "hi");
244        assert_eq!(decode_macro(&text), keys);
245    }
246
247    #[test]
248    fn roundtrip_with_special_keys_and_ctrl() {
249        let keys = vec![
250            Input {
251                key: Key::Char('i'),
252                ..Input::default()
253            },
254            Input {
255                key: Key::Char('X'),
256                ..Input::default()
257            },
258            Input {
259                key: Key::Esc,
260                ..Input::default()
261            },
262            Input {
263                key: Key::Char('d'),
264                ctrl: true,
265                ..Input::default()
266            },
267        ];
268        let text = encode_macro(&keys);
269        assert_eq!(text, "iX<Esc><C-d>");
270        assert_eq!(decode_macro(&text), keys);
271    }
272
273    #[test]
274    fn roundtrip_literal_lt() {
275        let keys = vec![Input {
276            key: Key::Char('<'),
277            ..Input::default()
278        }];
279        let text = encode_macro(&keys);
280        assert_eq!(text, "<lt>");
281        assert_eq!(decode_macro(&text), keys);
282    }
283
284    /// B15: an unrecognized `<...>` tag must replay as its literal characters
285    /// (`<`, tag body, `>`) rather than vanishing — mirrors the existing
286    /// unterminated-`<foo` handling just above it.
287    #[test]
288    fn unknown_tag_replays_literally() {
289        fn ch(c: char) -> Input {
290            Input {
291                key: Key::Char(c),
292                ..Input::default()
293            }
294        }
295        let decoded = decode_macro("<xyz>ok");
296        let expected = vec![
297            ch('<'),
298            ch('x'),
299            ch('y'),
300            ch('z'),
301            ch('>'),
302            ch('o'),
303            ch('k'),
304        ];
305        assert_eq!(decoded, expected);
306    }
307
308    /// `<Space>` is a space, as `nvim_input` reads it.
309    #[test]
310    fn space_tag_decodes_to_a_space() {
311        let space = Input {
312            key: Key::Char(' '),
313            ..Input::default()
314        };
315        assert_eq!(decode_macro("<Space>"), vec![space]);
316    }
317
318    /// A `<` that opens no known tag is literal and scanning resumes right
319    /// after it, so a known tag nested inside the would-be tag still decodes:
320    /// `<<$<Esc>` is `<`, `<`, `$`, Escape, as nvim reads it.
321    #[test]
322    fn literal_lt_rescans_for_a_nested_tag() {
323        fn ch(c: char) -> Input {
324            Input {
325                key: Key::Char(c),
326                ..Input::default()
327            }
328        }
329        let esc = Input {
330            key: Key::Esc,
331            ..Input::default()
332        };
333        assert_eq!(
334            decode_macro("<<$<Esc>"),
335            vec![ch('<'), ch('<'), ch('$'), esc]
336        );
337        assert_eq!(decode_macro("<<Esc>"), vec![ch('<'), esc]);
338        assert_eq!(decode_macro("<ab"), vec![ch('<'), ch('a'), ch('b')]);
339        assert_eq!(
340            decode_macro("<C-ab>"),
341            "<C-ab>".chars().map(ch).collect::<Vec<_>>()
342        );
343    }
344
345    /// Round-trip sanity: encode/decode must still agree after the fix.
346    #[test]
347    fn roundtrip_still_holds_after_unknown_tag_fix() {
348        let keys = vec![
349            Input {
350                key: Key::Char('d'),
351                ctrl: true,
352                ..Input::default()
353            },
354            Input {
355                key: Key::Esc,
356                ..Input::default()
357            },
358        ];
359        let text = encode_macro(&keys);
360        assert_eq!(decode_macro(&text), keys);
361    }
362}