Skip to main content

tui/
keymap.rs

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
6use crate::app::App;
7use crate::event::Intent;
8use crate::focus::{FocusTarget, current_focus};
9use crate::modal::{KeyPolicy, Modal};
10use crate::pane::PaneId;
11use crate::view::Tab;
12use crate::widgets::{EditMode, VimAction, VimMode, WidgetKind};
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14
15#[must_use]
16pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
17    match current_focus(app) {
18        FocusTarget::CmdLine => {
19            if app.cmdline.editor.mode() == EditMode::Vim
20                && app.cmdline.editor.vim_mode() == VimMode::Normal
21            {
22                translate_vim_normal(key)
23            } else {
24                translate_cmdline_insert(key)
25            }
26        }
27        FocusTarget::ConsoleInput => translate_console_insert(app.console.panes.focused(), key),
28        FocusTarget::Overlay => translate_modal(app.overlays.top(), key),
29        FocusTarget::ViewPane => translate_tab(app, key),
30    }
31}
32
33fn translate_tab(app: &App, key: KeyEvent) -> Option<Intent> {
34    match key.code {
35        KeyCode::Char('q') => Some(Intent::Quit),
36        KeyCode::Char(':') => Some(Intent::OpenCommandLine),
37        KeyCode::Char('?') => Some(Intent::OpenHelp),
38        KeyCode::Tab => Some(Intent::NextTab),
39        KeyCode::BackTab => Some(Intent::PreviousTab),
40        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        KeyCode::Char('6') => Some(Intent::SelectTab(Tab::Console)),
46        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
47            Some(Intent::ToggleEditMode)
48        }
49        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        KeyCode::Char('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
90            Some(Intent::ConsoleFocus)
91        }
92        _ => None,
93    }
94}
95
96/// Translate keys while the console input is focused, branching on which
97/// pane currently has focus within the console.
98fn translate_console_insert(pane: PaneId, key: KeyEvent) -> Option<Intent> {
99    // Tab/BackTab cycle panes regardless of which is active.
100    if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) {
101        return Some(Intent::ConsoleCyclePane);
102    }
103    match pane {
104        PaneId::Prompt => translate_prompt_key(key),
105        PaneId::Scrollback => translate_scrollback_key(key),
106    }
107}
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.
113fn translate_prompt_key(key: KeyEvent) -> Option<Intent> {
114    match key.code {
115        KeyCode::Esc => Some(Intent::ConsoleBlur),
116        KeyCode::Enter => Some(Intent::ConsoleSubmit),
117        KeyCode::Up => Some(Intent::ConsoleHistoryPrev),
118        KeyCode::Down => Some(Intent::ConsoleHistoryNext),
119        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        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
125            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        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
140        _ => None,
141    }
142}
143
144fn translate_scrollback_key(key: KeyEvent) -> Option<Intent> {
145    match key.code {
146        KeyCode::Esc => Some(Intent::ConsoleBlur),
147        KeyCode::Up => Some(Intent::ScrollLineUp),
148        KeyCode::Down => Some(Intent::ScrollLineDown),
149        KeyCode::PageUp => Some(Intent::ScrollPageUp),
150        KeyCode::PageDown => Some(Intent::ScrollPageDown),
151        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
152            Some(Intent::ConsoleInterrupt)
153        }
154        _ => None,
155    }
156}
157
158fn translate_modal(modal: Option<&Modal>, key: KeyEvent) -> Option<Intent> {
159    // Confirm overlay: y/Enter confirm, n/q/Esc cancel.
160    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    }
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    let policy = modal.map_or(KeyPolicy::Display, Modal::key_policy);
172    let focused_kind = focused_widget_kind(modal);
173    let splits_sub = focused_splits_sub_kind(modal);
174    let splits_boundary = focused_splits_boundary(modal);
175    match key.code {
176        KeyCode::Esc => Some(Intent::CloseTopmost),
177        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        KeyCode::Tab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
181            Some((_, true)) => Some(Intent::NextTab),
182            _ => Some(Intent::ColNext),
183        },
184        KeyCode::BackTab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
185            Some((true, _)) => Some(Intent::PreviousTab),
186            _ => Some(Intent::ColPrev),
187        },
188        // Splits: Up/Down navigate Select options when on a Select col, rows otherwise.
189        KeyCode::Up
190            if focused_kind == Some(WidgetKind::Splits)
191                && splits_sub == Some(WidgetKind::Select) =>
192        {
193            Some(Intent::SelectPrev)
194        }
195        KeyCode::Down
196            if focused_kind == Some(WidgetKind::Splits)
197                && splits_sub == Some(WidgetKind::Select) =>
198        {
199            Some(Intent::SelectNext)
200        }
201        KeyCode::Up if focused_kind == Some(WidgetKind::Splits) => Some(Intent::RowPrev),
202        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        KeyCode::Char('+') if focused_kind == Some(WidgetKind::Splits) => Some(Intent::SplitAddRow),
207        KeyCode::Char('n')
208            if focused_kind == Some(WidgetKind::Splits)
209                && key.modifiers.contains(KeyModifiers::CONTROL) =>
210        {
211            Some(Intent::SplitAddRow)
212        }
213        KeyCode::Char('-') if focused_kind == Some(WidgetKind::Splits) => {
214            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        KeyCode::Up if focused_kind == Some(WidgetKind::Date) => Some(Intent::DateStepBack),
224        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        KeyCode::Up if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectPrev),
227        KeyCode::Down if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectNext),
228        KeyCode::Enter if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectConfirm),
229        KeyCode::Enter => Some(Intent::SubmitCommandLine),
230        KeyCode::Tab => Some(Intent::NextTab),
231        KeyCode::BackTab => Some(Intent::PreviousTab),
232        KeyCode::Backspace => Some(Intent::DeleteBackward),
233        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
234        _ => None,
235    }
236}
237
238/// Extract the focused widget kind from the topmost modal, if it is a Form.
239fn focused_widget_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
240    match modal? {
241        Modal::Form(form) => form.focused_widget_kind(),
242        Modal::Help | Modal::Confirm { .. } => None,
243    }
244}
245
246/// When the focused widget is Splits, returns the kind of its focused sub-widget.
247fn focused_splits_sub_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
248    match modal? {
249        Modal::Form(form) => form.focused_splits_sub_kind(),
250        Modal::Help | Modal::Confirm { .. } => None,
251    }
252}
253
254/// When the focused widget is Splits, returns `(at_first_cell, at_last_cell)`.
255fn focused_splits_boundary(modal: Option<&Modal>) -> Option<(bool, bool)> {
256    match modal? {
257        Modal::Form(form) => form.focused_splits_boundary(),
258        Modal::Help | Modal::Confirm { .. } => None,
259    }
260}
261
262fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
263    match key.code {
264        KeyCode::Esc => Some(Intent::CloseTopmost),
265        KeyCode::Enter => Some(Intent::SubmitCommandLine),
266        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        KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
273            Some(Intent::MoveHome)
274        }
275        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        KeyCode::Char(c) => Some(Intent::InsertChar(c)),
285        _ => None,
286    }
287}
288
289fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
290    match key.code {
291        KeyCode::Esc => Some(Intent::CloseTopmost),
292        KeyCode::Enter => Some(Intent::SubmitCommandLine),
293        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        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}
310
311#[cfg(test)]
312mod tests;