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:
121
src/model/category.rs
Normal file
121
src/model/category.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type CategoryId = usize;
|
||||
pub type ItemId = usize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
/// Parent group name, if any
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||
Self { id, name: name.into(), group: None }
|
||||
}
|
||||
|
||||
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||
self.group = Some(group.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Group {
|
||||
pub name: String,
|
||||
/// Parent group name for nested hierarchies
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self { name: name.into(), parent: None }
|
||||
}
|
||||
|
||||
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
||||
self.parent = Some(parent.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Category {
|
||||
pub id: CategoryId,
|
||||
pub name: String,
|
||||
/// Items in insertion order
|
||||
pub items: IndexMap<String, Item>,
|
||||
/// Named groups (hierarchy nodes)
|
||||
pub groups: Vec<Group>,
|
||||
/// Next item id counter
|
||||
next_item_id: ItemId,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
items: IndexMap::new(),
|
||||
groups: Vec::new(),
|
||||
next_item_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
|
||||
let name = name.into();
|
||||
if let Some(item) = self.items.get(&name) {
|
||||
return item.id;
|
||||
}
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
self.items.insert(name.clone(), Item::new(id, name));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
|
||||
let name = name.into();
|
||||
let group = group.into();
|
||||
if let Some(item) = self.items.get(&name) {
|
||||
return item.id;
|
||||
}
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
self.items.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_group(&mut self, group: Group) {
|
||||
if !self.groups.iter().any(|g| g.name == group.name) {
|
||||
self.groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_by_name(&self, name: &str) -> Option<&Item> {
|
||||
self.items.get(name)
|
||||
}
|
||||
|
||||
pub fn item_index(&self, name: &str) -> Option<usize> {
|
||||
self.items.get_index_of(name)
|
||||
}
|
||||
|
||||
/// Returns item names in order, grouped hierarchically
|
||||
pub fn ordered_item_names(&self) -> Vec<&str> {
|
||||
self.items.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Returns unique group names at the top level
|
||||
pub fn top_level_groups(&self) -> Vec<&str> {
|
||||
let mut seen = Vec::new();
|
||||
for item in self.items.values() {
|
||||
if let Some(g) = &item.group {
|
||||
if !seen.contains(&g.as_str()) {
|
||||
seen.push(g.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
}
|
||||
158
src/model/cell.rs
Normal file
158
src/model/cell.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
/// Sorted by category name for canonical form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct CellKey(pub Vec<(String, String)>);
|
||||
|
||||
impl CellKey {
|
||||
pub fn new(mut coords: Vec<(String, String)>) -> Self {
|
||||
coords.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Self(coords)
|
||||
}
|
||||
|
||||
pub fn get(&self, category: &str) -> Option<&str> {
|
||||
self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn with(mut self, category: impl Into<String>, item: impl Into<String>) -> Self {
|
||||
let cat = category.into();
|
||||
let itm = item.into();
|
||||
if let Some(pos) = self.0.iter().position(|(c, _)| c == &cat) {
|
||||
self.0[pos].1 = itm;
|
||||
} else {
|
||||
self.0.push((cat, itm));
|
||||
self.0.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn without(&self, category: &str) -> Self {
|
||||
Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect())
|
||||
}
|
||||
|
||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||
partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let parts: Vec<_> = self.0.iter().map(|(c, v)| format!("{c}={v}")).collect();
|
||||
write!(f, "{{{}}}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CellValue {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
CellValue::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, CellValue::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CellValue::Number(n) => {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
write!(f, "{}", *n as i64)
|
||||
} else {
|
||||
write!(f, "{n:.4}")
|
||||
}
|
||||
}
|
||||
CellValue::Text(s) => write!(f, "{s}"),
|
||||
CellValue::Empty => write!(f, ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CellValue {
|
||||
fn default() -> Self {
|
||||
CellValue::Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DataStore {
|
||||
cells: HashMap<CellKey, CellValue>,
|
||||
}
|
||||
|
||||
impl Serialize for DataStore {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeSeq;
|
||||
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||
for (k, v) in &self.cells {
|
||||
seq.serialize_element(&(k, v))?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DataStore {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
||||
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
|
||||
Ok(DataStore { cells })
|
||||
}
|
||||
}
|
||||
|
||||
impl DataStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
||||
if value.is_empty() {
|
||||
self.cells.remove(&key);
|
||||
} else {
|
||||
self.cells.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &CellKey) -> &CellValue {
|
||||
self.cells.get(key).unwrap_or(&CellValue::Empty)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
||||
self.cells.get_mut(key)
|
||||
}
|
||||
|
||||
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
|
||||
&self.cells
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &CellKey) {
|
||||
self.cells.remove(key);
|
||||
}
|
||||
|
||||
/// Sum all cells matching partial coordinates
|
||||
pub fn sum_matching(&self, partial: &[(String, String)]) -> f64 {
|
||||
self.cells.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.filter_map(|(_, v)| v.as_f64())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// All cells where partial coords match
|
||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||
self.cells.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
7
src/model/mod.rs
Normal file
7
src/model/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod model;
|
||||
|
||||
pub use category::{Category, CategoryId, Group, Item, ItemId};
|
||||
pub use cell::{CellKey, CellValue, DataStore};
|
||||
pub use model::Model;
|
||||
250
src/model/model.rs
Normal file
250
src/model/model.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user