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.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 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}