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