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            _ => {
196                // Unrecognized `<tag>` — emit the literal characters (`<`,
197                // tag body, `>`) so the caller doesn't silently lose user
198                // text (mirrors the unterminated-`<foo` branch above).
199                out.push(Input {
200                    key: Key::Char('<'),
201                    ..Input::default()
202                });
203                for ch in tag.chars() {
204                    out.push(Input {
205                        key: Key::Char(ch),
206                        ..Input::default()
207                    });
208                }
209                out.push(Input {
210                    key: Key::Char('>'),
211                    ..Input::default()
212                });
213                continue;
214            }
215        };
216        out.push(input);
217    }
218    out
219}
220
221/// Decode a [`crate::types::Input`] (alias: [`crate::PlannedInput`]) to the
222/// engine-internal [`Input`] type.
223///
224/// Returns `None` for variants the legacy FSM does not dispatch
225/// (`Mouse`, `Paste`, `FocusGained`, `FocusLost`, `Resize`) and for any
226/// special-key variant that maps to [`Key::Null`] (e.g. `SpecialKey::Insert`,
227/// `SpecialKey::F(_)`).
228///
229/// Phase 6.6g.1: extracted from `Editor::feed_input` so that both the
230/// engine-internal path and `hjkl_vim::feed_input` share the same decode
231/// without duplication.
232pub fn from_planned(planned: crate::types::Input) -> Option<Input> {
233    use crate::types::{Input as PlannedInput, SpecialKey};
234    let (key, mods) = match planned {
235        PlannedInput::Char(c, m) => (Key::Char(c), m),
236        PlannedInput::Key(k, m) => {
237            let key = match k {
238                SpecialKey::Esc => Key::Esc,
239                SpecialKey::Enter => Key::Enter,
240                SpecialKey::Backspace => Key::Backspace,
241                SpecialKey::Tab => Key::Tab,
242                // Engine's internal `Key` doesn't model BackTab as a
243                // distinct variant — fall through to the FSM as
244                // shift+Tab, matching crossterm semantics.
245                SpecialKey::BackTab => Key::Tab,
246                SpecialKey::Up => Key::Up,
247                SpecialKey::Down => Key::Down,
248                SpecialKey::Left => Key::Left,
249                SpecialKey::Right => Key::Right,
250                SpecialKey::Home => Key::Home,
251                SpecialKey::End => Key::End,
252                SpecialKey::PageUp => Key::PageUp,
253                SpecialKey::PageDown => Key::PageDown,
254                // Engine's `Key` has no Insert / F(n) — drop to Null
255                // (FSM ignores it) which matches the crossterm path
256                // (`crossterm_to_input` mapped these to Null too).
257                SpecialKey::Insert => Key::Null,
258                SpecialKey::Delete => Key::Delete,
259                SpecialKey::F(_) => Key::Null,
260            };
261            let m = if matches!(k, SpecialKey::BackTab) {
262                crate::types::Modifiers { shift: true, ..m }
263            } else {
264                m
265            };
266            (key, m)
267        }
268        // Variants the legacy FSM doesn't consume yet.
269        PlannedInput::Mouse(_)
270        | PlannedInput::Paste(_)
271        | PlannedInput::FocusGained
272        | PlannedInput::FocusLost
273        | PlannedInput::Resize(_, _) => return None,
274    };
275    if key == Key::Null {
276        return None;
277    }
278    Some(Input {
279        key,
280        ctrl: mods.ctrl,
281        alt: mods.alt,
282        shift: mods.shift,
283    })
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn roundtrip_simple_chars() {
292        let keys = vec![
293            Input {
294                key: Key::Char('h'),
295                ..Input::default()
296            },
297            Input {
298                key: Key::Char('i'),
299                ..Input::default()
300            },
301        ];
302        let text = encode_macro(&keys);
303        assert_eq!(text, "hi");
304        assert_eq!(decode_macro(&text), keys);
305    }
306
307    #[test]
308    fn roundtrip_with_special_keys_and_ctrl() {
309        let keys = vec![
310            Input {
311                key: Key::Char('i'),
312                ..Input::default()
313            },
314            Input {
315                key: Key::Char('X'),
316                ..Input::default()
317            },
318            Input {
319                key: Key::Esc,
320                ..Input::default()
321            },
322            Input {
323                key: Key::Char('d'),
324                ctrl: true,
325                ..Input::default()
326            },
327        ];
328        let text = encode_macro(&keys);
329        assert_eq!(text, "iX<Esc><C-d>");
330        assert_eq!(decode_macro(&text), keys);
331    }
332
333    #[test]
334    fn roundtrip_literal_lt() {
335        let keys = vec![Input {
336            key: Key::Char('<'),
337            ..Input::default()
338        }];
339        let text = encode_macro(&keys);
340        assert_eq!(text, "<lt>");
341        assert_eq!(decode_macro(&text), keys);
342    }
343
344    /// B15: an unrecognized `<...>` tag must replay as its literal characters
345    /// (`<`, tag body, `>`) rather than vanishing — mirrors the existing
346    /// unterminated-`<foo` handling just above it.
347    #[test]
348    fn unknown_tag_replays_literally() {
349        fn ch(c: char) -> Input {
350            Input {
351                key: Key::Char(c),
352                ..Input::default()
353            }
354        }
355        let decoded = decode_macro("<xyz>ok");
356        let expected = vec![
357            ch('<'),
358            ch('x'),
359            ch('y'),
360            ch('z'),
361            ch('>'),
362            ch('o'),
363            ch('k'),
364        ];
365        assert_eq!(decoded, expected);
366    }
367
368    /// Round-trip sanity: encode/decode must still agree after the fix.
369    #[test]
370    fn roundtrip_still_holds_after_unknown_tag_fix() {
371        let keys = vec![
372            Input {
373                key: Key::Char('d'),
374                ctrl: true,
375                ..Input::default()
376            },
377            Input {
378                key: Key::Esc,
379                ..Input::default()
380            },
381        ];
382        let text = encode_macro(&keys);
383        assert_eq!(decode_macro(&text), keys);
384    }
385}