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
mod completion;
10
mod modal_open;
11
mod submit;
12

            
13
use crate::app::App;
14
use crate::command::{build_form, build_report_request};
15
use crate::focus::{FocusTarget, current_focus};
16
use crate::modal::Modal;
17
use crate::palette;
18
use crate::pane::PaneId;
19
use crate::view::{Cmd, DrawCtx, Handled, Tab};
20
use crate::widgets::{EditMode, Editor, FocusedSubWidget, VimAction, VimMode, Widget};
21
use cli_core::{CommandNode, command_tree};
22
use 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
};
27
use submit::{execute_confirm, submit_form};
28

            
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
pub 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.
97
375
pub fn apply(app: &mut App, intent: Intent) {
98
375
    match current_focus(app) {
99
38
        FocusTarget::Overlay => handle_overlay(app, intent),
100
207
        FocusTarget::CmdLine => handle_command_line(app, intent),
101
85
        FocusTarget::ConsoleInput => handle_console(app, intent),
102
45
        FocusTarget::ViewPane => handle_tab(app, intent),
103
    }
104
375
}
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.
110
85
fn handle_console(app: &mut App, intent: Intent) {
111
85
    match intent {
112
2
        Intent::ConsoleBlur => app.console_focused = false,
113
        Intent::ConsoleSubmit => {
114
5
            if let Some(form) = app.console.take_complete_form() {
115
4
                app.submit_console_form(form);
116
4
            }
117
        }
118
2
        Intent::ConsoleInterrupt => app.interrupt_console(),
119
2
        Intent::ConsoleHistoryPrev => app.console.history_prev(),
120
1
        Intent::ConsoleHistoryNext => app.console.history_next(),
121
3
        Intent::ConsoleCyclePane => app.console.panes.cycle(true),
122
2
        Intent::ScrollLineUp => app.console.scroll_up(1),
123
2
        Intent::ScrollLineDown => app.console.scroll_down(1),
124
2
        Intent::ScrollPageUp => app.console.scroll_up(10),
125
1
        Intent::ScrollPageDown => app.console.scroll_down(10),
126
62
        Intent::InsertChar(c) => app.console.input.insert_char(c),
127
1
        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
85
}
138

            
139
38
fn handle_overlay(app: &mut App, intent: Intent) {
140
38
    match intent {
141
3
        Intent::CloseTopmost => {
142
3
            app.overlays.pop();
143
3
        }
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
3
        Intent::ConfirmYes => confirm_topmost(app),
147
        Intent::SubmitCommandLine => submit_modal(app),
148
        _ => {
149
32
            if let Some(top) = app.overlays.top_mut() {
150
32
                apply_modal_intent(top, intent);
151
32
            }
152
        }
153
    }
154
38
}
155

            
156
/// Confirm the topmost overlay, but only when it is a `Modal::Confirm`.
157
3
fn confirm_topmost(app: &mut App) {
158
3
    if !matches!(app.overlays.top(), Some(Modal::Confirm { .. })) {
159
1
        return;
160
2
    }
161
2
    if let Some(Modal::Confirm { action, .. }) = app.overlays.pop() {
162
2
        execute_confirm(app, action);
163
2
    }
164
3
}
165

            
166
/// Apply Enter to the topmost overlay (form submit or confirm action).
167
fn 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

            
178
32
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
179
32
    match modal {
180
31
        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
1
            Intent::NextTab => form.cycle(true),
184
            Intent::PreviousTab => form.cycle(false),
185
            _ => {
186
30
                if let Some(field) = form.focused_field_mut() {
187
30
                    apply_widget_intent(&mut field.widget, intent);
188
30
                }
189
            }
190
        },
191
1
        Modal::Help | Modal::Confirm { .. } => {}
192
    }
193
32
}
194

            
195
30
fn apply_widget_intent(widget: &mut Widget, intent: Intent) {
196
30
    match widget {
197
20
        Widget::Text(ed) => apply_editor_intent(ed, intent),
198
3
        Widget::Amount(aw) => match intent {
199
3
            Intent::InsertChar(c) => aw.insert_char(c),
200
            _ => apply_editor_intent(aw.as_editor_mut(), intent),
201
        },
202
2
        Widget::Date(dw) => match intent {
203
1
            Intent::DateStepForward => dw.step(1),
204
1
            Intent::DateStepBack => dw.step(-1),
205
            _ => apply_editor_intent(dw.as_editor_mut(), intent),
206
        },
207
4
        Widget::Select(sw) => match intent {
208
1
            Intent::SelectNext => sw.next(),
209
1
            Intent::SelectPrev => sw.prev(),
210
            Intent::SelectConfirm => sw.confirm(),
211
1
            Intent::InsertChar(c) => sw.filter_push(c),
212
1
            Intent::DeleteBackward => sw.filter_pop(),
213
            _ => {}
214
        },
215
1
        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
1
            _ => match sw.focused_subwidget_mut() {
223
1
                Some(FocusedSubWidget::Select(s)) => match intent {
224
1
                    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
30
}
237

            
238
210
fn apply_editor_intent(editor: &mut Editor, intent: Intent) {
239
210
    match intent {
240
210
        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
210
}
252

            
253
207
fn handle_command_line(app: &mut App, intent: Intent) {
254
207
    match intent {
255
        Intent::CloseTopmost => {
256
2
            if app.edit_mode == EditMode::Vim && app.cmdline.editor.vim_mode() == VimMode::Insert {
257
1
                app.cmdline.editor.enter_normal_mode();
258
1
            } else {
259
1
                app.cmdline.active = false;
260
1
            }
261
        }
262
9
        Intent::SubmitCommandLine => {
263
9
            let buffer = app.cmdline.editor.buffer().to_string();
264
9
            app.close_command_line();
265
9
            submit_palette(app, &buffer);
266
9
        }
267
6
        Intent::CompleteCommandLine => completion::apply_command_completion(app),
268
        // Any other edit invalidates the displayed completion candidates.
269
190
        _ => {
270
190
            app.cmdline.completions.clear();
271
190
            apply_editor_intent(&mut app.cmdline.editor, intent);
272
190
        }
273
    }
274
207
}
275

            
276
/// Parse a command-palette input and act on the resolved command.
277
9
fn submit_palette(app: &mut App, input: &str) {
278
9
    let query = palette::parse(input);
279
9
    if query.path.is_empty() {
280
        app.set_status("");
281
        return;
282
9
    }
283
9
    let tree = command_tree();
284
9
    match palette::resolve(&tree, &query) {
285
8
        Some(node) => apply_resolved_command(app, node, &query.path, &query.args),
286
1
        None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
287
    }
288
9
}
289

            
290
8
fn apply_resolved_command(
291
8
    app: &mut App,
292
8
    node: &CommandNode,
293
8
    path: &[String],
294
8
    args: &[(String, String)],
295
8
) {
296
8
    let path_str = path.join(" ");
297
8
    if path_str.starts_with("reports ") {
298
1
        apply_report_command(app, path, args);
299
1
        return;
300
7
    }
301
7
    match build_form(node, path, args) {
302
1
        Err(e) => app.set_status(format!("command error: {e}")),
303
        Ok(None) => {
304
3
            let segments: Vec<&str> = path.iter().map(String::as_str).collect();
305
3
            match segments.as_slice() {
306
3
                ["config", "set"] => open_config_set_modal(app, args),
307
1
                ["account", "create"] => open_account_create_modal(app),
308
                ["account", "tag"] => open_account_tag_modal_for_selected(app),
309
1
                ["commodity", "create"] => open_commodity_create_modal(app),
310
                ["commodity", "convert"] => open_commodity_convert_modal(app),
311
1
                ["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
3
        Ok(Some(form)) => {
317
3
            app.submit_console_form(form);
318
3
            app.switch_tab(Tab::Console);
319
3
        }
320
    }
321
8
}
322

            
323
1
fn apply_report_command(app: &mut App, path: &[String], args: &[(String, String)]) {
324
1
    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
1
            let kind = match path.join(" ").as_str() {
332
1
                "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
1
            let chart_default = args
340
1
                .iter()
341
1
                .find(|(k, _)| k == "chart")
342
1
                .map(|(_, v)| v.clone())
343
1
                .unwrap_or_else(|| "bar".to_string());
344
1
            open_report_params_modal(app, kind, &chart_default);
345
        }
346
    }
347
1
}
348

            
349
fn 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

            
358
45
fn handle_tab(app: &mut App, intent: Intent) {
359
45
    let ctx = DrawCtx {
360
45
        edit_mode: app.edit_mode,
361
45
        is_active: app.console_focused,
362
45
    };
363
45
    if let Handled::Consumed(cmds) = app.active_view_mut().handle(intent, &ctx) {
364
        execute(app, cmds);
365
        return;
366
45
    }
367
4
    match intent {
368
6
        Intent::Quit => app.request_quit(),
369
1
        Intent::NextTab => app.next_tab(),
370
        Intent::PreviousTab => app.previous_tab(),
371
2
        Intent::SelectTab(t) => app.switch_tab(t),
372
19
        Intent::OpenCommandLine => app.open_command_line(),
373
4
        Intent::ConsoleFocus if app.active_tab == Tab::Console => {
374
3
            app.console.panes.focus(PaneId::Prompt);
375
3
            app.console_focused = true;
376
3
        }
377
2
        Intent::OpenHelp => app.overlays.push(Modal::Help),
378
        Intent::ToggleEditMode => {
379
2
            let next = match app.edit_mode {
380
1
                EditMode::Emacs => EditMode::Vim,
381
1
                EditMode::Vim => EditMode::Emacs,
382
            };
383
2
            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
4
        Intent::ListDelete if app.active_tab == Tab::Transactions => {
400
3
            let maybe_id = app.transactions.selected_id().map(str::to_string);
401
3
            if let Some(id) = maybe_id {
402
1
                app.overlays.push(Modal::Confirm {
403
1
                    prompt: format!("Delete transaction {id}? (y/N)"),
404
1
                    action: crate::modal::ConfirmAction::DeleteTransaction(id),
405
1
                });
406
2
            }
407
        }
408
5
        Intent::ListEdit if app.active_tab == Tab::Transactions => {
409
3
            let maybe_id = app.transactions.selected_id().map(str::to_string);
410
3
            if let Some(id) = maybe_id {
411
2
                app.open_transaction_edit_async(&id);
412
2
            } else {
413
1
                app.set_status("no transaction selected");
414
1
            }
415
        }
416
2
        Intent::ListEdit if app.active_tab == Tab::Accounts => {
417
2
            open_account_tag_modal_for_selected(app);
418
2
        }
419
2
        _ => {}
420
    }
421
45
}
422

            
423
#[cfg(test)]
424
mod tests;