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

21
src/view/axis.rs Normal file
View File

@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Axis {
Row,
Column,
Page,
/// Not yet assigned
Unassigned,
}
impl std::fmt::Display for Axis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Axis::Row => write!(f, "Row ↕"),
Axis::Column => write!(f, "Col ↔"),
Axis::Page => write!(f, "Page ☰"),
Axis::Unassigned => write!(f, ""),
}
}
}

5
src/view/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod view;
pub mod axis;
pub use view::View;
pub use axis::Axis;

127
src/view/view.rs Normal file
View File

@ -0,0 +1,127 @@
use std::collections::{HashMap, HashSet};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::axis::Axis;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct View {
pub name: String,
/// Axis assignment for each category
pub category_axes: IndexMap<String, Axis>,
/// For page axis: selected item per category
pub page_selections: HashMap<String, String>,
/// Hidden items per category
pub hidden_items: HashMap<String, HashSet<String>>,
/// Collapsed groups per category
pub collapsed_groups: HashMap<String, HashSet<String>>,
/// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String,
/// Scroll offset for grid
pub row_offset: usize,
pub col_offset: usize,
/// Selected cell (row_idx, col_idx)
pub selected: (usize, usize),
}
impl View {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
category_axes: IndexMap::new(),
page_selections: HashMap::new(),
hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(),
number_format: ",.0".to_string(),
row_offset: 0,
col_offset: 0,
selected: (0, 0),
}
}
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Auto-assign: first → Row, second → Column, rest → Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
let axis = if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
} else {
Axis::Page
};
self.category_axes.insert(cat_name.to_string(), axis);
}
}
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
if let Some(a) = self.category_axes.get_mut(cat_name) {
*a = axis;
}
}
pub fn axis_of(&self, cat_name: &str) -> Axis {
self.category_axes.get(cat_name).copied().unwrap_or(Axis::Unassigned)
}
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
self.category_axes.iter()
.filter(|(_, &a)| a == axis)
.map(|(n, _)| n.as_str())
.collect()
}
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
self.page_selections.insert(cat_name.to_string(), item.to_string());
}
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
self.page_selections.get(cat_name).map(|s| s.as_str())
}
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
let set = self.collapsed_groups.entry(cat_name.to_string()).or_default();
if set.contains(group_name) {
set.remove(group_name);
} else {
set.insert(group_name.to_string());
}
}
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
self.collapsed_groups
.get(cat_name)
.map(|s| s.contains(group_name))
.unwrap_or(false)
}
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
self.hidden_items.entry(cat_name.to_string()).or_default().insert(item_name.to_string());
}
pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
if let Some(set) = self.hidden_items.get_mut(cat_name) {
set.remove(item_name);
}
}
pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
self.hidden_items.get(cat_name).map(|s| s.contains(item_name)).unwrap_or(false)
}
/// Cycle axis for a category: Row → Column → Page → Row
pub fn cycle_axis(&mut self, cat_name: &str) {
let current = self.axis_of(cat_name);
let next = match current {
Axis::Row => Axis::Column,
Axis::Column => Axis::Page,
Axis::Page => Axis::Row,
Axis::Unassigned => Axis::Row,
};
self.set_axis(cat_name, next);
self.selected = (0, 0);
self.row_offset = 0;
self.col_offset = 0;
}
}