1
//! Application state for the TUI.
2
//!
3
//! The TUI is organised as a small state machine:
4
//!
5
//! - A top-level tab row decides which tab body is rendered.
6
//! - Each tab body is a multi-pane area managed by the tab itself.
7
//! - An overlay stack sits on top of the whole lot and intercepts input
8
//!   when non-empty.
9
//! - A bottom command line is always visible.
10
//!
11
//! All of this is pure state: no rendering happens in this file. The
12
//! draw layer reads from `App` and renders; the event layer mutates
13
//! `App` via named methods so tests can drive state transitions
14
//! without a real terminal.
15

            
16
mod convert;
17
mod edit;
18
mod eval;
19
mod form_options;
20
mod mutation;
21

            
22
use std::collections::HashMap;
23

            
24
use crate::overlay::OverlayStack;
25
use crate::route::Route;
26
use crate::tabs::config::ConfigTab;
27
use crate::tabs::list::ListTab;
28
use crate::tabs::nms::ConsoleState;
29
use crate::tabs::nms_eval::ConsoleEval;
30
use crate::tabs::reports::ReportsTab;
31
use crate::view::{Tab, ViewId, ViewMut, ViewRef};
32
use crate::widgets::{EditMode, Editor};
33
use cli_core::render::schema;
34
use plotting::ChartSpec;
35
use sqlx::types::Uuid;
36

            
37
/// Command-line palette editor state.
38
pub struct CmdLine {
39
    pub editor: Editor,
40
    pub active: bool,
41
    /// Completion candidates for the current buffer, refreshed on each Tab press.
42
    pub completions: Vec<String>,
43
}
44

            
45
impl CmdLine {
46
157
    fn new(edit_mode: EditMode) -> Self {
47
157
        Self {
48
157
            editor: Editor::new(edit_mode),
49
157
            active: false,
50
157
            completions: Vec::new(),
51
157
        }
52
157
    }
53
}
54

            
55
pub struct App {
56
    pub user_id: Uuid,
57
    pub active_tab: Tab,
58
    pub overlays: OverlayStack,
59
    pub cmdline: CmdLine,
60
    pub edit_mode: EditMode,
61
    pub status: String,
62
    pub should_quit: bool,
63
    /// Whether the console input prompt has keyboard focus.
64
    pub console_focused: bool,
65
    /// The async eval bridge. `None` until attached via [`App::attach_console`].
66
    console_eval: Option<ConsoleEval>,
67
    /// Chart spec the active tab wants the runtime to emit as kitty graphics.
68
    pending_chart: Option<ChartSpec>,
69
    /// Routes pending eval reply ids to their destination view.
70
    pending_routes: HashMap<i64, Route>,
71
    /// Monotonic counter for form-options fetches; shared across all forms
72
    /// that populate Selects. A stale reply (wrong seq) is silently dropped.
73
    form_options_seq: u64,
74
    pub accounts: ListTab,
75
    pub transactions: ListTab,
76
    pub commodities: ListTab,
77
    pub reports: ReportsTab,
78
    pub config: ConfigTab,
79
    pub console: ConsoleState,
80
}
81

            
82
impl App {
83
    #[must_use]
84
157
    pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
85
157
        Self {
86
157
            user_id,
87
157
            active_tab: Tab::Reports,
88
157
            overlays: OverlayStack::new(),
89
157
            cmdline: CmdLine::new(edit_mode),
90
157
            edit_mode,
91
157
            status: String::new(),
92
157
            should_quit: false,
93
157
            console_focused: false,
94
157
            console_eval: None,
95
157
            pending_chart: None,
96
157
            pending_routes: HashMap::new(),
97
157
            form_options_seq: 0,
98
157
            accounts: ListTab::new("(list-accounts)", &schema::ACCOUNTS),
99
157
            transactions: ListTab::new("(list-transactions \"\")", &schema::TRANSACTIONS),
100
157
            commodities: ListTab::new("(list-commodities)", &schema::COMMODITIES),
101
157
            reports: ReportsTab::new(),
102
157
            config: ConfigTab::new(),
103
157
            console: ConsoleState::new(),
104
157
        }
105
157
    }
106

            
107
31
    pub fn active_view(&self) -> ViewRef<'_> {
108
31
        match self.active_tab {
109
1
            ViewId::Accounts => ViewRef::List(ViewId::Accounts, &self.accounts),
110
            ViewId::Transactions => ViewRef::List(ViewId::Transactions, &self.transactions),
111
            ViewId::Commodities => ViewRef::List(ViewId::Commodities, &self.commodities),
112
22
            ViewId::Reports => ViewRef::Reports(&self.reports),
113
            ViewId::Config => ViewRef::Config(&self.config),
114
8
            ViewId::Console => ViewRef::Console(&self.console),
115
        }
116
31
    }
117

            
118
45
    pub fn active_view_mut(&mut self) -> ViewMut<'_> {
119
45
        match self.active_tab {
120
6
            ViewId::Accounts => ViewMut::List(ViewId::Accounts, &mut self.accounts),
121
6
            ViewId::Transactions => ViewMut::List(ViewId::Transactions, &mut self.transactions),
122
            ViewId::Commodities => ViewMut::List(ViewId::Commodities, &mut self.commodities),
123
29
            ViewId::Reports => ViewMut::Reports(&mut self.reports),
124
            ViewId::Config => ViewMut::Config(&mut self.config),
125
4
            ViewId::Console => ViewMut::Console(&mut self.console),
126
        }
127
45
    }
128

            
129
    pub fn queue_chart(&mut self, spec: ChartSpec) {
130
        self.pending_chart = Some(spec);
131
    }
132

            
133
16
    pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
134
16
        self.pending_chart.take()
135
16
    }
136

            
137
13
    fn activate_tab(&mut self, tab: Tab) {
138
13
        if tab != Tab::Console {
139
9
            self.console_focused = false;
140
9
        }
141
13
        self.active_tab = tab;
142
13
        self.ensure_tab_loaded(tab);
143
13
    }
144

            
145
5
    pub fn next_tab(&mut self) {
146
5
        let idx = self.active_tab.index();
147
5
        self.activate_tab(Tab::ALL[(idx + 1) % Tab::ALL.len()]);
148
5
    }
149

            
150
1
    pub fn previous_tab(&mut self) {
151
1
        let idx = self.active_tab.index();
152
1
        let len = Tab::ALL.len();
153
1
        self.activate_tab(Tab::ALL[(idx + len - 1) % len]);
154
1
    }
155

            
156
7
    pub fn switch_tab(&mut self, tab: Tab) {
157
7
        self.activate_tab(tab);
158
7
    }
159

            
160
28
    pub fn open_command_line(&mut self) {
161
28
        self.cmdline.editor = Editor::new(self.edit_mode);
162
28
        self.cmdline.active = true;
163
28
        self.cmdline.completions.clear();
164
28
    }
165

            
166
10
    pub fn close_command_line(&mut self) {
167
10
        self.cmdline.active = false;
168
10
        self.cmdline.completions.clear();
169
10
    }
170

            
171
4
    pub fn set_status(&mut self, msg: impl Into<String>) {
172
4
        self.status = msg.into();
173
4
    }
174

            
175
8
    pub fn request_quit(&mut self) {
176
8
        self.should_quit = true;
177
8
    }
178

            
179
8
    pub fn set_edit_mode(&mut self, mode: EditMode) {
180
8
        self.edit_mode = mode;
181
8
        self.cmdline.editor.set_mode(mode);
182
8
    }
183

            
184
9
    pub fn attach_console(&mut self, eval: ConsoleEval) {
185
9
        self.console_eval = Some(eval);
186
9
    }
187
}
188

            
189
#[cfg(test)]
190
mod tests;