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