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
}
36

            
37
/// Apply an intent to the app. The caller (the real event loop) is
38
/// responsible for translating crossterm key events into intents.
39
85
pub fn apply(app: &mut App, intent: Intent) {
40
85
    if handle_modal(app, intent) {
41
2
        return;
42
83
    }
43
83
    if app.command_line_active {
44
63
        handle_command_line(app, intent);
45
63
        return;
46
20
    }
47
20
    handle_tab(app, intent);
48
85
}
49

            
50
85
fn handle_modal(app: &mut App, intent: Intent) -> bool {
51
85
    if app.modals.is_empty() {
52
83
        return false;
53
2
    }
54
2
    if matches!(intent, Intent::CloseTopmost) {
55
1
        app.modals.pop();
56
1
        return true;
57
1
    }
58
1
    if let Some(top) = app.modals.top_mut() {
59
1
        apply_modal_intent(top, intent);
60
1
    }
61
1
    true
62
85
}
63

            
64
1
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
65
1
    let Modal::ConfigSet(form) = modal else {
66
1
        return;
67
    };
68
    if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
69
        form.focus = match form.focus {
70
            ConfigSetField::Name => ConfigSetField::Value,
71
            ConfigSetField::Value => ConfigSetField::Name,
72
        };
73
        return;
74
    }
75
    let editor = match form.focus {
76
        ConfigSetField::Name => &mut form.name,
77
        ConfigSetField::Value => &mut form.value,
78
    };
79
    match intent {
80
        Intent::InsertChar(c) => editor.insert_char(c),
81
        Intent::DeleteBackward => editor.delete_backward(),
82
        Intent::MoveLeft => editor.move_left(),
83
        Intent::MoveRight => editor.move_right(),
84
        Intent::MoveHome => editor.move_home(),
85
        Intent::MoveEnd => editor.move_end(),
86
        Intent::KillToEnd => editor.kill_to_end(),
87
        Intent::KillWordBackward => editor.kill_word_backward(),
88
        Intent::Vim(action) => editor.vim_action(action),
89
        _ => {}
90
    }
91
1
}
92

            
93
63
fn handle_command_line(app: &mut App, intent: Intent) {
94
63
    let editor = &mut app.command_line;
95
63
    match intent {
96
        Intent::CloseTopmost => {
97
2
            if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
98
1
                editor.enter_normal_mode();
99
1
            } else {
100
1
                app.command_line_active = false;
101
1
            }
102
        }
103
4
        Intent::SubmitCommandLine => {
104
4
            let buffer = editor.buffer().to_string();
105
4
            app.close_command_line();
106
4
            submit_palette(app, &buffer);
107
4
        }
108
57
        Intent::InsertChar(c) => editor.insert_char(c),
109
        Intent::DeleteBackward => editor.delete_backward(),
110
        Intent::MoveLeft => editor.move_left(),
111
        Intent::MoveRight => editor.move_right(),
112
        Intent::MoveHome => editor.move_home(),
113
        Intent::MoveEnd => editor.move_end(),
114
        Intent::KillToEnd => editor.kill_to_end(),
115
        Intent::KillWordBackward => editor.kill_word_backward(),
116
        Intent::Vim(action) => editor.vim_action(action),
117
        _ => {}
118
    }
119
63
}
120

            
121
/// Parse a command-palette input and act on the resolved command.
122
/// Known leaves either open a matching modal form or set the status
123
/// with a friendly resolution trace. Unknown paths surface a clear
124
/// error in the status line.
125
4
fn submit_palette(app: &mut App, input: &str) {
126
4
    let query = palette::parse(input);
127
4
    if query.path.is_empty() {
128
        app.set_status("");
129
        return;
130
4
    }
131
4
    let tree = command_tree();
132
4
    match palette::resolve(&tree, &query) {
133
3
        Some(node) => apply_resolved_command(app, node, &query.args),
134
1
        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
135
    }
136
4
}
137

            
138
3
fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
139
    // Map recognised leaves onto TUI actions. Unknown leaves simply
140
    // surface as status-line text for now — the runnable layer is
141
    // wired in Phase 3 follow-up work as per-tab commands land.
142
3
    match node.name.as_str() {
143
3
        "set" => open_config_set_modal(app, args),
144
2
        "version" => app.set_status("nomisync automation CLI carries `version` for now"),
145
        _ => app.set_status(format!("resolved: {}", node.name)),
146
    }
147
3
}
148

            
149
1
fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
150
1
    let mut name = Editor::new(app.edit_mode);
151
1
    let mut value = Editor::new(app.edit_mode);
152
2
    for (k, v) in args {
153
2
        match k.as_str() {
154
2
            "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
155
1
            "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
156
            _ => {}
157
        }
158
    }
159
1
    app.modals.push(Modal::ConfigSet(ConfigSetModal {
160
1
        name,
161
1
        value,
162
1
        focus: ConfigSetField::Name,
163
1
    }));
164
1
}
165

            
166
20
fn handle_tab(app: &mut App, intent: Intent) {
167
20
    match intent {
168
6
        Intent::Quit => app.request_quit(),
169
1
        Intent::NextTab => app.next_tab(),
170
        Intent::PreviousTab => app.previous_tab(),
171
2
        Intent::SelectTab(t) => app.switch_tab(t),
172
7
        Intent::OpenCommandLine => app.open_command_line(),
173
2
        Intent::OpenHelp => app.modals.push(Modal::Help),
174
        Intent::ToggleEditMode => {
175
2
            let next = match app.edit_mode {
176
1
                EditMode::Emacs => EditMode::Vim,
177
1
                EditMode::Vim => EditMode::Emacs,
178
            };
179
2
            app.set_edit_mode(next);
180
        }
181
        _ => {}
182
    }
183
20
}
184

            
185
#[cfg(test)]
186
mod tests {
187
    use super::*;
188
    use sqlx::types::Uuid;
189

            
190
12
    fn make_app() -> App {
191
12
        App::new(Uuid::new_v4(), EditMode::Emacs)
192
12
    }
193

            
194
    #[test]
195
1
    fn quit_intent_sets_quit_flag() {
196
1
        let mut app = make_app();
197
1
        apply(&mut app, Intent::Quit);
198
1
        assert!(app.should_quit);
199
1
    }
200

            
201
    #[test]
202
1
    fn next_tab_intent_advances_tab() {
203
1
        let mut app = make_app();
204
1
        app.active_tab = Tab::Accounts;
205
1
        apply(&mut app, Intent::NextTab);
206
1
        assert_eq!(app.active_tab, Tab::Transactions);
207
1
    }
208

            
209
    #[test]
210
1
    fn select_tab_intent_jumps_directly() {
211
1
        let mut app = make_app();
212
1
        apply(&mut app, Intent::SelectTab(Tab::Reports));
213
1
        assert_eq!(app.active_tab, Tab::Reports);
214
1
    }
215

            
216
    #[test]
217
1
    fn open_command_line_activates_and_accepts_input() {
218
1
        let mut app = make_app();
219
1
        apply(&mut app, Intent::OpenCommandLine);
220
1
        assert!(app.command_line_active);
221
1
        apply(&mut app, Intent::InsertChar('v'));
222
1
        apply(&mut app, Intent::InsertChar('x'));
223
1
        assert_eq!(app.command_line.buffer(), "vx");
224
1
    }
225

            
226
    #[test]
227
1
    fn command_line_escape_exits_when_already_in_normal_mode() {
228
1
        let mut app = make_app();
229
1
        app.set_edit_mode(EditMode::Vim);
230
1
        apply(&mut app, Intent::OpenCommandLine);
231
1
        app.command_line.enter_normal_mode();
232
1
        apply(&mut app, Intent::CloseTopmost);
233
1
        assert!(!app.command_line_active);
234
1
    }
235

            
236
    #[test]
237
1
    fn command_line_escape_first_drops_vim_to_normal() {
238
1
        let mut app = make_app();
239
1
        app.set_edit_mode(EditMode::Vim);
240
1
        apply(&mut app, Intent::OpenCommandLine);
241
1
        apply(&mut app, Intent::InsertChar('x'));
242
1
        assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
243
1
        apply(&mut app, Intent::CloseTopmost);
244
1
        assert!(
245
1
            app.command_line_active,
246
            "first Esc should keep cmdline open"
247
        );
248
1
        assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
249
1
    }
250

            
251
    #[test]
252
1
    fn submit_command_line_records_buffer_and_closes() {
253
1
        let mut app = make_app();
254
1
        apply(&mut app, Intent::OpenCommandLine);
255
7
        for c in "version".chars() {
256
7
            apply(&mut app, Intent::InsertChar(c));
257
7
        }
258
1
        apply(&mut app, Intent::SubmitCommandLine);
259
1
        assert!(!app.command_line_active);
260
1
        assert!(
261
1
            app.status.contains("version"),
262
            "status should report the resolved command, got {}",
263
            app.status
264
        );
265
1
    }
266

            
267
    #[test]
268
1
    fn submit_command_line_with_unknown_path_surfaces_error() {
269
1
        let mut app = make_app();
270
1
        apply(&mut app, Intent::OpenCommandLine);
271
9
        for c in "bogus-cmd".chars() {
272
9
            apply(&mut app, Intent::InsertChar(c));
273
9
        }
274
1
        apply(&mut app, Intent::SubmitCommandLine);
275
1
        assert!(app.status.contains("unknown"));
276
1
    }
277

            
278
    #[test]
279
1
    fn submit_config_set_opens_form_modal() {
280
1
        let mut app = make_app();
281
1
        apply(&mut app, Intent::OpenCommandLine);
282
31
        for c in "config set name=locale value=en".chars() {
283
31
            apply(&mut app, Intent::InsertChar(c));
284
31
        }
285
1
        apply(&mut app, Intent::SubmitCommandLine);
286
1
        assert!(!app.modals.is_empty());
287
1
        match app.modals.top() {
288
1
            Some(Modal::ConfigSet(form)) => {
289
1
                assert_eq!(form.name.buffer(), "locale");
290
1
                assert_eq!(form.value.buffer(), "en");
291
            }
292
            other => panic!("expected ConfigSet modal, got {other:?}"),
293
        }
294
1
    }
295

            
296
    #[test]
297
1
    fn open_help_pushes_a_modal() {
298
1
        let mut app = make_app();
299
1
        apply(&mut app, Intent::OpenHelp);
300
1
        assert!(!app.modals.is_empty());
301
1
        assert!(matches!(app.modals.top(), Some(Modal::Help)));
302
1
    }
303

            
304
    #[test]
305
1
    fn close_topmost_pops_modal_before_touching_tabs() {
306
1
        let mut app = make_app();
307
1
        apply(&mut app, Intent::OpenHelp);
308
1
        apply(&mut app, Intent::Quit);
309
1
        assert!(
310
1
            !app.should_quit,
311
            "quit should be swallowed by the modal layer"
312
        );
313
1
        apply(&mut app, Intent::CloseTopmost);
314
1
        assert!(app.modals.is_empty());
315
1
        apply(&mut app, Intent::Quit);
316
1
        assert!(app.should_quit);
317
1
    }
318

            
319
    #[test]
320
1
    fn toggle_edit_mode_flips_emacs_vim() {
321
1
        let mut app = make_app();
322
1
        assert_eq!(app.edit_mode, EditMode::Emacs);
323
1
        apply(&mut app, Intent::ToggleEditMode);
324
1
        assert_eq!(app.edit_mode, EditMode::Vim);
325
1
        apply(&mut app, Intent::ToggleEditMode);
326
1
        assert_eq!(app.edit_mode, EditMode::Emacs);
327
1
    }
328
}