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::widgets::{EditMode, Editor};
18
use plotting::ChartSpec;
19
use sqlx::types::Uuid;
20

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

            
30
impl 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
96
    pub fn label(self) -> &'static str {
41
96
        match self {
42
17
            Tab::Accounts => "Accounts",
43
16
            Tab::Transactions => "Transactions",
44
16
            Tab::Commodities => "Commodities",
45
31
            Tab::Reports => "Reports",
46
16
            Tab::Config => "Config",
47
        }
48
96
    }
49
}
50

            
51
#[derive(Debug)]
52
pub 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

            
68
impl App {
69
    #[must_use]
70
33
    pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
71
33
        Self {
72
33
            user_id,
73
33
            active_tab: Tab::Reports,
74
33
            modals: Stack::new(),
75
33
            command_line: Editor::new(edit_mode),
76
33
            command_line_active: false,
77
33
            edit_mode,
78
33
            status: String::new(),
79
33
            should_quit: false,
80
33
            pending_chart: None,
81
33
        }
82
33
    }
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
16
    pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
91
16
        self.pending_chart.take()
92
16
    }
93

            
94
4
    pub fn next_tab(&mut self) {
95
4
        let idx = Tab::ALL
96
4
            .iter()
97
9
            .position(|t| *t == self.active_tab)
98
4
            .unwrap_or(0);
99
4
        self.active_tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
100
4
    }
101

            
102
1
    pub fn previous_tab(&mut self) {
103
1
        let idx = Tab::ALL
104
1
            .iter()
105
1
            .position(|t| *t == self.active_tab)
106
1
            .unwrap_or(0);
107
1
        let len = Tab::ALL.len();
108
1
        self.active_tab = Tab::ALL[(idx + len - 1) % len];
109
1
    }
110

            
111
3
    pub fn switch_tab(&mut self, tab: Tab) {
112
3
        self.active_tab = tab;
113
3
    }
114

            
115
12
    pub fn open_command_line(&mut self) {
116
12
        self.command_line = Editor::new(self.edit_mode);
117
12
        self.command_line_active = true;
118
12
    }
119

            
120
5
    pub fn close_command_line(&mut self) {
121
5
        self.command_line_active = false;
122
5
    }
123

            
124
3
    pub fn set_status(&mut self, msg: impl Into<String>) {
125
3
        self.status = msg.into();
126
3
    }
127

            
128
8
    pub fn request_quit(&mut self) {
129
8
        self.should_quit = true;
130
8
    }
131

            
132
6
    pub fn set_edit_mode(&mut self, mode: EditMode) {
133
6
        self.edit_mode = mode;
134
6
        self.command_line.set_mode(mode);
135
6
    }
136
}
137

            
138
#[cfg(test)]
139
mod tests {
140
    use super::*;
141

            
142
7
    fn make() -> App {
143
7
        App::new(Uuid::new_v4(), EditMode::Emacs)
144
7
    }
145

            
146
    #[test]
147
1
    fn next_tab_wraps_around() {
148
1
        let mut app = make();
149
1
        app.active_tab = Tab::Config;
150
1
        app.next_tab();
151
1
        assert_eq!(app.active_tab, Tab::Accounts);
152
1
    }
153

            
154
    #[test]
155
1
    fn previous_tab_wraps_around() {
156
1
        let mut app = make();
157
1
        app.active_tab = Tab::Accounts;
158
1
        app.previous_tab();
159
1
        assert_eq!(app.active_tab, Tab::Config);
160
1
    }
161

            
162
    #[test]
163
1
    fn next_tab_advances_in_order() {
164
1
        let mut app = make();
165
1
        app.active_tab = Tab::Accounts;
166
1
        app.next_tab();
167
1
        assert_eq!(app.active_tab, Tab::Transactions);
168
1
        app.next_tab();
169
1
        assert_eq!(app.active_tab, Tab::Commodities);
170
1
    }
171

            
172
    #[test]
173
1
    fn switch_tab_sets_target() {
174
1
        let mut app = make();
175
1
        app.switch_tab(Tab::Reports);
176
1
        assert_eq!(app.active_tab, Tab::Reports);
177
1
    }
178

            
179
    #[test]
180
1
    fn open_and_close_command_line() {
181
1
        let mut app = make();
182
1
        assert!(!app.command_line_active);
183
1
        app.open_command_line();
184
1
        assert!(app.command_line_active);
185
1
        app.close_command_line();
186
1
        assert!(!app.command_line_active);
187
1
    }
188

            
189
    #[test]
190
1
    fn request_quit_sets_flag() {
191
1
        let mut app = make();
192
1
        assert!(!app.should_quit);
193
1
        app.request_quit();
194
1
        assert!(app.should_quit);
195
1
    }
196

            
197
    #[test]
198
1
    fn set_edit_mode_propagates_to_command_line() {
199
1
        let mut app = make();
200
1
        app.open_command_line();
201
1
        app.command_line.insert_char('x');
202
1
        app.set_edit_mode(EditMode::Vim);
203
1
        assert_eq!(app.command_line.mode(), EditMode::Vim);
204
1
    }
205
}