envelope_cli/tui/
app.rs

1//! Application state for the TUI
2//!
3//! The App struct holds all state needed for rendering and handling events.
4
5use crate::config::paths::EnvelopePaths;
6use crate::config::settings::Settings;
7use crate::models::{AccountId, BudgetPeriod, CategoryGroupId, CategoryId, TransactionId};
8use crate::storage::Storage;
9
10use super::dialogs::account::AccountFormState;
11use super::dialogs::adjustment::AdjustmentDialogState;
12use super::dialogs::budget::BudgetDialogState;
13use super::dialogs::bulk_categorize::BulkCategorizeState;
14use super::dialogs::category::CategoryFormState;
15use super::dialogs::group::GroupFormState;
16use super::dialogs::income::IncomeFormState;
17use super::dialogs::move_funds::MoveFundsState;
18use super::dialogs::reconcile_start::ReconcileStartState;
19use super::dialogs::transaction::TransactionFormState;
20use super::dialogs::unlock_confirm::UnlockConfirmState;
21use super::views::reconcile::ReconciliationState;
22
23/// Which view is currently active
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum ActiveView {
26    #[default]
27    Accounts,
28    Register,
29    Budget,
30    Reports,
31    Reconcile,
32}
33
34/// What to display in the budget header
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum BudgetHeaderDisplay {
37    /// Show Available to Assign / Overspent (default)
38    #[default]
39    AvailableToBudget,
40    /// Show total checking account balance
41    Checking,
42    /// Show total savings account balance
43    Savings,
44    /// Show total credit card balance
45    Credit,
46    /// Show total cash balance
47    Cash,
48    /// Show total investment balance
49    Investment,
50    /// Show total line of credit balance
51    LineOfCredit,
52    /// Show total for other account types
53    Other,
54}
55
56impl BudgetHeaderDisplay {
57    /// Cycle to the next display mode
58    pub fn next(self) -> Self {
59        match self {
60            Self::AvailableToBudget => Self::Checking,
61            Self::Checking => Self::Savings,
62            Self::Savings => Self::Credit,
63            Self::Credit => Self::Cash,
64            Self::Cash => Self::Investment,
65            Self::Investment => Self::LineOfCredit,
66            Self::LineOfCredit => Self::Other,
67            Self::Other => Self::AvailableToBudget,
68        }
69    }
70
71    /// Cycle to the previous display mode
72    pub fn prev(self) -> Self {
73        match self {
74            Self::AvailableToBudget => Self::Other,
75            Self::Checking => Self::AvailableToBudget,
76            Self::Savings => Self::Checking,
77            Self::Credit => Self::Savings,
78            Self::Cash => Self::Credit,
79            Self::Investment => Self::Cash,
80            Self::LineOfCredit => Self::Investment,
81            Self::Other => Self::LineOfCredit,
82        }
83    }
84
85    /// Get the display label for this mode
86    pub fn label(&self) -> &'static str {
87        match self {
88            Self::AvailableToBudget => "Available to Assign",
89            Self::Checking => "Checking",
90            Self::Savings => "Savings",
91            Self::Credit => "Credit Cards",
92            Self::Cash => "Cash",
93            Self::Investment => "Investment",
94            Self::LineOfCredit => "Line of Credit",
95            Self::Other => "Other",
96        }
97    }
98}
99
100/// Which panel currently has focus
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum FocusedPanel {
103    #[default]
104    Sidebar,
105    Main,
106}
107
108/// Mode of input
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum InputMode {
111    #[default]
112    Normal,
113    Editing,
114    Command,
115}
116
117/// Currently active dialog (if any)
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub enum ActiveDialog {
120    #[default]
121    None,
122    AddTransaction,
123    EditTransaction(TransactionId),
124    AddAccount,
125    EditAccount(AccountId),
126    AddCategory,
127    EditCategory(CategoryId),
128    AddGroup,
129    EditGroup(CategoryGroupId),
130    MoveFunds,
131    CommandPalette,
132    Help,
133    Confirm(String),
134    BulkCategorize,
135    ReconcileStart,
136    UnlockConfirm(UnlockConfirmState),
137    Adjustment,
138    Budget,
139    Income,
140}
141
142/// Main application state
143pub struct App<'a> {
144    /// The storage layer
145    pub storage: &'a Storage,
146
147    /// Application settings
148    pub settings: &'a Settings,
149
150    /// Paths configuration
151    pub paths: &'a EnvelopePaths,
152
153    /// Whether the app should quit
154    pub should_quit: bool,
155
156    /// Currently active view
157    pub active_view: ActiveView,
158
159    /// Which panel is focused
160    pub focused_panel: FocusedPanel,
161
162    /// Current input mode
163    pub input_mode: InputMode,
164
165    /// Currently active dialog
166    pub active_dialog: ActiveDialog,
167
168    /// Selected account (if any)
169    pub selected_account: Option<AccountId>,
170
171    /// Selected account index in the list
172    pub selected_account_index: usize,
173
174    /// Selected transaction (if any)
175    pub selected_transaction: Option<TransactionId>,
176
177    /// Selected transaction index in the register
178    pub selected_transaction_index: usize,
179
180    /// Selected category (for budget view)
181    pub selected_category: Option<CategoryId>,
182
183    /// Selected category index
184    pub selected_category_index: usize,
185
186    /// Current budget period being viewed
187    pub current_period: BudgetPeriod,
188
189    /// What to display in the budget header (toggle between ATB and account balances)
190    pub budget_header_display: BudgetHeaderDisplay,
191
192    /// Show archived accounts
193    pub show_archived: bool,
194
195    /// Multi-selection mode (for bulk operations)
196    pub multi_select_mode: bool,
197
198    /// Selected transaction IDs for bulk operations
199    pub selected_transactions: Vec<TransactionId>,
200
201    /// Scroll offset for the main view
202    pub scroll_offset: usize,
203
204    /// Status message to display
205    pub status_message: Option<String>,
206
207    /// Command palette input
208    pub command_input: String,
209
210    /// Filtered commands for palette
211    pub command_results: Vec<usize>,
212
213    /// Selected command index in palette
214    pub selected_command_index: usize,
215
216    /// Transaction form state
217    pub transaction_form: TransactionFormState,
218
219    /// Move funds dialog state
220    pub move_funds_state: MoveFundsState,
221
222    /// Bulk categorize dialog state
223    pub bulk_categorize_state: BulkCategorizeState,
224
225    /// Reconciliation view state
226    pub reconciliation_state: ReconciliationState,
227
228    /// Reconcile start dialog state
229    pub reconcile_start_state: ReconcileStartState,
230
231    /// Adjustment dialog state
232    pub adjustment_dialog_state: AdjustmentDialogState,
233
234    /// Account form dialog state
235    pub account_form: AccountFormState,
236
237    /// Category form dialog state
238    pub category_form: CategoryFormState,
239
240    /// Group form dialog state
241    pub group_form: GroupFormState,
242
243    /// Unified budget dialog state (period budget + target)
244    pub budget_dialog_state: BudgetDialogState,
245
246    /// Income form dialog state
247    pub income_form: IncomeFormState,
248
249    /// Pending 'g' keypress for Vim-style gg (go to top)
250    pub pending_g: bool,
251}
252
253impl<'a> App<'a> {
254    /// Create a new App instance
255    pub fn new(storage: &'a Storage, settings: &'a Settings, paths: &'a EnvelopePaths) -> Self {
256        // Initialize selected_account to first account (default view is Accounts)
257        let selected_account = storage
258            .accounts
259            .get_active()
260            .ok()
261            .and_then(|accounts| accounts.first().map(|a| a.id));
262
263        Self {
264            storage,
265            settings,
266            paths,
267            should_quit: false,
268            active_view: ActiveView::default(),
269            focused_panel: FocusedPanel::default(),
270            input_mode: InputMode::default(),
271            active_dialog: ActiveDialog::default(),
272            selected_account,
273            selected_account_index: 0,
274            selected_transaction: None,
275            selected_transaction_index: 0,
276            selected_category: None,
277            selected_category_index: 0,
278            current_period: BudgetPeriod::current_month(),
279            budget_header_display: BudgetHeaderDisplay::default(),
280            show_archived: false,
281            multi_select_mode: false,
282            selected_transactions: Vec::new(),
283            scroll_offset: 0,
284            status_message: None,
285            command_input: String::new(),
286            command_results: Vec::new(),
287            selected_command_index: 0,
288            transaction_form: TransactionFormState::new(),
289            move_funds_state: MoveFundsState::new(),
290            bulk_categorize_state: BulkCategorizeState::new(),
291            reconciliation_state: ReconciliationState::new(),
292            reconcile_start_state: ReconcileStartState::new(),
293            adjustment_dialog_state: AdjustmentDialogState::default(),
294            account_form: AccountFormState::new(),
295            category_form: CategoryFormState::new(),
296            group_form: GroupFormState::new(),
297            budget_dialog_state: BudgetDialogState::new(),
298            income_form: IncomeFormState::new(),
299            pending_g: false,
300        }
301    }
302
303    /// Request to quit the application
304    pub fn quit(&mut self) {
305        self.should_quit = true;
306    }
307
308    /// Set a status message
309    pub fn set_status(&mut self, message: impl Into<String>) {
310        self.status_message = Some(message.into());
311    }
312
313    /// Clear the status message
314    pub fn clear_status(&mut self) {
315        self.status_message = None;
316    }
317
318    /// Switch to a different view
319    pub fn switch_view(&mut self, view: ActiveView) {
320        self.active_view = view;
321        self.scroll_offset = 0;
322
323        // Reset selection based on view
324        match view {
325            ActiveView::Accounts => {
326                self.selected_account_index = 0;
327                // Initialize selected_account to first account
328                if let Ok(accounts) = self.storage.accounts.get_active() {
329                    self.selected_account = accounts.first().map(|a| a.id);
330                }
331            }
332            ActiveView::Register => {
333                self.selected_transaction_index = 0;
334                // Initialize selected_transaction to first transaction (sorted by date desc)
335                if let Some(account_id) = self.selected_account {
336                    let mut txns = self
337                        .storage
338                        .transactions
339                        .get_by_account(account_id)
340                        .unwrap_or_default();
341                    txns.sort_by(|a, b| b.date.cmp(&a.date));
342                    self.selected_transaction = txns.first().map(|t| t.id);
343                }
344            }
345            ActiveView::Budget => {
346                self.selected_category_index = 0;
347                // Initialize selected_category to first category (in visual order)
348                let groups = self.storage.categories.get_all_groups().unwrap_or_default();
349                let all_categories = self
350                    .storage
351                    .categories
352                    .get_all_categories()
353                    .unwrap_or_default();
354                // Find first category in visual order (first category in first group)
355                for group in &groups {
356                    if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
357                        self.selected_category = Some(cat.id);
358                        break;
359                    }
360                }
361            }
362            ActiveView::Reports => {}
363            ActiveView::Reconcile => {
364                // Initialize reconciliation state if account is selected
365                if let Some(account_id) = self.selected_account {
366                    self.reconciliation_state.init_for_account(account_id);
367                }
368            }
369        }
370    }
371
372    /// Toggle focus between sidebar and main panel
373    pub fn toggle_panel_focus(&mut self) {
374        self.focused_panel = match self.focused_panel {
375            FocusedPanel::Sidebar => FocusedPanel::Main,
376            FocusedPanel::Main => FocusedPanel::Sidebar,
377        };
378        // Initialize selection when switching to main panel
379        if self.focused_panel == FocusedPanel::Main {
380            self.ensure_selection_initialized();
381        }
382    }
383
384    /// Ensure selection is initialized for the current view
385    pub fn ensure_selection_initialized(&mut self) {
386        match self.active_view {
387            ActiveView::Accounts => {
388                if self.selected_account.is_none() {
389                    if let Ok(accounts) = self.storage.accounts.get_active() {
390                        self.selected_account = accounts.first().map(|a| a.id);
391                    }
392                }
393            }
394            ActiveView::Register => {
395                if self.selected_transaction.is_none() {
396                    if let Some(account_id) = self.selected_account {
397                        let mut txns = self
398                            .storage
399                            .transactions
400                            .get_by_account(account_id)
401                            .unwrap_or_default();
402                        txns.sort_by(|a, b| b.date.cmp(&a.date));
403                        self.selected_transaction = txns.first().map(|t| t.id);
404                    }
405                }
406            }
407            ActiveView::Budget => {
408                if self.selected_category.is_none() {
409                    let groups = self.storage.categories.get_all_groups().unwrap_or_default();
410                    let all_categories = self
411                        .storage
412                        .categories
413                        .get_all_categories()
414                        .unwrap_or_default();
415                    for group in &groups {
416                        if let Some(cat) = all_categories.iter().find(|c| c.group_id == group.id) {
417                            self.selected_category = Some(cat.id);
418                            break;
419                        }
420                    }
421                }
422            }
423            _ => {}
424        }
425    }
426
427    /// Open a dialog
428    pub fn open_dialog(&mut self, dialog: ActiveDialog) {
429        self.active_dialog = dialog.clone();
430        match &dialog {
431            ActiveDialog::CommandPalette => {
432                self.command_input.clear();
433                self.input_mode = InputMode::Command;
434            }
435            ActiveDialog::AddTransaction => {
436                // Reset form for new transaction
437                self.transaction_form = TransactionFormState::new();
438                self.transaction_form
439                    .set_focus(super::dialogs::transaction::TransactionField::Date);
440                self.input_mode = InputMode::Editing;
441            }
442            ActiveDialog::EditTransaction(txn_id) => {
443                // Load transaction data into form
444                if let Ok(Some(txn)) = self.storage.transactions.get(*txn_id) {
445                    let categories: Vec<_> = self
446                        .storage
447                        .categories
448                        .get_all_categories()
449                        .unwrap_or_default()
450                        .iter()
451                        .map(|c| (c.id, c.name.clone()))
452                        .collect();
453                    self.transaction_form =
454                        TransactionFormState::from_transaction(&txn, &categories);
455                    self.transaction_form
456                        .set_focus(super::dialogs::transaction::TransactionField::Date);
457                }
458                self.input_mode = InputMode::Editing;
459            }
460            ActiveDialog::AddAccount => {
461                // Reset form for new account
462                self.account_form = AccountFormState::new();
463                self.account_form
464                    .set_focus(super::dialogs::account::AccountField::Name);
465                self.input_mode = InputMode::Editing;
466            }
467            ActiveDialog::EditAccount(account_id) => {
468                // Load account data into form
469                if let Ok(Some(account)) = self.storage.accounts.get(*account_id) {
470                    self.account_form = AccountFormState::from_account(&account);
471                    self.account_form
472                        .set_focus(super::dialogs::account::AccountField::Name);
473                }
474                self.input_mode = InputMode::Editing;
475            }
476            ActiveDialog::AddCategory => {
477                // Reset form for new category and load available groups
478                self.category_form = CategoryFormState::new();
479                let groups: Vec<_> = self
480                    .storage
481                    .categories
482                    .get_all_groups()
483                    .unwrap_or_default()
484                    .into_iter()
485                    .map(|g| (g.id, g.name))
486                    .collect();
487                self.category_form.init_with_groups(groups);
488                self.input_mode = InputMode::Editing;
489            }
490            ActiveDialog::EditCategory(category_id) => {
491                // Load category data into form
492                if let Ok(Some(category)) = self.storage.categories.get_category(*category_id) {
493                    let groups: Vec<_> = self
494                        .storage
495                        .categories
496                        .get_all_groups()
497                        .unwrap_or_default()
498                        .into_iter()
499                        .map(|g| (g.id, g.name.clone()))
500                        .collect();
501                    self.category_form.init_for_edit(&category, groups);
502                }
503                self.input_mode = InputMode::Editing;
504            }
505            ActiveDialog::AddGroup => {
506                // Reset form for new group
507                self.group_form = GroupFormState::new();
508                self.input_mode = InputMode::Editing;
509            }
510            ActiveDialog::EditGroup(group_id) => {
511                // Load group data into form for editing
512                if let Ok(Some(group)) = self.storage.categories.get_group(*group_id) {
513                    self.group_form = GroupFormState::new();
514                    self.group_form.init_for_edit(&group);
515                }
516                self.input_mode = InputMode::Editing;
517            }
518            ActiveDialog::Budget => {
519                // Initialize unified budget dialog for selected category
520                if let Some(category_id) = self.selected_category {
521                    if let Ok(Some(category)) = self.storage.categories.get_category(category_id) {
522                        let budget_service = crate::services::BudgetService::new(self.storage);
523                        let summary = budget_service
524                            .get_category_summary(category_id, &self.current_period)
525                            .unwrap_or_else(|_| {
526                                crate::models::CategoryBudgetSummary::empty(category_id)
527                            });
528                        let suggested = budget_service
529                            .get_suggested_budget(category_id, &self.current_period)
530                            .ok()
531                            .flatten();
532                        let existing_target = self
533                            .storage
534                            .targets
535                            .get_for_category(category_id)
536                            .ok()
537                            .flatten();
538                        self.budget_dialog_state.init_for_category(
539                            category_id,
540                            category.name,
541                            summary.budgeted,
542                            suggested,
543                            existing_target.as_ref(),
544                        );
545                        self.input_mode = InputMode::Editing;
546                    }
547                }
548            }
549            ActiveDialog::Income => {
550                // Initialize income dialog for current period
551                self.income_form
552                    .init_for_period(&self.current_period, self.storage);
553                self.input_mode = InputMode::Editing;
554            }
555            _ => {}
556        }
557    }
558
559    /// Close the current dialog
560    pub fn close_dialog(&mut self) {
561        self.active_dialog = ActiveDialog::None;
562        self.input_mode = InputMode::Normal;
563    }
564
565    /// Check if a dialog is active
566    pub fn has_dialog(&self) -> bool {
567        !matches!(self.active_dialog, ActiveDialog::None)
568    }
569
570    /// Move selection up in the current view
571    pub fn move_up(&mut self) {
572        match self.focused_panel {
573            FocusedPanel::Sidebar => {
574                if self.selected_account_index > 0 {
575                    self.selected_account_index -= 1;
576                }
577            }
578            FocusedPanel::Main => match self.active_view {
579                ActiveView::Register => {
580                    if self.selected_transaction_index > 0 {
581                        self.selected_transaction_index -= 1;
582                    }
583                }
584                ActiveView::Budget => {
585                    if self.selected_category_index > 0 {
586                        self.selected_category_index -= 1;
587                    }
588                }
589                _ => {}
590            },
591        }
592    }
593
594    /// Move selection down in the current view
595    pub fn move_down(&mut self, max: usize) {
596        match self.focused_panel {
597            FocusedPanel::Sidebar => {
598                if self.selected_account_index < max.saturating_sub(1) {
599                    self.selected_account_index += 1;
600                }
601            }
602            FocusedPanel::Main => match self.active_view {
603                ActiveView::Register => {
604                    if self.selected_transaction_index < max.saturating_sub(1) {
605                        self.selected_transaction_index += 1;
606                    }
607                }
608                ActiveView::Budget => {
609                    if self.selected_category_index < max.saturating_sub(1) {
610                        self.selected_category_index += 1;
611                    }
612                }
613                _ => {}
614            },
615        }
616    }
617
618    /// Go to previous budget period
619    pub fn prev_period(&mut self) {
620        self.current_period = self.current_period.prev();
621    }
622
623    /// Go to next budget period
624    pub fn next_period(&mut self) {
625        self.current_period = self.current_period.next();
626    }
627
628    /// Toggle multi-select mode
629    pub fn toggle_multi_select(&mut self) {
630        self.multi_select_mode = !self.multi_select_mode;
631        if !self.multi_select_mode {
632            self.selected_transactions.clear();
633        }
634    }
635
636    /// Toggle selection of current transaction in multi-select mode
637    pub fn toggle_transaction_selection(&mut self) {
638        if let Some(txn_id) = self.selected_transaction {
639            if self.selected_transactions.contains(&txn_id) {
640                self.selected_transactions.retain(|&id| id != txn_id);
641            } else {
642                self.selected_transactions.push(txn_id);
643            }
644        }
645    }
646}