Skip to main content

mdcat/mdless/
keys.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Keystroke decoder.
6//!
7//! [`KeyEvent`] in, [`Command`] out. [`Decoder`] tracks pending
8//! prefixes (`gg`, `]]`, `m{reg}`, numeric counts, search input).
9
10use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11
12/// One user intent produced by key decoding.
13///
14/// `Noop` means "unrecognised input"; unknown keys map to this instead
15/// of a separate `Option::None` so the event loop stays linear.
16#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17#[allow(missing_docs)]
18pub enum Command {
19    Noop,
20    Quit,
21    Redraw,
22    ScrollDown(u16),
23    ScrollUp(u16),
24    PageDown,
25    PageUp,
26    HalfPageDown,
27    HalfPageUp,
28    Home,
29    End,
30    /// Jump to 1-indexed rendered line `n` (numeric prefix + `G`).
31    GotoLine(usize),
32    /// Enter search-input mode; subsequent keys build a pattern.
33    BeginSearch(SearchDirection),
34    SearchChar(char),
35    SearchBackspace,
36    SearchCommit,
37    SearchCancel,
38    SearchNext,
39    SearchPrev,
40    ClearHighlights,
41    NextHeading,
42    PrevHeading,
43    ToggleToc,
44    /// Activate the current TOC entry (`Enter` while the modal is open).
45    TocActivate,
46    /// Save the current viewport top under bookmark `letter` (`m{a-z}`).
47    SetBookmark(char),
48    /// Jump to bookmark `letter` (`'{a-z}`).
49    JumpBookmark(char),
50    ToggleLineNumbers,
51}
52
53/// Direction selected by `/` vs `?`.
54#[derive(Debug, Clone, Copy, Eq, PartialEq)]
55#[allow(missing_docs)]
56pub enum SearchDirection {
57    Forward,
58    Backward,
59}
60
61/// Stateful decoder: absorbs prefix keys and emits commands.
62///
63/// Each field tracks one kind of pending prefix (`gg`, `]]`, numeric
64/// count, `m`/`'` bookmark register, or active `/` / `?` input). The
65/// next keystroke either completes a digraph or cancels and dispatches.
66#[derive(Debug, Default)]
67pub struct Decoder {
68    count: u32,
69    pending_g: bool,
70    pending_bracket: Option<char>,
71    pending_mark_set: bool,
72    pending_mark_jump: bool,
73    searching: bool,
74}
75
76impl Decoder {
77    /// True while the decoder is collecting a `/` / `?` pattern.
78    pub fn in_search(&self) -> bool {
79        self.searching
80    }
81
82    /// Feed one key event; get back the resulting command.
83    pub fn feed(&mut self, key: KeyEvent) -> Command {
84        let KeyEvent {
85            code, modifiers, ..
86        } = key;
87
88        // Ctrl+C always quits, even mid-prefix or mid-search.
89        if modifiers.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c')) {
90            *self = Self::default();
91            return Command::Quit;
92        }
93
94        if self.searching {
95            return self.feed_search(code);
96        }
97
98        // Bookmark register capture: the previous key was `m` or `'`,
99        // so consume the next ASCII letter as the register name.
100        if self.pending_mark_set {
101            self.pending_mark_set = false;
102            return match code {
103                KeyCode::Char(c) if c.is_ascii_alphabetic() => Command::SetBookmark(c),
104                _ => Command::Noop,
105            };
106        }
107        if self.pending_mark_jump {
108            self.pending_mark_jump = false;
109            return match code {
110                KeyCode::Char(c) if c.is_ascii_alphabetic() => Command::JumpBookmark(c),
111                _ => Command::Noop,
112            };
113        }
114
115        // Collect a numeric count. Digits keep accumulating; any other
116        // key consumes the count and dispatches.
117        if let KeyCode::Char(c) = code {
118            if c.is_ascii_digit() && modifiers.is_empty() {
119                // Lone `0` at the start is Home (less-compatible); digits
120                // after an existing count extend it.
121                if self.count == 0 && c == '0' {
122                    return Command::Home;
123                }
124                self.count = self.count.saturating_mul(10) + (c as u32 - b'0' as u32);
125                return Command::Noop;
126            }
127        }
128
129        let count = std::mem::take(&mut self.count);
130        let prev_g = std::mem::replace(&mut self.pending_g, false);
131        let prev_bracket = self.pending_bracket.take();
132
133        match (code, modifiers) {
134            (KeyCode::Char('q'), KeyModifiers::NONE) => Command::Quit,
135            (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) => {
136                Command::ScrollDown(count.max(1).try_into().unwrap_or(1))
137            }
138            (KeyCode::Char('k'), KeyModifiers::NONE) | (KeyCode::Up, _) => {
139                Command::ScrollUp(count.max(1).try_into().unwrap_or(1))
140            }
141            (KeyCode::Char(' '), KeyModifiers::NONE) | (KeyCode::PageDown, _) => Command::PageDown,
142            (KeyCode::Char('f'), KeyModifiers::CONTROL) => Command::PageDown,
143            (KeyCode::Char('b'), KeyModifiers::NONE) | (KeyCode::PageUp, _) => Command::PageUp,
144            (KeyCode::Char('b'), KeyModifiers::CONTROL) => Command::PageUp,
145            (KeyCode::Char('d'), KeyModifiers::CONTROL) => Command::HalfPageDown,
146            (KeyCode::Char('u'), KeyModifiers::CONTROL) => Command::HalfPageUp,
147            (KeyCode::Char('l'), KeyModifiers::CONTROL) => Command::Redraw,
148            (KeyCode::Home, _) => Command::Home,
149            (KeyCode::End, _) => Command::End,
150            (KeyCode::Char('g'), KeyModifiers::NONE) => {
151                if prev_g {
152                    Command::Home
153                } else {
154                    self.pending_g = true;
155                    Command::Noop
156                }
157            }
158            (KeyCode::Char('G'), _) => {
159                if count > 0 {
160                    Command::GotoLine(count as usize)
161                } else {
162                    Command::End
163                }
164            }
165            (KeyCode::Char('/'), KeyModifiers::NONE) => {
166                self.searching = true;
167                Command::BeginSearch(SearchDirection::Forward)
168            }
169            (KeyCode::Char('?'), _) => {
170                self.searching = true;
171                Command::BeginSearch(SearchDirection::Backward)
172            }
173            (KeyCode::Char('n'), KeyModifiers::NONE) => Command::SearchNext,
174            (KeyCode::Char('N'), _) => Command::SearchPrev,
175            (KeyCode::Char(']'), KeyModifiers::NONE) => {
176                if prev_bracket == Some(']') {
177                    Command::NextHeading
178                } else {
179                    self.pending_bracket = Some(']');
180                    Command::Noop
181                }
182            }
183            (KeyCode::Char('['), KeyModifiers::NONE) => {
184                if prev_bracket == Some('[') {
185                    Command::PrevHeading
186                } else {
187                    self.pending_bracket = Some('[');
188                    Command::Noop
189                }
190            }
191            (KeyCode::Char('T'), _) => Command::ToggleToc,
192            (KeyCode::Char('m'), KeyModifiers::NONE) => {
193                self.pending_mark_set = true;
194                Command::Noop
195            }
196            (KeyCode::Char('\''), KeyModifiers::NONE) => {
197                self.pending_mark_jump = true;
198                Command::Noop
199            }
200            (KeyCode::Enter, _) => Command::TocActivate,
201            (KeyCode::Char('#'), _) => Command::ToggleLineNumbers,
202            (KeyCode::Esc, _) => Command::ClearHighlights,
203            _ => Command::Noop,
204        }
205    }
206
207    /// Search-input mode: absorb characters, commit on Enter, cancel on Esc.
208    fn feed_search(&mut self, code: KeyCode) -> Command {
209        match code {
210            KeyCode::Enter => {
211                self.searching = false;
212                Command::SearchCommit
213            }
214            KeyCode::Esc => {
215                self.searching = false;
216                Command::SearchCancel
217            }
218            KeyCode::Backspace => Command::SearchBackspace,
219            KeyCode::Char(c) => Command::SearchChar(c),
220            _ => Command::Noop,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn key(c: char) -> KeyEvent {
230        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
231    }
232
233    fn key_mod(c: char, m: KeyModifiers) -> KeyEvent {
234        KeyEvent::new(KeyCode::Char(c), m)
235    }
236
237    #[test]
238    fn single_keys_map_directly() {
239        let mut d = Decoder::default();
240        assert_eq!(d.feed(key('j')), Command::ScrollDown(1));
241        assert_eq!(d.feed(key('k')), Command::ScrollUp(1));
242        assert_eq!(d.feed(key(' ')), Command::PageDown);
243        assert_eq!(d.feed(key('b')), Command::PageUp);
244        assert_eq!(d.feed(key('G')), Command::End);
245        assert_eq!(d.feed(key('q')), Command::Quit);
246    }
247
248    #[test]
249    fn double_g_is_home() {
250        let mut d = Decoder::default();
251        assert_eq!(d.feed(key('g')), Command::Noop);
252        assert_eq!(d.feed(key('g')), Command::Home);
253    }
254
255    #[test]
256    fn numeric_prefix_drives_goto_line() {
257        let mut d = Decoder::default();
258        for c in "42".chars() {
259            assert_eq!(d.feed(key(c)), Command::Noop);
260        }
261        assert_eq!(d.feed(key('G')), Command::GotoLine(42));
262    }
263
264    #[test]
265    fn numeric_prefix_multiplies_scroll() {
266        let mut d = Decoder::default();
267        assert_eq!(d.feed(key('5')), Command::Noop);
268        assert_eq!(d.feed(key('j')), Command::ScrollDown(5));
269    }
270
271    #[test]
272    fn ctrl_c_quits_mid_prefix() {
273        let mut d = Decoder::default();
274        assert_eq!(d.feed(key('9')), Command::Noop);
275        assert_eq!(d.feed(key_mod('c', KeyModifiers::CONTROL)), Command::Quit);
276        // Count was cleared, so a fresh `G` is End not GotoLine(9).
277        assert_eq!(d.feed(key('G')), Command::End);
278    }
279
280    #[test]
281    fn lone_zero_goes_to_first_column() {
282        let mut d = Decoder::default();
283        assert_eq!(d.feed(key('0')), Command::Home);
284    }
285
286    #[test]
287    fn unknown_key_is_noop_not_error() {
288        let mut d = Decoder::default();
289        assert_eq!(d.feed(key('x')), Command::Noop);
290    }
291
292    #[test]
293    fn double_bracket_emits_heading_jumps() {
294        let mut d = Decoder::default();
295        assert_eq!(d.feed(key(']')), Command::Noop);
296        assert_eq!(d.feed(key(']')), Command::NextHeading);
297        assert_eq!(d.feed(key('[')), Command::Noop);
298        assert_eq!(d.feed(key('[')), Command::PrevHeading);
299    }
300
301    #[test]
302    fn mismatched_bracket_cancels_pending() {
303        let mut d = Decoder::default();
304        assert_eq!(d.feed(key(']')), Command::Noop);
305        // A non-bracket key consumes the pending state and dispatches.
306        assert_eq!(d.feed(key('j')), Command::ScrollDown(1));
307        // Second `]` alone doesn't fire — the pending flag cleared above.
308        assert_eq!(d.feed(key(']')), Command::Noop);
309    }
310
311    #[test]
312    fn capital_t_toggles_toc() {
313        let mut d = Decoder::default();
314        assert_eq!(d.feed(key('T')), Command::ToggleToc);
315    }
316
317    #[test]
318    fn m_letter_sets_bookmark_register() {
319        let mut d = Decoder::default();
320        assert_eq!(d.feed(key('m')), Command::Noop);
321        assert_eq!(d.feed(key('a')), Command::SetBookmark('a'));
322    }
323
324    #[test]
325    fn apostrophe_letter_jumps_to_bookmark() {
326        let mut d = Decoder::default();
327        assert_eq!(d.feed(key('\'')), Command::Noop);
328        assert_eq!(d.feed(key('q')), Command::JumpBookmark('q'));
329    }
330
331    #[test]
332    fn hash_toggles_line_numbers() {
333        let mut d = Decoder::default();
334        assert_eq!(d.feed(key('#')), Command::ToggleLineNumbers);
335    }
336
337    #[test]
338    fn bookmark_register_rejects_non_letter() {
339        let mut d = Decoder::default();
340        assert_eq!(d.feed(key('m')), Command::Noop);
341        assert_eq!(d.feed(key('1')), Command::Noop);
342        // Pending flag cleared: a fresh `j` decodes normally.
343        assert_eq!(d.feed(key('j')), Command::ScrollDown(1));
344    }
345}