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::widgets::{EditMode, Editor};
18use plotting::ChartSpec;
19use sqlx::types::Uuid;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Tab {
23    Accounts,
24    Transactions,
25    Commodities,
26    Reports,
27    Config,
28}
29
30impl Tab {
31    pub const ALL: [Tab; 5] = [
32        Tab::Accounts,
33        Tab::Transactions,
34        Tab::Commodities,
35        Tab::Reports,
36        Tab::Config,
37    ];
38
39    #[must_use]
40    pub fn label(self) -> &'static str {
41        match self {
42            Tab::Accounts => "Accounts",
43            Tab::Transactions => "Transactions",
44            Tab::Commodities => "Commodities",
45            Tab::Reports => "Reports",
46            Tab::Config => "Config",
47        }
48    }
49}
50
51#[derive(Debug)]
52pub struct App {
53    pub user_id: Uuid,
54    pub active_tab: Tab,
55    pub modals: Stack,
56    pub command_line: Editor,
57    pub command_line_active: bool,
58    pub edit_mode: EditMode,
59    pub status: String,
60    pub should_quit: bool,
61    /// Chart spec the active tab wants the runtime to emit as kitty
62    /// graphics on the next frame. Drained by
63    /// [`crate::runtime::run_loop`] after each `draw`. Stays `None`
64    /// when the tab doesn't (yet) have data to render.
65    pending_chart: Option<ChartSpec>,
66}
67
68impl App {
69    #[must_use]
70    pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
71        Self {
72            user_id,
73            active_tab: Tab::Reports,
74            modals: Stack::new(),
75            command_line: Editor::new(edit_mode),
76            command_line_active: false,
77            edit_mode,
78            status: String::new(),
79            should_quit: false,
80            pending_chart: None,
81        }
82    }
83
84    /// Queue a chart for the next frame.
85    pub fn queue_chart(&mut self, spec: ChartSpec) {
86        self.pending_chart = Some(spec);
87    }
88
89    /// Drain the queued chart, if any.
90    pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
91        self.pending_chart.take()
92    }
93
94    pub fn next_tab(&mut self) {
95        let idx = Tab::ALL
96            .iter()
97            .position(|t| *t == self.active_tab)
98            .unwrap_or(0);
99        self.active_tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
100    }
101
102    pub fn previous_tab(&mut self) {
103        let idx = Tab::ALL
104            .iter()
105            .position(|t| *t == self.active_tab)
106            .unwrap_or(0);
107        let len = Tab::ALL.len();
108        self.active_tab = Tab::ALL[(idx + len - 1) % len];
109    }
110
111    pub fn switch_tab(&mut self, tab: Tab) {
112        self.active_tab = tab;
113    }
114
115    pub fn open_command_line(&mut self) {
116        self.command_line = Editor::new(self.edit_mode);
117        self.command_line_active = true;
118    }
119
120    pub fn close_command_line(&mut self) {
121        self.command_line_active = false;
122    }
123
124    pub fn set_status(&mut self, msg: impl Into<String>) {
125        self.status = msg.into();
126    }
127
128    pub fn request_quit(&mut self) {
129        self.should_quit = true;
130    }
131
132    pub fn set_edit_mode(&mut self, mode: EditMode) {
133        self.edit_mode = mode;
134        self.command_line.set_mode(mode);
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn make() -> App {
143        App::new(Uuid::new_v4(), EditMode::Emacs)
144    }
145
146    #[test]
147    fn next_tab_wraps_around() {
148        let mut app = make();
149        app.active_tab = Tab::Config;
150        app.next_tab();
151        assert_eq!(app.active_tab, Tab::Accounts);
152    }
153
154    #[test]
155    fn previous_tab_wraps_around() {
156        let mut app = make();
157        app.active_tab = Tab::Accounts;
158        app.previous_tab();
159        assert_eq!(app.active_tab, Tab::Config);
160    }
161
162    #[test]
163    fn next_tab_advances_in_order() {
164        let mut app = make();
165        app.active_tab = Tab::Accounts;
166        app.next_tab();
167        assert_eq!(app.active_tab, Tab::Transactions);
168        app.next_tab();
169        assert_eq!(app.active_tab, Tab::Commodities);
170    }
171
172    #[test]
173    fn switch_tab_sets_target() {
174        let mut app = make();
175        app.switch_tab(Tab::Reports);
176        assert_eq!(app.active_tab, Tab::Reports);
177    }
178
179    #[test]
180    fn open_and_close_command_line() {
181        let mut app = make();
182        assert!(!app.command_line_active);
183        app.open_command_line();
184        assert!(app.command_line_active);
185        app.close_command_line();
186        assert!(!app.command_line_active);
187    }
188
189    #[test]
190    fn request_quit_sets_flag() {
191        let mut app = make();
192        assert!(!app.should_quit);
193        app.request_quit();
194        assert!(app.should_quit);
195    }
196
197    #[test]
198    fn set_edit_mode_propagates_to_command_line() {
199        let mut app = make();
200        app.open_command_line();
201        app.command_line.insert_char('x');
202        app.set_edit_mode(EditMode::Vim);
203        assert_eq!(app.command_line.mode(), EditMode::Vim);
204    }
205}