1use 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
55fn 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 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 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}