1mod 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 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 ListDelete,
66 ListEdit,
68 RefreshTab,
69 EditConfig,
70 SelectNext,
71 SelectPrev,
72 SelectConfirm,
73 ColNext,
75 ColPrev,
77 RowNext,
79 RowPrev,
81 SplitAddRow,
83 SplitRemoveRow,
85 CompleteCommandLine,
87 DateStepBack,
89 DateStepForward,
91}
92
93pub 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
106fn 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 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
156fn 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
166fn 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 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 _ => {
270 app.cmdline.completions.clear();
271 apply_editor_intent(&mut app.cmdline.editor, intent);
272 }
273 }
274}
275
276fn 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;