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
24
pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
15
24
    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
12
    }
23
12
    if !app.modals.is_empty() {
24
2
        return translate_modal(key);
25
10
    }
26
10
    translate_tab(key)
27
24
}
28

            
29
10
fn translate_tab(key: KeyEvent) -> Option<Intent> {
30
    match key.code {
31
4
        KeyCode::Char('q') => Some(Intent::Quit),
32
1
        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
33
        KeyCode::Char('?') => Some(Intent::OpenHelp),
34
1
        KeyCode::Tab => Some(Intent::NextTab),
35
        KeyCode::BackTab => Some(Intent::PreviousTab),
36
2
        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
2
        _ => None,
45
    }
46
10
}
47

            
48
2
fn translate_modal(key: KeyEvent) -> Option<Intent> {
49
2
    match key.code {
50
1
        KeyCode::Esc | KeyCode::Char('q') => Some(Intent::CloseTopmost),
51
1
        _ => None,
52
    }
53
2
}
54

            
55
10
fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
56
1
    match key.code {
57
        KeyCode::Esc => Some(Intent::CloseTopmost),
58
1
        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
1
        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
65
1
            Some(Intent::MoveHome)
66
        }
67
1
        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
8
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
77
        _ => None,
78
    }
79
10
}
80

            
81
2
fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
82
2
    match key.code {
83
        KeyCode::Esc => Some(Intent::CloseTopmost),
84
        KeyCode::Enter => Some(Intent::SubmitCommandLine),
85
1
        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
1
        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
2
}
105

            
106
#[cfg(test)]
107
mod tests {
108
    use super::*;
109
    use crate::widgets::EditMode;
110
    use sqlx::types::Uuid;
111

            
112
8
    fn key(code: KeyCode) -> KeyEvent {
113
8
        KeyEvent::new(code, KeyModifiers::NONE)
114
8
    }
115

            
116
1
    fn ctrl(code: KeyCode) -> KeyEvent {
117
1
        KeyEvent::new(code, KeyModifiers::CONTROL)
118
1
    }
119

            
120
7
    fn app() -> App {
121
7
        App::new(Uuid::new_v4(), EditMode::Emacs)
122
7
    }
123

            
124
    #[test]
125
1
    fn tab_layer_handles_digit_shortcuts() {
126
1
        assert_eq!(
127
1
            translate(&app(), key(KeyCode::Char('1'))),
128
            Some(Intent::SelectTab(Tab::Accounts))
129
        );
130
1
    }
131

            
132
    #[test]
133
1
    fn tab_layer_handles_tab_key() {
134
1
        assert_eq!(translate(&app(), key(KeyCode::Tab)), Some(Intent::NextTab));
135
1
    }
136

            
137
    #[test]
138
1
    fn modal_layer_only_accepts_close() {
139
1
        let mut a = app();
140
1
        a.modals.push(crate::modal::Modal::Help);
141
1
        assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
142
1
        assert_eq!(translate(&a, key(KeyCode::Char('1'))), None);
143
1
    }
144

            
145
    #[test]
146
1
    fn cmdline_insert_accepts_ctrl_a_as_home() {
147
1
        let mut a = app();
148
1
        a.open_command_line();
149
1
        assert_eq!(
150
1
            translate(&a, ctrl(KeyCode::Char('a'))),
151
            Some(Intent::MoveHome)
152
        );
153
1
    }
154

            
155
    #[test]
156
1
    fn cmdline_insert_accepts_plain_characters() {
157
1
        let mut a = app();
158
1
        a.open_command_line();
159
1
        assert_eq!(
160
1
            translate(&a, key(KeyCode::Char('q'))),
161
            Some(Intent::InsertChar('q'))
162
        );
163
1
    }
164

            
165
    #[test]
166
1
    fn cmdline_vim_normal_routes_motion_keys() {
167
1
        let mut a = app();
168
1
        a.set_edit_mode(EditMode::Vim);
169
1
        a.open_command_line();
170
1
        a.command_line.enter_normal_mode();
171
1
        assert_eq!(
172
1
            translate(&a, key(KeyCode::Char('h'))),
173
            Some(Intent::Vim(VimAction::MoveLeft))
174
        );
175
1
        assert_eq!(
176
1
            translate(&a, key(KeyCode::Char('$'))),
177
            Some(Intent::Vim(VimAction::MoveEnd))
178
        );
179
1
    }
180

            
181
    #[test]
182
1
    fn unknown_key_returns_none() {
183
1
        assert_eq!(translate(&app(), key(KeyCode::F(12))), None);
184
1
    }
185
}