Skip to main content

tui/
keymap.rs

1//! Key-event translation: crossterm [`KeyEvent`] → [`Intent`].
2//!
3//! Translation depends on app state: when the command line is active
4//! we route to either vim-normal or emacs/insert handlers; when a
5//! modal is open we only accept the "close topmost" keys; otherwise
6//! tab-level shortcuts apply.
7
8use crate::app::{App, Tab};
9use crate::event::Intent;
10use crate::widgets::{EditMode, VimAction, VimMode};
11use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12
13#[must_use]
14pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
15    if app.command_line_active {
16        if app.command_line.mode() == EditMode::Vim
17            && app.command_line.vim_mode() == VimMode::Normal
18        {
19            return translate_vim_normal(key);
20        }
21        return translate_cmdline_insert(key);
22    }
23    if app.console_input_active {
24        return translate_console_insert(key);
25    }
26    if !app.modals.is_empty() {
27        return translate_modal(key);
28    }
29    translate_tab(app, key)
30}
31
32fn translate_tab(app: &App, key: KeyEvent) -> Option<Intent> {
33    match key.code {
34        KeyCode::Char('q') => Some(Intent::Quit),
35        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
36        KeyCode::Char('?') => Some(Intent::OpenHelp),
37        KeyCode::Tab => Some(Intent::NextTab),
38        KeyCode::BackTab => Some(Intent::PreviousTab),
39        KeyCode::Char('1') => Some(Intent::SelectTab(Tab::Accounts)),
40        KeyCode::Char('2') => Some(Intent::SelectTab(Tab::Transactions)),
41        KeyCode::Char('3') => Some(Intent::SelectTab(Tab::Commodities)),
42        KeyCode::Char('4') => Some(Intent::SelectTab(Tab::Reports)),
43        KeyCode::Char('5') => Some(Intent::SelectTab(Tab::Config)),
44        KeyCode::Char('6') => Some(Intent::SelectTab(Tab::Console)),
45        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
46            Some(Intent::ToggleEditMode)
47        }
48        KeyCode::Char('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
49            Some(Intent::ConsoleFocus)
50        }
51        _ => None,
52    }
53}
54
55/// Translate keys while the console input is focused. The console is an
56/// Emacs-only line editor (see [`crate::tabs::nms::ConsoleState::new`]):
57/// there is no vim-normal routing, so every key is the insert layer with
58/// console-specific submit / blur / interrupt / history bindings.
59fn translate_console_insert(key: KeyEvent) -> Option<Intent> {
60    match key.code {
61        KeyCode::Esc => Some(Intent::ConsoleBlur),
62        KeyCode::Enter => Some(Intent::ConsoleSubmit),
63        KeyCode::Up => Some(Intent::ConsoleHistoryPrev),
64        KeyCode::Down => Some(Intent::ConsoleHistoryNext),
65        KeyCode::Backspace => Some(Intent::DeleteBackward),
66        KeyCode::Left => Some(Intent::MoveLeft),
67        KeyCode::Right => Some(Intent::MoveRight),
68        KeyCode::Home => Some(Intent::MoveHome),
69        KeyCode::End => Some(Intent::MoveEnd),
70        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
71            Some(Intent::ConsoleInterrupt)
72        }
73        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
74            Some(Intent::MoveHome)
75        }
76        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
77            Some(Intent::MoveEnd)
78        }
79        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
80            Some(Intent::KillToEnd)
81        }
82        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
83            Some(Intent::KillWordBackward)
84        }
85        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
86        _ => None,
87    }
88}
89
90fn translate_modal(key: KeyEvent) -> Option<Intent> {
91    match key.code {
92        KeyCode::Esc | KeyCode::Char('q') => Some(Intent::CloseTopmost),
93        _ => None,
94    }
95}
96
97fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
98    match key.code {
99        KeyCode::Esc => Some(Intent::CloseTopmost),
100        KeyCode::Enter => Some(Intent::SubmitCommandLine),
101        KeyCode::Backspace => Some(Intent::DeleteBackward),
102        KeyCode::Left => Some(Intent::MoveLeft),
103        KeyCode::Right => Some(Intent::MoveRight),
104        KeyCode::Home => Some(Intent::MoveHome),
105        KeyCode::End => Some(Intent::MoveEnd),
106        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
107            Some(Intent::MoveHome)
108        }
109        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
110            Some(Intent::MoveEnd)
111        }
112        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
113            Some(Intent::KillToEnd)
114        }
115        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
116            Some(Intent::KillWordBackward)
117        }
118        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
119        _ => None,
120    }
121}
122
123fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
124    match key.code {
125        KeyCode::Esc => Some(Intent::CloseTopmost),
126        KeyCode::Enter => Some(Intent::SubmitCommandLine),
127        KeyCode::Char('h') => Some(Intent::Vim(VimAction::MoveLeft)),
128        KeyCode::Char('l') => Some(Intent::Vim(VimAction::MoveRight)),
129        KeyCode::Char('0') => Some(Intent::Vim(VimAction::MoveHome)),
130        KeyCode::Char('$') => Some(Intent::Vim(VimAction::MoveEnd)),
131        KeyCode::Char('w') => Some(Intent::Vim(VimAction::WordForward)),
132        KeyCode::Char('b') => Some(Intent::Vim(VimAction::WordBackward)),
133        KeyCode::Char('x') => Some(Intent::Vim(VimAction::DeleteChar)),
134        KeyCode::Char('i') => Some(Intent::Vim(VimAction::InsertAtCursor)),
135        KeyCode::Char('a') => Some(Intent::Vim(VimAction::InsertAfterCursor)),
136        KeyCode::Char('I') => Some(Intent::Vim(VimAction::InsertAtLineStart)),
137        KeyCode::Char('A') => Some(Intent::Vim(VimAction::InsertAtLineEnd)),
138        // `dw`/`db` are two-keystroke in vim but we map them directly
139        // to `D` (delete-word-forward) and `B` (delete-word-backward)
140        // so the editor engine stays stateless. Callers wanting the
141        // two-keystroke form can add a chord layer later.
142        KeyCode::Char('D') => Some(Intent::Vim(VimAction::DeleteWordForward)),
143        KeyCode::Char('B') => Some(Intent::Vim(VimAction::DeleteWordBackward)),
144        _ => None,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::widgets::EditMode;
152    use sqlx::types::Uuid;
153
154    fn key(code: KeyCode) -> KeyEvent {
155        KeyEvent::new(code, KeyModifiers::NONE)
156    }
157
158    fn ctrl(code: KeyCode) -> KeyEvent {
159        KeyEvent::new(code, KeyModifiers::CONTROL)
160    }
161
162    fn app() -> App {
163        App::new(Uuid::new_v4(), EditMode::Emacs)
164    }
165
166    #[test]
167    fn tab_layer_handles_digit_shortcuts() {
168        assert_eq!(
169            translate(&app(), key(KeyCode::Char('1'))),
170            Some(Intent::SelectTab(Tab::Accounts))
171        );
172    }
173
174    #[test]
175    fn tab_layer_handles_tab_key() {
176        assert_eq!(translate(&app(), key(KeyCode::Tab)), Some(Intent::NextTab));
177    }
178
179    #[test]
180    fn modal_layer_only_accepts_close() {
181        let mut a = app();
182        a.modals.push(crate::modal::Modal::Help);
183        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
184        assert_eq!(translate(&a, key(KeyCode::Char('1'))), None);
185    }
186
187    #[test]
188    fn cmdline_insert_accepts_ctrl_a_as_home() {
189        let mut a = app();
190        a.open_command_line();
191        assert_eq!(
192            translate(&a, ctrl(KeyCode::Char('a'))),
193            Some(Intent::MoveHome)
194        );
195    }
196
197    #[test]
198    fn cmdline_insert_accepts_plain_characters() {
199        let mut a = app();
200        a.open_command_line();
201        assert_eq!(
202            translate(&a, key(KeyCode::Char('q'))),
203            Some(Intent::InsertChar('q'))
204        );
205    }
206
207    #[test]
208    fn cmdline_vim_normal_routes_motion_keys() {
209        let mut a = app();
210        a.set_edit_mode(EditMode::Vim);
211        a.open_command_line();
212        a.command_line.enter_normal_mode();
213        assert_eq!(
214            translate(&a, key(KeyCode::Char('h'))),
215            Some(Intent::Vim(VimAction::MoveLeft))
216        );
217        assert_eq!(
218            translate(&a, key(KeyCode::Char('$'))),
219            Some(Intent::Vim(VimAction::MoveEnd))
220        );
221    }
222
223    #[test]
224    fn unknown_key_returns_none() {
225        assert_eq!(translate(&app(), key(KeyCode::F(12))), None);
226    }
227
228    #[test]
229    fn console_tab_unfocused_focuses_on_i_and_enter() {
230        let mut a = app();
231        a.active_tab = Tab::Console;
232        assert_eq!(
233            translate(&a, key(KeyCode::Char('i'))),
234            Some(Intent::ConsoleFocus)
235        );
236        assert_eq!(
237            translate(&a, key(KeyCode::Enter)),
238            Some(Intent::ConsoleFocus)
239        );
240    }
241
242    #[test]
243    fn console_tab_unfocused_still_navigates() {
244        let mut a = app();
245        a.active_tab = Tab::Console;
246        assert_eq!(translate(&a, key(KeyCode::Char('q'))), Some(Intent::Quit));
247        assert_eq!(translate(&a, key(KeyCode::Tab)), Some(Intent::NextTab));
248        assert_eq!(
249            translate(&a, key(KeyCode::Char('1'))),
250            Some(Intent::SelectTab(Tab::Accounts))
251        );
252        assert_eq!(
253            translate(&a, key(KeyCode::Char('6'))),
254            Some(Intent::SelectTab(Tab::Console))
255        );
256        assert_eq!(
257            translate(&a, key(KeyCode::Char(':'))),
258            Some(Intent::OpenCommandLine)
259        );
260    }
261
262    #[test]
263    fn focus_keys_are_inert_on_other_tabs() {
264        let mut a = app();
265        a.active_tab = Tab::Accounts;
266        assert_eq!(translate(&a, key(KeyCode::Char('i'))), None);
267        assert_eq!(translate(&a, key(KeyCode::Enter)), None);
268    }
269
270    #[test]
271    fn console_focused_ctrl_c_maps_to_interrupt() {
272        let mut a = app();
273        a.console_input_active = true;
274        assert_eq!(
275            translate(&a, ctrl(KeyCode::Char('c'))),
276            Some(Intent::ConsoleInterrupt)
277        );
278    }
279
280    #[test]
281    fn console_stays_emacs_even_after_vim_toggle() {
282        let mut a = app();
283        a.set_edit_mode(EditMode::Vim);
284        a.console_input_active = true;
285        // The console input editor is built Emacs-only, so a vim motion
286        // key is a literal insert here (never a vim-normal action) and
287        // Esc blurs the console rather than entering vim-normal.
288        assert_eq!(a.console.input.mode(), EditMode::Emacs);
289        assert_eq!(
290            translate(&a, key(KeyCode::Char('h'))),
291            Some(Intent::InsertChar('h'))
292        );
293        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::ConsoleBlur));
294    }
295
296    #[test]
297    fn console_focused_routes_editing_and_control_keys() {
298        let mut a = app();
299        a.console_input_active = true;
300        assert_eq!(
301            translate(&a, key(KeyCode::Char('x'))),
302            Some(Intent::InsertChar('x'))
303        );
304        assert_eq!(
305            translate(&a, key(KeyCode::Enter)),
306            Some(Intent::ConsoleSubmit)
307        );
308        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::ConsoleBlur));
309        assert_eq!(
310            translate(&a, ctrl(KeyCode::Char('c'))),
311            Some(Intent::ConsoleInterrupt)
312        );
313        assert_eq!(
314            translate(&a, key(KeyCode::Up)),
315            Some(Intent::ConsoleHistoryPrev)
316        );
317        assert_eq!(
318            translate(&a, key(KeyCode::Down)),
319            Some(Intent::ConsoleHistoryNext)
320        );
321        assert_eq!(
322            translate(&a, key(KeyCode::Backspace)),
323            Some(Intent::DeleteBackward)
324        );
325    }
326}