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