Lines
97.3 %
Functions
57.89 %
Branches
100 %
//! Application state for the TUI.
//!
//! The TUI is organised as a small state machine:
//! - A top-level tab row decides which tab body is rendered.
//! - Each tab body is a multi-pane area managed by the tab itself.
//! - A modal stack sits on top of the whole lot and intercepts input
//! when non-empty.
//! - A bottom command line is always visible.
//! All of this is pure state: no rendering happens in this file. The
//! draw layer reads from `App` and renders; the event layer mutates
//! `App` via named methods so tests can drive state transitions
//! without a real terminal.
use crate::modal::Stack;
use crate::widgets::{EditMode, Editor};
use plotting::ChartSpec;
use sqlx::types::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Accounts,
Transactions,
Commodities,
Reports,
Config,
}
impl Tab {
pub const ALL: [Tab; 5] = [
Tab::Accounts,
Tab::Transactions,
Tab::Commodities,
Tab::Reports,
Tab::Config,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
Tab::Accounts => "Accounts",
Tab::Transactions => "Transactions",
Tab::Commodities => "Commodities",
Tab::Reports => "Reports",
Tab::Config => "Config",
#[derive(Debug)]
pub struct App {
pub user_id: Uuid,
pub active_tab: Tab,
pub modals: Stack,
pub command_line: Editor,
pub command_line_active: bool,
pub edit_mode: EditMode,
pub status: String,
pub should_quit: bool,
/// Chart spec the active tab wants the runtime to emit as kitty
/// graphics on the next frame. Drained by
/// [`crate::runtime::run_loop`] after each `draw`. Stays `None`
/// when the tab doesn't (yet) have data to render.
pending_chart: Option<ChartSpec>,
impl App {
pub fn new(user_id: Uuid, edit_mode: EditMode) -> Self {
Self {
user_id,
active_tab: Tab::Reports,
modals: Stack::new(),
command_line: Editor::new(edit_mode),
command_line_active: false,
edit_mode,
status: String::new(),
should_quit: false,
pending_chart: None,
/// Queue a chart for the next frame.
pub fn queue_chart(&mut self, spec: ChartSpec) {
self.pending_chart = Some(spec);
/// Drain the queued chart, if any.
pub fn take_pending_chart(&mut self) -> Option<ChartSpec> {
self.pending_chart.take()
pub fn next_tab(&mut self) {
let idx = Tab::ALL
.iter()
.position(|t| *t == self.active_tab)
.unwrap_or(0);
self.active_tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
pub fn previous_tab(&mut self) {
let len = Tab::ALL.len();
self.active_tab = Tab::ALL[(idx + len - 1) % len];
pub fn switch_tab(&mut self, tab: Tab) {
self.active_tab = tab;
pub fn open_command_line(&mut self) {
self.command_line = Editor::new(self.edit_mode);
self.command_line_active = true;
pub fn close_command_line(&mut self) {
self.command_line_active = false;
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status = msg.into();
pub fn request_quit(&mut self) {
self.should_quit = true;
pub fn set_edit_mode(&mut self, mode: EditMode) {
self.edit_mode = mode;
self.command_line.set_mode(mode);
#[cfg(test)]
mod tests {
use super::*;
fn make() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn next_tab_wraps_around() {
let mut app = make();
app.active_tab = Tab::Config;
app.next_tab();
assert_eq!(app.active_tab, Tab::Accounts);
fn previous_tab_wraps_around() {
app.active_tab = Tab::Accounts;
app.previous_tab();
assert_eq!(app.active_tab, Tab::Config);
fn next_tab_advances_in_order() {
assert_eq!(app.active_tab, Tab::Transactions);
assert_eq!(app.active_tab, Tab::Commodities);
fn switch_tab_sets_target() {
app.switch_tab(Tab::Reports);
assert_eq!(app.active_tab, Tab::Reports);
fn open_and_close_command_line() {
assert!(!app.command_line_active);
app.open_command_line();
assert!(app.command_line_active);
app.close_command_line();
fn request_quit_sets_flag() {
assert!(!app.should_quit);
app.request_quit();
assert!(app.should_quit);
fn set_edit_mode_propagates_to_command_line() {
app.command_line.insert_char('x');
app.set_edit_mode(EditMode::Vim);
assert_eq!(app.command_line.mode(), EditMode::Vim);