Lines
83.71 %
Functions
72.41 %
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.
use crate::app::{App, Tab};
use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
use crate::palette;
use crate::widgets::{EditMode, Editor, VimAction, VimMode};
use cli_core::{CommandNode, command_tree};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Quit,
NextTab,
PreviousTab,
SelectTab(Tab),
OpenCommandLine,
CloseTopmost,
SubmitCommandLine,
InsertChar(char),
DeleteBackward,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
KillToEnd,
KillWordBackward,
Vim(VimAction),
ToggleEditMode,
OpenHelp,
}
/// Apply an intent to the app. The caller (the real event loop) is
/// responsible for translating crossterm key events into intents.
pub fn apply(app: &mut App, intent: Intent) {
if handle_modal(app, intent) {
return;
if app.command_line_active {
handle_command_line(app, intent);
handle_tab(app, intent);
fn handle_modal(app: &mut App, intent: Intent) -> bool {
if app.modals.is_empty() {
return false;
if matches!(intent, Intent::CloseTopmost) {
app.modals.pop();
return true;
if let Some(top) = app.modals.top_mut() {
apply_modal_intent(top, intent);
true
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
let Modal::ConfigSet(form) = modal else {
};
if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
form.focus = match form.focus {
ConfigSetField::Name => ConfigSetField::Value,
ConfigSetField::Value => ConfigSetField::Name,
let editor = match form.focus {
ConfigSetField::Name => &mut form.name,
ConfigSetField::Value => &mut form.value,
match 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) {
let editor = &mut app.command_line;
Intent::CloseTopmost => {
if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
editor.enter_normal_mode();
} else {
app.command_line_active = false;
Intent::SubmitCommandLine => {
let buffer = editor.buffer().to_string();
app.close_command_line();
submit_palette(app, &buffer);
/// Parse a command-palette input and act on the resolved command.
/// Known leaves either open a matching modal form or set the status
/// with a friendly resolution trace. Unknown paths surface a clear
/// error in the status line.
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.args),
None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
// Map recognised leaves onto TUI actions. Unknown leaves simply
// surface as status-line text for now — the runnable layer is
// wired in Phase 3 follow-up work as per-tab commands land.
match node.name.as_str() {
"set" => open_config_set_modal(app, args),
"version" => app.set_status("nomisync automation CLI carries `version` for now"),
_ => app.set_status(format!("resolved: {}", node.name)),
fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
let mut name = Editor::new(app.edit_mode);
let mut value = Editor::new(app.edit_mode);
for (k, v) in args {
match k.as_str() {
"name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
"value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
app.modals.push(Modal::ConfigSet(ConfigSetModal {
name,
value,
focus: ConfigSetField::Name,
}));
fn handle_tab(app: &mut App, intent: Intent) {
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::OpenHelp => app.modals.push(Modal::Help),
Intent::ToggleEditMode => {
let next = match app.edit_mode {
EditMode::Emacs => EditMode::Vim,
EditMode::Vim => EditMode::Emacs,
app.set_edit_mode(next);
#[cfg(test)]
mod tests {
use super::*;
use sqlx::types::Uuid;
fn make_app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn quit_intent_sets_quit_flag() {
let mut app = make_app();
apply(&mut app, Intent::Quit);
assert!(app.should_quit);
fn next_tab_intent_advances_tab() {
app.active_tab = Tab::Accounts;
apply(&mut app, Intent::NextTab);
assert_eq!(app.active_tab, Tab::Transactions);
fn select_tab_intent_jumps_directly() {
apply(&mut app, Intent::SelectTab(Tab::Reports));
assert_eq!(app.active_tab, Tab::Reports);
fn open_command_line_activates_and_accepts_input() {
apply(&mut app, Intent::OpenCommandLine);
assert!(app.command_line_active);
apply(&mut app, Intent::InsertChar('v'));
apply(&mut app, Intent::InsertChar('x'));
assert_eq!(app.command_line.buffer(), "vx");
fn command_line_escape_exits_when_already_in_normal_mode() {
app.set_edit_mode(EditMode::Vim);
app.command_line.enter_normal_mode();
apply(&mut app, Intent::CloseTopmost);
assert!(!app.command_line_active);
fn command_line_escape_first_drops_vim_to_normal() {
assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
assert!(
app.command_line_active,
"first Esc should keep cmdline open"
);
assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
fn submit_command_line_records_buffer_and_closes() {
for c in "version".chars() {
apply(&mut app, Intent::InsertChar(c));
apply(&mut app, Intent::SubmitCommandLine);
app.status.contains("version"),
"status should report the resolved command, got {}",
app.status
fn submit_command_line_with_unknown_path_surfaces_error() {
for c in "bogus-cmd".chars() {
assert!(app.status.contains("unknown"));
fn submit_config_set_opens_form_modal() {
for c in "config set name=locale value=en".chars() {
assert!(!app.modals.is_empty());
match app.modals.top() {
Some(Modal::ConfigSet(form)) => {
assert_eq!(form.name.buffer(), "locale");
assert_eq!(form.value.buffer(), "en");
other => panic!("expected ConfigSet modal, got {other:?}"),
fn open_help_pushes_a_modal() {
apply(&mut app, Intent::OpenHelp);
assert!(matches!(app.modals.top(), Some(Modal::Help)));
fn close_topmost_pops_modal_before_touching_tabs() {
!app.should_quit,
"quit should be swallowed by the modal layer"
assert!(app.modals.is_empty());
fn toggle_edit_mode_flips_emacs_vim() {
assert_eq!(app.edit_mode, EditMode::Emacs);
apply(&mut app, Intent::ToggleEditMode);
assert_eq!(app.edit_mode, EditMode::Vim);