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

321
src/ui/grid.rs Normal file
View File

@ -0,0 +1,321 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::ui::app::AppMode;
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const MIN_COL_WIDTH: u16 = 6;
pub struct GridWidget<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub search_query: &'a str,
}
impl<'a> GridWidget<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
Self { model, mode, search_query }
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
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);
// Gather row items
let row_items: Vec<Vec<String>> = if row_cats.is_empty() {
vec![vec![]]
} else {
let cat_name = row_cats[0];
let cat = match self.model.category(cat_name) {
Some(c) => c,
None => return,
};
cat.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(|item| vec![item.to_string()])
.collect()
};
// Gather col items
let col_items: Vec<Vec<String>> = if col_cats.is_empty() {
vec![vec![]]
} else {
let cat_name = col_cats[0];
let cat = match self.model.category(cat_name) {
Some(c) => c,
None => return,
};
cat.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(|item| vec![item.to_string()])
.collect()
};
// Page filter coords
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat_name.to_string(), sel)
}).collect();
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
// Available cols
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_items: Vec<_> = col_items.iter()
.skip(col_offset)
.take(available_cols.max(1))
.collect();
let available_rows = area.height.saturating_sub(2) as usize; // header + border
let visible_row_items: Vec<_> = row_items.iter()
.skip(row_offset)
.take(available_rows.max(1))
.collect();
let mut y = area.y;
// Column headers
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let row_header_col = format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize);
buf.set_string(area.x, y, &row_header_col, Style::default());
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
let label = col_item.join("/");
let styled = if abs_ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
};
let truncated = truncate(&label, COL_WIDTH as usize);
buf.set_string(x, y, format!("{:>width$}", truncated, width = COL_WIDTH as usize), styled);
x += COL_WIDTH;
if x >= area.x + area.width { break; }
}
y += 1;
// Separator
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
y += 1;
// Data rows
for (ri, row_item) in visible_row_items.iter().enumerate() {
let abs_ri = ri + row_offset;
if y >= area.y + area.height { break; }
let row_label = row_item.join("/");
let row_style = if abs_ri == sel_row {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1);
buf.set_string(area.x, y,
format!("{:<width$}", row_header_str, width = ROW_HEADER_WIDTH as usize),
row_style);
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(coords);
let value = self.model.evaluate(&key);
let cell_str = format_value(&value);
let is_selected = abs_ri == sel_row && abs_ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
let cell_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if matches!(value, CellValue::Empty) {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize);
buf.set_string(x, y, formatted, cell_style);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row {
if let AppMode::Editing { buffer } = self.mode {
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
let edit_str = format!("{:<width$}", buffer, width = COL_WIDTH as usize);
buf.set_string(edit_x, y,
truncate(&edit_str, COL_WIDTH as usize),
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
}
}
y += 1;
}
// Total row
if !col_items.is_empty() && !row_items.is_empty() {
if y < area.y + area.height {
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
y += 1;
}
if y < area.y + area.height {
buf.set_string(area.x, y,
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let total: f64 = row_items.iter().map(|ri| {
let mut c = coords.clone();
for (cat, item) in row_cats.iter().zip(ri.iter()) {
c.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(c);
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
}).sum();
let total_str = format_f64(total);
buf.set_string(x, y,
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
x += COL_WIDTH;
}
}
}
}
}
impl<'a> Widget for GridWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view_name = self.model.active_view
.clone();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" View: {} ", view_name));
let inner = block.inner(area);
block.render(area, buf);
// Page axis bar
let view = self.model.active_view();
if let Some(view) = view {
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
if !page_cats.is_empty() && inner.height > 0 {
let page_info: Vec<String> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_else(|| "(none)".to_string());
format!("{cat_name} = {sel}")
}).collect();
let page_str = format!(" [{}] ", page_info.join(" | "));
buf.set_string(inner.x, inner.y,
&page_str,
Style::default().fg(Color::Magenta));
let grid_area = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
self.render_grid(grid_area, buf);
} else {
self.render_grid(inner, buf);
}
}
}
}
fn format_value(v: &CellValue) -> String {
match v {
CellValue::Number(n) => format_f64(*n),
CellValue::Text(s) => s.clone(),
CellValue::Empty => String::new(),
}
}
fn format_f64(n: f64) -> String {
if n == 0.0 {
return "0".to_string();
}
if n.fract() == 0.0 && n.abs() < 1e12 {
// Integer with comma formatting
let i = n as i64;
let s = i.to_string();
let is_neg = s.starts_with('-');
let digits = if is_neg { &s[1..] } else { &s[..] };
let mut result = String::new();
for (idx, c) in digits.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 { result.push(','); }
result.push(c);
}
if is_neg { result.push('-'); }
result.chars().rev().collect()
} else {
format!("{n:.2}")
}
}
fn truncate(s: &str, max_width: usize) -> String {
let w = s.width();
if w <= max_width {
s.to_string()
} else if max_width > 1 {
let mut result = String::new();
let mut width = 0;
for c in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
if width + cw + 1 > max_width { break; }
result.push(c);
width += cw;
}
result.push('…');
result
} else {
s.chars().take(max_width).collect()
}
}