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
9mod completion;
10mod modal_open;
11mod submit;
12
13use crate::app::App;
14use crate::command::{build_form, build_report_request};
15use crate::focus::{FocusTarget, current_focus};
16use crate::modal::Modal;
17use crate::palette;
18use crate::pane::PaneId;
19use crate::view::{Cmd, DrawCtx, Handled, Tab};
20use crate::widgets::{EditMode, Editor, FocusedSubWidget, VimAction, VimMode, Widget};
21use cli_core::{CommandNode, command_tree};
22use modal_open::{
23    open_account_create_modal, open_account_tag_modal_for_selected, open_commodity_convert_modal,
24    open_commodity_create_modal, open_config_set_modal, open_report_params_modal,
25    open_transaction_create_modal, open_transaction_tag_modal_for_selected,
26};
27use submit::{execute_confirm, submit_form};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Intent {
31    Quit,
32    NextTab,
33    PreviousTab,
34    SelectTab(Tab),
35    OpenCommandLine,
36    CloseTopmost,
37    SubmitCommandLine,
38    /// Confirm the topmost confirm overlay (equivalent to pressing `y`).
39    ConfirmYes,
40    InsertChar(char),
41    DeleteBackward,
42    MoveLeft,
43    MoveRight,
44    MoveHome,
45    MoveEnd,
46    KillToEnd,
47    KillWordBackward,
48    Vim(VimAction),
49    ToggleEditMode,
50    OpenHelp,
51    ConsoleFocus,
52    ConsoleBlur,
53    ConsoleSubmit,
54    ConsoleInterrupt,
55    ConsoleHistoryPrev,
56    ConsoleHistoryNext,
57    ConsoleCyclePane,
58    ScrollLineUp,
59    ScrollLineDown,
60    ScrollPageUp,
61    ScrollPageDown,
62    ListSelectNext,
63    ListSelectPrev,
64    /// Delete the currently selected list item (pushes a confirm overlay).
65    ListDelete,
66    /// Open the tag editor for the currently selected list item.
67    ListEdit,
68    RefreshTab,
69    EditConfig,
70    SelectNext,
71    SelectPrev,
72    SelectConfirm,
73    /// Splits: move focus to next column within the focused row.
74    ColNext,
75    /// Splits: move focus to previous column within the focused row.
76    ColPrev,
77    /// Splits: move focus to next row.
78    RowNext,
79    /// Splits: move focus to previous row.
80    RowPrev,
81    /// Splits: append a new blank row.
82    SplitAddRow,
83    /// Splits: remove the focused row (minimum 1 kept).
84    SplitRemoveRow,
85    /// Complete the current command-palette token (Tab in the command line).
86    CompleteCommandLine,
87    /// Step the focused Date field forward by one day (Up key).
88    DateStepBack,
89    /// Step the focused Date field forward by one day (Down key).
90    DateStepForward,
91}
92
93/// Apply an intent to the app. The caller (the real event loop) is
94/// responsible for translating crossterm key events into intents.
95///
96/// Dispatch follows the canonical focus order: Overlay > CmdLine > ConsoleInput > ViewPane.
97pub fn apply(app: &mut App, intent: Intent) {
98    match current_focus(app) {
99        FocusTarget::Overlay => handle_overlay(app, intent),
100        FocusTarget::CmdLine => handle_command_line(app, intent),
101        FocusTarget::ConsoleInput => handle_console(app, intent),
102        FocusTarget::ViewPane => handle_tab(app, intent),
103    }
104}
105
106/// Route input while the console is focused. `Enter` assembles the input
107/// line into the pending form; a complete (balanced) form is submitted
108/// to the eval, an incomplete one keeps buffering. Editing intents
109/// mutate the console input editor; history keys walk prior submissions.
110fn handle_console(app: &mut App, intent: Intent) {
111    match intent {
112        Intent::ConsoleBlur => app.console_focused = false,
113        Intent::ConsoleSubmit => {
114            if let Some(form) = app.console.take_complete_form() {
115                app.submit_console_form(form);
116            }
117        }
118        Intent::ConsoleInterrupt => app.interrupt_console(),
119        Intent::ConsoleHistoryPrev => app.console.history_prev(),
120        Intent::ConsoleHistoryNext => app.console.history_next(),
121        Intent::ConsoleCyclePane => app.console.panes.cycle(true),
122        Intent::ScrollLineUp => app.console.scroll_up(1),
123        Intent::ScrollLineDown => app.console.scroll_down(1),
124        Intent::ScrollPageUp => app.console.scroll_up(10),
125        Intent::ScrollPageDown => app.console.scroll_down(10),
126        Intent::InsertChar(c) => app.console.input.insert_char(c),
127        Intent::DeleteBackward => app.console.input.delete_backward(),
128        Intent::MoveLeft => app.console.input.move_left(),
129        Intent::MoveRight => app.console.input.move_right(),
130        Intent::MoveHome => app.console.input.move_home(),
131        Intent::MoveEnd => app.console.input.move_end(),
132        Intent::KillToEnd => app.console.input.kill_to_end(),
133        Intent::KillWordBackward => app.console.input.kill_word_backward(),
134        Intent::Vim(action) => app.console.input.vim_action(action),
135        _ => {}
136    }
137}
138
139fn handle_overlay(app: &mut App, intent: Intent) {
140    match intent {
141        Intent::CloseTopmost => {
142            app.overlays.pop();
143        }
144        // `ConfirmYes` only ever acts on a `Modal::Confirm`; for any other
145        // overlay it is a no-op (it must never submit a form).
146        Intent::ConfirmYes => confirm_topmost(app),
147        Intent::SubmitCommandLine => submit_modal(app),
148        _ => {
149            if let Some(top) = app.overlays.top_mut() {
150                apply_modal_intent(top, intent);
151            }
152        }
153    }
154}
155
156/// Confirm the topmost overlay, but only when it is a `Modal::Confirm`.
157fn confirm_topmost(app: &mut App) {
158    if !matches!(app.overlays.top(), Some(Modal::Confirm { .. })) {
159        return;
160    }
161    if let Some(Modal::Confirm { action, .. }) = app.overlays.pop() {
162        execute_confirm(app, action);
163    }
164}
165
166/// Apply Enter to the topmost overlay (form submit or confirm action).
167fn submit_modal(app: &mut App) {
168    let Some(modal) = app.overlays.pop() else {
169        return;
170    };
171    match modal {
172        Modal::Confirm { action, .. } => execute_confirm(app, action),
173        Modal::Form(form) => submit_form(app, form),
174        Modal::Help => {}
175    }
176}
177
178fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
179    match modal {
180        Modal::Form(form) => match intent {
181            // Tab/BackTab cycle fields only when focused widget is NOT Splits.
182            // When Splits is focused, keymap.rs emits ColNext/ColPrev instead.
183            Intent::NextTab => form.cycle(true),
184            Intent::PreviousTab => form.cycle(false),
185            _ => {
186                if let Some(field) = form.focused_field_mut() {
187                    apply_widget_intent(&mut field.widget, intent);
188                }
189            }
190        },
191        Modal::Help | Modal::Confirm { .. } => {}
192    }
193}
194
195fn apply_widget_intent(widget: &mut Widget, intent: Intent) {
196    match widget {
197        Widget::Text(ed) => apply_editor_intent(ed, intent),
198        Widget::Amount(aw) => match intent {
199            Intent::InsertChar(c) => aw.insert_char(c),
200            _ => apply_editor_intent(aw.as_editor_mut(), intent),
201        },
202        Widget::Date(dw) => match intent {
203            Intent::DateStepForward => dw.step(1),
204            Intent::DateStepBack => dw.step(-1),
205            _ => apply_editor_intent(dw.as_editor_mut(), intent),
206        },
207        Widget::Select(sw) => match intent {
208            Intent::SelectNext => sw.next(),
209            Intent::SelectPrev => sw.prev(),
210            Intent::SelectConfirm => sw.confirm(),
211            Intent::InsertChar(c) => sw.filter_push(c),
212            Intent::DeleteBackward => sw.filter_pop(),
213            _ => {}
214        },
215        Widget::Splits(sw) => match intent {
216            Intent::ColNext => sw.advance_cell(),
217            Intent::ColPrev => sw.retreat_cell(),
218            Intent::RowNext => sw.next_row(),
219            Intent::RowPrev => sw.prev_row(),
220            Intent::SplitAddRow => sw.add_row(),
221            Intent::SplitRemoveRow => sw.remove_row(),
222            _ => match sw.focused_subwidget_mut() {
223                Some(FocusedSubWidget::Select(s)) => match intent {
224                    Intent::SelectNext => s.next(),
225                    Intent::SelectPrev => s.prev(),
226                    _ => {}
227                },
228                Some(FocusedSubWidget::Amount(a)) => match intent {
229                    Intent::InsertChar(c) => a.insert_char(c),
230                    _ => apply_editor_intent(a.as_editor_mut(), intent),
231                },
232                None => {}
233            },
234        },
235    }
236}
237
238fn apply_editor_intent(editor: &mut Editor, intent: Intent) {
239    match intent {
240        Intent::InsertChar(c) => editor.insert_char(c),
241        Intent::DeleteBackward => editor.delete_backward(),
242        Intent::MoveLeft => editor.move_left(),
243        Intent::MoveRight => editor.move_right(),
244        Intent::MoveHome => editor.move_home(),
245        Intent::MoveEnd => editor.move_end(),
246        Intent::KillToEnd => editor.kill_to_end(),
247        Intent::KillWordBackward => editor.kill_word_backward(),
248        Intent::Vim(action) => editor.vim_action(action),
249        _ => {}
250    }
251}
252
253fn handle_command_line(app: &mut App, intent: Intent) {
254    match intent {
255        Intent::CloseTopmost => {
256            if app.edit_mode == EditMode::Vim && app.cmdline.editor.vim_mode() == VimMode::Insert {
257                app.cmdline.editor.enter_normal_mode();
258            } else {
259                app.cmdline.active = false;
260            }
261        }
262        Intent::SubmitCommandLine => {
263            let buffer = app.cmdline.editor.buffer().to_string();
264            app.close_command_line();
265            submit_palette(app, &buffer);
266        }
267        Intent::CompleteCommandLine => completion::apply_command_completion(app),
268        // Any other edit invalidates the displayed completion candidates.
269        _ => {
270            app.cmdline.completions.clear();
271            apply_editor_intent(&mut app.cmdline.editor, intent);
272        }
273    }
274}
275
276/// Parse a command-palette input and act on the resolved command.
277fn submit_palette(app: &mut App, input: &str) {
278    let query = palette::parse(input);
279    if query.path.is_empty() {
280        app.set_status("");
281        return;
282    }
283    let tree = command_tree();
284    match palette::resolve(&tree, &query) {
285        Some(node) => apply_resolved_command(app, node, &query.path, &query.args),
286        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
287    }
288}
289
290fn apply_resolved_command(
291    app: &mut App,
292    node: &CommandNode,
293    path: &[String],
294    args: &[(String, String)],
295) {
296    let path_str = path.join(" ");
297    if path_str.starts_with("reports ") {
298        apply_report_command(app, path, args);
299        return;
300    }
301    match build_form(node, path, args) {
302        Err(e) => app.set_status(format!("command error: {e}")),
303        Ok(None) => {
304            let segments: Vec<&str> = path.iter().map(String::as_str).collect();
305            match segments.as_slice() {
306                ["config", "set"] => open_config_set_modal(app, args),
307                ["account", "create"] => open_account_create_modal(app),
308                ["account", "tag"] => open_account_tag_modal_for_selected(app),
309                ["commodity", "create"] => open_commodity_create_modal(app),
310                ["commodity", "convert"] => open_commodity_convert_modal(app),
311                ["transaction", "create"] => open_transaction_create_modal(app),
312                ["transaction", "tag"] => open_transaction_tag_modal_for_selected(app),
313                _ => app.set_status(format!("`{path_str}` is not available in the TUI yet")),
314            }
315        }
316        Ok(Some(form)) => {
317            app.submit_console_form(form);
318            app.switch_tab(Tab::Console);
319        }
320    }
321}
322
323fn apply_report_command(app: &mut App, path: &[String], args: &[(String, String)]) {
324    match build_report_request(path, args) {
325        Err(e) => app.set_status(format!("command error: {e}")),
326        Ok(Some(req)) => {
327            app.switch_tab(Tab::Reports);
328            app.fetch_report(req.kind, &req.from, &req.to, &req.chart);
329        }
330        Ok(None) => {
331            let kind = match path.join(" ").as_str() {
332                "reports activity" => crate::tabs::reports::ReportKind::Activity,
333                "reports breakdown" => crate::tabs::reports::ReportKind::Breakdown,
334                _ => {
335                    app.set_status("unknown report command".to_string());
336                    return;
337                }
338            };
339            let chart_default = args
340                .iter()
341                .find(|(k, _)| k == "chart")
342                .map(|(_, v)| v.clone())
343                .unwrap_or_else(|| "bar".to_string());
344            open_report_params_modal(app, kind, &chart_default);
345        }
346    }
347}
348
349fn execute(app: &mut App, cmds: Vec<Cmd>) {
350    for cmd in cmds {
351        match cmd {
352            Cmd::Status(msg) => app.set_status(msg),
353            Cmd::SwitchView(id) => app.switch_tab(id),
354        }
355    }
356}
357
358fn handle_tab(app: &mut App, intent: Intent) {
359    let ctx = DrawCtx {
360        edit_mode: app.edit_mode,
361        is_active: app.console_focused,
362    };
363    if let Handled::Consumed(cmds) = app.active_view_mut().handle(intent, &ctx) {
364        execute(app, cmds);
365        return;
366    }
367    match intent {
368        Intent::Quit => app.request_quit(),
369        Intent::NextTab => app.next_tab(),
370        Intent::PreviousTab => app.previous_tab(),
371        Intent::SelectTab(t) => app.switch_tab(t),
372        Intent::OpenCommandLine => app.open_command_line(),
373        Intent::ConsoleFocus if app.active_tab == Tab::Console => {
374            app.console.panes.focus(PaneId::Prompt);
375            app.console_focused = true;
376        }
377        Intent::OpenHelp => app.overlays.push(Modal::Help),
378        Intent::ToggleEditMode => {
379            let next = match app.edit_mode {
380                EditMode::Emacs => EditMode::Vim,
381                EditMode::Vim => EditMode::Emacs,
382            };
383            app.set_edit_mode(next);
384        }
385        Intent::RefreshTab => app.refresh_tab(app.active_tab),
386        Intent::EditConfig if app.active_tab == Tab::Config => {
387            let maybe = {
388                let cfg = &app.config;
389                cfg.selected_key()
390                    .map(|k| (k.to_string(), cfg.selected_value().to_string()))
391            };
392            if let Some((key, value)) = maybe {
393                open_config_set_modal(
394                    app,
395                    &[("name".to_string(), key), ("value".to_string(), value)],
396                );
397            }
398        }
399        Intent::ListDelete if app.active_tab == Tab::Transactions => {
400            let maybe_id = app.transactions.selected_id().map(str::to_string);
401            if let Some(id) = maybe_id {
402                app.overlays.push(Modal::Confirm {
403                    prompt: format!("Delete transaction {id}? (y/N)"),
404                    action: crate::modal::ConfirmAction::DeleteTransaction(id),
405                });
406            }
407        }
408        Intent::ListEdit if app.active_tab == Tab::Transactions => {
409            let maybe_id = app.transactions.selected_id().map(str::to_string);
410            if let Some(id) = maybe_id {
411                app.open_transaction_edit_async(&id);
412            } else {
413                app.set_status("no transaction selected");
414            }
415        }
416        Intent::ListEdit if app.active_tab == Tab::Accounts => {
417            open_account_tag_modal_for_selected(app);
418        }
419        _ => {}
420    }
421}
422
423#[cfg(test)]
424mod tests;