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}
36
37/// Apply an intent to the app. The caller (the real event loop) is
38/// responsible for translating crossterm key events into intents.
39pub fn apply(app: &mut App, intent: Intent) {
40    if handle_modal(app, intent) {
41        return;
42    }
43    if app.command_line_active {
44        handle_command_line(app, intent);
45        return;
46    }
47    handle_tab(app, intent);
48}
49
50fn handle_modal(app: &mut App, intent: Intent) -> bool {
51    if app.modals.is_empty() {
52        return false;
53    }
54    if matches!(intent, Intent::CloseTopmost) {
55        app.modals.pop();
56        return true;
57    }
58    if let Some(top) = app.modals.top_mut() {
59        apply_modal_intent(top, intent);
60    }
61    true
62}
63
64fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
65    let Modal::ConfigSet(form) = modal else {
66        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}
92
93fn handle_command_line(app: &mut App, intent: Intent) {
94    let editor = &mut app.command_line;
95    match intent {
96        Intent::CloseTopmost => {
97            if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
98                editor.enter_normal_mode();
99            } else {
100                app.command_line_active = false;
101            }
102        }
103        Intent::SubmitCommandLine => {
104            let buffer = editor.buffer().to_string();
105            app.close_command_line();
106            submit_palette(app, &buffer);
107        }
108        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}
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.
125fn submit_palette(app: &mut App, input: &str) {
126    let query = palette::parse(input);
127    if query.path.is_empty() {
128        app.set_status("");
129        return;
130    }
131    let tree = command_tree();
132    match palette::resolve(&tree, &query) {
133        Some(node) => apply_resolved_command(app, node, &query.args),
134        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
135    }
136}
137
138fn 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    match node.name.as_str() {
143        "set" => open_config_set_modal(app, args),
144        "version" => app.set_status("nomisync automation CLI carries `version` for now"),
145        _ => app.set_status(format!("resolved: {}", node.name)),
146    }
147}
148
149fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
150    let mut name = Editor::new(app.edit_mode);
151    let mut value = Editor::new(app.edit_mode);
152    for (k, v) in args {
153        match k.as_str() {
154            "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
155            "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
156            _ => {}
157        }
158    }
159    app.modals.push(Modal::ConfigSet(ConfigSetModal {
160        name,
161        value,
162        focus: ConfigSetField::Name,
163    }));
164}
165
166fn handle_tab(app: &mut App, intent: Intent) {
167    match intent {
168        Intent::Quit => app.request_quit(),
169        Intent::NextTab => app.next_tab(),
170        Intent::PreviousTab => app.previous_tab(),
171        Intent::SelectTab(t) => app.switch_tab(t),
172        Intent::OpenCommandLine => app.open_command_line(),
173        Intent::OpenHelp => app.modals.push(Modal::Help),
174        Intent::ToggleEditMode => {
175            let next = match app.edit_mode {
176                EditMode::Emacs => EditMode::Vim,
177                EditMode::Vim => EditMode::Emacs,
178            };
179            app.set_edit_mode(next);
180        }
181        _ => {}
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use sqlx::types::Uuid;
189
190    fn make_app() -> App {
191        App::new(Uuid::new_v4(), EditMode::Emacs)
192    }
193
194    #[test]
195    fn quit_intent_sets_quit_flag() {
196        let mut app = make_app();
197        apply(&mut app, Intent::Quit);
198        assert!(app.should_quit);
199    }
200
201    #[test]
202    fn next_tab_intent_advances_tab() {
203        let mut app = make_app();
204        app.active_tab = Tab::Accounts;
205        apply(&mut app, Intent::NextTab);
206        assert_eq!(app.active_tab, Tab::Transactions);
207    }
208
209    #[test]
210    fn select_tab_intent_jumps_directly() {
211        let mut app = make_app();
212        apply(&mut app, Intent::SelectTab(Tab::Reports));
213        assert_eq!(app.active_tab, Tab::Reports);
214    }
215
216    #[test]
217    fn open_command_line_activates_and_accepts_input() {
218        let mut app = make_app();
219        apply(&mut app, Intent::OpenCommandLine);
220        assert!(app.command_line_active);
221        apply(&mut app, Intent::InsertChar('v'));
222        apply(&mut app, Intent::InsertChar('x'));
223        assert_eq!(app.command_line.buffer(), "vx");
224    }
225
226    #[test]
227    fn command_line_escape_exits_when_already_in_normal_mode() {
228        let mut app = make_app();
229        app.set_edit_mode(EditMode::Vim);
230        apply(&mut app, Intent::OpenCommandLine);
231        app.command_line.enter_normal_mode();
232        apply(&mut app, Intent::CloseTopmost);
233        assert!(!app.command_line_active);
234    }
235
236    #[test]
237    fn command_line_escape_first_drops_vim_to_normal() {
238        let mut app = make_app();
239        app.set_edit_mode(EditMode::Vim);
240        apply(&mut app, Intent::OpenCommandLine);
241        apply(&mut app, Intent::InsertChar('x'));
242        assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
243        apply(&mut app, Intent::CloseTopmost);
244        assert!(
245            app.command_line_active,
246            "first Esc should keep cmdline open"
247        );
248        assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
249    }
250
251    #[test]
252    fn submit_command_line_records_buffer_and_closes() {
253        let mut app = make_app();
254        apply(&mut app, Intent::OpenCommandLine);
255        for c in "version".chars() {
256            apply(&mut app, Intent::InsertChar(c));
257        }
258        apply(&mut app, Intent::SubmitCommandLine);
259        assert!(!app.command_line_active);
260        assert!(
261            app.status.contains("version"),
262            "status should report the resolved command, got {}",
263            app.status
264        );
265    }
266
267    #[test]
268    fn submit_command_line_with_unknown_path_surfaces_error() {
269        let mut app = make_app();
270        apply(&mut app, Intent::OpenCommandLine);
271        for c in "bogus-cmd".chars() {
272            apply(&mut app, Intent::InsertChar(c));
273        }
274        apply(&mut app, Intent::SubmitCommandLine);
275        assert!(app.status.contains("unknown"));
276    }
277
278    #[test]
279    fn submit_config_set_opens_form_modal() {
280        let mut app = make_app();
281        apply(&mut app, Intent::OpenCommandLine);
282        for c in "config set name=locale value=en".chars() {
283            apply(&mut app, Intent::InsertChar(c));
284        }
285        apply(&mut app, Intent::SubmitCommandLine);
286        assert!(!app.modals.is_empty());
287        match app.modals.top() {
288            Some(Modal::ConfigSet(form)) => {
289                assert_eq!(form.name.buffer(), "locale");
290                assert_eq!(form.value.buffer(), "en");
291            }
292            other => panic!("expected ConfigSet modal, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn open_help_pushes_a_modal() {
298        let mut app = make_app();
299        apply(&mut app, Intent::OpenHelp);
300        assert!(!app.modals.is_empty());
301        assert!(matches!(app.modals.top(), Some(Modal::Help)));
302    }
303
304    #[test]
305    fn close_topmost_pops_modal_before_touching_tabs() {
306        let mut app = make_app();
307        apply(&mut app, Intent::OpenHelp);
308        apply(&mut app, Intent::Quit);
309        assert!(
310            !app.should_quit,
311            "quit should be swallowed by the modal layer"
312        );
313        apply(&mut app, Intent::CloseTopmost);
314        assert!(app.modals.is_empty());
315        apply(&mut app, Intent::Quit);
316        assert!(app.should_quit);
317    }
318
319    #[test]
320    fn toggle_edit_mode_flips_emacs_vim() {
321        let mut app = make_app();
322        assert_eq!(app.edit_mode, EditMode::Emacs);
323        apply(&mut app, Intent::ToggleEditMode);
324        assert_eq!(app.edit_mode, EditMode::Vim);
325        apply(&mut app, Intent::ToggleEditMode);
326        assert_eq!(app.edit_mode, EditMode::Emacs);
327    }
328}