Skip to main content

tui/
app.rs

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//! - A modal 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
16use 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    /// Pure state for the Console tab's nomiscript REPL.
66    pub console: ConsoleState,
67    /// Whether keystrokes are routed into the console input editor. Unlike
68    /// `command_line_active`, the console editor is always Emacs-style
69    /// (see [`ConsoleState::new`]), so focus has no vim-normal sub-state.
70    pub console_input_active: bool,
71    /// The async eval bridge for the Console tab. `None` until an entry
72    /// point attaches one via [`App::attach_console`]; `App::new` stays
73    /// runtime-free so non-runtime tests keep working.
74    console_eval: Option<ConsoleEval>,
75    /// Chart spec the active tab wants the runtime to emit as kitty
76    /// graphics on the next frame. Drained by
77    /// [`crate::runtime::run_loop`] after each `draw`. Stays `None`
78    /// when the tab doesn't (yet) have data to render.
79    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    /// Queue a chart for the next frame.
102    pub fn queue_chart(&mut self, spec: ChartSpec) {
103        self.pending_chart = Some(spec);
104    }
105
106    /// Drain the queued chart, if any.
107    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    /// Attach an eval bridge so the Console tab can run forms. Called by
155    /// the entry points (sshd handler / standalone bin) once a runtime
156    /// `Handle` is in scope.
157    pub fn attach_console(&mut self, eval: ConsoleEval) {
158        self.console_eval = Some(eval);
159    }
160
161    /// Echo the submitted `form` into scrollback and route it to the
162    /// attached eval. With no eval attached, push a "console not
163    /// connected" notice; if the eval worker has stopped, surface an
164    /// "eval worker stopped" notice so the console never looks hung.
165    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    /// Request a cooperative cancel of the in-flight console eval. A
178    /// no-op when no eval is attached.
179    pub fn interrupt_console(&self) {
180        if let Some(eval) = &self.console_eval {
181            eval.interrupt();
182        }
183    }
184
185    /// Pull every ready eval result into the console scrollback,
186    /// formatting each envelope through [`format_result`]. A no-op when
187    /// no eval is attached or nothing is ready.
188    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        // Yield so the echo worker runs and answers before draining.
286        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}