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

            
8
use crate::app::{App, Tab};
9
use crate::event::Intent;
10
use crate::widgets::{EditMode, VimAction, VimMode};
11
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12

            
13
#[must_use]
14
43
pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
15
43
    if app.command_line_active {
16
12
        if app.command_line.mode() == EditMode::Vim
17
2
            && app.command_line.vim_mode() == VimMode::Normal
18
        {
19
2
            return translate_vim_normal(key);
20
10
        }
21
10
        return translate_cmdline_insert(key);
22
31
    }
23
31
    if app.console_input_active {
24
10
        return translate_console_insert(key);
25
21
    }
26
21
    if !app.modals.is_empty() {
27
2
        return translate_modal(key);
28
19
    }
29
19
    translate_tab(app, key)
30
43
}
31

            
32
19
fn translate_tab(app: &App, key: KeyEvent) -> Option<Intent> {
33
2
    match key.code {
34
5
        KeyCode::Char('q') => Some(Intent::Quit),
35
2
        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
36
        KeyCode::Char('?') => Some(Intent::OpenHelp),
37
2
        KeyCode::Tab => Some(Intent::NextTab),
38
        KeyCode::BackTab => Some(Intent::PreviousTab),
39
3
        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
1
        KeyCode::Char('6') => Some(Intent::SelectTab(Tab::Console)),
45
        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
46
            Some(Intent::ToggleEditMode)
47
        }
48
2
        KeyCode::Char('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
49
2
            Some(Intent::ConsoleFocus)
50
        }
51
4
        _ => None,
52
    }
53
19
}
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.
59
10
fn translate_console_insert(key: KeyEvent) -> Option<Intent> {
60
2
    match key.code {
61
2
        KeyCode::Esc => Some(Intent::ConsoleBlur),
62
1
        KeyCode::Enter => Some(Intent::ConsoleSubmit),
63
1
        KeyCode::Up => Some(Intent::ConsoleHistoryPrev),
64
1
        KeyCode::Down => Some(Intent::ConsoleHistoryNext),
65
1
        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
2
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
71
2
            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
2
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
86
        _ => None,
87
    }
88
10
}
89

            
90
2
fn translate_modal(key: KeyEvent) -> Option<Intent> {
91
2
    match key.code {
92
1
        KeyCode::Esc | KeyCode::Char('q') => Some(Intent::CloseTopmost),
93
1
        _ => None,
94
    }
95
2
}
96

            
97
10
fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
98
1
    match key.code {
99
        KeyCode::Esc => Some(Intent::CloseTopmost),
100
1
        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
1
        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
107
1
            Some(Intent::MoveHome)
108
        }
109
1
        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
8
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
119
        _ => None,
120
    }
121
10
}
122

            
123
2
fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
124
2
    match key.code {
125
        KeyCode::Esc => Some(Intent::CloseTopmost),
126
        KeyCode::Enter => Some(Intent::SubmitCommandLine),
127
1
        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
1
        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
2
}
147

            
148
#[cfg(test)]
149
mod tests {
150
    use super::*;
151
    use crate::widgets::EditMode;
152
    use sqlx::types::Uuid;
153

            
154
25
    fn key(code: KeyCode) -> KeyEvent {
155
25
        KeyEvent::new(code, KeyModifiers::NONE)
156
25
    }
157

            
158
3
    fn ctrl(code: KeyCode) -> KeyEvent {
159
3
        KeyEvent::new(code, KeyModifiers::CONTROL)
160
3
    }
161

            
162
13
    fn app() -> App {
163
13
        App::new(Uuid::new_v4(), EditMode::Emacs)
164
13
    }
165

            
166
    #[test]
167
1
    fn tab_layer_handles_digit_shortcuts() {
168
1
        assert_eq!(
169
1
            translate(&app(), key(KeyCode::Char('1'))),
170
            Some(Intent::SelectTab(Tab::Accounts))
171
        );
172
1
    }
173

            
174
    #[test]
175
1
    fn tab_layer_handles_tab_key() {
176
1
        assert_eq!(translate(&app(), key(KeyCode::Tab)), Some(Intent::NextTab));
177
1
    }
178

            
179
    #[test]
180
1
    fn modal_layer_only_accepts_close() {
181
1
        let mut a = app();
182
1
        a.modals.push(crate::modal::Modal::Help);
183
1
        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
184
1
        assert_eq!(translate(&a, key(KeyCode::Char('1'))), None);
185
1
    }
186

            
187
    #[test]
188
1
    fn cmdline_insert_accepts_ctrl_a_as_home() {
189
1
        let mut a = app();
190
1
        a.open_command_line();
191
1
        assert_eq!(
192
1
            translate(&a, ctrl(KeyCode::Char('a'))),
193
            Some(Intent::MoveHome)
194
        );
195
1
    }
196

            
197
    #[test]
198
1
    fn cmdline_insert_accepts_plain_characters() {
199
1
        let mut a = app();
200
1
        a.open_command_line();
201
1
        assert_eq!(
202
1
            translate(&a, key(KeyCode::Char('q'))),
203
            Some(Intent::InsertChar('q'))
204
        );
205
1
    }
206

            
207
    #[test]
208
1
    fn cmdline_vim_normal_routes_motion_keys() {
209
1
        let mut a = app();
210
1
        a.set_edit_mode(EditMode::Vim);
211
1
        a.open_command_line();
212
1
        a.command_line.enter_normal_mode();
213
1
        assert_eq!(
214
1
            translate(&a, key(KeyCode::Char('h'))),
215
            Some(Intent::Vim(VimAction::MoveLeft))
216
        );
217
1
        assert_eq!(
218
1
            translate(&a, key(KeyCode::Char('$'))),
219
            Some(Intent::Vim(VimAction::MoveEnd))
220
        );
221
1
    }
222

            
223
    #[test]
224
1
    fn unknown_key_returns_none() {
225
1
        assert_eq!(translate(&app(), key(KeyCode::F(12))), None);
226
1
    }
227

            
228
    #[test]
229
1
    fn console_tab_unfocused_focuses_on_i_and_enter() {
230
1
        let mut a = app();
231
1
        a.active_tab = Tab::Console;
232
1
        assert_eq!(
233
1
            translate(&a, key(KeyCode::Char('i'))),
234
            Some(Intent::ConsoleFocus)
235
        );
236
1
        assert_eq!(
237
1
            translate(&a, key(KeyCode::Enter)),
238
            Some(Intent::ConsoleFocus)
239
        );
240
1
    }
241

            
242
    #[test]
243
1
    fn console_tab_unfocused_still_navigates() {
244
1
        let mut a = app();
245
1
        a.active_tab = Tab::Console;
246
1
        assert_eq!(translate(&a, key(KeyCode::Char('q'))), Some(Intent::Quit));
247
1
        assert_eq!(translate(&a, key(KeyCode::Tab)), Some(Intent::NextTab));
248
1
        assert_eq!(
249
1
            translate(&a, key(KeyCode::Char('1'))),
250
            Some(Intent::SelectTab(Tab::Accounts))
251
        );
252
1
        assert_eq!(
253
1
            translate(&a, key(KeyCode::Char('6'))),
254
            Some(Intent::SelectTab(Tab::Console))
255
        );
256
1
        assert_eq!(
257
1
            translate(&a, key(KeyCode::Char(':'))),
258
            Some(Intent::OpenCommandLine)
259
        );
260
1
    }
261

            
262
    #[test]
263
1
    fn focus_keys_are_inert_on_other_tabs() {
264
1
        let mut a = app();
265
1
        a.active_tab = Tab::Accounts;
266
1
        assert_eq!(translate(&a, key(KeyCode::Char('i'))), None);
267
1
        assert_eq!(translate(&a, key(KeyCode::Enter)), None);
268
1
    }
269

            
270
    #[test]
271
1
    fn console_focused_ctrl_c_maps_to_interrupt() {
272
1
        let mut a = app();
273
1
        a.console_input_active = true;
274
1
        assert_eq!(
275
1
            translate(&a, ctrl(KeyCode::Char('c'))),
276
            Some(Intent::ConsoleInterrupt)
277
        );
278
1
    }
279

            
280
    #[test]
281
1
    fn console_stays_emacs_even_after_vim_toggle() {
282
1
        let mut a = app();
283
1
        a.set_edit_mode(EditMode::Vim);
284
1
        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
1
        assert_eq!(a.console.input.mode(), EditMode::Emacs);
289
1
        assert_eq!(
290
1
            translate(&a, key(KeyCode::Char('h'))),
291
            Some(Intent::InsertChar('h'))
292
        );
293
1
        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::ConsoleBlur));
294
1
    }
295

            
296
    #[test]
297
1
    fn console_focused_routes_editing_and_control_keys() {
298
1
        let mut a = app();
299
1
        a.console_input_active = true;
300
1
        assert_eq!(
301
1
            translate(&a, key(KeyCode::Char('x'))),
302
            Some(Intent::InsertChar('x'))
303
        );
304
1
        assert_eq!(
305
1
            translate(&a, key(KeyCode::Enter)),
306
            Some(Intent::ConsoleSubmit)
307
        );
308
1
        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::ConsoleBlur));
309
1
        assert_eq!(
310
1
            translate(&a, ctrl(KeyCode::Char('c'))),
311
            Some(Intent::ConsoleInterrupt)
312
        );
313
1
        assert_eq!(
314
1
            translate(&a, key(KeyCode::Up)),
315
            Some(Intent::ConsoleHistoryPrev)
316
        );
317
1
        assert_eq!(
318
1
            translate(&a, key(KeyCode::Down)),
319
            Some(Intent::ConsoleHistoryNext)
320
        );
321
1
        assert_eq!(
322
1
            translate(&a, key(KeyCode::Backspace)),
323
            Some(Intent::DeleteBackward)
324
        );
325
1
    }
326
}