Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

657
src/ui/app.rs Normal file
View File

@ -0,0 +1,657 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula;
use crate::import::wizard::{ImportWizard, WizardState};
use crate::persistence;
use crate::view::Axis;
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
Normal,
Editing { buffer: String },
FormulaEdit { buffer: String },
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect { cat_idx: usize },
ImportWizard,
ExportPrompt { buffer: String },
Help,
Quit,
}
pub struct App {
pub model: Model,
pub file_path: Option<PathBuf>,
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
pub last_autosave: Instant,
/// Input buffer for command-line style input
pub input_buffer: String,
/// Search query
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
/// Category panel cursor
pub cat_panel_cursor: usize,
/// View panel cursor
pub view_panel_cursor: usize,
/// Formula panel cursor
pub formula_cursor: usize,
pub dirty: bool,
}
impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
Self {
model,
file_path,
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
last_autosave: Instant::now(),
input_buffer: String::new(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
dirty: false,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
match &self.mode.clone() {
AppMode::Quit => {}
AppMode::Help => {
self.mode = AppMode::Normal;
}
AppMode::ImportWizard => {
self.handle_wizard_key(key)?;
}
AppMode::Editing { buffer } => {
self.handle_edit_key(key)?;
}
AppMode::FormulaEdit { buffer } => {
self.handle_formula_edit_key(key)?;
}
AppMode::FormulaPanel => {
self.handle_formula_panel_key(key)?;
}
AppMode::CategoryPanel => {
self.handle_category_panel_key(key)?;
}
AppMode::ViewPanel => {
self.handle_view_panel_key(key)?;
}
AppMode::TileSelect { cat_idx } => {
self.handle_tile_select_key(key)?;
}
AppMode::ExportPrompt { buffer } => {
self.handle_export_key(key)?;
}
AppMode::Normal => {
self.handle_normal_key(key)?;
}
}
Ok(())
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> {
if self.search_mode {
return self.handle_search_key(key);
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
self.mode = AppMode::Quit;
}
(KeyCode::F(1), _) => {
self.mode = AppMode::Help;
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
self.save()?;
}
(KeyCode::Char('f'), KeyModifiers::CONTROL) => {
self.formula_panel_open = !self.formula_panel_open;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.category_panel_open = !self.category_panel_open;
}
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
self.view_panel_open = !self.view_panel_open;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.mode = AppMode::ExportPrompt { buffer: String::new() };
}
// Navigation
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.move_selection(-1, 0);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
self.move_selection(1, 0);
}
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
self.move_selection(0, -1);
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
self.move_selection(0, 1);
}
(KeyCode::Enter, _) => {
let cell_key = self.selected_cell_key();
let current = cell_key.as_ref().map(|k| {
self.model.get_cell(k).to_string()
}).unwrap_or_default();
self.mode = AppMode::Editing { buffer: current };
}
(KeyCode::Char('/'), _) => {
self.search_mode = true;
self.search_query.clear();
}
// Tab cycles panels
(KeyCode::Tab, _) => {
if self.formula_panel_open {
self.mode = AppMode::FormulaPanel;
} else if self.category_panel_open {
self.mode = AppMode::CategoryPanel;
} else if self.view_panel_open {
self.mode = AppMode::ViewPanel;
}
}
// Tile movement with Ctrl+Arrow
(KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL)
| (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => {
let cat_names: Vec<String> = self.model.category_names()
.into_iter().map(String::from).collect();
if !cat_names.is_empty() {
self.mode = AppMode::TileSelect { cat_idx: 0 };
}
}
// Page axis navigation with [ ]
(KeyCode::Char('['), _) => {
self.page_prev();
}
(KeyCode::Char(']'), _) => {
self.page_next();
}
// Formula panel shortcut
(KeyCode::Char('F'), KeyModifiers::NONE) => {
self.formula_panel_open = true;
self.mode = AppMode::FormulaPanel;
}
_ => {}
}
Ok(())
}
fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.search_mode = false; }
KeyCode::Enter => { self.search_mode = false; }
KeyCode::Char(c) => { self.search_query.push(c); }
KeyCode::Backspace => { self.search_query.pop(); }
_ => {}
}
Ok(())
}
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::Editing { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => {
self.mode = AppMode::Normal;
}
KeyCode::Enter => {
// Commit value
if let Some(key) = self.selected_cell_key() {
let value = if buf.is_empty() {
CellValue::Empty
} else if let Ok(n) = buf.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(buf.clone())
};
self.model.set_cell(key, value);
self.dirty = true;
}
self.mode = AppMode::Normal;
self.move_selection(1, 0);
}
KeyCode::Char(c) => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::FormulaEdit { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => { self.mode = AppMode::FormulaPanel; }
KeyCode::Enter => {
// Try to parse and add formula
let first_cat = self.model.category_names().into_iter().next().map(String::from);
if let Some(cat) = first_cat {
match parse_formula(&buf, &cat) {
Ok(formula) => {
self.model.add_formula(formula);
self.status_msg = "Formula added".to_string();
self.dirty = true;
}
Err(e) => {
self.status_msg = format!("Formula error: {e}");
}
}
}
self.mode = AppMode::FormulaPanel;
}
KeyCode::Char(c) => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Char('a') | KeyCode::Char('n') => {
self.mode = AppMode::FormulaEdit { buffer: String::new() };
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.formula_cursor < self.model.formulas.len() {
let target = self.model.formulas[self.formula_cursor].target.clone();
self.model.remove_formula(&target);
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
self.dirty = true;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.formula_cursor + 1 < self.model.formulas.len() {
self.formula_cursor += 1;
}
}
_ => {}
}
Ok(())
}
fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.cat_panel_cursor + 1 < cat_names.len() {
self.cat_panel_cursor += 1;
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
// Cycle axis for selected category
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(cat_name);
}
}
}
_ => {}
}
Ok(())
}
fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let view_names: Vec<String> = self.model.views.keys().cloned().collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.view_panel_cursor + 1 < view_names.len() {
self.view_panel_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.switch_view(name);
self.mode = AppMode::Normal;
}
}
KeyCode::Char('n') => {
let new_name = format!("View {}", self.model.views.len() + 1);
self.model.create_view(&new_name);
let _ = self.model.switch_view(&new_name);
self.dirty = true;
self.mode = AppMode::Normal;
}
KeyCode::Delete | KeyCode::Char('d') => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.delete_view(name);
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
self.dirty = true;
}
}
_ => {}
}
Ok(())
}
fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 };
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Left | KeyCode::Char('h') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx > 0 { *cat_idx -= 1; }
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; }
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(name);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('r') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Row);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('c') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Column);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('p') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Page);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
_ => {}
}
Ok(())
}
fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Enter => {
let buf = if let AppMode::ExportPrompt { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
let path = PathBuf::from(buf);
let view_name = self.model.active_view.clone();
match persistence::export_csv(&self.model, &view_name, &path) {
Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); }
Err(e) => { self.status_msg = format!("Export error: {e}"); }
}
self.mode = AppMode::Normal;
}
KeyCode::Char(c) => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
if let Some(wizard) = &mut self.wizard {
match &wizard.state.clone() {
WizardState::Preview => {
match key.code {
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::SelectArrayPath => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Enter => wizard.confirm_path(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::ReviewProposals => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Char(' ') => wizard.toggle_proposal(),
KeyCode::Char('c') => wizard.cycle_proposal_kind(),
KeyCode::Enter => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::NameModel => {
match key.code {
KeyCode::Char(c) => wizard.push_name_char(c),
KeyCode::Backspace => wizard.pop_name_char(),
KeyCode::Enter => {
let result = wizard.build_model();
match result {
Ok(model) => {
self.model = model;
self.dirty = true;
self.status_msg = "Import successful!".to_string();
self.mode = AppMode::Normal;
self.wizard = None;
}
Err(e) => {
if let Some(w) = &mut self.wizard {
w.message = Some(format!("Error: {e}"));
}
}
}
}
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::Done => {
self.mode = AppMode::Normal;
self.wizard = None;
}
}
}
Ok(())
}
fn move_selection(&mut self, dr: i32, dc: i32) {
if let Some(view) = self.model.active_view_mut() {
let (r, c) = view.selected;
let new_r = (r as i32 + dr).max(0) as usize;
let new_c = (c as i32 + dc).max(0) as usize;
view.selected = (new_r, new_c);
}
}
fn page_next(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let next_idx = (idx + 1).min(items.len() - 1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[next_idx]);
}
break;
}
}
fn page_prev(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let prev_idx = idx.saturating_sub(1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[prev_idx]);
}
break;
}
}
pub fn selected_cell_key(&self) -> Option<CellKey> {
let view = self.model.active_view()?;
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
let (sel_row, sel_col) = view.selected;
let mut coords = vec![];
// Page coords
for cat_name in &page_cats {
let items = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())?;
coords.push((cat_name.to_string(), sel));
}
// Row coords
for (i, cat_name) in row_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_row)?.clone();
coords.push((cat_name.to_string(), item));
}
// Col coords
for (i, cat_name) in col_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_col)?.clone();
coords.push((cat_name.to_string(), item));
}
Some(CellKey::new(coords))
}
pub fn save(&mut self) -> Result<()> {
if let Some(path) = &self.file_path.clone() {
persistence::save(&self.model, path)?;
self.dirty = false;
self.status_msg = format!("Saved to {}", path.display());
} else {
self.status_msg = "No file path set. Use Ctrl+E to export.".to_string();
}
Ok(())
}
pub fn autosave_if_needed(&mut self) {
if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) {
if let Some(path) = &self.file_path.clone() {
let autosave_path = persistence::autosave_path(path);
let _ = persistence::save(&self.model, &autosave_path);
self.last_autosave = Instant::now();
}
}
}
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
self.wizard = Some(ImportWizard::new(json));
self.mode = AppMode::ImportWizard;
}
}