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

            
16
use crate::modal::Stack;
17
use crate::tabs::nms::{ConsoleState, format_result};
18
use crate::tabs::nms_eval::ConsoleEval;
19
use crate::widgets::{EditMode, Editor};
20
use plotting::ChartSpec;
21
use sqlx::types::Uuid;
22

            
23
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
pub enum Tab {
25
    Accounts,
26
    Transactions,
27
    Commodities,
28
    Reports,
29
    Config,
30
    Console,
31
}
32

            
33
impl 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
155
    pub fn label(self) -> &'static str {
45
155
        match self {
46
24
            Tab::Accounts => "Accounts",
47
23
            Tab::Transactions => "Transactions",
48
23
            Tab::Commodities => "Commodities",
49
38
            Tab::Reports => "Reports",
50
23
            Tab::Config => "Config",
51
24
            Tab::Console => "Console",
52
        }
53
155
    }
54
}
55

            
56
pub 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

            
82
impl App {
83
    #[must_use]
84
56
    pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
85
56
        Self {
86
56
            user_id,
87
56
            active_tab: Tab::Reports,
88
56
            modals: Stack::new(),
89
56
            command_line: Editor::new(edit_mode),
90
56
            command_line_active: false,
91
56
            edit_mode,
92
56
            status: String::new(),
93
56
            should_quit: false,
94
56
            console: ConsoleState::new(),
95
56
            console_input_active: false,
96
56
            console_eval: None,
97
56
            pending_chart: None,
98
56
        }
99
56
    }
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
16
    pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
108
16
        self.pending_chart.take()
109
16
    }
110

            
111
4
    pub fn next_tab(&mut self) {
112
4
        let idx = Tab::ALL
113
4
            .iter()
114
10
            .position(|t| *t == self.active_tab)
115
4
            .unwrap_or(0);
116
4
        self.active_tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
117
4
    }
118

            
119
1
    pub fn previous_tab(&mut self) {
120
1
        let idx = Tab::ALL
121
1
            .iter()
122
1
            .position(|t| *t == self.active_tab)
123
1
            .unwrap_or(0);
124
1
        let len = Tab::ALL.len();
125
1
        self.active_tab = Tab::ALL[(idx + len - 1) % len];
126
1
    }
127

            
128
3
    pub fn switch_tab(&mut self, tab: Tab) {
129
3
        self.active_tab = tab;
130
3
    }
131

            
132
12
    pub fn open_command_line(&mut self) {
133
12
        self.command_line = Editor::new(self.edit_mode);
134
12
        self.command_line_active = true;
135
12
    }
136

            
137
5
    pub fn close_command_line(&mut self) {
138
5
        self.command_line_active = false;
139
5
    }
140

            
141
3
    pub fn set_status(&mut self, msg: impl Into<String>) {
142
3
        self.status = msg.into();
143
3
    }
144

            
145
8
    pub fn request_quit(&mut self) {
146
8
        self.should_quit = true;
147
8
    }
148

            
149
7
    pub fn set_edit_mode(&mut self, mode: EditMode) {
150
7
        self.edit_mode = mode;
151
7
        self.command_line.set_mode(mode);
152
7
    }
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
4
    pub fn attach_console(&mut self, eval: ConsoleEval) {
158
4
        self.console_eval = Some(eval);
159
4
    }
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
7
    pub fn submit_console_form(&mut self, form: String) {
166
7
        self.console.push_scrollback(format!("> {form}"));
167
7
        let notice = match &mut self.console_eval {
168
4
            Some(eval) => match eval.submit(form) {
169
3
                true => return,
170
1
                false => "eval worker stopped",
171
            },
172
3
            None => "console not connected",
173
        };
174
4
        self.console.push_scrollback(notice);
175
7
    }
176

            
177
    /// Request a cooperative cancel of the in-flight console eval. A
178
    /// no-op when no eval is attached.
179
2
    pub fn interrupt_console(&self) {
180
2
        if let Some(eval) = &self.console_eval {
181
1
            eval.interrupt();
182
1
        }
183
2
    }
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
39
    pub fn drain_console(&mut self) {
189
39
        let Some(eval) = &mut self.console_eval else {
190
17
            return;
191
        };
192
22
        let responses = eval.drain();
193
22
        for envelope in responses {
194
3
            for line in format_result(&envelope) {
195
3
                self.console.push_scrollback(line);
196
3
            }
197
        }
198
39
    }
199
}
200

            
201
#[cfg(test)]
202
mod tests {
203
    use super::*;
204

            
205
11
    fn make() -> App {
206
11
        App::new(Uuid::new_v4(), EditMode::Emacs)
207
11
    }
208

            
209
    #[test]
210
1
    fn all_has_six_tabs_ending_in_console() {
211
1
        assert_eq!(Tab::ALL.len(), 6);
212
1
        assert_eq!(Tab::ALL[Tab::ALL.len() - 1], Tab::Console);
213
1
    }
214

            
215
    #[test]
216
1
    fn console_label_is_console() {
217
1
        assert_eq!(Tab::Console.label(), "Console");
218
1
    }
219

            
220
    #[test]
221
1
    fn next_tab_wraps_around() {
222
1
        let mut app = make();
223
1
        app.active_tab = Tab::Console;
224
1
        app.next_tab();
225
1
        assert_eq!(app.active_tab, Tab::Accounts);
226
1
    }
227

            
228
    #[test]
229
1
    fn previous_tab_wraps_around() {
230
1
        let mut app = make();
231
1
        app.active_tab = Tab::Accounts;
232
1
        app.previous_tab();
233
1
        assert_eq!(app.active_tab, Tab::Console);
234
1
    }
235

            
236
    #[test]
237
1
    fn next_tab_advances_in_order() {
238
1
        let mut app = make();
239
1
        app.active_tab = Tab::Accounts;
240
1
        app.next_tab();
241
1
        assert_eq!(app.active_tab, Tab::Transactions);
242
1
        app.next_tab();
243
1
        assert_eq!(app.active_tab, Tab::Commodities);
244
1
    }
245

            
246
    #[test]
247
1
    fn switch_tab_sets_target() {
248
1
        let mut app = make();
249
1
        app.switch_tab(Tab::Reports);
250
1
        assert_eq!(app.active_tab, Tab::Reports);
251
1
    }
252

            
253
    #[test]
254
1
    fn open_and_close_command_line() {
255
1
        let mut app = make();
256
1
        assert!(!app.command_line_active);
257
1
        app.open_command_line();
258
1
        assert!(app.command_line_active);
259
1
        app.close_command_line();
260
1
        assert!(!app.command_line_active);
261
1
    }
262

            
263
    #[test]
264
1
    fn request_quit_sets_flag() {
265
1
        let mut app = make();
266
1
        assert!(!app.should_quit);
267
1
        app.request_quit();
268
1
        assert!(app.should_quit);
269
1
    }
270

            
271
    #[test]
272
1
    fn set_edit_mode_propagates_to_command_line() {
273
1
        let mut app = make();
274
1
        app.open_command_line();
275
1
        app.command_line.insert_char('x');
276
1
        app.set_edit_mode(EditMode::Vim);
277
1
        assert_eq!(app.command_line.mode(), EditMode::Vim);
278
1
    }
279

            
280
    #[tokio::test]
281
1
    async fn submit_then_drain_routes_echo_into_scrollback() {
282
1
        let mut app = make();
283
1
        app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
284
1
        app.submit_console_form("(x)".to_string());
285
        // Yield so the echo worker runs and answers before draining.
286
1
        tokio::task::yield_now().await;
287
1
        app.drain_console();
288
1
        assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
289
1
        assert!(
290
1
            app.console
291
1
                .scrollback
292
1
                .iter()
293
2
                .any(|l| l.contains("(:id 0 :form (x))"))
294
1
        );
295
1
    }
296

            
297
    #[test]
298
1
    fn submit_without_eval_pushes_not_connected_notice() {
299
1
        let mut app = make();
300
1
        app.submit_console_form("(x)".to_string());
301
1
        assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
302
1
        assert!(
303
1
            app.console
304
1
                .scrollback
305
1
                .iter()
306
2
                .any(|l| l.contains("console not connected"))
307
        );
308
1
    }
309

            
310
    #[test]
311
1
    fn drain_without_eval_is_noop() {
312
1
        let mut app = make();
313
1
        app.drain_console();
314
1
        assert!(app.console.scrollback.is_empty());
315
1
    }
316

            
317
    #[tokio::test]
318
1
    async fn submit_after_worker_stops_surfaces_notice() {
319
1
        let mut app = make();
320
1
        let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
321
1
        let worker = eval.worker_handle();
322
1
        app.attach_console(eval);
323
1
        worker.abort();
324
1
        for _ in 0..200 {
325
2
            if worker.is_finished() {
326
1
                break;
327
1
            }
328
1
            tokio::task::yield_now().await;
329
        }
330
1
        app.submit_console_form("(x)".to_string());
331
1
        assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
332
1
        assert!(
333
1
            app.console
334
1
                .scrollback
335
1
                .iter()
336
2
                .any(|l| l == "eval worker stopped")
337
1
        );
338
1
    }
339
}