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    /// `Tab` — in split view, swap which pane is focused. No-op single-pane.
115    FocusOtherPane,
116    Noop,
117}
118
119pub fn translate(event: Event) -> Command {
120    match event {
121        Event::Resize(c, r) => Command::Resize(c, r),
122        Event::Key(KeyEvent { code, modifiers, .. }) => translate_key(code, modifiers),
123        Event::Mouse(m) => Command::MouseEvent(m),
124        _ => Command::Noop,
125    }
126}
127
128fn translate_key(code: KeyCode, mods: KeyModifiers) -> Command {
129    use KeyCode::*;
130    let ctrl = mods.contains(KeyModifiers::CONTROL);
131    match (code, ctrl) {
132        (Char('q'), false) | (Char('Q'), false) => Command::Quit,
133        (Char('c'), true) => Command::Quit,
134        (Down, _) | (Char('j'), false) | (Char('e'), false) | (Char('e'), true) | (Enter, _) => Command::ScrollLines(1),
135        (Char('y'), false) | (Char('y'), true) | (Up, _) | (Char('k'), false) => Command::ScrollLines(-1),
136        (Char('J'), false) => Command::ScrollLogicalLines(1),
137        (Char('K'), false) => Command::ScrollLogicalLines(-1),
138        (Char(' '), false) | (Char('f'), false) | (Char('f'), true) | (PageDown, _) => Command::PageDown,
139        (Char('b'), false) | (Char('b'), true) | (PageUp, _) => Command::PageUp,
140        (Char('d'), false) | (Char('d'), true) => Command::HalfPageDown,
141        (Char('u'), false) | (Char('u'), true) => Command::HalfPageUp,
142        (Char('0'), false) => Command::Digit(0),
143        (Char('1'), false) => Command::Digit(1),
144        (Char('2'), false) => Command::Digit(2),
145        (Char('3'), false) => Command::Digit(3),
146        (Char('4'), false) => Command::Digit(4),
147        (Char('5'), false) => Command::Digit(5),
148        (Char('6'), false) => Command::Digit(6),
149        (Char('7'), false) => Command::Digit(7),
150        (Char('8'), false) => Command::Digit(8),
151        (Char('9'), false) => Command::Digit(9),
152        (Char('g'), false) | (Char('<'), false) | (Home, _) => Command::GotoLine,
153        (Char('G'), false) | (Char('>'), false) | (End, _) => Command::GotoRecord,
154        (Char('%'), false) => Command::GotoPercent,
155        (Esc, _) => Command::Cancel,
156        (Char('r'), false) | (Char('l'), true) => Command::Refresh,
157        (Char('R'), false) => Command::Reload,
158        (Char('P'), false) => Command::TogglePrettify,
159        (Char('-'), false) => Command::OptionPrefix,
160        (Char('F'), false) => Command::ToggleFollow,
161        (Char('/'), false) => Command::SearchForward,
162        (Char('?'), false) => Command::SearchBackward,
163        (Char('n'), false) => Command::NextMatch,
164        (Char('N'), false) => Command::PreviousMatch,
165        (Char('m'), false) => Command::MarkSet,
166        (Char('\''), false) => Command::MarkJump,
167        (Char('!'), false) => Command::ShellEscape,
168        (Char('x'), true) => Command::CtrlXPrefix,
169        (Char(':'), false) => Command::ColonPrompt,
170        (Char(']'), true) => Command::TagPrompt,
171        (Char('t'), true) => Command::TagPop,
172        (F(1), _) => Command::OpenHelp,
173        (Left, false) if mods.contains(KeyModifiers::SHIFT) => Command::HScrollLeftStep,
174        (Right, false) if mods.contains(KeyModifiers::SHIFT) => Command::HScrollRightStep,
175        (Left, false) => Command::HScrollLeft,
176        (Right, false) => Command::HScrollRight,
177        (Char('p'), false) => Command::AnimPause,
178        (Char('.'), false) => Command::AnimStepForward,
179        (Char(','), false) => Command::AnimStepBack,
180        (Backspace, _) => Command::AnimRestart,
181        (Tab, _) => Command::FocusOtherPane,
182        _ => Command::Noop,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crossterm::event::{KeyCode, KeyEventKind, KeyEventState};
190
191    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
192        Event::Key(KeyEvent {
193            code, modifiers: mods,
194            kind: KeyEventKind::Press, state: KeyEventState::NONE,
195        })
196    }
197
198    #[test]
199    fn arrow_down_scrolls_one() {
200        assert_eq!(translate(key(KeyCode::Down, KeyModifiers::NONE)), Command::ScrollLines(1));
201    }
202
203    #[test]
204    fn j_scrolls_one() {
205        assert_eq!(translate(key(KeyCode::Char('j'), KeyModifiers::NONE)), Command::ScrollLines(1));
206    }
207
208    #[test]
209    fn space_pages_down() {
210        assert_eq!(translate(key(KeyCode::Char(' '), KeyModifiers::NONE)), Command::PageDown);
211    }
212
213    #[test]
214    fn ctrl_c_quits() {
215        assert_eq!(translate(key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Command::Quit);
216    }
217
218    #[test]
219    fn capital_g_goes_to_record() {
220        assert_eq!(translate(key(KeyCode::Char('G'), KeyModifiers::SHIFT)), Command::GotoRecord);
221    }
222
223    #[test]
224    fn lowercase_g_goes_to_line() {
225        assert_eq!(translate(key(KeyCode::Char('g'), KeyModifiers::NONE)), Command::GotoLine);
226    }
227
228    #[test]
229    fn percent_goes_to_percent() {
230        assert_eq!(translate(key(KeyCode::Char('%'), KeyModifiers::NONE)), Command::GotoPercent);
231    }
232
233    #[test]
234    fn digit_keys_produce_digit_commands() {
235        for d in 0u8..=9 {
236            let ch = char::from_digit(d as u32, 10).unwrap();
237            assert_eq!(
238                translate(key(KeyCode::Char(ch), KeyModifiers::NONE)),
239                Command::Digit(d),
240            );
241        }
242    }
243
244    #[test]
245    fn anim_keys_translate() {
246        assert_eq!(translate(key(KeyCode::Char('p'), KeyModifiers::NONE)), Command::AnimPause);
247        assert_eq!(translate(key(KeyCode::Char('.'), KeyModifiers::NONE)), Command::AnimStepForward);
248        assert_eq!(translate(key(KeyCode::Char(','), KeyModifiers::NONE)), Command::AnimStepBack);
249        assert_eq!(translate(key(KeyCode::Backspace, KeyModifiers::NONE)), Command::AnimRestart);
250    }
251
252    #[test]
253    fn esc_produces_cancel() {
254        assert_eq!(translate(key(KeyCode::Esc, KeyModifiers::NONE)), Command::Cancel);
255    }
256
257    #[test]
258    fn capital_j_jumps_one_logical_line_forward() {
259        assert_eq!(translate(key(KeyCode::Char('J'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(1));
260    }
261
262    #[test]
263    fn capital_k_jumps_one_logical_line_backward() {
264        assert_eq!(translate(key(KeyCode::Char('K'), KeyModifiers::SHIFT)), Command::ScrollLogicalLines(-1));
265    }
266
267    #[test]
268    fn capital_f_toggles_follow() {
269        assert_eq!(translate(key(KeyCode::Char('F'), KeyModifiers::SHIFT)), Command::ToggleFollow);
270    }
271
272    #[test]
273    fn lowercase_f_still_pages_down() {
274        assert_eq!(translate(key(KeyCode::Char('f'), KeyModifiers::NONE)), Command::PageDown);
275    }
276
277    #[test]
278    fn slash_opens_forward_search() {
279        assert_eq!(translate(key(KeyCode::Char('/'), KeyModifiers::NONE)), Command::SearchForward);
280    }
281
282    #[test]
283    fn question_mark_opens_backward_search() {
284        // `?` arrives as Char('?') with SHIFT on most layouts.
285        assert_eq!(translate(key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Command::SearchBackward);
286    }
287
288    #[test]
289    fn n_repeats_match_forward() {
290        assert_eq!(translate(key(KeyCode::Char('n'), KeyModifiers::NONE)), Command::NextMatch);
291    }
292
293    #[test]
294    fn capital_n_repeats_match_backward() {
295        assert_eq!(translate(key(KeyCode::Char('N'), KeyModifiers::SHIFT)), Command::PreviousMatch);
296    }
297
298    #[test]
299    fn capital_r_triggers_reload() {
300        assert_eq!(translate(key(KeyCode::Char('R'), KeyModifiers::SHIFT)), Command::Reload);
301    }
302
303    #[test]
304    fn lowercase_r_still_refreshes() {
305        assert_eq!(translate(key(KeyCode::Char('r'), KeyModifiers::NONE)), Command::Refresh);
306    }
307
308    #[test]
309    fn capital_p_toggles_prettify() {
310        assert_eq!(translate(key(KeyCode::Char('P'), KeyModifiers::SHIFT)), Command::TogglePrettify);
311    }
312
313    #[test]
314    fn lowercase_p_is_anim_pause() {
315        assert_eq!(translate(key(KeyCode::Char('p'), KeyModifiers::NONE)), Command::AnimPause);
316    }
317
318    #[test]
319    fn dash_is_option_prefix() {
320        assert_eq!(translate(key(KeyCode::Char('-'), KeyModifiers::NONE)), Command::OptionPrefix);
321    }
322
323    #[test]
324    fn resize_event() {
325        assert_eq!(translate(Event::Resize(80, 24)), Command::Resize(80, 24));
326    }
327
328    #[test]
329    fn m_key_produces_mark_set_command() {
330        let evt = key(KeyCode::Char('m'), KeyModifiers::NONE);
331        assert_eq!(translate(evt), Command::MarkSet);
332    }
333
334    #[test]
335    fn single_quote_key_produces_mark_jump_command() {
336        let evt = key(KeyCode::Char('\''), KeyModifiers::NONE);
337        assert_eq!(translate(evt), Command::MarkJump);
338    }
339
340    #[test]
341    fn ctrl_x_produces_ctrl_x_prefix_command() {
342        let evt = key(KeyCode::Char('x'), KeyModifiers::CONTROL);
343        assert_eq!(translate(evt), Command::CtrlXPrefix);
344    }
345
346    #[test]
347    fn bang_produces_shell_escape_command() {
348        let evt = key(KeyCode::Char('!'), KeyModifiers::NONE);
349        assert_eq!(translate(evt), Command::ShellEscape);
350    }
351
352    #[test]
353    fn colon_produces_colon_prompt_command() {
354        let evt = Event::Key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE));
355        assert_eq!(translate(evt), Command::ColonPrompt);
356    }
357
358    #[test]
359    fn ctrl_close_bracket_produces_tag_prompt() {
360        let evt = Event::Key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
361        assert_eq!(translate(evt), Command::TagPrompt);
362    }
363
364    #[test]
365    fn ctrl_t_produces_tag_pop() {
366        let evt = Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
367        assert_eq!(translate(evt), Command::TagPop);
368    }
369
370    #[test]
371    fn f1_opens_help() {
372        let evt = key(KeyCode::F(1), KeyModifiers::NONE);
373        assert_eq!(translate(evt), Command::OpenHelp);
374    }
375
376    #[test]
377    fn tab_is_focus_other_pane() {
378        assert_eq!(translate(key(KeyCode::Tab, KeyModifiers::NONE)), Command::FocusOtherPane);
379    }
380
381    #[test]
382    fn arrows_translate_to_hscroll() {
383        assert_eq!(translate(key(KeyCode::Right, KeyModifiers::NONE)), Command::HScrollRight);
384        assert_eq!(translate(key(KeyCode::Left, KeyModifiers::NONE)), Command::HScrollLeft);
385        assert_eq!(translate(key(KeyCode::Right, KeyModifiers::SHIFT)), Command::HScrollRightStep);
386        assert_eq!(translate(key(KeyCode::Left, KeyModifiers::SHIFT)), Command::HScrollLeftStep);
387    }
388}