1use crate::modal::Stack;
17use crate::tabs::nms::{ConsoleState, format_result};
18use crate::tabs::nms_eval::ConsoleEval;
19use crate::widgets::{EditMode, Editor};
20use plotting::ChartSpec;
21use sqlx::types::Uuid;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Tab {
25 Accounts,
26 Transactions,
27 Commodities,
28 Reports,
29 Config,
30 Console,
31}
32
33impl Tab {
34 pub const ALL: [Tab; 6] = [
35 Tab::Accounts,
36 Tab::Transactions,
37 Tab::Commodities,
38 Tab::Reports,
39 Tab::Config,
40 Tab::Console,
41 ];
42
43 #[must_use]
44 pub fn label(self) -> &'static str {
45 match self {
46 Tab::Accounts => "Accounts",
47 Tab::Transactions => "Transactions",
48 Tab::Commodities => "Commodities",
49 Tab::Reports => "Reports",
50 Tab::Config => "Config",
51 Tab::Console => "Console",
52 }
53 }
54}
55
56pub struct App {
57 pub user_id: Uuid,
58 pub active_tab: Tab,
59 pub modals: Stack,
60 pub command_line: Editor,
61 pub command_line_active: bool,
62 pub edit_mode: EditMode,
63 pub status: String,
64 pub should_quit: bool,
65 pub console: ConsoleState,
67 pub console_input_active: bool,
71 console_eval: Option<ConsoleEval>,
75 pending_chart: Option<ChartSpec>,
80}
81
82impl App {
83 #[must_use]
84 pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
85 Self {
86 user_id,
87 active_tab: Tab::Reports,
88 modals: Stack::new(),
89 command_line: Editor::new(edit_mode),
90 command_line_active: false,
91 edit_mode,
92 status: String::new(),
93 should_quit: false,
94 console: ConsoleState::new(),
95 console_input_active: false,
96 console_eval: None,
97 pending_chart: None,
98 }
99 }
100
101 pub fn queue_chart(&mut self, spec: ChartSpec) {
103 self.pending_chart = Some(spec);
104 }
105
106 pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
108 self.pending_chart.take()
109 }
110
111 pub fn next_tab(&mut self) {
112 let idx = Tab::ALL
113 .iter()
114 .position(|t| *t == self.active_tab)
115 .unwrap_or(0);
116 self.active_tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
117 }
118
119 pub fn previous_tab(&mut self) {
120 let idx = Tab::ALL
121 .iter()
122 .position(|t| *t == self.active_tab)
123 .unwrap_or(0);
124 let len = Tab::ALL.len();
125 self.active_tab = Tab::ALL[(idx + len - 1) % len];
126 }
127
128 pub fn switch_tab(&mut self, tab: Tab) {
129 self.active_tab = tab;
130 }
131
132 pub fn open_command_line(&mut self) {
133 self.command_line = Editor::new(self.edit_mode);
134 self.command_line_active = true;
135 }
136
137 pub fn close_command_line(&mut self) {
138 self.command_line_active = false;
139 }
140
141 pub fn set_status(&mut self, msg: impl Into<String>) {
142 self.status = msg.into();
143 }
144
145 pub fn request_quit(&mut self) {
146 self.should_quit = true;
147 }
148
149 pub fn set_edit_mode(&mut self, mode: EditMode) {
150 self.edit_mode = mode;
151 self.command_line.set_mode(mode);
152 }
153
154 pub fn attach_console(&mut self, eval: ConsoleEval) {
158 self.console_eval = Some(eval);
159 }
160
161 pub fn submit_console_form(&mut self, form: String) {
166 self.console.push_scrollback(format!("> {form}"));
167 let notice = match &mut self.console_eval {
168 Some(eval) => match eval.submit(form) {
169 true => return,
170 false => "eval worker stopped",
171 },
172 None => "console not connected",
173 };
174 self.console.push_scrollback(notice);
175 }
176
177 pub fn interrupt_console(&self) {
180 if let Some(eval) = &self.console_eval {
181 eval.interrupt();
182 }
183 }
184
185 pub fn drain_console(&mut self) {
189 let Some(eval) = &mut self.console_eval else {
190 return;
191 };
192 let responses = eval.drain();
193 for envelope in responses {
194 for line in format_result(&envelope) {
195 self.console.push_scrollback(line);
196 }
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn make() -> App {
206 App::new(Uuid::new_v4(), EditMode::Emacs)
207 }
208
209 #[test]
210 fn all_has_six_tabs_ending_in_console() {
211 assert_eq!(Tab::ALL.len(), 6);
212 assert_eq!(Tab::ALL[Tab::ALL.len() - 1], Tab::Console);
213 }
214
215 #[test]
216 fn console_label_is_console() {
217 assert_eq!(Tab::Console.label(), "Console");
218 }
219
220 #[test]
221 fn next_tab_wraps_around() {
222 let mut app = make();
223 app.active_tab = Tab::Console;
224 app.next_tab();
225 assert_eq!(app.active_tab, Tab::Accounts);
226 }
227
228 #[test]
229 fn previous_tab_wraps_around() {
230 let mut app = make();
231 app.active_tab = Tab::Accounts;
232 app.previous_tab();
233 assert_eq!(app.active_tab, Tab::Console);
234 }
235
236 #[test]
237 fn next_tab_advances_in_order() {
238 let mut app = make();
239 app.active_tab = Tab::Accounts;
240 app.next_tab();
241 assert_eq!(app.active_tab, Tab::Transactions);
242 app.next_tab();
243 assert_eq!(app.active_tab, Tab::Commodities);
244 }
245
246 #[test]
247 fn switch_tab_sets_target() {
248 let mut app = make();
249 app.switch_tab(Tab::Reports);
250 assert_eq!(app.active_tab, Tab::Reports);
251 }
252
253 #[test]
254 fn open_and_close_command_line() {
255 let mut app = make();
256 assert!(!app.command_line_active);
257 app.open_command_line();
258 assert!(app.command_line_active);
259 app.close_command_line();
260 assert!(!app.command_line_active);
261 }
262
263 #[test]
264 fn request_quit_sets_flag() {
265 let mut app = make();
266 assert!(!app.should_quit);
267 app.request_quit();
268 assert!(app.should_quit);
269 }
270
271 #[test]
272 fn set_edit_mode_propagates_to_command_line() {
273 let mut app = make();
274 app.open_command_line();
275 app.command_line.insert_char('x');
276 app.set_edit_mode(EditMode::Vim);
277 assert_eq!(app.command_line.mode(), EditMode::Vim);
278 }
279
280 #[tokio::test]
281 async fn submit_then_drain_routes_echo_into_scrollback() {
282 let mut app = make();
283 app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
284 app.submit_console_form("(x)".to_string());
285 tokio::task::yield_now().await;
287 app.drain_console();
288 assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
289 assert!(
290 app.console
291 .scrollback
292 .iter()
293 .any(|l| l.contains("(:id 0 :form (x))"))
294 );
295 }
296
297 #[test]
298 fn submit_without_eval_pushes_not_connected_notice() {
299 let mut app = make();
300 app.submit_console_form("(x)".to_string());
301 assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
302 assert!(
303 app.console
304 .scrollback
305 .iter()
306 .any(|l| l.contains("console not connected"))
307 );
308 }
309
310 #[test]
311 fn drain_without_eval_is_noop() {
312 let mut app = make();
313 app.drain_console();
314 assert!(app.console.scrollback.is_empty());
315 }
316
317 #[tokio::test]
318 async fn submit_after_worker_stops_surfaces_notice() {
319 let mut app = make();
320 let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
321 let worker = eval.worker_handle();
322 app.attach_console(eval);
323 worker.abort();
324 for _ in 0..200 {
325 if worker.is_finished() {
326 break;
327 }
328 tokio::task::yield_now().await;
329 }
330 app.submit_console_form("(x)".to_string());
331 assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
332 assert!(
333 app.console
334 .scrollback
335 .iter()
336 .any(|l| l == "eval worker stopped")
337 );
338 }
339}