1
//! Key-event translation: crossterm [`KeyEvent`] → [`Intent`].
2
//!
3
//! Translation switches on [`crate::focus::current_focus`] so the same
4
//! canonical precedence order governs both translation and dispatch.
5

            
6
use crate::app::App;
7
use crate::event::Intent;
8
use crate::focus::{FocusTarget, current_focus};
9
use crate::modal::{KeyPolicy, Modal};
10
use crate::pane::PaneId;
11
use crate::view::Tab;
12
use crate::widgets::{EditMode, VimAction, VimMode, WidgetKind};
13
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14

            
15
#[must_use]
16
81
pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
17
81
    match current_focus(app) {
18
        FocusTarget::CmdLine => {
19
13
            if app.cmdline.editor.mode() == EditMode::Vim
20
2
                && app.cmdline.editor.vim_mode() == VimMode::Normal
21
            {
22
2
                translate_vim_normal(key)
23
            } else {
24
11
                translate_cmdline_insert(key)
25
            }
26
        }
27
21
        FocusTarget::ConsoleInput => translate_console_insert(app.console.panes.focused(), key),
28
28
        FocusTarget::Overlay => translate_modal(app.overlays.top(), key),
29
19
        FocusTarget::ViewPane => translate_tab(app, key),
30
    }
31
81
}
32

            
33
19
fn translate_tab(app: &App, key: KeyEvent) -> Option<Intent> {
34
2
    match key.code {
35
5
        KeyCode::Char('q') => Some(Intent::Quit),
36
2
        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
37
        KeyCode::Char('?') => Some(Intent::OpenHelp),
38
2
        KeyCode::Tab => Some(Intent::NextTab),
39
        KeyCode::BackTab => Some(Intent::PreviousTab),
40
3
        KeyCode::Char('1') => Some(Intent::SelectTab(Tab::Accounts)),
41
        KeyCode::Char('2') => Some(Intent::SelectTab(Tab::Transactions)),
42
        KeyCode::Char('3') => Some(Intent::SelectTab(Tab::Commodities)),
43
        KeyCode::Char('4') => Some(Intent::SelectTab(Tab::Reports)),
44
        KeyCode::Char('5') => Some(Intent::SelectTab(Tab::Config)),
45
1
        KeyCode::Char('6') => Some(Intent::SelectTab(Tab::Console)),
46
        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
47
            Some(Intent::ToggleEditMode)
48
        }
49
2
        KeyCode::Char('e') | KeyCode::Enter if app.active_tab == Tab::Config => {
50
            Some(Intent::EditConfig)
51
        }
52
        KeyCode::Down | KeyCode::Char('j') if app.active_tab == Tab::Config => {
53
            Some(Intent::ListSelectNext)
54
        }
55
        KeyCode::Up | KeyCode::Char('k') if app.active_tab == Tab::Config => {
56
            Some(Intent::ListSelectPrev)
57
        }
58
        KeyCode::Char('r') if app.active_tab == Tab::Config => Some(Intent::RefreshTab),
59
        KeyCode::Down | KeyCode::Char('j')
60
            if matches!(
61
                app.active_tab,
62
                Tab::Accounts | Tab::Transactions | Tab::Commodities
63
            ) =>
64
        {
65
            Some(Intent::ListSelectNext)
66
        }
67
        KeyCode::Up | KeyCode::Char('k')
68
            if matches!(
69
                app.active_tab,
70
                Tab::Accounts | Tab::Transactions | Tab::Commodities
71
            ) =>
72
        {
73
            Some(Intent::ListSelectPrev)
74
        }
75
        KeyCode::Char('r')
76
            if matches!(
77
                app.active_tab,
78
                Tab::Accounts | Tab::Transactions | Tab::Commodities
79
            ) =>
80
        {
81
            Some(Intent::RefreshTab)
82
        }
83
        KeyCode::Char('d') | KeyCode::Delete if app.active_tab == Tab::Transactions => {
84
            Some(Intent::ListDelete)
85
        }
86
        KeyCode::Char('e') if matches!(app.active_tab, Tab::Accounts | Tab::Transactions) => {
87
            Some(Intent::ListEdit)
88
        }
89
2
        KeyCode::Char('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
90
2
            Some(Intent::ConsoleFocus)
91
        }
92
4
        _ => None,
93
    }
94
19
}
95

            
96
/// Translate keys while the console input is focused, branching on which
97
/// pane currently has focus within the console.
98
21
fn translate_console_insert(pane: PaneId, key: KeyEvent) -> Option<Intent> {
99
    // Tab/BackTab cycle panes regardless of which is active.
100
21
    if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) {
101
3
        return Some(Intent::ConsoleCyclePane);
102
18
    }
103
18
    match pane {
104
10
        PaneId::Prompt => translate_prompt_key(key),
105
8
        PaneId::Scrollback => translate_scrollback_key(key),
106
    }
107
21
}
108

            
109
/// The console is an Emacs-only line editor (see
110
/// [`crate::tabs::nms::ConsoleState::new`]): no vim-normal routing, so every
111
/// key is the insert layer with console-specific submit / blur / interrupt /
112
/// history bindings.
113
10
fn translate_prompt_key(key: KeyEvent) -> Option<Intent> {
114
2
    match key.code {
115
2
        KeyCode::Esc => Some(Intent::ConsoleBlur),
116
1
        KeyCode::Enter => Some(Intent::ConsoleSubmit),
117
1
        KeyCode::Up => Some(Intent::ConsoleHistoryPrev),
118
1
        KeyCode::Down => Some(Intent::ConsoleHistoryNext),
119
1
        KeyCode::Backspace => Some(Intent::DeleteBackward),
120
        KeyCode::Left => Some(Intent::MoveLeft),
121
        KeyCode::Right => Some(Intent::MoveRight),
122
        KeyCode::Home => Some(Intent::MoveHome),
123
        KeyCode::End => Some(Intent::MoveEnd),
124
2
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
125
2
            Some(Intent::ConsoleInterrupt)
126
        }
127
        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
128
            Some(Intent::MoveHome)
129
        }
130
        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
131
            Some(Intent::MoveEnd)
132
        }
133
        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
134
            Some(Intent::KillToEnd)
135
        }
136
        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
137
            Some(Intent::KillWordBackward)
138
        }
139
2
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
140
        _ => None,
141
    }
142
10
}
143

            
144
8
fn translate_scrollback_key(key: KeyEvent) -> Option<Intent> {
145
1
    match key.code {
146
1
        KeyCode::Esc => Some(Intent::ConsoleBlur),
147
1
        KeyCode::Up => Some(Intent::ScrollLineUp),
148
1
        KeyCode::Down => Some(Intent::ScrollLineDown),
149
1
        KeyCode::PageUp => Some(Intent::ScrollPageUp),
150
1
        KeyCode::PageDown => Some(Intent::ScrollPageDown),
151
1
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
152
1
            Some(Intent::ConsoleInterrupt)
153
        }
154
2
        _ => None,
155
    }
156
8
}
157

            
158
28
fn translate_modal(modal: Option<&Modal>, key: KeyEvent) -> Option<Intent> {
159
    // Confirm overlay: y/Enter confirm, n/q/Esc cancel.
160
28
    if matches!(modal, Some(Modal::Confirm { .. })) {
161
        return match key.code {
162
            KeyCode::Char('y') | KeyCode::Enter => Some(Intent::ConfirmYes),
163
            KeyCode::Char('n') | KeyCode::Char('q') | KeyCode::Esc => Some(Intent::CloseTopmost),
164
            _ => None,
165
        };
166
28
    }
167
    // Key policy decides whether `q` closes the overlay or types literally.
168
    // Input overlays (report params, config set) only close on Esc so any
169
    // character — including 'q' — is typed literally.
170
    // Display overlays (Help) also close on 'q'.
171
28
    let policy = modal.map_or(KeyPolicy::Display, Modal::key_policy);
172
28
    let focused_kind = focused_widget_kind(modal);
173
28
    let splits_sub = focused_splits_sub_kind(modal);
174
28
    let splits_boundary = focused_splits_boundary(modal);
175
4
    match key.code {
176
2
        KeyCode::Esc => Some(Intent::CloseTopmost),
177
3
        KeyCode::Char('q') if policy == KeyPolicy::Display => Some(Intent::CloseTopmost),
178
        // Splits Tab/BackTab walk cells; at the editor boundary they YIELD to
179
        // the outer field cycle so the user can return to Date/Note.
180
5
        KeyCode::Tab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
181
1
            Some((_, true)) => Some(Intent::NextTab),
182
2
            _ => Some(Intent::ColNext),
183
        },
184
3
        KeyCode::BackTab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
185
1
            Some((true, _)) => Some(Intent::PreviousTab),
186
2
            _ => Some(Intent::ColPrev),
187
        },
188
        // Splits: Up/Down navigate Select options when on a Select col, rows otherwise.
189
        KeyCode::Up
190
3
            if focused_kind == Some(WidgetKind::Splits)
191
2
                && splits_sub == Some(WidgetKind::Select) =>
192
        {
193
1
            Some(Intent::SelectPrev)
194
        }
195
        KeyCode::Down
196
3
            if focused_kind == Some(WidgetKind::Splits)
197
2
                && splits_sub == Some(WidgetKind::Select) =>
198
        {
199
1
            Some(Intent::SelectNext)
200
        }
201
2
        KeyCode::Up if focused_kind == Some(WidgetKind::Splits) => Some(Intent::RowPrev),
202
2
        KeyCode::Down if focused_kind == Some(WidgetKind::Splits) => Some(Intent::RowNext),
203
        // Splits: Enter submits the transaction (the Selects navigate by Up/Down,
204
        // so there is no dropdown to confirm) — falls through to SubmitCommandLine.
205
        // Splits: row add/remove shortcuts.
206
1
        KeyCode::Char('+') if focused_kind == Some(WidgetKind::Splits) => Some(Intent::SplitAddRow),
207
        KeyCode::Char('n')
208
1
            if focused_kind == Some(WidgetKind::Splits)
209
1
                && key.modifiers.contains(KeyModifiers::CONTROL) =>
210
        {
211
1
            Some(Intent::SplitAddRow)
212
        }
213
1
        KeyCode::Char('-') if focused_kind == Some(WidgetKind::Splits) => {
214
1
            Some(Intent::SplitRemoveRow)
215
        }
216
        KeyCode::Char('d')
217
            if focused_kind == Some(WidgetKind::Splits)
218
                && key.modifiers.contains(KeyModifiers::CONTROL) =>
219
        {
220
            Some(Intent::SplitRemoveRow)
221
        }
222
        // Date: Up/Down step the date by ±1 day.
223
1
        KeyCode::Up if focused_kind == Some(WidgetKind::Date) => Some(Intent::DateStepBack),
224
1
        KeyCode::Down if focused_kind == Some(WidgetKind::Date) => Some(Intent::DateStepForward),
225
        // Select widget-first: Up/Down/Enter consumed before the outer form layer.
226
1
        KeyCode::Up if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectPrev),
227
1
        KeyCode::Down if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectNext),
228
4
        KeyCode::Enter if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectConfirm),
229
3
        KeyCode::Enter => Some(Intent::SubmitCommandLine),
230
2
        KeyCode::Tab => Some(Intent::NextTab),
231
        KeyCode::BackTab => Some(Intent::PreviousTab),
232
1
        KeyCode::Backspace => Some(Intent::DeleteBackward),
233
2
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
234
        _ => None,
235
    }
236
28
}
237

            
238
/// Extract the focused widget kind from the topmost modal, if it is a Form.
239
28
fn focused_widget_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
240
28
    match modal? {
241
21
        Modal::Form(form) => form.focused_widget_kind(),
242
7
        Modal::Help | Modal::Confirm { .. } => None,
243
    }
244
28
}
245

            
246
/// When the focused widget is Splits, returns the kind of its focused sub-widget.
247
28
fn focused_splits_sub_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
248
28
    match modal? {
249
21
        Modal::Form(form) => form.focused_splits_sub_kind(),
250
7
        Modal::Help | Modal::Confirm { .. } => None,
251
    }
252
28
}
253

            
254
/// When the focused widget is Splits, returns `(at_first_cell, at_last_cell)`.
255
28
fn focused_splits_boundary(modal: Option<&Modal>) -> Option<(bool, bool)> {
256
28
    match modal? {
257
21
        Modal::Form(form) => form.focused_splits_boundary(),
258
7
        Modal::Help | Modal::Confirm { .. } => None,
259
    }
260
28
}
261

            
262
11
fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
263
1
    match key.code {
264
        KeyCode::Esc => Some(Intent::CloseTopmost),
265
1
        KeyCode::Enter => Some(Intent::SubmitCommandLine),
266
1
        KeyCode::Tab => Some(Intent::CompleteCommandLine),
267
        KeyCode::Backspace => Some(Intent::DeleteBackward),
268
        KeyCode::Left => Some(Intent::MoveLeft),
269
        KeyCode::Right => Some(Intent::MoveRight),
270
        KeyCode::Home => Some(Intent::MoveHome),
271
        KeyCode::End => Some(Intent::MoveEnd),
272
1
        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
273
1
            Some(Intent::MoveHome)
274
        }
275
1
        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
276
            Some(Intent::MoveEnd)
277
        }
278
        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
279
            Some(Intent::KillToEnd)
280
        }
281
        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
282
            Some(Intent::KillWordBackward)
283
        }
284
8
        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
285
        _ => None,
286
    }
287
11
}
288

            
289
2
fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
290
2
    match key.code {
291
        KeyCode::Esc => Some(Intent::CloseTopmost),
292
        KeyCode::Enter => Some(Intent::SubmitCommandLine),
293
1
        KeyCode::Char('h') => Some(Intent::Vim(VimAction::MoveLeft)),
294
        KeyCode::Char('l') => Some(Intent::Vim(VimAction::MoveRight)),
295
        KeyCode::Char('0') => Some(Intent::Vim(VimAction::MoveHome)),
296
1
        KeyCode::Char('$') => Some(Intent::Vim(VimAction::MoveEnd)),
297
        KeyCode::Char('w') => Some(Intent::Vim(VimAction::WordForward)),
298
        KeyCode::Char('b') => Some(Intent::Vim(VimAction::WordBackward)),
299
        KeyCode::Char('x') => Some(Intent::Vim(VimAction::DeleteChar)),
300
        KeyCode::Char('i') => Some(Intent::Vim(VimAction::InsertAtCursor)),
301
        KeyCode::Char('a') => Some(Intent::Vim(VimAction::InsertAfterCursor)),
302
        KeyCode::Char('I') => Some(Intent::Vim(VimAction::InsertAtLineStart)),
303
        KeyCode::Char('A') => Some(Intent::Vim(VimAction::InsertAtLineEnd)),
304
        // `dw`/`db` mapped directly to single-keystroke actions; chord layer is future work.
305
        KeyCode::Char('D') => Some(Intent::Vim(VimAction::DeleteWordForward)),
306
        KeyCode::Char('B') => Some(Intent::Vim(VimAction::DeleteWordBackward)),
307
        _ => None,
308
    }
309
2
}
310

            
311
#[cfg(test)]
312
mod tests;