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    HScrollLeft,
17    HScrollRight,
18    HScrollLeftStep,
19    HScrollRightStep,
20    Quit,
21    Resize(u16, u16),
22    Refresh,
23    ToggleLineNumbers,
24    ToggleChop,
25    ToggleFollow,
26    /// `/` — open the forward-search prompt.
27    SearchForward,
28    /// `?` — open the backward-search prompt.
29    SearchBackward,
30    /// `n` — repeat the last search in its original direction.
31    NextMatch,
32    /// `N` — repeat the last search in the opposite direction.
33    PreviousMatch,
34    /// `-` — option-toggle prefix: the next key chooses an option to flip
35    /// (`N` → line numbers, `S` → chop, `F` → follow).
36    OptionPrefix,
37    /// `R` — force-reload the source from disk now (only meaningful with
38    /// `--live`; no-op for static file sources and append-streaming follow).
39    Reload,
40    /// `Shift-P` — toggle pretty-printing on/off (cycles back to the last
41    /// active mode if currently off).
42    TogglePrettify,
43    /// Set a specific prettify mode (issued by the `-P<letter>` sub-prefix
44    /// after the user picks j/y/t/x/h/c).
45    SetPrettifyMode(PrettifyMode),
46    /// Re-run byte-based content detection and apply the result (`-Pa`).
47    RedetectPrettify,
48    /// A digit (0-9) was pressed. The app accumulates these into a numeric
49    /// prefix that the next non-digit command consumes.
50    Digit(u8),
51    /// Jump to physical line N (1-indexed). Without a prefix, behaves as
52    /// goto-top.
53    GotoLine,
54    /// Jump to record N (1-indexed). Without a prefix, behaves as
55    /// goto-bottom (preserves the existing bare-`G` behavior).
56    GotoRecord,
57    /// Jump to N percent through the file by bytes. Without a prefix,
58    /// behaves as goto-top.
59    GotoPercent,
60    /// Cancel any pending numeric prefix without firing a command.
61    Cancel,
62    /// First half of a set-mark sequence (the `m` key). The next keystroke
63    /// names the mark.
64    MarkSet,
65    /// First half of a jump-to-mark sequence (the `'` key). The next
66    /// keystroke names the mark.
67    MarkJump,
68    /// First half of the `Ctrl-X Ctrl-X` jump-to-previous-position chord.
69    /// The next keystroke must also be Ctrl-X.
70    CtrlXPrefix,
71    /// Jump to the previous position (Ctrl-X Ctrl-X in less). Dispatched
72    /// from the CtrlXPending mode intercept in app.rs.
73    JumpPrevious,
74    /// Enter the !cmd shell-escape prompt.
75    ShellEscape,
76    /// Enter the :colon-command prompt.
77    ColonPrompt,
78    /// Enter the tag-name prompt (`Ctrl-]`).
79    TagPrompt,
80    /// Pop the tag stack and jump back (`Ctrl-T`).
81    TagPop,
82    /// `:b` — open the file picker overlay.
83    OpenPicker,
84    /// `:tselect` — open the tag-match picker overlay. Caller must
85    /// pre-populate the active TagStack match list before dispatch.
86    OpenTagPicker,
87    /// `:help` or `F1` — open the help overlay.
88    OpenHelp,
89    /// Issued by the file picker when the user selects a file. The
90    /// argument is the index into the working FileSet.
91    SelectFile(usize),
92    /// Issued by the file picker when Ctrl-D removes a file. The
93    /// argument is the index into the working FileSet.
94    DropFileAt(usize),
95    /// Issued by the `:tselect` tag-picker when the user selects a match.
96    /// The argument is the index into the currently-active TagStack matches.
97    SelectTagMatch(usize),
98    /// Mouse event surfaced to the app loop. Translation to a concrete
99    /// scroll command happens in `app::run` based on whether an overlay
100    /// is active and on which axis the event was.
101    MouseEvent(crossterm::event::MouseEvent),
102    Noop,
103}
104
105pub fn translate(event: Event) -> Command {
106    match event {
107        Event::Resize(c, r) => Command::Resize(c, r),
108        Event::Key(KeyEvent { code, modifiers, .. }) => translate_key(code, modifiers),
109        Event::Mouse(m) => Command::MouseEvent(m),
110        _ => Command::Noop,
111    }
112}
113
114fn translate_key(code: KeyCode, mods: KeyModifiers) -> Command {
115    use KeyCode::*;
116    let ctrl = mods.contains(KeyModifiers::CONTROL);
117    match (code, ctrl) {
118        (Char('q'), false) | (Char('Q'), false) => Command::Quit,
119        (Char('c'), true) => Command::Quit,
120        (Down, _) | (Char('j'), false) | (Char('e'), false) | (Char('e'), true) | (Enter, _) => Command::ScrollLines(1),
121        (Char('y'), false) | (Char('y'), true) | (Up, _) | (Char('k'), false) => Command::ScrollLines(-1),
122        (Char('J'), false) => Command::ScrollLogicalLines(1),
123        (Char('K'), false) => Command::ScrollLogicalLines(-1),
124        (Char(' '), false) | (Char('f'), false) | (Char('f'), true) | (PageDown, _) => Command::PageDown,
125        (Char('b'), false) | (Char('b'), true) | (PageUp, _) => Command::PageUp,
126        (Char('d'), false) | (Char('d'), true) => Command::HalfPageDown,
127        (Char('u'), false) | (Char('u'), true) => Command::HalfPageUp,
128        (Char('0'), false) => Command::Digit(0),
129        (Char('1'), false) => Command::Digit(1),
130        (Char('2'), false) => Command::Digit(2),
131        (Char('3'), false) => Command::Digit(3),
132        (Char('4'), false) => Command::Digit(4),
133        (Char('5'), false) => Command::Digit(5),
134        (Char('6'), false) => Command::Digit(6),
135        (Char('7'), false) => Command::Digit(7),
136        (Char('8'), false) => Command::Digit(8),
137        (Char('9'), false) => Command::Digit(9),
138        (Char('g'), false) | (Char('<'), false) | (Home, _) => Command::GotoLine,
139        (Char('G'), false) | (Char('>'), false) | (End, _) => Command::GotoRecord,
140        (Char('%'), false) => Command::GotoPercent,
141        (Esc, _) => Command::Cancel,
142        (Char('r'), false) | (Char('l'), true) => Command::Refresh,
143        (Char('R'), false) => Command::Reload,
144        (Char('P'), false) => Command::TogglePrettify,
145        (Char('-'), false) => Command::OptionPrefix,
146        (Char('F'), false) => Command::ToggleFollow,
147        (Char('/'), false) => Command::SearchForward,
148        (Char('?'), false) => Command::SearchBackward,
149        (Char('n'), false) => Command::NextMatch,
150        (Char('N'), false) => Command::PreviousMatch,
151        (Char('m'), false) => Command::MarkSet,
152        (Char('\''), false) => Command::MarkJump,
153        (Char('!'), false) => Command::ShellEscape,
154        (Char('x'), true) => Command::CtrlXPrefix,
155        (Char(':'), false) => Command::ColonPrompt,
156        (Char(']'), true) => Command::TagPrompt,
157        (Char('t'), true) => Command::TagPop,
158        (F(1), _) => Command::OpenHelp,
159        (Left, false) if mods.contains(KeyModifiers::SHIFT) => Command::HScrollLeftStep,
160        (Right, false) if mods.contains(KeyModifiers::SHIFT) => Command::HScrollRightStep,
161        (Left, false) => Command::HScrollLeft,
162        (Right, false) => Command::HScrollRight,
163        _ => Command::Noop,
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crossterm::event::{KeyCode, KeyEventKind, KeyEventState};
171
172    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
173        Event::Key(KeyEvent {
174            code, modifiers: mods,
175            kind: KeyEventKind::Press, state: KeyEventState::NONE,
176        })
177    }
178
179    #[test]
180    fn arrow_down_scrolls_one() {
181        assert_eq!(translate(key(KeyCode::Down, KeyModifiers::NONE)), Command::ScrollLines(1));
182    }
183
184    #[test]
185    fn j_scrolls_one() {
186        assert_eq!(translate(key(KeyCode::Char('j'), KeyModifiers::NONE)), Command::ScrollLines(1));
187    }
188
189    #[test]
190    fn space_pages_down() {
191        assert_eq!(translate(key(KeyCode::Char(' '), KeyModifiers::NONE)), Command::PageDown);
192    }
193
194    #[test]
195    fn ctrl_c_quits() {
196        assert_eq!(translate(key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Command::Quit);
197    }
198
199    #[test]
200    fn capital_g_goes_to_record() {
201        assert_eq!(translate(key(KeyCode::Char('G'), KeyModifiers::SHIFT)), Command::GotoRecord);
202    }
203
204    #[test]
205    fn lowercase_g_goes_to_line() {
206        assert_eq!(translate(key(KeyCode::Char('g'), KeyModifiers::NONE)), Command::GotoLine);
207    }
208
209    #[test]
210    fn percent_goes_to_percent() {
211        assert_eq!(translate(key(KeyCode::Char('%'), KeyModifiers::NONE)), Command::GotoPercent);
212    }
213
214    #[test]
215    fn digit_keys_produce_digit_commands() {
216        for d in 0u8..=9 {
217            let ch = char::from_digit(d as u32, 10).unwrap();
218            assert_eq!(
219                translate(key(KeyCode::Char(ch), KeyModifiers::NONE)),
220                Command::Digit(d),
221            );
222        }
223    }
224
225    #[test]
226    fn esc_produces_cancel() {
227        assert_eq!(translate(key(KeyCode::Esc, KeyModifiers::NONE)), Command::Cancel);
228    }
229
230    #[test]
231    fn capital_j_jumps_one_logical_line_forward() {
232        assert_eq!(translate(key(KeyCode::Char('J'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(1));
233    }
234
235    #[test]
236    fn capital_k_jumps_one_logical_line_backward() {
237        assert_eq!(translate(key(KeyCode::Char('K'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(-1));
238    }
239
240    #[test]
241    fn capital_f_toggles_follow() {
242        assert_eq!(translate(key(KeyCode::Char('F'), KeyModifiers::SHIFT)), Command::ToggleFollow);
243    }
244
245    #[test]
246    fn lowercase_f_still_pages_down() {
247        assert_eq!(translate(key(KeyCode::Char('f'), KeyModifiers::NONE)), Command::PageDown);
248    }
249
250    #[test]
251    fn slash_opens_forward_search() {
252        assert_eq!(translate(key(KeyCode::Char('/'), KeyModifiers::NONE)), Command::SearchForward);
253    }
254
255    #[test]
256    fn question_mark_opens_backward_search() {
257        // `?` arrives as Char('?') with SHIFT on most layouts.
258        assert_eq!(translate(key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Command::SearchBackward);
259    }
260
261    #[test]
262    fn n_repeats_match_forward() {
263        assert_eq!(translate(key(KeyCode::Char('n'), KeyModifiers::NONE)), Command::NextMatch);
264    }
265
266    #[test]
267    fn capital_n_repeats_match_backward() {
268        assert_eq!(translate(key(KeyCode::Char('N'), KeyModifiers::SHIFT)), Command::PreviousMatch);
269    }
270
271    #[test]
272    fn capital_r_triggers_reload() {
273        assert_eq!(translate(key(KeyCode::Char('R'), KeyModifiers::SHIFT)), Command::Reload);
274    }
275
276    #[test]
277    fn lowercase_r_still_refreshes() {
278        assert_eq!(translate(key(KeyCode::Char('r'), KeyModifiers::NONE)), Command::Refresh);
279    }
280
281    #[test]
282    fn capital_p_toggles_prettify() {
283        assert_eq!(translate(key(KeyCode::Char('P'), KeyModifiers::SHIFT)), Command::TogglePrettify);
284    }
285
286    #[test]
287    fn lowercase_p_remains_unbound() {
288        assert_eq!(translate(key(KeyCode::Char('p'), KeyModifiers::NONE)), Command::Noop);
289    }
290
291    #[test]
292    fn dash_is_option_prefix() {
293        assert_eq!(translate(key(KeyCode::Char('-'), KeyModifiers::NONE)), Command::OptionPrefix);
294    }
295
296    #[test]
297    fn resize_event() {
298        assert_eq!(translate(Event::Resize(80, 24)), Command::Resize(80, 24));
299    }
300
301    #[test]
302    fn m_key_produces_mark_set_command() {
303        let evt = key(KeyCode::Char('m'), KeyModifiers::NONE);
304        assert_eq!(translate(evt), Command::MarkSet);
305    }
306
307    #[test]
308    fn single_quote_key_produces_mark_jump_command() {
309        let evt = key(KeyCode::Char('\''), KeyModifiers::NONE);
310        assert_eq!(translate(evt), Command::MarkJump);
311    }
312
313    #[test]
314    fn ctrl_x_produces_ctrl_x_prefix_command() {
315        let evt = key(KeyCode::Char('x'), KeyModifiers::CONTROL);
316        assert_eq!(translate(evt), Command::CtrlXPrefix);
317    }
318
319    #[test]
320    fn bang_produces_shell_escape_command() {
321        let evt = key(KeyCode::Char('!'), KeyModifiers::NONE);
322        assert_eq!(translate(evt), Command::ShellEscape);
323    }
324
325    #[test]
326    fn colon_produces_colon_prompt_command() {
327        let evt = Event::Key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE));
328        assert_eq!(translate(evt), Command::ColonPrompt);
329    }
330
331    #[test]
332    fn ctrl_close_bracket_produces_tag_prompt() {
333        let evt = Event::Key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
334        assert_eq!(translate(evt), Command::TagPrompt);
335    }
336
337    #[test]
338    fn ctrl_t_produces_tag_pop() {
339        let evt = Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
340        assert_eq!(translate(evt), Command::TagPop);
341    }
342
343    #[test]
344    fn f1_opens_help() {
345        let evt = key(KeyCode::F(1), KeyModifiers::NONE);
346        assert_eq!(translate(evt), Command::OpenHelp);
347    }
348
349    #[test]
350    fn arrows_translate_to_hscroll() {
351        assert_eq!(translate(key(KeyCode::Right, KeyModifiers::NONE)), Command::HScrollRight);
352        assert_eq!(translate(key(KeyCode::Left, KeyModifiers::NONE)), Command::HScrollLeft);
353        assert_eq!(translate(key(KeyCode::Right, KeyModifiers::SHIFT)), Command::HScrollRightStep);
354        assert_eq!(translate(key(KeyCode::Left, KeyModifiers::SHIFT)), Command::HScrollLeftStep);
355    }
356}