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.modals.is_empty() {
24        return translate_modal(key);
25    }
26    translate_tab(key)
27}
28
29fn translate_tab(key: KeyEvent) -> Option<Intent> {
30    match key.code {
31        KeyCode::Char('q') => Some(Intent::Quit),
32        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
33        KeyCode::Char('?') => Some(Intent::OpenHelp),
34        KeyCode::Tab => Some(Intent::NextTab),
35        KeyCode::BackTab => Some(Intent::PreviousTab),
36        KeyCode::Char('1') => Some(Intent::SelectTab(Tab::Accounts)),
37        KeyCode::Char('2') => Some(Intent::SelectTab(Tab::Transactions)),
38        KeyCode::Char('3') => Some(Intent::SelectTab(Tab::Commodities)),
39        KeyCode::Char('4') => Some(Intent::SelectTab(Tab::Reports)),
40        KeyCode::Char('5') => Some(Intent::SelectTab(Tab::Config)),
41        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
42            Some(Intent::ToggleEditMode)
43        }
44        _ => None,
45    }
46}
47
48fn translate_modal(key: KeyEvent) -> Option<Intent> {
49    match key.code {
50        KeyCode::Esc | KeyCode::Char('q') => Some(Intent::CloseTopmost),
51        _ => None,
52    }
53}
54
55fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
56    match key.code {
57        KeyCode::Esc => Some(Intent::CloseTopmost),
58        KeyCode::Enter => Some(Intent::SubmitCommandLine),
59        KeyCode::Backspace => Some(Intent::DeleteBackward),
60        KeyCode::Left => Some(Intent::MoveLeft),
61        KeyCode::Right => Some(Intent::MoveRight),
62        KeyCode::Home => Some(Intent::MoveHome),
63        KeyCode::End => Some(Intent::MoveEnd),
64        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
65            Some(Intent::MoveHome)
66        }
67        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
68            Some(Intent::MoveEnd)
69        }
70        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
71            Some(Intent::KillToEnd)
72        }
73        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
74            Some(Intent::KillWordBackward)
75        }
76        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
77        _ => None,
78    }
79}
80
81fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
82    match key.code {
83        KeyCode::Esc => Some(Intent::CloseTopmost),
84        KeyCode::Enter => Some(Intent::SubmitCommandLine),
85        KeyCode::Char('h') => Some(Intent::Vim(VimAction::MoveLeft)),
86        KeyCode::Char('l') => Some(Intent::Vim(VimAction::MoveRight)),
87        KeyCode::Char('0') => Some(Intent::Vim(VimAction::MoveHome)),
88        KeyCode::Char('$') => Some(Intent::Vim(VimAction::MoveEnd)),
89        KeyCode::Char('w') => Some(Intent::Vim(VimAction::WordForward)),
90        KeyCode::Char('b') => Some(Intent::Vim(VimAction::WordBackward)),
91        KeyCode::Char('x') => Some(Intent::Vim(VimAction::DeleteChar)),
92        KeyCode::Char('i') => Some(Intent::Vim(VimAction::InsertAtCursor)),
93        KeyCode::Char('a') => Some(Intent::Vim(VimAction::InsertAfterCursor)),
94        KeyCode::Char('I') => Some(Intent::Vim(VimAction::InsertAtLineStart)),
95        KeyCode::Char('A') => Some(Intent::Vim(VimAction::InsertAtLineEnd)),
96        // `dw`/`db` are two-keystroke in vim but we map them directly
97        // to `D` (delete-word-forward) and `B` (delete-word-backward)
98        // so the editor engine stays stateless. Callers wanting the
99        // two-keystroke form can add a chord layer later.
100        KeyCode::Char('D') => Some(Intent::Vim(VimAction::DeleteWordForward)),
101        KeyCode::Char('B') => Some(Intent::Vim(VimAction::DeleteWordBackward)),
102        _ => None,
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::widgets::EditMode;
110    use sqlx::types::Uuid;
111
112    fn key(code: KeyCode) -> KeyEvent {
113        KeyEvent::new(code, KeyModifiers::NONE)
114    }
115
116    fn ctrl(code: KeyCode) -> KeyEvent {
117        KeyEvent::new(code, KeyModifiers::CONTROL)
118    }
119
120    fn app() -> App {
121        App::new(Uuid::new_v4(), EditMode::Emacs)
122    }
123
124    #[test]
125    fn tab_layer_handles_digit_shortcuts() {
126        assert_eq!(
127            translate(&app(), key(KeyCode::Char('1'))),
128            Some(Intent::SelectTab(Tab::Accounts))
129        );
130    }
131
132    #[test]
133    fn tab_layer_handles_tab_key() {
134        assert_eq!(translate(&app(), key(KeyCode::Tab)), Some(Intent::NextTab));
135    }
136
137    #[test]
138    fn modal_layer_only_accepts_close() {
139        let mut a = app();
140        a.modals.push(crate::modal::Modal::Help);
141        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
142        assert_eq!(translate(&a, key(KeyCode::Char('1'))), None);
143    }
144
145    #[test]
146    fn cmdline_insert_accepts_ctrl_a_as_home() {
147        let mut a = app();
148        a.open_command_line();
149        assert_eq!(
150            translate(&a, ctrl(KeyCode::Char('a'))),
151            Some(Intent::MoveHome)
152        );
153    }
154
155    #[test]
156    fn cmdline_insert_accepts_plain_characters() {
157        let mut a = app();
158        a.open_command_line();
159        assert_eq!(
160            translate(&a, key(KeyCode::Char('q'))),
161            Some(Intent::InsertChar('q'))
162        );
163    }
164
165    #[test]
166    fn cmdline_vim_normal_routes_motion_keys() {
167        let mut a = app();
168        a.set_edit_mode(EditMode::Vim);
169        a.open_command_line();
170        a.command_line.enter_normal_mode();
171        assert_eq!(
172            translate(&a, key(KeyCode::Char('h'))),
173            Some(Intent::Vim(VimAction::MoveLeft))
174        );
175        assert_eq!(
176            translate(&a, key(KeyCode::Char('$'))),
177            Some(Intent::Vim(VimAction::MoveEnd))
178        );
179    }
180
181    #[test]
182    fn unknown_key_returns_none() {
183        assert_eq!(translate(&app(), key(KeyCode::F(12))), None);
184    }
185}