Skip to main content

tui/
event.rs

1//! Key-event routing.
2//!
3//! Rather than drive crossterm `KeyEvent` types through unit tests
4//! (which would pull the terminal into test scope) we translate key
5//! events into a small internal vocabulary and dispatch that. The
6//! vocabulary is expressive enough to drive every interactive
7//! operation in the TUI.
8
9use crate::app::{App, Tab};
10use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
11use crate::palette;
12use crate::widgets::{EditMode, Editor, VimAction, VimMode};
13use cli_core::{CommandNode, command_tree};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Intent {
17    Quit,
18    NextTab,
19    PreviousTab,
20    SelectTab(Tab),
21    OpenCommandLine,
22    CloseTopmost,
23    SubmitCommandLine,
24    InsertChar(char),
25    DeleteBackward,
26    MoveLeft,
27    MoveRight,
28    MoveHome,
29    MoveEnd,
30    KillToEnd,
31    KillWordBackward,
32    Vim(VimAction),
33    ToggleEditMode,
34    OpenHelp,
35    ConsoleFocus,
36    ConsoleBlur,
37    ConsoleSubmit,
38    ConsoleInterrupt,
39    ConsoleHistoryPrev,
40    ConsoleHistoryNext,
41}
42
43/// Apply an intent to the app. The caller (the real event loop) is
44/// responsible for translating crossterm key events into intents.
45pub fn apply(app: &mut App, intent: Intent) {
46    if handle_modal(app, intent) {
47        return;
48    }
49    if app.command_line_active {
50        handle_command_line(app, intent);
51        return;
52    }
53    if app.console_input_active {
54        handle_console(app, intent);
55        return;
56    }
57    handle_tab(app, intent);
58}
59
60/// Route input while the console is focused. `Enter` assembles the input
61/// line into the pending form; a complete (balanced) form is submitted
62/// to the eval, an incomplete one keeps buffering. Editing intents
63/// mutate the console input editor; history keys walk prior submissions.
64fn handle_console(app: &mut App, intent: Intent) {
65    match intent {
66        Intent::ConsoleBlur => app.console_input_active = false,
67        Intent::ConsoleSubmit => {
68            if let Some(form) = app.console.take_complete_form() {
69                app.submit_console_form(form);
70            }
71        }
72        Intent::ConsoleInterrupt => app.interrupt_console(),
73        Intent::ConsoleHistoryPrev => app.console.history_prev(),
74        Intent::ConsoleHistoryNext => app.console.history_next(),
75        Intent::InsertChar(c) => app.console.input.insert_char(c),
76        Intent::DeleteBackward => app.console.input.delete_backward(),
77        Intent::MoveLeft => app.console.input.move_left(),
78        Intent::MoveRight => app.console.input.move_right(),
79        Intent::MoveHome => app.console.input.move_home(),
80        Intent::MoveEnd => app.console.input.move_end(),
81        Intent::KillToEnd => app.console.input.kill_to_end(),
82        Intent::KillWordBackward => app.console.input.kill_word_backward(),
83        Intent::Vim(action) => app.console.input.vim_action(action),
84        _ => {}
85    }
86}
87
88fn handle_modal(app: &mut App, intent: Intent) -> bool {
89    if app.modals.is_empty() {
90        return false;
91    }
92    if matches!(intent, Intent::CloseTopmost) {
93        app.modals.pop();
94        return true;
95    }
96    if let Some(top) = app.modals.top_mut() {
97        apply_modal_intent(top, intent);
98    }
99    true
100}
101
102fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
103    let Modal::ConfigSet(form) = modal else {
104        return;
105    };
106    if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
107        form.focus = match form.focus {
108            ConfigSetField::Name => ConfigSetField::Value,
109            ConfigSetField::Value => ConfigSetField::Name,
110        };
111        return;
112    }
113    let editor = match form.focus {
114        ConfigSetField::Name => &mut form.name,
115        ConfigSetField::Value => &mut form.value,
116    };
117    match intent {
118        Intent::InsertChar(c) => editor.insert_char(c),
119        Intent::DeleteBackward => editor.delete_backward(),
120        Intent::MoveLeft => editor.move_left(),
121        Intent::MoveRight => editor.move_right(),
122        Intent::MoveHome => editor.move_home(),
123        Intent::MoveEnd => editor.move_end(),
124        Intent::KillToEnd => editor.kill_to_end(),
125        Intent::KillWordBackward => editor.kill_word_backward(),
126        Intent::Vim(action) => editor.vim_action(action),
127        _ => {}
128    }
129}
130
131fn handle_command_line(app: &mut App, intent: Intent) {
132    let editor = &mut app.command_line;
133    match intent {
134        Intent::CloseTopmost => {
135            if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
136                editor.enter_normal_mode();
137            } else {
138                app.command_line_active = false;
139            }
140        }
141        Intent::SubmitCommandLine => {
142            let buffer = editor.buffer().to_string();
143            app.close_command_line();
144            submit_palette(app, &buffer);
145        }
146        Intent::InsertChar(c) => editor.insert_char(c),
147        Intent::DeleteBackward => editor.delete_backward(),
148        Intent::MoveLeft => editor.move_left(),
149        Intent::MoveRight => editor.move_right(),
150        Intent::MoveHome => editor.move_home(),
151        Intent::MoveEnd => editor.move_end(),
152        Intent::KillToEnd => editor.kill_to_end(),
153        Intent::KillWordBackward => editor.kill_word_backward(),
154        Intent::Vim(action) => editor.vim_action(action),
155        _ => {}
156    }
157}
158
159/// Parse a command-palette input and act on the resolved command.
160/// Known leaves either open a matching modal form or set the status
161/// with a friendly resolution trace. Unknown paths surface a clear
162/// error in the status line.
163fn submit_palette(app: &mut App, input: &str) {
164    let query = palette::parse(input);
165    if query.path.is_empty() {
166        app.set_status("");
167        return;
168    }
169    let tree = command_tree();
170    match palette::resolve(&tree, &query) {
171        Some(node) => apply_resolved_command(app, node, &query.args),
172        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
173    }
174}
175
176fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
177    // Map recognised leaves onto TUI actions. Unknown leaves simply
178    // surface as status-line text for now — the runnable layer is
179    // wired in Phase 3 follow-up work as per-tab commands land.
180    match node.name.as_str() {
181        "set" => open_config_set_modal(app, args),
182        "version" => app.set_status("nomisync automation CLI carries `version` for now"),
183        _ => app.set_status(format!("resolved: {}", node.name)),
184    }
185}
186
187fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
188    let mut name = Editor::new(app.edit_mode);
189    let mut value = Editor::new(app.edit_mode);
190    for (k, v) in args {
191        match k.as_str() {
192            "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
193            "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
194            _ => {}
195        }
196    }
197    app.modals.push(Modal::ConfigSet(ConfigSetModal {
198        name,
199        value,
200        focus: ConfigSetField::Name,
201    }));
202}
203
204fn handle_tab(app: &mut App, intent: Intent) {
205    match intent {
206        Intent::Quit => app.request_quit(),
207        Intent::NextTab => app.next_tab(),
208        Intent::PreviousTab => app.previous_tab(),
209        Intent::SelectTab(t) => app.switch_tab(t),
210        Intent::OpenCommandLine => app.open_command_line(),
211        Intent::ConsoleFocus => app.console_input_active = true,
212        Intent::OpenHelp => app.modals.push(Modal::Help),
213        Intent::ToggleEditMode => {
214            let next = match app.edit_mode {
215                EditMode::Emacs => EditMode::Vim,
216                EditMode::Vim => EditMode::Emacs,
217            };
218            app.set_edit_mode(next);
219        }
220        _ => {}
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use sqlx::types::Uuid;
228
229    fn make_app() -> App {
230        App::new(Uuid::new_v4(), EditMode::Emacs)
231    }
232
233    #[test]
234    fn quit_intent_sets_quit_flag() {
235        let mut app = make_app();
236        apply(&mut app, Intent::Quit);
237        assert!(app.should_quit);
238    }
239
240    #[test]
241    fn next_tab_intent_advances_tab() {
242        let mut app = make_app();
243        app.active_tab = Tab::Accounts;
244        apply(&mut app, Intent::NextTab);
245        assert_eq!(app.active_tab, Tab::Transactions);
246    }
247
248    #[test]
249    fn select_tab_intent_jumps_directly() {
250        let mut app = make_app();
251        apply(&mut app, Intent::SelectTab(Tab::Reports));
252        assert_eq!(app.active_tab, Tab::Reports);
253    }
254
255    #[test]
256    fn open_command_line_activates_and_accepts_input() {
257        let mut app = make_app();
258        apply(&mut app, Intent::OpenCommandLine);
259        assert!(app.command_line_active);
260        apply(&mut app, Intent::InsertChar('v'));
261        apply(&mut app, Intent::InsertChar('x'));
262        assert_eq!(app.command_line.buffer(), "vx");
263    }
264
265    #[test]
266    fn command_line_escape_exits_when_already_in_normal_mode() {
267        let mut app = make_app();
268        app.set_edit_mode(EditMode::Vim);
269        apply(&mut app, Intent::OpenCommandLine);
270        app.command_line.enter_normal_mode();
271        apply(&mut app, Intent::CloseTopmost);
272        assert!(!app.command_line_active);
273    }
274
275    #[test]
276    fn command_line_escape_first_drops_vim_to_normal() {
277        let mut app = make_app();
278        app.set_edit_mode(EditMode::Vim);
279        apply(&mut app, Intent::OpenCommandLine);
280        apply(&mut app, Intent::InsertChar('x'));
281        assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
282        apply(&mut app, Intent::CloseTopmost);
283        assert!(
284            app.command_line_active,
285            "first Esc should keep cmdline open"
286        );
287        assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
288    }
289
290    #[test]
291    fn submit_command_line_records_buffer_and_closes() {
292        let mut app = make_app();
293        apply(&mut app, Intent::OpenCommandLine);
294        for c in "version".chars() {
295            apply(&mut app, Intent::InsertChar(c));
296        }
297        apply(&mut app, Intent::SubmitCommandLine);
298        assert!(!app.command_line_active);
299        assert!(
300            app.status.contains("version"),
301            "status should report the resolved command, got {}",
302            app.status
303        );
304    }
305
306    #[test]
307    fn submit_command_line_with_unknown_path_surfaces_error() {
308        let mut app = make_app();
309        apply(&mut app, Intent::OpenCommandLine);
310        for c in "bogus-cmd".chars() {
311            apply(&mut app, Intent::InsertChar(c));
312        }
313        apply(&mut app, Intent::SubmitCommandLine);
314        assert!(app.status.contains("unknown"));
315    }
316
317    #[test]
318    fn submit_config_set_opens_form_modal() {
319        let mut app = make_app();
320        apply(&mut app, Intent::OpenCommandLine);
321        for c in "config set name=locale value=en".chars() {
322            apply(&mut app, Intent::InsertChar(c));
323        }
324        apply(&mut app, Intent::SubmitCommandLine);
325        assert!(!app.modals.is_empty());
326        match app.modals.top() {
327            Some(Modal::ConfigSet(form)) => {
328                assert_eq!(form.name.buffer(), "locale");
329                assert_eq!(form.value.buffer(), "en");
330            }
331            other => panic!("expected ConfigSet modal, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn open_help_pushes_a_modal() {
337        let mut app = make_app();
338        apply(&mut app, Intent::OpenHelp);
339        assert!(!app.modals.is_empty());
340        assert!(matches!(app.modals.top(), Some(Modal::Help)));
341    }
342
343    #[test]
344    fn close_topmost_pops_modal_before_touching_tabs() {
345        let mut app = make_app();
346        apply(&mut app, Intent::OpenHelp);
347        apply(&mut app, Intent::Quit);
348        assert!(
349            !app.should_quit,
350            "quit should be swallowed by the modal layer"
351        );
352        apply(&mut app, Intent::CloseTopmost);
353        assert!(app.modals.is_empty());
354        apply(&mut app, Intent::Quit);
355        assert!(app.should_quit);
356    }
357
358    #[test]
359    fn toggle_edit_mode_flips_emacs_vim() {
360        let mut app = make_app();
361        assert_eq!(app.edit_mode, EditMode::Emacs);
362        apply(&mut app, Intent::ToggleEditMode);
363        assert_eq!(app.edit_mode, EditMode::Vim);
364        apply(&mut app, Intent::ToggleEditMode);
365        assert_eq!(app.edit_mode, EditMode::Emacs);
366    }
367
368    #[test]
369    fn console_focus_sets_flag_and_blur_clears_it() {
370        let mut app = make_app();
371        app.active_tab = Tab::Console;
372        apply(&mut app, Intent::ConsoleFocus);
373        assert!(app.console_input_active);
374        apply(&mut app, Intent::ConsoleBlur);
375        assert!(!app.console_input_active);
376    }
377
378    #[test]
379    fn console_editing_intents_mutate_input_editor() {
380        let mut app = make_app();
381        app.console_input_active = true;
382        apply(&mut app, Intent::InsertChar('('));
383        apply(&mut app, Intent::InsertChar('a'));
384        assert_eq!(app.console.input.buffer(), "(a");
385        apply(&mut app, Intent::DeleteBackward);
386        assert_eq!(app.console.input.buffer(), "(");
387    }
388
389    #[test]
390    fn console_submit_incomplete_form_keeps_buffering() {
391        let mut app = make_app();
392        app.console_input_active = true;
393        for c in "(list".chars() {
394            apply(&mut app, Intent::InsertChar(c));
395        }
396        apply(&mut app, Intent::ConsoleSubmit);
397        assert_eq!(app.console.pending, "(list");
398        assert!(app.console.input.buffer().is_empty());
399    }
400
401    #[tokio::test]
402    async fn console_submit_routes_complete_form_to_echo_eval() {
403        use crate::tabs::nms_eval::ConsoleEval;
404        let mut app = make_app();
405        app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
406        app.console_input_active = true;
407        for c in "(+ 1 2)".chars() {
408            apply(&mut app, Intent::InsertChar(c));
409        }
410        apply(&mut app, Intent::ConsoleSubmit);
411        tokio::task::yield_now().await;
412        app.drain_console();
413        assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
414        assert!(
415            app.console
416                .scrollback
417                .iter()
418                .any(|l| l.contains("(:id 0 :form (+ 1 2))"))
419        );
420    }
421
422    #[test]
423    fn console_history_keys_navigate_prior_submissions() {
424        let mut app = make_app();
425        app.console_input_active = true;
426        for form in ["(a)", "(b)"] {
427            for c in form.chars() {
428                apply(&mut app, Intent::InsertChar(c));
429            }
430            apply(&mut app, Intent::ConsoleSubmit);
431        }
432        apply(&mut app, Intent::ConsoleHistoryPrev);
433        assert_eq!(app.console.input.buffer(), "(b)");
434        apply(&mut app, Intent::ConsoleHistoryPrev);
435        assert_eq!(app.console.input.buffer(), "(a)");
436        apply(&mut app, Intent::ConsoleHistoryNext);
437        assert_eq!(app.console.input.buffer(), "(b)");
438    }
439
440    #[test]
441    fn console_interrupt_without_eval_does_not_panic() {
442        let mut app = make_app();
443        app.console_input_active = true;
444        apply(&mut app, Intent::ConsoleInterrupt);
445    }
446
447    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
448    async fn console_interrupt_intent_reaches_attached_eval() {
449        use crate::tabs::nms_eval::ConsoleEval;
450        use rpc::{ScriptCtx, ScriptLimits};
451        use std::time::Duration;
452        use tokio::time::sleep;
453
454        // Unbounded fuel so the runaway loop only ever stops on the
455        // explicit interrupt — driving the cancel through the real event
456        // layer (apply -> App::interrupt_console -> eval.interrupt).
457        let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
458            fuel: u64::MAX,
459            ..ScriptLimits::default()
460        });
461        let eval =
462            ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
463        let mut app = make_app();
464        app.attach_console(eval);
465        app.console_input_active = true;
466        for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
467            apply(&mut app, Intent::InsertChar(c));
468        }
469        apply(&mut app, Intent::ConsoleSubmit);
470        sleep(Duration::from_millis(40)).await;
471        apply(&mut app, Intent::ConsoleInterrupt);
472
473        let mut interrupted = false;
474        for _ in 0..200 {
475            app.drain_console();
476            if app
477                .console
478                .scrollback
479                .iter()
480                .any(|l| l.contains(":code interrupted"))
481            {
482                interrupted = true;
483                break;
484            }
485            sleep(Duration::from_millis(20)).await;
486        }
487        assert!(interrupted, "interrupt did not reach the eval");
488    }
489}