Lines
61.73 %
Functions
50 %
Branches
100 %
//! Key-event translation: crossterm [`KeyEvent`] → [`Intent`].
//!
//! Translation switches on [`crate::focus::current_focus`] so the same
//! canonical precedence order governs both translation and dispatch.
use crate::app::App;
use crate::event::Intent;
use crate::focus::{FocusTarget, current_focus};
use crate::modal::{KeyPolicy, Modal};
use crate::pane::PaneId;
use crate::view::Tab;
use crate::widgets::{EditMode, VimAction, VimMode, WidgetKind};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[must_use]
pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
match current_focus(app) {
FocusTarget::CmdLine => {
if app.cmdline.editor.mode() == EditMode::Vim
&& app.cmdline.editor.vim_mode() == VimMode::Normal
{
translate_vim_normal(key)
} else {
translate_cmdline_insert(key)
}
FocusTarget::ConsoleInput => translate_console_insert(app.console.panes.focused(), key),
FocusTarget::Overlay => translate_modal(app.overlays.top(), key),
FocusTarget::ViewPane => translate_tab(app, key),
fn translate_tab(app: &App, key: KeyEvent) -> Option<Intent> {
match key.code {
KeyCode::Char('q') => Some(Intent::Quit),
KeyCode::Char(':') => Some(Intent::OpenCommandLine),
KeyCode::Char('?') => Some(Intent::OpenHelp),
KeyCode::Tab => Some(Intent::NextTab),
KeyCode::BackTab => Some(Intent::PreviousTab),
KeyCode::Char('1') => Some(Intent::SelectTab(Tab::Accounts)),
KeyCode::Char('2') => Some(Intent::SelectTab(Tab::Transactions)),
KeyCode::Char('3') => Some(Intent::SelectTab(Tab::Commodities)),
KeyCode::Char('4') => Some(Intent::SelectTab(Tab::Reports)),
KeyCode::Char('5') => Some(Intent::SelectTab(Tab::Config)),
KeyCode::Char('6') => Some(Intent::SelectTab(Tab::Console)),
KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::ToggleEditMode)
KeyCode::Char('e') | KeyCode::Enter if app.active_tab == Tab::Config => {
Some(Intent::EditConfig)
KeyCode::Down | KeyCode::Char('j') if app.active_tab == Tab::Config => {
Some(Intent::ListSelectNext)
KeyCode::Up | KeyCode::Char('k') if app.active_tab == Tab::Config => {
Some(Intent::ListSelectPrev)
KeyCode::Char('r') if app.active_tab == Tab::Config => Some(Intent::RefreshTab),
KeyCode::Down | KeyCode::Char('j')
if matches!(
app.active_tab,
Tab::Accounts | Tab::Transactions | Tab::Commodities
) =>
KeyCode::Up | KeyCode::Char('k')
KeyCode::Char('r')
Some(Intent::RefreshTab)
KeyCode::Char('d') | KeyCode::Delete if app.active_tab == Tab::Transactions => {
Some(Intent::ListDelete)
KeyCode::Char('e') if matches!(app.active_tab, Tab::Accounts | Tab::Transactions) => {
Some(Intent::ListEdit)
KeyCode::Char('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
Some(Intent::ConsoleFocus)
_ => None,
/// Translate keys while the console input is focused, branching on which
/// pane currently has focus within the console.
fn translate_console_insert(pane: PaneId, key: KeyEvent) -> Option<Intent> {
// Tab/BackTab cycle panes regardless of which is active.
if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) {
return Some(Intent::ConsoleCyclePane);
match pane {
PaneId::Prompt => translate_prompt_key(key),
PaneId::Scrollback => translate_scrollback_key(key),
/// The console is an Emacs-only line editor (see
/// [`crate::tabs::nms::ConsoleState::new`]): no vim-normal routing, so every
/// key is the insert layer with console-specific submit / blur / interrupt /
/// history bindings.
fn translate_prompt_key(key: KeyEvent) -> Option<Intent> {
KeyCode::Esc => Some(Intent::ConsoleBlur),
KeyCode::Enter => Some(Intent::ConsoleSubmit),
KeyCode::Up => Some(Intent::ConsoleHistoryPrev),
KeyCode::Down => Some(Intent::ConsoleHistoryNext),
KeyCode::Backspace => Some(Intent::DeleteBackward),
KeyCode::Left => Some(Intent::MoveLeft),
KeyCode::Right => Some(Intent::MoveRight),
KeyCode::Home => Some(Intent::MoveHome),
KeyCode::End => Some(Intent::MoveEnd),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::ConsoleInterrupt)
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::MoveHome)
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::MoveEnd)
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::KillToEnd)
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::KillWordBackward)
KeyCode::Char(c) => Some(Intent::InsertChar(c)),
fn translate_scrollback_key(key: KeyEvent) -> Option<Intent> {
KeyCode::Up => Some(Intent::ScrollLineUp),
KeyCode::Down => Some(Intent::ScrollLineDown),
KeyCode::PageUp => Some(Intent::ScrollPageUp),
KeyCode::PageDown => Some(Intent::ScrollPageDown),
fn translate_modal(modal: Option<&Modal>, key: KeyEvent) -> Option<Intent> {
// Confirm overlay: y/Enter confirm, n/q/Esc cancel.
if matches!(modal, Some(Modal::Confirm { .. })) {
return match key.code {
KeyCode::Char('y') | KeyCode::Enter => Some(Intent::ConfirmYes),
KeyCode::Char('n') | KeyCode::Char('q') | KeyCode::Esc => Some(Intent::CloseTopmost),
};
// Key policy decides whether `q` closes the overlay or types literally.
// Input overlays (report params, config set) only close on Esc so any
// character — including 'q' — is typed literally.
// Display overlays (Help) also close on 'q'.
let policy = modal.map_or(KeyPolicy::Display, Modal::key_policy);
let focused_kind = focused_widget_kind(modal);
let splits_sub = focused_splits_sub_kind(modal);
let splits_boundary = focused_splits_boundary(modal);
KeyCode::Esc => Some(Intent::CloseTopmost),
KeyCode::Char('q') if policy == KeyPolicy::Display => Some(Intent::CloseTopmost),
// Splits Tab/BackTab walk cells; at the editor boundary they YIELD to
// the outer field cycle so the user can return to Date/Note.
KeyCode::Tab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
Some((_, true)) => Some(Intent::NextTab),
_ => Some(Intent::ColNext),
},
KeyCode::BackTab if focused_kind == Some(WidgetKind::Splits) => match splits_boundary {
Some((true, _)) => Some(Intent::PreviousTab),
_ => Some(Intent::ColPrev),
// Splits: Up/Down navigate Select options when on a Select col, rows otherwise.
KeyCode::Up
if focused_kind == Some(WidgetKind::Splits)
&& splits_sub == Some(WidgetKind::Select) =>
Some(Intent::SelectPrev)
KeyCode::Down
Some(Intent::SelectNext)
KeyCode::Up if focused_kind == Some(WidgetKind::Splits) => Some(Intent::RowPrev),
KeyCode::Down if focused_kind == Some(WidgetKind::Splits) => Some(Intent::RowNext),
// Splits: Enter submits the transaction (the Selects navigate by Up/Down,
// so there is no dropdown to confirm) — falls through to SubmitCommandLine.
// Splits: row add/remove shortcuts.
KeyCode::Char('+') if focused_kind == Some(WidgetKind::Splits) => Some(Intent::SplitAddRow),
KeyCode::Char('n')
&& key.modifiers.contains(KeyModifiers::CONTROL) =>
Some(Intent::SplitAddRow)
KeyCode::Char('-') if focused_kind == Some(WidgetKind::Splits) => {
Some(Intent::SplitRemoveRow)
KeyCode::Char('d')
// Date: Up/Down step the date by ±1 day.
KeyCode::Up if focused_kind == Some(WidgetKind::Date) => Some(Intent::DateStepBack),
KeyCode::Down if focused_kind == Some(WidgetKind::Date) => Some(Intent::DateStepForward),
// Select widget-first: Up/Down/Enter consumed before the outer form layer.
KeyCode::Up if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectPrev),
KeyCode::Down if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectNext),
KeyCode::Enter if focused_kind == Some(WidgetKind::Select) => Some(Intent::SelectConfirm),
KeyCode::Enter => Some(Intent::SubmitCommandLine),
/// Extract the focused widget kind from the topmost modal, if it is a Form.
fn focused_widget_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
match modal? {
Modal::Form(form) => form.focused_widget_kind(),
Modal::Help | Modal::Confirm { .. } => None,
/// When the focused widget is Splits, returns the kind of its focused sub-widget.
fn focused_splits_sub_kind(modal: Option<&Modal>) -> Option<WidgetKind> {
Modal::Form(form) => form.focused_splits_sub_kind(),
/// When the focused widget is Splits, returns `(at_first_cell, at_last_cell)`.
fn focused_splits_boundary(modal: Option<&Modal>) -> Option<(bool, bool)> {
Modal::Form(form) => form.focused_splits_boundary(),
fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
KeyCode::Tab => Some(Intent::CompleteCommandLine),
fn translate_vim_normal(key: KeyEvent) -> Option<Intent> {
KeyCode::Char('h') => Some(Intent::Vim(VimAction::MoveLeft)),
KeyCode::Char('l') => Some(Intent::Vim(VimAction::MoveRight)),
KeyCode::Char('0') => Some(Intent::Vim(VimAction::MoveHome)),
KeyCode::Char('$') => Some(Intent::Vim(VimAction::MoveEnd)),
KeyCode::Char('w') => Some(Intent::Vim(VimAction::WordForward)),
KeyCode::Char('b') => Some(Intent::Vim(VimAction::WordBackward)),
KeyCode::Char('x') => Some(Intent::Vim(VimAction::DeleteChar)),
KeyCode::Char('i') => Some(Intent::Vim(VimAction::InsertAtCursor)),
KeyCode::Char('a') => Some(Intent::Vim(VimAction::InsertAfterCursor)),
KeyCode::Char('I') => Some(Intent::Vim(VimAction::InsertAtLineStart)),
KeyCode::Char('A') => Some(Intent::Vim(VimAction::InsertAtLineEnd)),
// `dw`/`db` mapped directly to single-keystroke actions; chord layer is future work.
KeyCode::Char('D') => Some(Intent::Vim(VimAction::DeleteWordForward)),
KeyCode::Char('B') => Some(Intent::Vim(VimAction::DeleteWordBackward)),
#[cfg(test)]
mod tests;