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:
219
src/command/dispatch.rs
Normal file
219
src/command/dispatch.rs
Normal file
@ -0,0 +1,219 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::view::Axis;
|
||||
use crate::persistence;
|
||||
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
|
||||
use super::types::{CellValueArg, Command, CommandResult};
|
||||
|
||||
/// Execute a command against the model, returning a result.
|
||||
/// This is the single authoritative mutation path used by both the TUI and headless modes.
|
||||
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
||||
match cmd {
|
||||
Command::AddCategory { name } => {
|
||||
match model.add_category(name) {
|
||||
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::AddItem { category, item } => {
|
||||
match model.category_mut(category) {
|
||||
Some(cat) => { cat.add_item(item); CommandResult::ok() }
|
||||
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||
}
|
||||
}
|
||||
|
||||
Command::AddItemInGroup { category, item, group } => {
|
||||
match model.category_mut(category) {
|
||||
Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() }
|
||||
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||
}
|
||||
}
|
||||
|
||||
Command::SetCell { coords, value } => {
|
||||
let kv: Vec<(String, String)> = coords.iter()
|
||||
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||
.collect();
|
||||
// Ensure items exist
|
||||
for (cat_name, item_name) in &kv {
|
||||
if let Some(cat) = model.category_mut(cat_name) {
|
||||
cat.add_item(item_name);
|
||||
}
|
||||
}
|
||||
let key = CellKey::new(kv);
|
||||
let cell_value = match value {
|
||||
CellValueArg::Number { number } => CellValue::Number(*number),
|
||||
CellValueArg::Text { text } => CellValue::Text(text.clone()),
|
||||
};
|
||||
model.set_cell(key, cell_value);
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
Command::ClearCell { coords } => {
|
||||
let kv: Vec<(String, String)> = coords.iter()
|
||||
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||
.collect();
|
||||
let key = CellKey::new(kv);
|
||||
model.set_cell(key, CellValue::Empty);
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
Command::AddFormula { raw, target_category } => {
|
||||
match parse_formula(raw, target_category) {
|
||||
Ok(formula) => {
|
||||
// Ensure the target item exists in the target category
|
||||
let target = formula.target.clone();
|
||||
let cat_name = formula.target_category.clone();
|
||||
if let Some(cat) = model.category_mut(&cat_name) {
|
||||
cat.add_item(&target);
|
||||
}
|
||||
model.add_formula(formula);
|
||||
CommandResult::ok_msg(format!("Formula '{raw}' added"))
|
||||
}
|
||||
Err(e) => CommandResult::err(format!("Parse error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Command::RemoveFormula { target } => {
|
||||
model.remove_formula(target);
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
Command::CreateView { name } => {
|
||||
model.create_view(name);
|
||||
CommandResult::ok()
|
||||
}
|
||||
|
||||
Command::DeleteView { name } => {
|
||||
match model.delete_view(name) {
|
||||
Ok(_) => CommandResult::ok(),
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::SwitchView { name } => {
|
||||
match model.switch_view(name) {
|
||||
Ok(_) => CommandResult::ok(),
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::SetAxis { category, axis } => {
|
||||
let ax = match axis.to_lowercase().as_str() {
|
||||
"row" | "rows" => Axis::Row,
|
||||
"column" | "col" | "columns" => Axis::Column,
|
||||
"page" | "pages" | "filter" => Axis::Page,
|
||||
other => return CommandResult::err(format!("Unknown axis '{other}'")),
|
||||
};
|
||||
match model.active_view_mut() {
|
||||
Some(view) => { view.set_axis(category, ax); CommandResult::ok() }
|
||||
None => CommandResult::err("No active view"),
|
||||
}
|
||||
}
|
||||
|
||||
Command::SetPageSelection { category, item } => {
|
||||
match model.active_view_mut() {
|
||||
Some(view) => { view.set_page_selection(category, item); CommandResult::ok() }
|
||||
None => CommandResult::err("No active view"),
|
||||
}
|
||||
}
|
||||
|
||||
Command::ToggleGroup { category, group } => {
|
||||
match model.active_view_mut() {
|
||||
Some(view) => { view.toggle_group_collapse(category, group); CommandResult::ok() }
|
||||
None => CommandResult::err("No active view"),
|
||||
}
|
||||
}
|
||||
|
||||
Command::Save { path } => {
|
||||
match persistence::save(model, std::path::Path::new(path)) {
|
||||
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::Load { path } => {
|
||||
match persistence::load(std::path::Path::new(path)) {
|
||||
Ok(loaded) => {
|
||||
*model = loaded;
|
||||
CommandResult::ok_msg(format!("Loaded from {path}"))
|
||||
}
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::ExportCsv { path } => {
|
||||
let view_name = model.active_view.clone();
|
||||
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
|
||||
Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")),
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Command::ImportJson { path, model_name, array_path } => {
|
||||
import_json_headless(model, path, model_name.as_deref(), array_path.as_deref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_json_headless(
|
||||
model: &mut Model,
|
||||
path: &str,
|
||||
model_name: Option<&str>,
|
||||
array_path: Option<&str>,
|
||||
) -> CommandResult {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
|
||||
};
|
||||
let value: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
|
||||
};
|
||||
|
||||
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
|
||||
match extract_array_at_path(&value, ap) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return CommandResult::err(format!("No array at path '{ap}'")),
|
||||
}
|
||||
} else if let Some(arr) = value.as_array() {
|
||||
arr.clone()
|
||||
} else {
|
||||
// Find first array
|
||||
let paths = crate::import::analyzer::find_array_paths(&value);
|
||||
if let Some(first) = paths.first() {
|
||||
match extract_array_at_path(&value, first) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return CommandResult::err("Could not extract records array"),
|
||||
}
|
||||
} else {
|
||||
return CommandResult::err("No array found in JSON");
|
||||
}
|
||||
};
|
||||
|
||||
let proposals = analyze_records(&records);
|
||||
|
||||
// Auto-accept all and build
|
||||
let wizard = crate::import::wizard::ImportWizard {
|
||||
state: crate::import::wizard::WizardState::NameModel,
|
||||
raw: value,
|
||||
array_paths: vec![],
|
||||
selected_path: array_path.unwrap_or("").to_string(),
|
||||
records,
|
||||
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
|
||||
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
||||
cursor: 0,
|
||||
message: None,
|
||||
};
|
||||
|
||||
match wizard.build_model() {
|
||||
Ok(new_model) => {
|
||||
*model = new_model;
|
||||
CommandResult::ok_msg("JSON imported successfully")
|
||||
}
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
12
src/command/mod.rs
Normal file
12
src/command/mod.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! Command layer — all model mutations go through this layer so they can be
|
||||
//! replayed, scripted, and tested without the TUI.
|
||||
//!
|
||||
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
|
||||
//! The headless CLI (--cmd / --script) routes through here, and the TUI
|
||||
//! App also calls dispatch() for every user action that mutates state.
|
||||
|
||||
pub mod dispatch;
|
||||
pub mod types;
|
||||
|
||||
pub use types::{Command, CommandResult};
|
||||
pub use dispatch::dispatch;
|
||||
99
src/command/types.rs
Normal file
99
src/command/types.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// All commands that can mutate a Model.
|
||||
///
|
||||
/// Serialized as `{"op": "<variant>", ...rest}` where `rest` contains
|
||||
/// the variant's fields flattened into the same JSON object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "op")]
|
||||
pub enum Command {
|
||||
/// Add a category (dimension).
|
||||
AddCategory { name: String },
|
||||
|
||||
/// Add an item to a category.
|
||||
AddItem { category: String, item: String },
|
||||
|
||||
/// Add an item inside a named group.
|
||||
AddItemInGroup { category: String, item: String, group: String },
|
||||
|
||||
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
|
||||
SetCell {
|
||||
coords: Vec<[String; 2]>,
|
||||
#[serde(flatten)]
|
||||
value: CellValueArg,
|
||||
},
|
||||
|
||||
/// Clear a cell.
|
||||
ClearCell { coords: Vec<[String; 2]> },
|
||||
|
||||
/// Add or replace a formula.
|
||||
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
|
||||
/// `target_category` names the category that owns the formula target.
|
||||
AddFormula { raw: String, target_category: String },
|
||||
|
||||
/// Remove a formula by its target name.
|
||||
RemoveFormula { target: String },
|
||||
|
||||
/// Create a new view.
|
||||
CreateView { name: String },
|
||||
|
||||
/// Delete a view.
|
||||
DeleteView { name: String },
|
||||
|
||||
/// Switch the active view.
|
||||
SwitchView { name: String },
|
||||
|
||||
/// Set the axis of a category in the active view.
|
||||
/// axis: "row" | "column" | "page"
|
||||
SetAxis { category: String, axis: String },
|
||||
|
||||
/// Set the page-axis selection for a category.
|
||||
SetPageSelection { category: String, item: String },
|
||||
|
||||
/// Toggle collapse of a group in the active view.
|
||||
ToggleGroup { category: String, group: String },
|
||||
|
||||
/// Save the model to a file path.
|
||||
Save { path: String },
|
||||
|
||||
/// Load a model from a file path (replaces current model).
|
||||
Load { path: String },
|
||||
|
||||
/// Export the active view to CSV.
|
||||
ExportCsv { path: String },
|
||||
|
||||
/// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals).
|
||||
ImportJson {
|
||||
path: String,
|
||||
model_name: Option<String>,
|
||||
/// Dot-path to the records array (empty = root)
|
||||
array_path: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inline value for SetCell
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CellValueArg {
|
||||
Number { number: f64 },
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandResult {
|
||||
pub fn ok() -> Self {
|
||||
Self { ok: true, message: None }
|
||||
}
|
||||
pub fn ok_msg(msg: impl Into<String>) -> Self {
|
||||
Self { ok: true, message: Some(msg.into()) }
|
||||
}
|
||||
pub fn err(msg: impl Into<String>) -> Self {
|
||||
Self { ok: false, message: Some(msg.into()) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user