Lines
69.29 %
Functions
36.11 %
Branches
100 %
//! Key-event routing.
//!
//! Rather than drive crossterm `KeyEvent` types through unit tests
//! (which would pull the terminal into test scope) we translate key
//! events into a small internal vocabulary and dispatch that. The
//! vocabulary is expressive enough to drive every interactive
//! operation in the TUI.
mod completion;
mod modal_open;
mod submit;
use crate::app::App;
use crate::command::{build_form, build_report_request};
use crate::focus::{FocusTarget, current_focus};
use crate::modal::Modal;
use crate::palette;
use crate::pane::PaneId;
use crate::view::{Cmd, DrawCtx, Handled, Tab};
use crate::widgets::{EditMode, Editor, FocusedSubWidget, VimAction, VimMode, Widget};
use cli_core::{CommandNode, command_tree};
use modal_open::{
open_account_create_modal, open_account_tag_modal_for_selected, open_commodity_convert_modal,
open_commodity_create_modal, open_config_set_modal, open_report_params_modal,
open_transaction_create_modal, open_transaction_tag_modal_for_selected,
};
use submit::{execute_confirm, submit_form};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Quit,
NextTab,
PreviousTab,
SelectTab(Tab),
OpenCommandLine,
CloseTopmost,
SubmitCommandLine,
/// Confirm the topmost confirm overlay (equivalent to pressing `y`).
ConfirmYes,
InsertChar(char),
DeleteBackward,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
KillToEnd,
KillWordBackward,
Vim(VimAction),
ToggleEditMode,
OpenHelp,
ConsoleFocus,
ConsoleBlur,
ConsoleSubmit,
ConsoleInterrupt,
ConsoleHistoryPrev,
ConsoleHistoryNext,
ConsoleCyclePane,
ScrollLineUp,
ScrollLineDown,
ScrollPageUp,
ScrollPageDown,
ListSelectNext,
ListSelectPrev,
/// Delete the currently selected list item (pushes a confirm overlay).
ListDelete,
/// Open the tag editor for the currently selected list item.
ListEdit,
RefreshTab,
EditConfig,
SelectNext,
SelectPrev,
SelectConfirm,
/// Splits: move focus to next column within the focused row.
ColNext,
/// Splits: move focus to previous column within the focused row.
ColPrev,
/// Splits: move focus to next row.
RowNext,
/// Splits: move focus to previous row.
RowPrev,
/// Splits: append a new blank row.
SplitAddRow,
/// Splits: remove the focused row (minimum 1 kept).
SplitRemoveRow,
/// Complete the current command-palette token (Tab in the command line).
CompleteCommandLine,
/// Step the focused Date field forward by one day (Up key).
DateStepBack,
/// Step the focused Date field forward by one day (Down key).
DateStepForward,
}
/// Apply an intent to the app. The caller (the real event loop) is
/// responsible for translating crossterm key events into intents.
///
/// Dispatch follows the canonical focus order: Overlay > CmdLine > ConsoleInput > ViewPane.
pub fn apply(app: &mut App, intent: Intent) {
match current_focus(app) {
FocusTarget::Overlay => handle_overlay(app, intent),
FocusTarget::CmdLine => handle_command_line(app, intent),
FocusTarget::ConsoleInput => handle_console(app, intent),
FocusTarget::ViewPane => handle_tab(app, intent),
/// Route input while the console is focused. `Enter` assembles the input
/// line into the pending form; a complete (balanced) form is submitted
/// to the eval, an incomplete one keeps buffering. Editing intents
/// mutate the console input editor; history keys walk prior submissions.
fn handle_console(app: &mut App, intent: Intent) {
match intent {
Intent::ConsoleBlur => app.console_focused = false,
Intent::ConsoleSubmit => {
if let Some(form) = app.console.take_complete_form() {
app.submit_console_form(form);
Intent::ConsoleInterrupt => app.interrupt_console(),
Intent::ConsoleHistoryPrev => app.console.history_prev(),
Intent::ConsoleHistoryNext => app.console.history_next(),
Intent::ConsoleCyclePane => app.console.panes.cycle(true),
Intent::ScrollLineUp => app.console.scroll_up(1),
Intent::ScrollLineDown => app.console.scroll_down(1),
Intent::ScrollPageUp => app.console.scroll_up(10),
Intent::ScrollPageDown => app.console.scroll_down(10),
Intent::InsertChar(c) => app.console.input.insert_char(c),
Intent::DeleteBackward => app.console.input.delete_backward(),
Intent::MoveLeft => app.console.input.move_left(),
Intent::MoveRight => app.console.input.move_right(),
Intent::MoveHome => app.console.input.move_home(),
Intent::MoveEnd => app.console.input.move_end(),
Intent::KillToEnd => app.console.input.kill_to_end(),
Intent::KillWordBackward => app.console.input.kill_word_backward(),
Intent::Vim(action) => app.console.input.vim_action(action),
_ => {}
fn handle_overlay(app: &mut App, intent: Intent) {
Intent::CloseTopmost => {
app.overlays.pop();
// `ConfirmYes` only ever acts on a `Modal::Confirm`; for any other
// overlay it is a no-op (it must never submit a form).
Intent::ConfirmYes => confirm_topmost(app),
Intent::SubmitCommandLine => submit_modal(app),
_ => {
if let Some(top) = app.overlays.top_mut() {
apply_modal_intent(top, intent);
/// Confirm the topmost overlay, but only when it is a `Modal::Confirm`.
fn confirm_topmost(app: &mut App) {
if !matches!(app.overlays.top(), Some(Modal::Confirm { .. })) {
return;
if let Some(Modal::Confirm { action, .. }) = app.overlays.pop() {
execute_confirm(app, action);
/// Apply Enter to the topmost overlay (form submit or confirm action).
fn submit_modal(app: &mut App) {
let Some(modal) = app.overlays.pop() else {
match modal {
Modal::Confirm { action, .. } => execute_confirm(app, action),
Modal::Form(form) => submit_form(app, form),
Modal::Help => {}
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
Modal::Form(form) => match intent {
// Tab/BackTab cycle fields only when focused widget is NOT Splits.
// When Splits is focused, keymap.rs emits ColNext/ColPrev instead.
Intent::NextTab => form.cycle(true),
Intent::PreviousTab => form.cycle(false),
if let Some(field) = form.focused_field_mut() {
apply_widget_intent(&mut field.widget, intent);
},
Modal::Help | Modal::Confirm { .. } => {}
fn apply_widget_intent(widget: &mut Widget, intent: Intent) {
match widget {
Widget::Text(ed) => apply_editor_intent(ed, intent),
Widget::Amount(aw) => match intent {
Intent::InsertChar(c) => aw.insert_char(c),
_ => apply_editor_intent(aw.as_editor_mut(), intent),
Widget::Date(dw) => match intent {
Intent::DateStepForward => dw.step(1),
Intent::DateStepBack => dw.step(-1),
_ => apply_editor_intent(dw.as_editor_mut(), intent),
Widget::Select(sw) => match intent {
Intent::SelectNext => sw.next(),
Intent::SelectPrev => sw.prev(),
Intent::SelectConfirm => sw.confirm(),
Intent::InsertChar(c) => sw.filter_push(c),
Intent::DeleteBackward => sw.filter_pop(),
Widget::Splits(sw) => match intent {
Intent::ColNext => sw.advance_cell(),
Intent::ColPrev => sw.retreat_cell(),
Intent::RowNext => sw.next_row(),
Intent::RowPrev => sw.prev_row(),
Intent::SplitAddRow => sw.add_row(),
Intent::SplitRemoveRow => sw.remove_row(),
_ => match sw.focused_subwidget_mut() {
Some(FocusedSubWidget::Select(s)) => match intent {
Intent::SelectNext => s.next(),
Intent::SelectPrev => s.prev(),
Some(FocusedSubWidget::Amount(a)) => match intent {
Intent::InsertChar(c) => a.insert_char(c),
_ => apply_editor_intent(a.as_editor_mut(), intent),
None => {}
fn apply_editor_intent(editor: &mut Editor, intent: Intent) {
Intent::InsertChar(c) => editor.insert_char(c),
Intent::DeleteBackward => editor.delete_backward(),
Intent::MoveLeft => editor.move_left(),
Intent::MoveRight => editor.move_right(),
Intent::MoveHome => editor.move_home(),
Intent::MoveEnd => editor.move_end(),
Intent::KillToEnd => editor.kill_to_end(),
Intent::KillWordBackward => editor.kill_word_backward(),
Intent::Vim(action) => editor.vim_action(action),
fn handle_command_line(app: &mut App, intent: Intent) {
if app.edit_mode == EditMode::Vim && app.cmdline.editor.vim_mode() == VimMode::Insert {
app.cmdline.editor.enter_normal_mode();
} else {
app.cmdline.active = false;
Intent::SubmitCommandLine => {
let buffer = app.cmdline.editor.buffer().to_string();
app.close_command_line();
submit_palette(app, &buffer);
Intent::CompleteCommandLine => completion::apply_command_completion(app),
// Any other edit invalidates the displayed completion candidates.
app.cmdline.completions.clear();
apply_editor_intent(&mut app.cmdline.editor, intent);
/// Parse a command-palette input and act on the resolved command.
fn submit_palette(app: &mut App, input: &str) {
let query = palette::parse(input);
if query.path.is_empty() {
app.set_status("");
let tree = command_tree();
match palette::resolve(&tree, &query) {
Some(node) => apply_resolved_command(app, node, &query.path, &query.args),
None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
fn apply_resolved_command(
app: &mut App,
node: &CommandNode,
path: &[String],
args: &[(String, String)],
) {
let path_str = path.join(" ");
if path_str.starts_with("reports ") {
apply_report_command(app, path, args);
match build_form(node, path, args) {
Err(e) => app.set_status(format!("command error: {e}")),
Ok(None) => {
let segments: Vec<&str> = path.iter().map(String::as_str).collect();
match segments.as_slice() {
["config", "set"] => open_config_set_modal(app, args),
["account", "create"] => open_account_create_modal(app),
["account", "tag"] => open_account_tag_modal_for_selected(app),
["commodity", "create"] => open_commodity_create_modal(app),
["commodity", "convert"] => open_commodity_convert_modal(app),
["transaction", "create"] => open_transaction_create_modal(app),
["transaction", "tag"] => open_transaction_tag_modal_for_selected(app),
_ => app.set_status(format!("`{path_str}` is not available in the TUI yet")),
Ok(Some(form)) => {
app.switch_tab(Tab::Console);
fn apply_report_command(app: &mut App, path: &[String], args: &[(String, String)]) {
match build_report_request(path, args) {
Ok(Some(req)) => {
app.switch_tab(Tab::Reports);
app.fetch_report(req.kind, &req.from, &req.to, &req.chart);
let kind = match path.join(" ").as_str() {
"reports activity" => crate::tabs::reports::ReportKind::Activity,
"reports breakdown" => crate::tabs::reports::ReportKind::Breakdown,
app.set_status("unknown report command".to_string());
let chart_default = args
.iter()
.find(|(k, _)| k == "chart")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "bar".to_string());
open_report_params_modal(app, kind, &chart_default);
fn execute(app: &mut App, cmds: Vec<Cmd>) {
for cmd in cmds {
match cmd {
Cmd::Status(msg) => app.set_status(msg),
Cmd::SwitchView(id) => app.switch_tab(id),
fn handle_tab(app: &mut App, intent: Intent) {
let ctx = DrawCtx {
edit_mode: app.edit_mode,
is_active: app.console_focused,
if let Handled::Consumed(cmds) = app.active_view_mut().handle(intent, &ctx) {
execute(app, cmds);
Intent::Quit => app.request_quit(),
Intent::NextTab => app.next_tab(),
Intent::PreviousTab => app.previous_tab(),
Intent::SelectTab(t) => app.switch_tab(t),
Intent::OpenCommandLine => app.open_command_line(),
Intent::ConsoleFocus if app.active_tab == Tab::Console => {
app.console.panes.focus(PaneId::Prompt);
app.console_focused = true;
Intent::OpenHelp => app.overlays.push(Modal::Help),
Intent::ToggleEditMode => {
let next = match app.edit_mode {
EditMode::Emacs => EditMode::Vim,
EditMode::Vim => EditMode::Emacs,
app.set_edit_mode(next);
Intent::RefreshTab => app.refresh_tab(app.active_tab),
Intent::EditConfig if app.active_tab == Tab::Config => {
let maybe = {
let cfg = &app.config;
cfg.selected_key()
.map(|k| (k.to_string(), cfg.selected_value().to_string()))
if let Some((key, value)) = maybe {
open_config_set_modal(
app,
&[("name".to_string(), key), ("value".to_string(), value)],
);
Intent::ListDelete if app.active_tab == Tab::Transactions => {
let maybe_id = app.transactions.selected_id().map(str::to_string);
if let Some(id) = maybe_id {
app.overlays.push(Modal::Confirm {
prompt: format!("Delete transaction {id}? (y/N)"),
action: crate::modal::ConfirmAction::DeleteTransaction(id),
});
Intent::ListEdit if app.active_tab == Tab::Transactions => {
app.open_transaction_edit_async(&id);
app.set_status("no transaction selected");
Intent::ListEdit if app.active_tab == Tab::Accounts => {
open_account_tag_modal_for_selected(app);
#[cfg(test)]
mod tests;