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