Skip to main content

tess/
input.rs

1use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
2
3use crate::prettify::PrettifyMode;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Command {
7    ScrollLines(i64),
8    /// `J` / `K` — jump forward or backward by one whole logical line,
9    /// skipping any remaining wrap rows of the current line. Useful for
10    /// long lines that wrap many screen rows.
11    ScrollLogicalLines(i64),
12    PageDown,
13    PageUp,
14    HalfPageDown,
15    HalfPageUp,
16    Quit,
17    Resize(u16, u16),
18    Refresh,
19    ToggleLineNumbers,
20    ToggleChop,
21    ToggleFollow,
22    /// `/` — open the forward-search prompt.
23    SearchForward,
24    /// `?` — open the backward-search prompt.
25    SearchBackward,
26    /// `n` — repeat the last search in its original direction.
27    NextMatch,
28    /// `N` — repeat the last search in the opposite direction.
29    PreviousMatch,
30    /// `-` — option-toggle prefix: the next key chooses an option to flip
31    /// (`N` → line numbers, `S` → chop, `F` → follow).
32    OptionPrefix,
33    /// `R` — force-reload the source from disk now (only meaningful with
34    /// `--live`; no-op for static file sources and append-streaming follow).
35    Reload,
36    /// `Shift-P` — toggle pretty-printing on/off (cycles back to the last
37    /// active mode if currently off).
38    TogglePrettify,
39    /// Set a specific prettify mode (issued by the `-P<letter>` sub-prefix
40    /// after the user picks j/y/t/x/h/c).
41    SetPrettifyMode(PrettifyMode),
42    /// Re-run byte-based content detection and apply the result (`-Pa`).
43    RedetectPrettify,
44    /// A digit (0-9) was pressed. The app accumulates these into a numeric
45    /// prefix that the next non-digit command consumes.
46    Digit(u8),
47    /// Jump to physical line N (1-indexed). Without a prefix, behaves as
48    /// goto-top.
49    GotoLine,
50    /// Jump to record N (1-indexed). Without a prefix, behaves as
51    /// goto-bottom (preserves the existing bare-`G` behavior).
52    GotoRecord,
53    /// Jump to N percent through the file by bytes. Without a prefix,
54    /// behaves as goto-top.
55    GotoPercent,
56    /// Cancel any pending numeric prefix without firing a command.
57    Cancel,
58    /// First half of a set-mark sequence (the `m` key). The next keystroke
59    /// names the mark.
60    MarkSet,
61    /// First half of a jump-to-mark sequence (the `'` key). The next
62    /// keystroke names the mark.
63    MarkJump,
64    /// First half of the `Ctrl-X Ctrl-X` jump-to-previous-position chord.
65    /// The next keystroke must also be Ctrl-X.
66    CtrlXPrefix,
67    /// Jump to the previous position (Ctrl-X Ctrl-X in less). Dispatched
68    /// from the CtrlXPending mode intercept in app.rs.
69    JumpPrevious,
70    /// Enter the !cmd shell-escape prompt.
71    ShellEscape,
72    /// Enter the :colon-command prompt.
73    ColonPrompt,
74    Noop,
75}
76
77pub fn translate(event: Event) -> Command {
78    match event {
79        Event::Resize(c, r) => Command::Resize(c, r),
80        Event::Key(KeyEvent { code, modifiers, .. }) => translate_key(code, modifiers),
81        _ => Command::Noop,
82    }
83}
84
85fn translate_key(code: KeyCode, mods: KeyModifiers) -> Command {
86    use KeyCode::*;
87    let ctrl = mods.contains(KeyModifiers::CONTROL);
88    match (code, ctrl) {
89        (Char('q'), false) | (Char('Q'), false) => Command::Quit,
90        (Char('c'), true) => Command::Quit,
91        (Down, _) | (Char('j'), false) | (Char('e'), false) | (Char('e'), true) | (Enter, _) => Command::ScrollLines(1),
92        (Char('y'), false) | (Char('y'), true) | (Up, _) | (Char('k'), false) => Command::ScrollLines(-1),
93        (Char('J'), false) => Command::ScrollLogicalLines(1),
94        (Char('K'), false) => Command::ScrollLogicalLines(-1),
95        (Char(' '), false) | (Char('f'), false) | (Char('f'), true) | (PageDown, _) => Command::PageDown,
96        (Char('b'), false) | (Char('b'), true) | (PageUp, _) => Command::PageUp,
97        (Char('d'), false) | (Char('d'), true) => Command::HalfPageDown,
98        (Char('u'), false) | (Char('u'), true) => Command::HalfPageUp,
99        (Char('0'), false) => Command::Digit(0),
100        (Char('1'), false) => Command::Digit(1),
101        (Char('2'), false) => Command::Digit(2),
102        (Char('3'), false) => Command::Digit(3),
103        (Char('4'), false) => Command::Digit(4),
104        (Char('5'), false) => Command::Digit(5),
105        (Char('6'), false) => Command::Digit(6),
106        (Char('7'), false) => Command::Digit(7),
107        (Char('8'), false) => Command::Digit(8),
108        (Char('9'), false) => Command::Digit(9),
109        (Char('g'), false) | (Char('<'), false) | (Home, _) => Command::GotoLine,
110        (Char('G'), false) | (Char('>'), false) | (End, _) => Command::GotoRecord,
111        (Char('%'), false) => Command::GotoPercent,
112        (Esc, _) => Command::Cancel,
113        (Char('r'), false) | (Char('l'), true) => Command::Refresh,
114        (Char('R'), false) => Command::Reload,
115        (Char('P'), false) => Command::TogglePrettify,
116        (Char('-'), false) => Command::OptionPrefix,
117        (Char('F'), false) => Command::ToggleFollow,
118        (Char('/'), false) => Command::SearchForward,
119        (Char('?'), false) => Command::SearchBackward,
120        (Char('n'), false) => Command::NextMatch,
121        (Char('N'), false) => Command::PreviousMatch,
122        (Char('m'), false) => Command::MarkSet,
123        (Char('\''), false) => Command::MarkJump,
124        (Char('!'), false) => Command::ShellEscape,
125        (Char('x'), true) => Command::CtrlXPrefix,
126        (Char(':'), false) => Command::ColonPrompt,
127        _ => Command::Noop,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crossterm::event::{KeyCode, KeyEventKind, KeyEventState};
135
136    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
137        Event::Key(KeyEvent {
138            code, modifiers: mods,
139            kind: KeyEventKind::Press, state: KeyEventState::NONE,
140        })
141    }
142
143    #[test]
144    fn arrow_down_scrolls_one() {
145        assert_eq!(translate(key(KeyCode::Down, KeyModifiers::NONE)), Command::ScrollLines(1));
146    }
147
148    #[test]
149    fn j_scrolls_one() {
150        assert_eq!(translate(key(KeyCode::Char('j'), KeyModifiers::NONE)), Command::ScrollLines(1));
151    }
152
153    #[test]
154    fn space_pages_down() {
155        assert_eq!(translate(key(KeyCode::Char(' '), KeyModifiers::NONE)), Command::PageDown);
156    }
157
158    #[test]
159    fn ctrl_c_quits() {
160        assert_eq!(translate(key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Command::Quit);
161    }
162
163    #[test]
164    fn capital_g_goes_to_record() {
165        assert_eq!(translate(key(KeyCode::Char('G'), KeyModifiers::SHIFT)), Command::GotoRecord);
166    }
167
168    #[test]
169    fn lowercase_g_goes_to_line() {
170        assert_eq!(translate(key(KeyCode::Char('g'), KeyModifiers::NONE)), Command::GotoLine);
171    }
172
173    #[test]
174    fn percent_goes_to_percent() {
175        assert_eq!(translate(key(KeyCode::Char('%'), KeyModifiers::NONE)), Command::GotoPercent);
176    }
177
178    #[test]
179    fn digit_keys_produce_digit_commands() {
180        for d in 0u8..=9 {
181            let ch = char::from_digit(d as u32, 10).unwrap();
182            assert_eq!(
183                translate(key(KeyCode::Char(ch), KeyModifiers::NONE)),
184                Command::Digit(d),
185            );
186        }
187    }
188
189    #[test]
190    fn esc_produces_cancel() {
191        assert_eq!(translate(key(KeyCode::Esc, KeyModifiers::NONE)), Command::Cancel);
192    }
193
194    #[test]
195    fn capital_j_jumps_one_logical_line_forward() {
196        assert_eq!(translate(key(KeyCode::Char('J'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(1));
197    }
198
199    #[test]
200    fn capital_k_jumps_one_logical_line_backward() {
201        assert_eq!(translate(key(KeyCode::Char('K'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(-1));
202    }
203
204    #[test]
205    fn capital_f_toggles_follow() {
206        assert_eq!(translate(key(KeyCode::Char('F'), KeyModifiers::SHIFT)), Command::ToggleFollow);
207    }
208
209    #[test]
210    fn lowercase_f_still_pages_down() {
211        assert_eq!(translate(key(KeyCode::Char('f'), KeyModifiers::NONE)), Command::PageDown);
212    }
213
214    #[test]
215    fn slash_opens_forward_search() {
216        assert_eq!(translate(key(KeyCode::Char('/'), KeyModifiers::NONE)), Command::SearchForward);
217    }
218
219    #[test]
220    fn question_mark_opens_backward_search() {
221        // `?` arrives as Char('?') with SHIFT on most layouts.
222        assert_eq!(translate(key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Command::SearchBackward);
223    }
224
225    #[test]
226    fn n_repeats_match_forward() {
227        assert_eq!(translate(key(KeyCode::Char('n'), KeyModifiers::NONE)), Command::NextMatch);
228    }
229
230    #[test]
231    fn capital_n_repeats_match_backward() {
232        assert_eq!(translate(key(KeyCode::Char('N'), KeyModifiers::SHIFT)), Command::PreviousMatch);
233    }
234
235    #[test]
236    fn capital_r_triggers_reload() {
237        assert_eq!(translate(key(KeyCode::Char('R'), KeyModifiers::SHIFT)), Command::Reload);
238    }
239
240    #[test]
241    fn lowercase_r_still_refreshes() {
242        assert_eq!(translate(key(KeyCode::Char('r'), KeyModifiers::NONE)), Command::Refresh);
243    }
244
245    #[test]
246    fn capital_p_toggles_prettify() {
247        assert_eq!(translate(key(KeyCode::Char('P'), KeyModifiers::SHIFT)), Command::TogglePrettify);
248    }
249
250    #[test]
251    fn lowercase_p_remains_unbound() {
252        assert_eq!(translate(key(KeyCode::Char('p'), KeyModifiers::NONE)), Command::Noop);
253    }
254
255    #[test]
256    fn dash_is_option_prefix() {
257        assert_eq!(translate(key(KeyCode::Char('-'), KeyModifiers::NONE)), Command::OptionPrefix);
258    }
259
260    #[test]
261    fn resize_event() {
262        assert_eq!(translate(Event::Resize(80, 24)), Command::Resize(80, 24));
263    }
264
265    #[test]
266    fn m_key_produces_mark_set_command() {
267        let evt = key(KeyCode::Char('m'), KeyModifiers::NONE);
268        assert_eq!(translate(evt), Command::MarkSet);
269    }
270
271    #[test]
272    fn single_quote_key_produces_mark_jump_command() {
273        let evt = key(KeyCode::Char('\''), KeyModifiers::NONE);
274        assert_eq!(translate(evt), Command::MarkJump);
275    }
276
277    #[test]
278    fn ctrl_x_produces_ctrl_x_prefix_command() {
279        let evt = key(KeyCode::Char('x'), KeyModifiers::CONTROL);
280        assert_eq!(translate(evt), Command::CtrlXPrefix);
281    }
282
283    #[test]
284    fn bang_produces_shell_escape_command() {
285        let evt = key(KeyCode::Char('!'), KeyModifiers::NONE);
286        assert_eq!(translate(evt), Command::ShellEscape);
287    }
288
289    #[test]
290    fn colon_produces_colon_prompt_command() {
291        let evt = Event::Key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE));
292        assert_eq!(translate(evt), Command::ColonPrompt);
293    }
294}