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

250
src/model/model.rs Normal file
View File

@ -0,0 +1,250 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use anyhow::{anyhow, Result};
use super::category::{Category, CategoryId};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::Formula;
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>,
pub data: DataStore,
pub formulas: Vec<Formula>,
pub views: IndexMap<String, View>,
pub active_view: String,
next_category_id: CategoryId,
}
impl Model {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
Self {
name,
categories: IndexMap::new(),
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 0,
}
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
if self.categories.len() >= MAX_CATEGORIES {
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
}
if self.categories.contains_key(&name) {
return Ok(self.categories[&name].id);
}
let id = self.next_category_id;
self.next_category_id += 1;
self.categories.insert(name.clone(), Category::new(id, name.clone()));
// Add to all views
for view in self.views.values_mut() {
view.on_category_added(&name);
}
Ok(id)
}
pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
self.categories.get_mut(name)
}
pub fn category(&self, name: &str) -> Option<&Category> {
self.categories.get(name)
}
pub fn set_cell(&mut self, key: CellKey, value: CellValue) {
self.data.set(key, value);
}
pub fn get_cell(&self, key: &CellKey) -> &CellValue {
self.data.get(key)
}
pub fn add_formula(&mut self, formula: Formula) {
// Replace if same target
if let Some(pos) = self.formulas.iter().position(|f| f.target == formula.target) {
self.formulas[pos] = formula;
} else {
self.formulas.push(formula);
}
}
pub fn remove_formula(&mut self, target: &str) {
self.formulas.retain(|f| f.target != target);
}
pub fn active_view(&self) -> Option<&View> {
self.views.get(&self.active_view)
}
pub fn active_view_mut(&mut self) -> Option<&mut View> {
self.views.get_mut(&self.active_view)
}
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
let name = name.into();
let mut view = View::new(name.clone());
// Copy category assignments from default if any
for cat_name in self.categories.keys() {
view.on_category_added(cat_name);
}
self.views.insert(name.clone(), view);
self.views.get_mut(&name).unwrap()
}
pub fn switch_view(&mut self, name: &str) -> Result<()> {
if self.views.contains_key(name) {
self.active_view = name.to_string();
Ok(())
} else {
Err(anyhow!("View '{name}' not found"))
}
}
pub fn delete_view(&mut self, name: &str) -> Result<()> {
if self.views.len() <= 1 {
return Err(anyhow!("Cannot delete the last view"));
}
self.views.shift_remove(name);
if self.active_view == name {
self.active_view = self.views.keys().next().unwrap().clone();
}
Ok(())
}
/// Return all category names
pub fn category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
}
/// Evaluate a computed value at a given key, considering formulas
pub fn evaluate(&self, key: &CellKey) -> CellValue {
// Check if the last category dimension in the key corresponds to a formula target
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target {
return self.eval_formula(formula, key);
}
}
}
self.data.get(key).clone()
}
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> CellValue {
use crate::formula::{Expr, AggFunc};
// Check WHERE filter first
if let Some(filter) = &formula.filter {
if let Some(item_val) = context.get(&filter.category) {
if item_val != filter.item.as_str() {
return self.data.get(context).clone();
}
}
}
fn find_item_category<'a>(model: &'a Model, item_name: &str) -> Option<&'a str> {
for (cat_name, cat) in &model.categories {
if cat.items.contains_key(item_name) {
return Some(cat_name.as_str());
}
}
None
}
fn eval_expr(
expr: &Expr,
context: &CellKey,
model: &Model,
target_category: &str,
) -> Option<f64> {
match expr {
Expr::Number(n) => Some(*n),
Expr::Ref(name) => {
let cat = find_item_category(model, name).unwrap_or(name);
let new_key = context.clone().with(cat, name);
model.evaluate(&new_key).as_f64()
}
Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category)?;
let rv = eval_expr(r, context, model, target_category)?;
Some(match op.as_str() {
"+" => lv + rv,
"-" => lv - rv,
"*" => lv * rv,
"/" => if rv == 0.0 { 0.0 } else { lv / rv },
"^" => lv.powf(rv),
_ => return None,
})
}
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?),
Expr::Agg(func, _inner, _filter) => {
let partial = context.without(target_category);
let values: Vec<f64> = model.data.matching_cells(&partial.0)
.into_iter()
.filter_map(|(_, v)| v.as_f64())
.collect();
match func {
AggFunc::Sum => Some(values.iter().sum()),
AggFunc::Avg => {
if values.is_empty() { None }
else { Some(values.iter().sum::<f64>() / values.len() as f64) }
}
AggFunc::Min => values.iter().cloned().reduce(f64::min),
AggFunc::Max => values.iter().cloned().reduce(f64::max),
AggFunc::Count => Some(values.len() as f64),
}
}
Expr::If(cond, then, else_) => {
let cv = eval_bool(cond, context, model, target_category)?;
if cv {
eval_expr(then, context, model, target_category)
} else {
eval_expr(else_, context, model, target_category)
}
}
}
}
fn eval_bool(
expr: &Expr,
context: &CellKey,
model: &Model,
target_category: &str,
) -> Option<bool> {
match expr {
Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category)?;
let rv = eval_expr(r, context, model, target_category)?;
Some(match op.as_str() {
"=" | "==" => (lv - rv).abs() < 1e-10,
"!=" => (lv - rv).abs() >= 1e-10,
"<" => lv < rv,
">" => lv > rv,
"<=" => lv <= rv,
">=" => lv >= rv,
_ => return None,
})
}
_ => None,
}
}
match eval_expr(&formula.expr, context, self, &formula.target_category) {
Some(n) => CellValue::Number(n),
None => CellValue::Empty,
}
}
}