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//! - An overlay 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
16mod convert;
17mod edit;
18mod eval;
19mod form_options;
20mod mutation;
21
22use std::collections::HashMap;
23
24use crate::overlay::OverlayStack;
25use crate::route::Route;
26use crate::tabs::config::ConfigTab;
27use crate::tabs::list::ListTab;
28use crate::tabs::nms::ConsoleState;
29use crate::tabs::nms_eval::ConsoleEval;
30use crate::tabs::reports::ReportsTab;
31use crate::view::{Tab, ViewId, ViewMut, ViewRef};
32use crate::widgets::{EditMode, Editor};
33use cli_core::render::schema;
34use plotting::ChartSpec;
35use sqlx::types::Uuid;
36
37/// Command-line palette editor state.
38pub struct CmdLine {
39    pub editor: Editor,
40    pub active: bool,
41    /// Completion candidates for the current buffer, refreshed on each Tab press.
42    pub completions: Vec<String>,
43}
44
45impl CmdLine {
46    fn new(edit_mode: EditMode) -> Self {
47        Self {
48            editor: Editor::new(edit_mode),
49            active: false,
50            completions: Vec::new(),
51        }
52    }
53}
54
55pub struct App {
56    pub user_id: Uuid,
57    pub active_tab: Tab,
58    pub overlays: OverlayStack,
59    pub cmdline: CmdLine,
60    pub edit_mode: EditMode,
61    pub status: String,
62    pub should_quit: bool,
63    /// Whether the console input prompt has keyboard focus.
64    pub console_focused: bool,
65    /// The async eval bridge. `None` until attached via [`App::attach_console`].
66    console_eval: Option<ConsoleEval>,
67    /// Chart spec the active tab wants the runtime to emit as kitty graphics.
68    pending_chart: Option<ChartSpec>,
69    /// Routes pending eval reply ids to their destination view.
70    pending_routes: HashMap<i64, Route>,
71    /// Monotonic counter for form-options fetches; shared across all forms
72    /// that populate Selects. A stale reply (wrong seq) is silently dropped.
73    form_options_seq: u64,
74    pub accounts: ListTab,
75    pub transactions: ListTab,
76    pub commodities: ListTab,
77    pub reports: ReportsTab,
78    pub config: ConfigTab,
79    pub console: ConsoleState,
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            overlays: OverlayStack::new(),
89            cmdline: CmdLine::new(edit_mode),
90            edit_mode,
91            status: String::new(),
92            should_quit: false,
93            console_focused: false,
94            console_eval: None,
95            pending_chart: None,
96            pending_routes: HashMap::new(),
97            form_options_seq: 0,
98            accounts: ListTab::new("(list-accounts)", &schema::ACCOUNTS),
99            transactions: ListTab::new("(list-transactions \"\")", &schema::TRANSACTIONS),
100            commodities: ListTab::new("(list-commodities)", &schema::COMMODITIES),
101            reports: ReportsTab::new(),
102            config: ConfigTab::new(),
103            console: ConsoleState::new(),
104        }
105    }
106
107    pub fn active_view(&self) -> ViewRef<'_> {
108        match self.active_tab {
109            ViewId::Accounts => ViewRef::List(ViewId::Accounts, &self.accounts),
110            ViewId::Transactions => ViewRef::List(ViewId::Transactions, &self.transactions),
111            ViewId::Commodities => ViewRef::List(ViewId::Commodities, &self.commodities),
112            ViewId::Reports => ViewRef::Reports(&self.reports),
113            ViewId::Config => ViewRef::Config(&self.config),
114            ViewId::Console => ViewRef::Console(&self.console),
115        }
116    }
117
118    pub fn active_view_mut(&mut self) -> ViewMut<'_> {
119        match self.active_tab {
120            ViewId::Accounts => ViewMut::List(ViewId::Accounts, &mut self.accounts),
121            ViewId::Transactions => ViewMut::List(ViewId::Transactions, &mut self.transactions),
122            ViewId::Commodities => ViewMut::List(ViewId::Commodities, &mut self.commodities),
123            ViewId::Reports => ViewMut::Reports(&mut self.reports),
124            ViewId::Config => ViewMut::Config(&mut self.config),
125            ViewId::Console => ViewMut::Console(&mut self.console),
126        }
127    }
128
129    pub fn queue_chart(&mut self, spec: ChartSpec) {
130        self.pending_chart = Some(spec);
131    }
132
133    pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
134        self.pending_chart.take()
135    }
136
137    fn activate_tab(&mut self, tab: Tab) {
138        if tab != Tab::Console {
139            self.console_focused = false;
140        }
141        self.active_tab = tab;
142        self.ensure_tab_loaded(tab);
143    }
144
145    pub fn next_tab(&mut self) {
146        let idx = self.active_tab.index();
147        self.activate_tab(Tab::ALL[(idx + 1) % Tab::ALL.len()]);
148    }
149
150    pub fn previous_tab(&mut self) {
151        let idx = self.active_tab.index();
152        let len = Tab::ALL.len();
153        self.activate_tab(Tab::ALL[(idx + len - 1) % len]);
154    }
155
156    pub fn switch_tab(&mut self, tab: Tab) {
157        self.activate_tab(tab);
158    }
159
160    pub fn open_command_line(&mut self) {
161        self.cmdline.editor = Editor::new(self.edit_mode);
162        self.cmdline.active = true;
163        self.cmdline.completions.clear();
164    }
165
166    pub fn close_command_line(&mut self) {
167        self.cmdline.active = false;
168        self.cmdline.completions.clear();
169    }
170
171    pub fn set_status(&mut self, msg: impl Into<String>) {
172        self.status = msg.into();
173    }
174
175    pub fn request_quit(&mut self) {
176        self.should_quit = true;
177    }
178
179    pub fn set_edit_mode(&mut self, mode: EditMode) {
180        self.edit_mode = mode;
181        self.cmdline.editor.set_mode(mode);
182    }
183
184    pub fn attach_console(&mut self, eval: ConsoleEval) {
185        self.console_eval = Some(eval);
186    }
187}
188
189#[cfg(test)]
190mod tests;