Lines
98.48 %
Functions
50.63 %
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::tabs::nms::{ConsoleState, format_result};
use crate::tabs::nms_eval::ConsoleEval;
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,
Console,
}
impl Tab {
pub const ALL: [Tab; 6] = [
Tab::Accounts,
Tab::Transactions,
Tab::Commodities,
Tab::Reports,
Tab::Config,
Tab::Console,
];
#[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",
Tab::Console => "Console",
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,
/// Pure state for the Console tab's nomiscript REPL.
pub console: ConsoleState,
/// Whether keystrokes are routed into the console input editor. Unlike
/// `command_line_active`, the console editor is always Emacs-style
/// (see [`ConsoleState::new`]), so focus has no vim-normal sub-state.
pub console_input_active: bool,
/// The async eval bridge for the Console tab. `None` until an entry
/// point attaches one via [`App::attach_console`]; `App::new` stays
/// runtime-free so non-runtime tests keep working.
console_eval: Option<ConsoleEval>,
/// 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,
console: ConsoleState::new(),
console_input_active: false,
console_eval: None,
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);
/// Attach an eval bridge so the Console tab can run forms. Called by
/// the entry points (sshd handler / standalone bin) once a runtime
/// `Handle` is in scope.
pub fn attach_console(&mut self, eval: ConsoleEval) {
self.console_eval = Some(eval);
/// Echo the submitted `form` into scrollback and route it to the
/// attached eval. With no eval attached, push a "console not
/// connected" notice; if the eval worker has stopped, surface an
/// "eval worker stopped" notice so the console never looks hung.
pub fn submit_console_form(&mut self, form: String) {
self.console.push_scrollback(format!("> {form}"));
let notice = match &mut self.console_eval {
Some(eval) => match eval.submit(form) {
true => return,
false => "eval worker stopped",
},
None => "console not connected",
};
self.console.push_scrollback(notice);
/// Request a cooperative cancel of the in-flight console eval. A
/// no-op when no eval is attached.
pub fn interrupt_console(&self) {
if let Some(eval) = &self.console_eval {
eval.interrupt();
/// Pull every ready eval result into the console scrollback,
/// formatting each envelope through [`format_result`]. A no-op when
/// no eval is attached or nothing is ready.
pub fn drain_console(&mut self) {
let Some(eval) = &mut self.console_eval else {
return;
let responses = eval.drain();
for envelope in responses {
for line in format_result(&envelope) {
self.console.push_scrollback(line);
#[cfg(test)]
mod tests {
use super::*;
fn make() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn all_has_six_tabs_ending_in_console() {
assert_eq!(Tab::ALL.len(), 6);
assert_eq!(Tab::ALL[Tab::ALL.len() - 1], Tab::Console);
fn console_label_is_console() {
assert_eq!(Tab::Console.label(), "Console");
fn next_tab_wraps_around() {
let mut app = make();
app.active_tab = Tab::Console;
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::Console);
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);
#[tokio::test]
async fn submit_then_drain_routes_echo_into_scrollback() {
app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
app.submit_console_form("(x)".to_string());
// Yield so the echo worker runs and answers before draining.
tokio::task::yield_now().await;
app.drain_console();
assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
assert!(
app.console
.scrollback
.any(|l| l.contains("(:id 0 :form (x))"))
);
fn submit_without_eval_pushes_not_connected_notice() {
.any(|l| l.contains("console not connected"))
fn drain_without_eval_is_noop() {
assert!(app.console.scrollback.is_empty());
async fn submit_after_worker_stops_surfaces_notice() {
let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
let worker = eval.worker_handle();
app.attach_console(eval);
worker.abort();
for _ in 0..200 {
if worker.is_finished() {
break;
.any(|l| l == "eval worker stopped")