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