Lines
100 %
Functions
Branches
//! Tests for key-event translation.
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 input_modal_types_q_but_help_closes_on_q() {
use crate::form::Form;
// Help (display) closes on q.
let mut help = app();
help.overlays.push(Modal::Help);
assert_eq!(
translate(&help, key(KeyCode::Char('q'))),
Some(Intent::CloseTopmost)
);
// Form (input) types q; only Esc closes it.
let mut input = app();
input.overlays.push(Modal::Form(Form::config_set(
crate::widgets::Editor::new(EditMode::Emacs),
)));
translate(&input, key(KeyCode::Char('q'))),
Some(Intent::InsertChar('q'))
translate(&input, key(KeyCode::Esc)),
fn tab_layer_handles_digit_shortcuts() {
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_closes_and_routes_input() {
let mut a = app();
a.overlays.push(crate::modal::Modal::Help);
// Esc/q close the modal stack.
assert_eq!(translate(&a, key(KeyCode::Esc)), Some(Intent::CloseTopmost));
translate(&a, key(KeyCode::Char('q'))),
// Other keys are routed to the modal (input modals need typing, Tab to
// cycle focus, Enter to submit).
translate(&a, key(KeyCode::Char('1'))),
Some(Intent::InsertChar('1'))
assert_eq!(translate(&a, key(KeyCode::Tab)), Some(Intent::NextTab));
translate(&a, key(KeyCode::Enter)),
Some(Intent::SubmitCommandLine)
translate(&a, key(KeyCode::Backspace)),
Some(Intent::DeleteBackward)
fn cmdline_insert_accepts_ctrl_a_as_home() {
a.open_command_line();
translate(&a, ctrl(KeyCode::Char('a'))),
Some(Intent::MoveHome)
fn cmdline_insert_accepts_plain_characters() {
fn cmdline_insert_tab_triggers_completion() {
translate(&a, key(KeyCode::Tab)),
Some(Intent::CompleteCommandLine)
fn cmdline_vim_normal_routes_motion_keys() {
a.set_edit_mode(EditMode::Vim);
a.cmdline.editor.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'))),
Some(Intent::ConsoleFocus)
fn console_tab_unfocused_still_navigates() {
assert_eq!(translate(&a, key(KeyCode::Char('q'))), Some(Intent::Quit));
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_focused = true;
translate(&a, ctrl(KeyCode::Char('c'))),
Some(Intent::ConsoleInterrupt)
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)
fn console_focused_tab_cycles_pane() {
Some(Intent::ConsoleCyclePane)
translate(&a, key(KeyCode::BackTab)),
fn scrollback_pane_routes_scroll_keys() {
a.console.panes.focus(PaneId::Scrollback);
assert_eq!(translate(&a, key(KeyCode::Up)), Some(Intent::ScrollLineUp));
Some(Intent::ScrollLineDown)
translate(&a, key(KeyCode::PageUp)),
Some(Intent::ScrollPageUp)
translate(&a, key(KeyCode::PageDown)),
Some(Intent::ScrollPageDown)
fn scrollback_pane_letter_key_returns_none() {
assert_eq!(translate(&a, key(KeyCode::Char('x'))), None);
fn scrollback_pane_tab_cycles_back_to_prompt() {
fn app_with_select_modal() -> App {
use crate::form::{Field, Form, FormKind};
use crate::widgets::{SelectWidget, Widget};
let form = Form {
fields: vec![Field {
label: "account",
widget: Widget::Select(SelectWidget::new()),
}],
focus: 0,
kind: FormKind::ConfigSet,
entity_id: None,
};
a.overlays.push(Modal::Form(form));
a
fn select_focused_up_routes_to_select_prev() {
let a = app_with_select_modal();
assert_eq!(translate(&a, key(KeyCode::Up)), Some(Intent::SelectPrev));
fn select_focused_down_routes_to_select_next() {
assert_eq!(translate(&a, key(KeyCode::Down)), Some(Intent::SelectNext));
fn select_focused_enter_routes_to_select_confirm_not_submit() {
Some(Intent::SelectConfirm)
fn text_focused_enter_still_maps_to_submit() {
use crate::modal::Form;
a.overlays.push(Modal::Form(Form::config_set(
fn select_focused_tab_still_cycles_outer_fields() {
/// An app whose topmost modal is a transaction-create form with the Splits
/// field focused and the focused column set to `col`.
fn app_with_splits_modal(col: usize) -> App {
use crate::widgets::Widget;
let mut form = Form::transaction_create(EditMode::Emacs);
form.focus = 2; // the Splits field
if let Widget::Splits(ref mut sw) = form.fields[2].widget {
sw.col_focus = col;
fn splits_select_col_down_routes_to_select_next() {
use crate::widgets::splits::COL_FROM;
let a = app_with_splits_modal(COL_FROM);
// Widget-first: on a Select column, Down drives the Select options.
fn splits_amount_col_down_routes_to_row_next() {
use crate::widgets::splits::COL_VALUE;
let a = app_with_splits_modal(COL_VALUE);
// On an Amount column, Up/Down navigate rows, not Select options.
assert_eq!(translate(&a, key(KeyCode::Down)), Some(Intent::RowNext));
assert_eq!(translate(&a, key(KeyCode::Up)), Some(Intent::RowPrev));
fn splits_tab_navigates_columns_mid_row() {
use crate::widgets::splits::COL_TO;
// Mid-row (not at a boundary): Tab/BackTab walk cells, not outer fields.
let a = app_with_splits_modal(COL_TO);
assert_eq!(translate(&a, key(KeyCode::Tab)), Some(Intent::ColNext));
assert_eq!(translate(&a, key(KeyCode::BackTab)), Some(Intent::ColPrev));
fn splits_backtab_at_first_cell_yields_to_outer_form() {
// (row 0, col 0) is the first cell — BackTab yields to the outer cycle.
Some(Intent::PreviousTab)
fn splits_tab_at_last_cell_yields_to_outer_form() {
use crate::widgets::splits::COL_TO_AMOUNT;
// (last row, last col) is the last cell — Tab yields to the outer cycle.
let a = app_with_splits_modal(COL_TO_AMOUNT);
fn splits_enter_submits_the_form() {
// Enter on a split Select column submits (no dropdown to confirm).
fn splits_add_and_remove_row_shortcuts() {
translate(&a, key(KeyCode::Char('+'))),
Some(Intent::SplitAddRow)
translate(&a, key(KeyCode::Char('-'))),
Some(Intent::SplitRemoveRow)
translate(&a, ctrl(KeyCode::Char('n'))),