Lines
77.4 %
Functions
64.71 %
Branches
100 %
//! Key-event translation: crossterm [`KeyEvent`] → [`Intent`].
//!
//! Translation depends on app state: when the command line is active
//! we route to either vim-normal or emacs/insert handlers; when a
//! modal is open we only accept the "close topmost" keys; otherwise
//! tab-level shortcuts apply.
use crate::app::{App, Tab};
use crate::event::Intent;
use crate::widgets::{EditMode, VimAction, VimMode};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[must_use]
pub fn translate(app: &App, key: KeyEvent) -> Option<Intent> {
if app.command_line_active {
if app.command_line.mode() == EditMode::Vim
&& app.command_line.vim_mode() == VimMode::Normal
{
return translate_vim_normal(key);
}
return translate_cmdline_insert(key);
if app.console_input_active {
return translate_console_insert(key);
if !app.modals.is_empty() {
return translate_modal(key);
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('i') | KeyCode::Enter if app.active_tab == Tab::Console => {
Some(Intent::ConsoleFocus)
_ => None,
/// Translate keys while the console input is focused. The console is an
/// Emacs-only line editor (see [`crate::tabs::nms::ConsoleState::new`]):
/// there is no vim-normal routing, so every key is the insert layer with
/// console-specific submit / blur / interrupt / history bindings.
fn translate_console_insert(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_modal(key: KeyEvent) -> Option<Intent> {
KeyCode::Esc | KeyCode::Char('q') => Some(Intent::CloseTopmost),
fn translate_cmdline_insert(key: KeyEvent) -> Option<Intent> {
KeyCode::Esc => Some(Intent::CloseTopmost),
KeyCode::Enter => Some(Intent::SubmitCommandLine),
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` are two-keystroke in vim but we map them directly
// to `D` (delete-word-forward) and `B` (delete-word-backward)
// so the editor engine stays stateless. Callers wanting the
// two-keystroke form can add a chord layer later.
KeyCode::Char('D') => Some(Intent::Vim(VimAction::DeleteWordForward)),
KeyCode::Char('B') => Some(Intent::Vim(VimAction::DeleteWordBackward)),
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::EditMode;
use sqlx::types::Uuid;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
fn app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn tab_layer_handles_digit_shortcuts() {
assert_eq!(
translate(&app(), key(KeyCode::Char('1'))),
Some(Intent::SelectTab(Tab::Accounts))
);
fn tab_layer_handles_tab_key() {
assert_eq!(translate(&app(), key(KeyCode::Tab)), Some(Intent::NextTab));
fn modal_layer_only_accepts_close() {
let mut a = app();
a.modals.push(crate::modal::Modal::Help);
assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
assert_eq!(translate(&a, key(KeyCode::Char('1'))), None);
fn cmdline_insert_accepts_ctrl_a_as_home() {
a.open_command_line();
translate(&a, ctrl(KeyCode::Char('a'))),
fn cmdline_insert_accepts_plain_characters() {
translate(&a, key(KeyCode::Char('q'))),
Some(Intent::InsertChar('q'))
fn cmdline_vim_normal_routes_motion_keys() {
a.set_edit_mode(EditMode::Vim);
a.command_line.enter_normal_mode();
translate(&a, key(KeyCode::Char('h'))),
Some(Intent::Vim(VimAction::MoveLeft))
translate(&a, key(KeyCode::Char('$'))),
Some(Intent::Vim(VimAction::MoveEnd))
fn unknown_key_returns_none() {
assert_eq!(translate(&app(), key(KeyCode::F(12))), None);
fn console_tab_unfocused_focuses_on_i_and_enter() {
a.active_tab = Tab::Console;
translate(&a, key(KeyCode::Char('i'))),
translate(&a, key(KeyCode::Enter)),
fn console_tab_unfocused_still_navigates() {
assert_eq!(translate(&a, key(KeyCode::Char('q'))), Some(Intent::Quit));
assert_eq!(translate(&a, key(KeyCode::Tab)), Some(Intent::NextTab));
translate(&a, key(KeyCode::Char('1'))),
translate(&a, key(KeyCode::Char('6'))),
Some(Intent::SelectTab(Tab::Console))
translate(&a, key(KeyCode::Char(':'))),
Some(Intent::OpenCommandLine)
fn focus_keys_are_inert_on_other_tabs() {
a.active_tab = Tab::Accounts;
assert_eq!(translate(&a, key(KeyCode::Char('i'))), None);
assert_eq!(translate(&a, key(KeyCode::Enter)), None);
fn console_focused_ctrl_c_maps_to_interrupt() {
a.console_input_active = true;
translate(&a, ctrl(KeyCode::Char('c'))),
fn console_stays_emacs_even_after_vim_toggle() {
// The console input editor is built Emacs-only, so a vim motion
// key is a literal insert here (never a vim-normal action) and
// Esc blurs the console rather than entering vim-normal.
assert_eq!(a.console.input.mode(), EditMode::Emacs);
Some(Intent::InsertChar('h'))
assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::ConsoleBlur));
fn console_focused_routes_editing_and_control_keys() {
translate(&a, key(KeyCode::Char('x'))),
Some(Intent::InsertChar('x'))
Some(Intent::ConsoleSubmit)
translate(&a, key(KeyCode::Up)),
Some(Intent::ConsoleHistoryPrev)
translate(&a, key(KeyCode::Down)),
Some(Intent::ConsoleHistoryNext)
translate(&a, key(KeyCode::Backspace)),
Some(Intent::DeleteBackward)