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#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn roundtrip_simple_chars() {
208        let keys = vec![
209            Input {
210                key: Key::Char('h'),
211                ..Input::default()
212            },
213            Input {
214                key: Key::Char('i'),
215                ..Input::default()
216            },
217        ];
218        let text = encode_macro(&keys);
219        assert_eq!(text, "hi");
220        assert_eq!(decode_macro(&text), keys);
221    }
222
223    #[test]
224    fn roundtrip_with_special_keys_and_ctrl() {
225        let keys = vec![
226            Input {
227                key: Key::Char('i'),
228                ..Input::default()
229            },
230            Input {
231                key: Key::Char('X'),
232                ..Input::default()
233            },
234            Input {
235                key: Key::Esc,
236                ..Input::default()
237            },
238            Input {
239                key: Key::Char('d'),
240                ctrl: true,
241                ..Input::default()
242            },
243        ];
244        let text = encode_macro(&keys);
245        assert_eq!(text, "iX<Esc><C-d>");
246        assert_eq!(decode_macro(&text), keys);
247    }
248
249    #[test]
250    fn roundtrip_literal_lt() {
251        let keys = vec![Input {
252            key: Key::Char('<'),
253            ..Input::default()
254        }];
255        let text = encode_macro(&keys);
256        assert_eq!(text, "<lt>");
257        assert_eq!(decode_macro(&text), keys);
258    }
259}