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