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

            
9
use crate::app::{App, Tab};
10
use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
11
use crate::palette;
12
use crate::widgets::{EditMode, Editor, VimAction, VimMode};
13
use cli_core::{CommandNode, command_tree};
14

            
15
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
pub 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.
45
160
pub fn apply(app: &mut App, intent: Intent) {
46
160
    if handle_modal(app, intent) {
47
2
        return;
48
158
    }
49
158
    if app.command_line_active {
50
63
        handle_command_line(app, intent);
51
63
        return;
52
95
    }
53
95
    if app.console_input_active {
54
74
        handle_console(app, intent);
55
74
        return;
56
21
    }
57
21
    handle_tab(app, intent);
58
160
}
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.
64
74
fn handle_console(app: &mut App, intent: Intent) {
65
74
    match intent {
66
1
        Intent::ConsoleBlur => app.console_input_active = false,
67
        Intent::ConsoleSubmit => {
68
5
            if let Some(form) = app.console.take_complete_form() {
69
4
                app.submit_console_form(form);
70
4
            }
71
        }
72
2
        Intent::ConsoleInterrupt => app.interrupt_console(),
73
2
        Intent::ConsoleHistoryPrev => app.console.history_prev(),
74
1
        Intent::ConsoleHistoryNext => app.console.history_next(),
75
62
        Intent::InsertChar(c) => app.console.input.insert_char(c),
76
1
        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
74
}
87

            
88
160
fn handle_modal(app: &mut App, intent: Intent) -> bool {
89
160
    if app.modals.is_empty() {
90
158
        return false;
91
2
    }
92
2
    if matches!(intent, Intent::CloseTopmost) {
93
1
        app.modals.pop();
94
1
        return true;
95
1
    }
96
1
    if let Some(top) = app.modals.top_mut() {
97
1
        apply_modal_intent(top, intent);
98
1
    }
99
1
    true
100
160
}
101

            
102
1
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
103
1
    let Modal::ConfigSet(form) = modal else {
104
1
        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
1
}
130

            
131
63
fn handle_command_line(app: &mut App, intent: Intent) {
132
63
    let editor = &mut app.command_line;
133
63
    match intent {
134
        Intent::CloseTopmost => {
135
2
            if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
136
1
                editor.enter_normal_mode();
137
1
            } else {
138
1
                app.command_line_active = false;
139
1
            }
140
        }
141
4
        Intent::SubmitCommandLine => {
142
4
            let buffer = editor.buffer().to_string();
143
4
            app.close_command_line();
144
4
            submit_palette(app, &buffer);
145
4
        }
146
57
        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
63
}
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.
163
4
fn submit_palette(app: &mut App, input: &str) {
164
4
    let query = palette::parse(input);
165
4
    if query.path.is_empty() {
166
        app.set_status("");
167
        return;
168
4
    }
169
4
    let tree = command_tree();
170
4
    match palette::resolve(&tree, &query) {
171
3
        Some(node) => apply_resolved_command(app, node, &query.args),
172
1
        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
173
    }
174
4
}
175

            
176
3
fn 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
3
    match node.name.as_str() {
181
3
        "set" => open_config_set_modal(app, args),
182
2
        "version" => app.set_status("nomisync automation CLI carries `version` for now"),
183
        _ => app.set_status(format!("resolved: {}", node.name)),
184
    }
185
3
}
186

            
187
1
fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
188
1
    let mut name = Editor::new(app.edit_mode);
189
1
    let mut value = Editor::new(app.edit_mode);
190
2
    for (k, v) in args {
191
2
        match k.as_str() {
192
2
            "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
193
1
            "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
194
            _ => {}
195
        }
196
    }
197
1
    app.modals.push(Modal::ConfigSet(ConfigSetModal {
198
1
        name,
199
1
        value,
200
1
        focus: ConfigSetField::Name,
201
1
    }));
202
1
}
203

            
204
21
fn handle_tab(app: &mut App, intent: Intent) {
205
21
    match intent {
206
6
        Intent::Quit => app.request_quit(),
207
1
        Intent::NextTab => app.next_tab(),
208
        Intent::PreviousTab => app.previous_tab(),
209
2
        Intent::SelectTab(t) => app.switch_tab(t),
210
7
        Intent::OpenCommandLine => app.open_command_line(),
211
1
        Intent::ConsoleFocus => app.console_input_active = true,
212
2
        Intent::OpenHelp => app.modals.push(Modal::Help),
213
        Intent::ToggleEditMode => {
214
2
            let next = match app.edit_mode {
215
1
                EditMode::Emacs => EditMode::Vim,
216
1
                EditMode::Vim => EditMode::Emacs,
217
            };
218
2
            app.set_edit_mode(next);
219
        }
220
        _ => {}
221
    }
222
21
}
223

            
224
#[cfg(test)]
225
mod tests {
226
    use super::*;
227
    use sqlx::types::Uuid;
228

            
229
19
    fn make_app() -> App {
230
19
        App::new(Uuid::new_v4(), EditMode::Emacs)
231
19
    }
232

            
233
    #[test]
234
1
    fn quit_intent_sets_quit_flag() {
235
1
        let mut app = make_app();
236
1
        apply(&mut app, Intent::Quit);
237
1
        assert!(app.should_quit);
238
1
    }
239

            
240
    #[test]
241
1
    fn next_tab_intent_advances_tab() {
242
1
        let mut app = make_app();
243
1
        app.active_tab = Tab::Accounts;
244
1
        apply(&mut app, Intent::NextTab);
245
1
        assert_eq!(app.active_tab, Tab::Transactions);
246
1
    }
247

            
248
    #[test]
249
1
    fn select_tab_intent_jumps_directly() {
250
1
        let mut app = make_app();
251
1
        apply(&mut app, Intent::SelectTab(Tab::Reports));
252
1
        assert_eq!(app.active_tab, Tab::Reports);
253
1
    }
254

            
255
    #[test]
256
1
    fn open_command_line_activates_and_accepts_input() {
257
1
        let mut app = make_app();
258
1
        apply(&mut app, Intent::OpenCommandLine);
259
1
        assert!(app.command_line_active);
260
1
        apply(&mut app, Intent::InsertChar('v'));
261
1
        apply(&mut app, Intent::InsertChar('x'));
262
1
        assert_eq!(app.command_line.buffer(), "vx");
263
1
    }
264

            
265
    #[test]
266
1
    fn command_line_escape_exits_when_already_in_normal_mode() {
267
1
        let mut app = make_app();
268
1
        app.set_edit_mode(EditMode::Vim);
269
1
        apply(&mut app, Intent::OpenCommandLine);
270
1
        app.command_line.enter_normal_mode();
271
1
        apply(&mut app, Intent::CloseTopmost);
272
1
        assert!(!app.command_line_active);
273
1
    }
274

            
275
    #[test]
276
1
    fn command_line_escape_first_drops_vim_to_normal() {
277
1
        let mut app = make_app();
278
1
        app.set_edit_mode(EditMode::Vim);
279
1
        apply(&mut app, Intent::OpenCommandLine);
280
1
        apply(&mut app, Intent::InsertChar('x'));
281
1
        assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
282
1
        apply(&mut app, Intent::CloseTopmost);
283
1
        assert!(
284
1
            app.command_line_active,
285
            "first Esc should keep cmdline open"
286
        );
287
1
        assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
288
1
    }
289

            
290
    #[test]
291
1
    fn submit_command_line_records_buffer_and_closes() {
292
1
        let mut app = make_app();
293
1
        apply(&mut app, Intent::OpenCommandLine);
294
7
        for c in "version".chars() {
295
7
            apply(&mut app, Intent::InsertChar(c));
296
7
        }
297
1
        apply(&mut app, Intent::SubmitCommandLine);
298
1
        assert!(!app.command_line_active);
299
1
        assert!(
300
1
            app.status.contains("version"),
301
            "status should report the resolved command, got {}",
302
            app.status
303
        );
304
1
    }
305

            
306
    #[test]
307
1
    fn submit_command_line_with_unknown_path_surfaces_error() {
308
1
        let mut app = make_app();
309
1
        apply(&mut app, Intent::OpenCommandLine);
310
9
        for c in "bogus-cmd".chars() {
311
9
            apply(&mut app, Intent::InsertChar(c));
312
9
        }
313
1
        apply(&mut app, Intent::SubmitCommandLine);
314
1
        assert!(app.status.contains("unknown"));
315
1
    }
316

            
317
    #[test]
318
1
    fn submit_config_set_opens_form_modal() {
319
1
        let mut app = make_app();
320
1
        apply(&mut app, Intent::OpenCommandLine);
321
31
        for c in "config set name=locale value=en".chars() {
322
31
            apply(&mut app, Intent::InsertChar(c));
323
31
        }
324
1
        apply(&mut app, Intent::SubmitCommandLine);
325
1
        assert!(!app.modals.is_empty());
326
1
        match app.modals.top() {
327
1
            Some(Modal::ConfigSet(form)) => {
328
1
                assert_eq!(form.name.buffer(), "locale");
329
1
                assert_eq!(form.value.buffer(), "en");
330
            }
331
            other => panic!("expected ConfigSet modal, got {other:?}"),
332
        }
333
1
    }
334

            
335
    #[test]
336
1
    fn open_help_pushes_a_modal() {
337
1
        let mut app = make_app();
338
1
        apply(&mut app, Intent::OpenHelp);
339
1
        assert!(!app.modals.is_empty());
340
1
        assert!(matches!(app.modals.top(), Some(Modal::Help)));
341
1
    }
342

            
343
    #[test]
344
1
    fn close_topmost_pops_modal_before_touching_tabs() {
345
1
        let mut app = make_app();
346
1
        apply(&mut app, Intent::OpenHelp);
347
1
        apply(&mut app, Intent::Quit);
348
1
        assert!(
349
1
            !app.should_quit,
350
            "quit should be swallowed by the modal layer"
351
        );
352
1
        apply(&mut app, Intent::CloseTopmost);
353
1
        assert!(app.modals.is_empty());
354
1
        apply(&mut app, Intent::Quit);
355
1
        assert!(app.should_quit);
356
1
    }
357

            
358
    #[test]
359
1
    fn toggle_edit_mode_flips_emacs_vim() {
360
1
        let mut app = make_app();
361
1
        assert_eq!(app.edit_mode, EditMode::Emacs);
362
1
        apply(&mut app, Intent::ToggleEditMode);
363
1
        assert_eq!(app.edit_mode, EditMode::Vim);
364
1
        apply(&mut app, Intent::ToggleEditMode);
365
1
        assert_eq!(app.edit_mode, EditMode::Emacs);
366
1
    }
367

            
368
    #[test]
369
1
    fn console_focus_sets_flag_and_blur_clears_it() {
370
1
        let mut app = make_app();
371
1
        app.active_tab = Tab::Console;
372
1
        apply(&mut app, Intent::ConsoleFocus);
373
1
        assert!(app.console_input_active);
374
1
        apply(&mut app, Intent::ConsoleBlur);
375
1
        assert!(!app.console_input_active);
376
1
    }
377

            
378
    #[test]
379
1
    fn console_editing_intents_mutate_input_editor() {
380
1
        let mut app = make_app();
381
1
        app.console_input_active = true;
382
1
        apply(&mut app, Intent::InsertChar('('));
383
1
        apply(&mut app, Intent::InsertChar('a'));
384
1
        assert_eq!(app.console.input.buffer(), "(a");
385
1
        apply(&mut app, Intent::DeleteBackward);
386
1
        assert_eq!(app.console.input.buffer(), "(");
387
1
    }
388

            
389
    #[test]
390
1
    fn console_submit_incomplete_form_keeps_buffering() {
391
1
        let mut app = make_app();
392
1
        app.console_input_active = true;
393
5
        for c in "(list".chars() {
394
5
            apply(&mut app, Intent::InsertChar(c));
395
5
        }
396
1
        apply(&mut app, Intent::ConsoleSubmit);
397
1
        assert_eq!(app.console.pending, "(list");
398
1
        assert!(app.console.input.buffer().is_empty());
399
1
    }
400

            
401
    #[tokio::test]
402
1
    async fn console_submit_routes_complete_form_to_echo_eval() {
403
        use crate::tabs::nms_eval::ConsoleEval;
404
1
        let mut app = make_app();
405
1
        app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
406
1
        app.console_input_active = true;
407
7
        for c in "(+ 1 2)".chars() {
408
7
            apply(&mut app, Intent::InsertChar(c));
409
7
        }
410
1
        apply(&mut app, Intent::ConsoleSubmit);
411
1
        tokio::task::yield_now().await;
412
1
        app.drain_console();
413
1
        assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
414
1
        assert!(
415
1
            app.console
416
1
                .scrollback
417
1
                .iter()
418
2
                .any(|l| l.contains("(:id 0 :form (+ 1 2))"))
419
1
        );
420
1
    }
421

            
422
    #[test]
423
1
    fn console_history_keys_navigate_prior_submissions() {
424
1
        let mut app = make_app();
425
1
        app.console_input_active = true;
426
2
        for form in ["(a)", "(b)"] {
427
6
            for c in form.chars() {
428
6
                apply(&mut app, Intent::InsertChar(c));
429
6
            }
430
2
            apply(&mut app, Intent::ConsoleSubmit);
431
        }
432
1
        apply(&mut app, Intent::ConsoleHistoryPrev);
433
1
        assert_eq!(app.console.input.buffer(), "(b)");
434
1
        apply(&mut app, Intent::ConsoleHistoryPrev);
435
1
        assert_eq!(app.console.input.buffer(), "(a)");
436
1
        apply(&mut app, Intent::ConsoleHistoryNext);
437
1
        assert_eq!(app.console.input.buffer(), "(b)");
438
1
    }
439

            
440
    #[test]
441
1
    fn console_interrupt_without_eval_does_not_panic() {
442
1
        let mut app = make_app();
443
1
        app.console_input_active = true;
444
1
        apply(&mut app, Intent::ConsoleInterrupt);
445
1
    }
446

            
447
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
448
1
    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
1
        let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
458
1
            fuel: u64::MAX,
459
1
            ..ScriptLimits::default()
460
1
        });
461
1
        let eval =
462
1
            ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
463
1
        let mut app = make_app();
464
1
        app.attach_console(eval);
465
1
        app.console_input_active = true;
466
42
        for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
467
42
            apply(&mut app, Intent::InsertChar(c));
468
42
        }
469
1
        apply(&mut app, Intent::ConsoleSubmit);
470
1
        sleep(Duration::from_millis(40)).await;
471
1
        apply(&mut app, Intent::ConsoleInterrupt);
472

            
473
1
        let mut interrupted = false;
474
1
        for _ in 0..200 {
475
20
            app.drain_console();
476
20
            if app
477
20
                .console
478
20
                .scrollback
479
20
                .iter()
480
21
                .any(|l| l.contains(":code interrupted"))
481
1
            {
482
1
                interrupted = true;
483
1
                break;
484
19
            }
485
19
            sleep(Duration::from_millis(20)).await;
486
1
        }
487
1
        assert!(interrupted, "interrupt did not reach the eval");
488
1
    }
489
}