Lines
70.83 %
Functions
75 %
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.modals.is_empty() {
return translate_modal(key);
translate_tab(key)
fn translate_tab(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('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Intent::ToggleEditMode)
_ => None,
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),
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('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_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);