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    /// Enter the tag-name prompt (`Ctrl-]`).
75    TagPrompt,
76    /// Pop the tag stack and jump back (`Ctrl-T`).
77    TagPop,
78    Noop,
79}
80
81pub fn translate(event: Event) -> Command {
82    match event {
83        Event::Resize(c, r) => Command::Resize(c, r),
84        Event::Key(KeyEvent { code, modifiers, .. }) => translate_key(code, modifiers),
85        _ => Command::Noop,
86    }
87}
88
89fn translate_key(code: KeyCode, mods: KeyModifiers) -> Command {
90    use KeyCode::*;
91    let ctrl = mods.contains(KeyModifiers::CONTROL);
92    match (code, ctrl) {
93        (Char('q'), false) | (Char('Q'), false) => Command::Quit,
94        (Char('c'), true) => Command::Quit,
95        (Down, _) | (Char('j'), false) | (Char('e'), false) | (Char('e'), true) | (Enter, _) => Command::ScrollLines(1),
96        (Char('y'), false) | (Char('y'), true) | (Up, _) | (Char('k'), false) => Command::ScrollLines(-1),
97        (Char('J'), false) => Command::ScrollLogicalLines(1),
98        (Char('K'), false) => Command::ScrollLogicalLines(-1),
99        (Char(' '), false) | (Char('f'), false) | (Char('f'), true) | (PageDown, _) => Command::PageDown,
100        (Char('b'), false) | (Char('b'), true) | (PageUp, _) => Command::PageUp,
101        (Char('d'), false) | (Char('d'), true) => Command::HalfPageDown,
102        (Char('u'), false) | (Char('u'), true) => Command::HalfPageUp,
103        (Char('0'), false) => Command::Digit(0),
104        (Char('1'), false) => Command::Digit(1),
105        (Char('2'), false) => Command::Digit(2),
106        (Char('3'), false) => Command::Digit(3),
107        (Char('4'), false) => Command::Digit(4),
108        (Char('5'), false) => Command::Digit(5),
109        (Char('6'), false) => Command::Digit(6),
110        (Char('7'), false) => Command::Digit(7),
111        (Char('8'), false) => Command::Digit(8),
112        (Char('9'), false) => Command::Digit(9),
113        (Char('g'), false) | (Char('<'), false) | (Home, _) => Command::GotoLine,
114        (Char('G'), false) | (Char('>'), false) | (End, _) => Command::GotoRecord,
115        (Char('%'), false) => Command::GotoPercent,
116        (Esc, _) => Command::Cancel,
117        (Char('r'), false) | (Char('l'), true) => Command::Refresh,
118        (Char('R'), false) => Command::Reload,
119        (Char('P'), false) => Command::TogglePrettify,
120        (Char('-'), false) => Command::OptionPrefix,
121        (Char('F'), false) => Command::ToggleFollow,
122        (Char('/'), false) => Command::SearchForward,
123        (Char('?'), false) => Command::SearchBackward,
124        (Char('n'), false) => Command::NextMatch,
125        (Char('N'), false) => Command::PreviousMatch,
126        (Char('m'), false) => Command::MarkSet,
127        (Char('\''), false) => Command::MarkJump,
128        (Char('!'), false) => Command::ShellEscape,
129        (Char('x'), true) => Command::CtrlXPrefix,
130        (Char(':'), false) => Command::ColonPrompt,
131        (Char(']'), true) => Command::TagPrompt,
132        (Char('t'), true) => Command::TagPop,
133        _ => Command::Noop,
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crossterm::event::{KeyCode, KeyEventKind, KeyEventState};
141
142    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
143        Event::Key(KeyEvent {
144            code, modifiers: mods,
145            kind: KeyEventKind::Press, state: KeyEventState::NONE,
146        })
147    }
148
149    #[test]
150    fn arrow_down_scrolls_one() {
151        assert_eq!(translate(key(KeyCode::Down, KeyModifiers::NONE)), Command::ScrollLines(1));
152    }
153
154    #[test]
155    fn j_scrolls_one() {
156        assert_eq!(translate(key(KeyCode::Char('j'), KeyModifiers::NONE)), Command::ScrollLines(1));
157    }
158
159    #[test]
160    fn space_pages_down() {
161        assert_eq!(translate(key(KeyCode::Char(' '), KeyModifiers::NONE)), Command::PageDown);
162    }
163
164    #[test]
165    fn ctrl_c_quits() {
166        assert_eq!(translate(key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Command::Quit);
167    }
168
169    #[test]
170    fn capital_g_goes_to_record() {
171        assert_eq!(translate(key(KeyCode::Char('G'), KeyModifiers::SHIFT)), Command::GotoRecord);
172    }
173
174    #[test]
175    fn lowercase_g_goes_to_line() {
176        assert_eq!(translate(key(KeyCode::Char('g'), KeyModifiers::NONE)), Command::GotoLine);
177    }
178
179    #[test]
180    fn percent_goes_to_percent() {
181        assert_eq!(translate(key(KeyCode::Char('%'), KeyModifiers::NONE)), Command::GotoPercent);
182    }
183
184    #[test]
185    fn digit_keys_produce_digit_commands() {
186        for d in 0u8..=9 {
187            let ch = char::from_digit(d as u32, 10).unwrap();
188            assert_eq!(
189                translate(key(KeyCode::Char(ch), KeyModifiers::NONE)),
190                Command::Digit(d),
191            );
192        }
193    }
194
195    #[test]
196    fn esc_produces_cancel() {
197        assert_eq!(translate(key(KeyCode::Esc, KeyModifiers::NONE)), Command::Cancel);
198    }
199
200    #[test]
201    fn capital_j_jumps_one_logical_line_forward() {
202        assert_eq!(translate(key(KeyCode::Char('J'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(1));
203    }
204
205    #[test]
206    fn capital_k_jumps_one_logical_line_backward() {
207        assert_eq!(translate(key(KeyCode::Char('K'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(-1));
208    }
209
210    #[test]
211    fn capital_f_toggles_follow() {
212        assert_eq!(translate(key(KeyCode::Char('F'), KeyModifiers::SHIFT)), Command::ToggleFollow);
213    }
214
215    #[test]
216    fn lowercase_f_still_pages_down() {
217        assert_eq!(translate(key(KeyCode::Char('f'), KeyModifiers::NONE)), Command::PageDown);
218    }
219
220    #[test]
221    fn slash_opens_forward_search() {
222        assert_eq!(translate(key(KeyCode::Char('/'), KeyModifiers::NONE)), Command::SearchForward);
223    }
224
225    #[test]
226    fn question_mark_opens_backward_search() {
227        // `?` arrives as Char('?') with SHIFT on most layouts.
228        assert_eq!(translate(key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Command::SearchBackward);
229    }
230
231    #[test]
232    fn n_repeats_match_forward() {
233        assert_eq!(translate(key(KeyCode::Char('n'), KeyModifiers::NONE)), Command::NextMatch);
234    }
235
236    #[test]
237    fn capital_n_repeats_match_backward() {
238        assert_eq!(translate(key(KeyCode::Char('N'), KeyModifiers::SHIFT)), Command::PreviousMatch);
239    }
240
241    #[test]
242    fn capital_r_triggers_reload() {
243        assert_eq!(translate(key(KeyCode::Char('R'), KeyModifiers::SHIFT)), Command::Reload);
244    }
245
246    #[test]
247    fn lowercase_r_still_refreshes() {
248        assert_eq!(translate(key(KeyCode::Char('r'), KeyModifiers::NONE)), Command::Refresh);
249    }
250
251    #[test]
252    fn capital_p_toggles_prettify() {
253        assert_eq!(translate(key(KeyCode::Char('P'), KeyModifiers::SHIFT)), Command::TogglePrettify);
254    }
255
256    #[test]
257    fn lowercase_p_remains_unbound() {
258        assert_eq!(translate(key(KeyCode::Char('p'), KeyModifiers::NONE)), Command::Noop);
259    }
260
261    #[test]
262    fn dash_is_option_prefix() {
263        assert_eq!(translate(key(KeyCode::Char('-'), KeyModifiers::NONE)), Command::OptionPrefix);
264    }
265
266    #[test]
267    fn resize_event() {
268        assert_eq!(translate(Event::Resize(80, 24)), Command::Resize(80, 24));
269    }
270
271    #[test]
272    fn m_key_produces_mark_set_command() {
273        let evt = key(KeyCode::Char('m'), KeyModifiers::NONE);
274        assert_eq!(translate(evt), Command::MarkSet);
275    }
276
277    #[test]
278    fn single_quote_key_produces_mark_jump_command() {
279        let evt = key(KeyCode::Char('\''), KeyModifiers::NONE);
280        assert_eq!(translate(evt), Command::MarkJump);
281    }
282
283    #[test]
284    fn ctrl_x_produces_ctrl_x_prefix_command() {
285        let evt = key(KeyCode::Char('x'), KeyModifiers::CONTROL);
286        assert_eq!(translate(evt), Command::CtrlXPrefix);
287    }
288
289    #[test]
290    fn bang_produces_shell_escape_command() {
291        let evt = key(KeyCode::Char('!'), KeyModifiers::NONE);
292        assert_eq!(translate(evt), Command::ShellEscape);
293    }
294
295    #[test]
296    fn colon_produces_colon_prompt_command() {
297        let evt = Event::Key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE));
298        assert_eq!(translate(evt), Command::ColonPrompt);
299    }
300
301    #[test]
302    fn ctrl_close_bracket_produces_tag_prompt() {
303        let evt = Event::Key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
304        assert_eq!(translate(evt), Command::TagPrompt);
305    }
306
307    #[test]
308    fn ctrl_t_produces_tag_pop() {
309        let evt = Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
310        assert_eq!(translate(evt), Command::TagPop);
311    }
312}