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

15
.cargo/config.toml Normal file
View File

@ -0,0 +1,15 @@
[build]
# Always build a fully-static musl binary by default.
# The devShell sets CARGO_BUILD_TARGET and CARGO_TARGET_*_LINKER,
# so the linker is resolved automatically via the environment.
target = "x86_64-unknown-linux-musl"
[target.x86_64-unknown-linux-gnu]
# Use gcc (not rust-lld) for host-target build scripts on NixOS.
# rust-lld bundled with rust-overlay can't resolve glibc symbols on NixOS.
linker = "gcc"
[target.x86_64-unknown-linux-musl]
# Linker is provided via CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER
# in the nix devShell. Outside nix, musl-gcc must be in PATH.
rustflags = ["-C", "target-feature=+crt-static"]

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
target/
*.autosave
.DS_Store

1077
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "improvise"
version = "0.1.0"
edition = "2021"
description = "Multi-dimensional data modeling terminal application"
license = "MIT"
[[bin]]
name = "improvise"
path = "src/main.rs"
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"
indexmap = { version = "2", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
flate2 = "1"
unicode-width = "0.2"
dirs = "5"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

277
SPEC.md Normal file
View File

@ -0,0 +1,277 @@
# Improvise — Multi-Dimensional Data Modeling Terminal Application
## Context
Traditional spreadsheets conflate data, formulas, and presentation into a single flat grid addressed by opaque cell references (A1, B7). This makes models fragile, hard to audit, and impossible to rearrange without rewriting formulas. We are building a terminal application that treats data as a multi-dimensional, semantically labeled structure — separating data, computation, and views into independent layers. The result is a tool where formulas reference meaningful names, views can be rearranged instantly, and the same dataset can be explored from multiple perspectives simultaneously.
The application compiles to a single static binary (`x86_64-unknown-linux-musl`) and provides a rich TUI experience.
---
## 1. Core Data Model
### 1.1 Categories and Items
- Data is organized into **categories** (dimensions) and **items** (members of a dimension).
- Example: Category "Region" contains items "North", "South", "East", "West".
- Example: Category "Time" contains items "Q1", "Q2", "Q3", "Q4".
- Items within a category can be organized into **groups** forming a hierarchy.
- Example: Items "Jan", "Feb", "Mar" grouped under "Q1"; quarters grouped under "2025".
- Groups are collapsible/expandable for drill-down.
- A model supports up to **12 categories**.
### 1.2 Data Cells
- Each data cell is identified by the intersection of one item from each active category — not by grid coordinates.
- Cells hold numeric values, text, or empty/null.
- The underlying storage is a sparse multi-dimensional array (`HashMap<CellKey, CellValue>`).
### 1.3 Models
- A **model** is the top-level container: it holds all categories, items, groups, data cells, formulas, and views.
- Models are saved to and loaded from a single `.improv` file (JSON format).
---
## 2. Formula System
### 2.1 Named Formulas
- Formulas reference categories and items by name, not by cell address.
- Example: `Profit = Revenue - Cost`
- Example: `Tax = Revenue * 0.08`
- Example: `Margin = Profit / Revenue`
- A formula applies uniformly across all intersections of the referenced categories. No copying or dragging.
### 2.2 Formula Panel
- Formulas are defined in a **dedicated formula panel**, separate from the data grid.
- All formulas are visible in one place for easy auditing.
- Formulas cannot be accidentally overwritten by data entry.
### 2.3 Scoped Formulas (WHERE clause)
- A formula can be scoped to a subset of items:
- Example: `Discount = 0.10 * Price WHERE Region = "West"`
### 2.4 Aggregation
- Built-in aggregation functions: `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`.
### 2.5 Formula Language
- Expression-based (not Turing-complete).
- Operators: `+`, `-`, `*`, `/`, `^`, unary `-`.
- Comparisons: `=`, `!=`, `<`, `>`, `<=`, `>=`.
- Conditionals: `IF(condition, then, else)`.
- `WHERE` clause for filtering: `SUM(Sales WHERE Region = "East")`.
- Parentheses for grouping.
- Literal numbers and quoted strings.
---
## 3. View System
### 3.1 Views as First-Class Objects
- A **view** is a named configuration specifying:
- Which categories are assigned to **rows**, **columns**, and **pages** (filters/slicers).
- Which items/groups are visible vs. hidden.
- Sort order (future).
- Number formatting.
- Multiple views can exist per model. Each is independent.
- Editing data in any view updates the underlying model; all other views reflect the change.
### 3.2 Category Tiles
- Each category is represented as a **tile** displayed in the tile bar.
- The user can move tiles between row, column, and page axes to instantly pivot/rearrange the view.
- Moving a tile triggers an instant recalculation and re-render of the grid.
### 3.3 Page Axis (Slicing)
- Categories assigned to the page axis act as filters.
- The user selects a single item from a paged category using `[` and `]`.
### 3.4 Collapsing and Expanding
- Groups can be collapsed/expanded per-view (future: keyboard shortcut in grid).
---
## 4. JSON Import Wizard
### 4.1 Purpose
- Users can import arbitrary JSON files to bootstrap a model.
### 4.2 Wizard Flow (interactive TUI)
**Step 1: Preview** — Structural summary of the JSON.
**Step 2: Select Array Path** — If the JSON is not a flat array, the user selects which key path contains the primary record array.
**Step 3: Review Proposals** — Fields are analyzed and proposed as:
- Category (small number of distinct string values)
- Measure (numeric)
- Time Category (date-like strings)
- Label/Identifier (skip)
**Step 4: Name the Model** — User names the model and confirms.
### 4.3 Headless Import
```
improvise --cmd '{"op":"ImportJson","path":"data.json"}'
```
---
## 5. Terminal UI
### 5.1 Layout
```
+---------------------------------------------------------------+
| Improvise | Model: Sales 2025 [*] [F1 Help] [Ctrl+Q] |
+---------------------------------------------------------------+
| [Page: Region = East] |
| | Q1 | Q2 | Q3 | Q4 | |
|--------------+---------+---------+---------+---------+--------|
| Shirts | 1,200 | 1,450 | 1,100 | 1,800 | |
| Pants | 800 | 920 | 750 | 1,200 | |
| ... |
|--------------+---------+---------+---------+---------+--------|
| Total | 4,100 | 4,670 | 3,750 | 5,800 | |
+---------------------------------------------------------------+
| Tiles: [Time ↔] [Product ↕] [Region ☰] Ctrl+↑↓←→ tiles |
+---------------------------------------------------------------+
| NORMAL | Default | Ctrl+F:formulas Ctrl+C:categories ... |
+---------------------------------------------------------------+
```
### 5.2 Panels
- **Grid panel** (main): Scrollable table of the current view.
- **Tile bar**: Category tiles with axis symbols. `Ctrl+Arrow` enters tile-select mode.
- **Formula panel**: `Ctrl+F` — list and edit formulas.
- **Category panel**: `Ctrl+C` — manage categories and axis assignments.
- **View panel**: `Ctrl+V` — switch, create, delete views.
- **Status bar**: Mode, active view name, keyboard hints.
### 5.3 Navigation and Editing
| Key | Action |
|-----|--------|
| ↑↓←→ / hjkl | Move cursor |
| Enter | Edit cell |
| Esc | Cancel edit |
| Tab | Focus next open panel |
| / | Search |
| [ / ] | Page axis prev/next |
| Ctrl+Arrow | Tile select mode |
| Enter/Space (tile) | Cycle axis (Row→Col→Page) |
| r / c / p (tile) | Set axis directly |
| Ctrl+F | Toggle formula panel |
| Ctrl+C | Toggle category panel |
| Ctrl+V | Toggle view panel |
| Ctrl+S | Save |
| Ctrl+E | Export CSV |
| F1 | Help |
| Ctrl+Q | Quit |
---
## 6. Command Layer (Headless Mode)
All model mutations go through a typed command layer. This enables:
- Scripting without the TUI
- Replay / audit log
- Testing without rendering
### 6.1 Command Format
JSON object with an `op` field:
```json
{"op": "CommandName", ...args}
```
### 6.2 Available Commands
| op | Required fields | Description |
|----|-----------------|-------------|
| `AddCategory` | `name` | Add a category/dimension |
| `AddItem` | `category`, `item` | Add an item to a category |
| `AddItemInGroup` | `category`, `item`, `group` | Add an item in a named group |
| `SetCell` | `coords: [[cat,item],...]`, `number` or `text` | Set a cell value |
| `ClearCell` | `coords` | Clear a cell |
| `AddFormula` | `raw`, `target_category` | Add/replace a formula |
| `RemoveFormula` | `target` | Remove a formula by target name |
| `CreateView` | `name` | Create a new view |
| `DeleteView` | `name` | Delete a view |
| `SwitchView` | `name` | Switch the active view |
| `SetAxis` | `category`, `axis` (`"row"/"column"/"page"`) | Set category axis |
| `SetPageSelection` | `category`, `item` | Set page-axis filter |
| `ToggleGroup` | `category`, `group` | Toggle group collapse |
| `Save` | `path` | Save model to file |
| `Load` | `path` | Load model from file |
| `ExportCsv` | `path` | Export active view to CSV |
| `ImportJson` | `path`, `model_name?`, `array_path?` | Import JSON file |
### 6.3 Response Format
```json
{"ok": true, "message": "optional message"}
{"ok": false, "message": "error description"}
```
### 6.4 Invocation
```bash
# Single command
improvise model.improv --cmd '{"op":"SetCell","coords":[["Region","East"],["Measure","Revenue"]],"number":1200}'
# Script file (one JSON object per line, # comments allowed)
improvise model.improv --script setup.jsonl
```
---
## 7. Persistence
### 7.1 File Format
Native format: JSON-based `.improv` file containing all categories, items, groups, data cells, formulas, and view definitions.
Compressed variant: `.improv.gz` (gzip, same JSON payload).
### 7.2 Export
- `Ctrl+E` in TUI or `ExportCsv` command: exports active view to CSV.
### 7.3 Autosave
- Periodic autosave (every 30 seconds when dirty) to `.model.improv.autosave`.
---
## 8. Technology
| Concern | Choice |
|---------|--------|
| Language | Rust (stable) |
| TUI | [Ratatui](https://github.com/ratatui-org/ratatui) + Crossterm |
| Serialization | `serde` + `serde_json` |
| Static binary | `x86_64-unknown-linux-musl` via `musl-gcc` |
| Dev environment | Nix flake with `rust-overlay` |
| No runtime deps | Single binary, no database, no network |
---
## 9. Non-Goals (v1)
- Scripting/macro language beyond the formula system.
- Collaborative/multi-user editing.
- Live external data sources (databases, APIs).
- Charts or graphical visualization.
- Multi-level undo history.
---
## 10. Verification
```bash
# Build
nix develop --command cargo build --release
file target/x86_64-unknown-linux-musl/release/improvise # → statically linked
# Import test
./improvise --cmd '{"op":"ImportJson","path":"sample.json"}' --cmd '{"op":"Save","path":"test.improv"}'
# Formula test
./improvise test.improv \
--cmd '{"op":"AddFormula","raw":"Profit = Revenue - Cost","target_category":"Measure"}'
# Headless script
./improvise new.improv --script tests/setup.jsonl
# TUI
./improvise model.improv
```

82
flake.lock generated Normal file
View File

@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1773975983,
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

62
flake.nix Normal file
View File

@ -0,0 +1,62 @@
{
description = "Improvise Multi-Dimensional Data Modeling Terminal Application";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "clippy" "rustfmt" ];
targets = [ "x86_64-unknown-linux-musl" ];
};
muslCC = pkgs.pkgsMusl.stdenv.cc;
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
# Provide cc (gcc) for building proc-macro / build-script crates
# that target the host (x86_64-unknown-linux-gnu).
pkgs.gcc
# musl-gcc wrapper for the static musl target.
muslCC
];
# Tell Cargo which linker to use for each target so it never
# falls back to rust-lld (which can't find glibc on NixOS).
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER =
"${pkgs.gcc}/bin/gcc";
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER =
"${muslCC}/bin/cc";
# Default build target: static musl binary.
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
RUST_BACKTRACE = "1";
};
packages.default =
(pkgs.pkgsMusl.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
}).buildRustPackage {
pname = "improvise";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
};
});
}

219
src/command/dispatch.rs Normal file
View 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
View 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
View 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()) }
}
}

58
src/formula/ast.rs Normal file
View File

@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AggFunc {
Sum,
Avg,
Min,
Max,
Count,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Filter {
pub category: String,
pub item: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Expr {
Number(f64),
Ref(String),
BinOp(String, Box<Expr>, Box<Expr>),
UnaryMinus(Box<Expr>),
Agg(AggFunc, Box<Expr>, Option<Filter>),
If(Box<Expr>, Box<Expr>, Box<Expr>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Formula {
/// The raw formula text, e.g. "Profit = Revenue - Cost"
pub raw: String,
/// The item/dimension name this formula computes, e.g. "Profit"
pub target: String,
/// The category containing the target item
pub target_category: String,
/// The expression to evaluate
pub expr: Expr,
/// Optional WHERE filter
pub filter: Option<Filter>,
}
impl Formula {
pub fn new(
raw: impl Into<String>,
target: impl Into<String>,
target_category: impl Into<String>,
expr: Expr,
filter: Option<Filter>,
) -> Self {
Self {
raw: raw.into(),
target: target.into(),
target_category: target_category.into(),
expr,
filter,
}
}
}

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

@ -0,0 +1,5 @@
pub mod parser;
pub mod ast;
pub use ast::{AggFunc, Expr, Filter, Formula};
pub use parser::parse_formula;

306
src/formula/parser.rs Normal file
View File

@ -0,0 +1,306 @@
use anyhow::{anyhow, Result};
use super::ast::{AggFunc, Expr, Filter, Formula};
/// Parse a formula string like "Profit = Revenue - Cost"
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
let raw = raw.trim();
// Split on first `=` to get target = expression
let eq_pos = raw.find('=').ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
let target = raw[..eq_pos].trim().to_string();
let rest = raw[eq_pos + 1..].trim();
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(|w| parse_where(w)).transpose()?;
let expr = parse_expr(expr_str.trim())?;
Ok(Formula::new(raw, target, target_category, expr, filter))
}
fn split_where(s: &str) -> (&str, Option<&str>) {
// Find WHERE not inside parens or quotes
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
b'"' => {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
}
_ if depth == 0 => {
if s[i..].to_ascii_uppercase().starts_with("WHERE") {
let before = &s[..i];
let after = &s[i + 5..];
if before.ends_with(char::is_whitespace) || i == 0 {
return (before.trim(), Some(after.trim()));
}
}
}
_ => {}
}
i += 1;
}
(s, None)
}
fn parse_where(s: &str) -> Result<Filter> {
// Format: Category = "Item" or Category = Item
let eq_pos = s.find('=').ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
let category = s[..eq_pos].trim().to_string();
let item_raw = s[eq_pos + 1..].trim();
let item = item_raw.trim_matches('"').to_string();
Ok(Filter { category, item })
}
/// Parse an expression using recursive descent
pub fn parse_expr(s: &str) -> Result<Expr> {
let tokens = tokenize(s)?;
let mut pos = 0;
let expr = parse_add_sub(&tokens, &mut pos)?;
if pos < tokens.len() {
return Err(anyhow!("Unexpected token at position {pos}: {:?}", tokens[pos]));
}
Ok(expr)
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(f64),
Ident(String),
Str(String),
Plus,
Minus,
Star,
Slash,
Caret,
LParen,
RParen,
Comma,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
}
fn tokenize(s: &str) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
' ' | '\t' | '\n' => i += 1,
'+' => { tokens.push(Token::Plus); i += 1; }
'-' => { tokens.push(Token::Minus); i += 1; }
'*' => { tokens.push(Token::Star); i += 1; }
'/' => { tokens.push(Token::Slash); i += 1; }
'^' => { tokens.push(Token::Caret); i += 1; }
'(' => { tokens.push(Token::LParen); i += 1; }
')' => { tokens.push(Token::RParen); i += 1; }
',' => { tokens.push(Token::Comma); i += 1; }
'!' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ne); i += 2; }
'<' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Le); i += 2; }
'>' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ge); i += 2; }
'<' => { tokens.push(Token::Lt); i += 1; }
'>' => { tokens.push(Token::Gt); i += 1; }
'=' => { tokens.push(Token::Eq); i += 1; }
'"' => {
i += 1;
let mut s = String::new();
while i < chars.len() && chars[i] != '"' {
s.push(chars[i]);
i += 1;
}
if i < chars.len() { i += 1; }
tokens.push(Token::Str(s));
}
c if c.is_ascii_digit() || c == '.' => {
let mut num = String::new();
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
num.push(chars[i]);
i += 1;
}
tokens.push(Token::Number(num.parse()?));
}
c if c.is_alphabetic() || c == '_' => {
let mut ident = String::new();
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') {
// Don't consume trailing spaces if next non-space is operator
if chars[i] == ' ' {
// Peek ahead
let j = i + 1;
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
if matches!(next_nonspace, Some('+') | Some('-') | Some('*') | Some('/') | Some('^') | Some(')') | Some(',') | None) {
break;
}
}
ident.push(chars[i]);
i += 1;
}
let ident = ident.trim_end().to_string();
tokens.push(Token::Ident(ident));
}
c => return Err(anyhow!("Unexpected character '{c}' in expression")),
}
}
Ok(tokens)
}
fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_mul_div(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Plus => "+",
Token::Minus => "-",
_ => break,
};
*pos += 1;
let right = parse_mul_div(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_pow(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Star => "*",
Token::Slash => "/",
_ => break,
};
*pos += 1;
let right = parse_pow(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let base = parse_unary(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Caret {
*pos += 1;
let exp = parse_unary(tokens, pos)?;
return Ok(Expr::BinOp("^".to_string(), Box::new(base), Box::new(exp)));
}
Ok(base)
}
fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos < tokens.len() && tokens[*pos] == Token::Minus {
*pos += 1;
let e = parse_primary(tokens, pos)?;
return Ok(Expr::UnaryMinus(Box::new(e)));
}
parse_primary(tokens, pos)
}
fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos >= tokens.len() {
return Err(anyhow!("Unexpected end of expression"));
}
match &tokens[*pos].clone() {
Token::Number(n) => {
*pos += 1;
Ok(Expr::Number(*n))
}
Token::Ident(name) => {
let name = name.clone();
*pos += 1;
// Check for function call
let upper = name.to_ascii_uppercase();
match upper.as_str() {
"SUM" | "AVG" | "MIN" | "MAX" | "COUNT" => {
let func = match upper.as_str() {
"SUM" => AggFunc::Sum,
"AVG" => AggFunc::Avg,
"MIN" => AggFunc::Min,
"MAX" => AggFunc::Max,
"COUNT" => AggFunc::Count,
_ => unreachable!(),
};
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let inner = parse_add_sub(tokens, pos)?;
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.to_ascii_uppercase() == "WHERE" {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
t => return Err(anyhow!("Expected category name, got {t:?}")),
};
// expect =
if *pos < tokens.len() && tokens[*pos] == Token::Eq { *pos += 1; }
let item = match &tokens[*pos] {
Token::Str(s) | Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
t => return Err(anyhow!("Expected item name, got {t:?}")),
};
Some(Filter { category: cat, item })
} else { None }
} else { None }
} else { None };
// expect )
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
}
return Ok(Expr::Agg(func, Box::new(inner), filter));
}
Ok(Expr::Ref(name))
}
"IF" => {
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let cond = parse_comparison(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
let then = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
let else_ = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen { *pos += 1; }
return Ok(Expr::If(Box::new(cond), Box::new(then), Box::new(else_)));
}
Ok(Expr::Ref(name))
}
_ => Ok(Expr::Ref(name)),
}
}
Token::LParen => {
*pos += 1;
let e = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
}
Ok(e)
}
t => Err(anyhow!("Unexpected token in expression: {t:?}")),
}
}
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let left = parse_add_sub(tokens, pos)?;
if *pos >= tokens.len() { return Ok(left); }
let op = match &tokens[*pos] {
Token::Eq => "=",
Token::Ne => "!=",
Token::Lt => "<",
Token::Gt => ">",
Token::Le => "<=",
Token::Ge => ">=",
_ => return Ok(left),
};
*pos += 1;
let right = parse_add_sub(tokens, pos)?;
Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)))
}

160
src/import/analyzer.rs Normal file
View File

@ -0,0 +1,160 @@
use std::collections::HashSet;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub enum FieldKind {
/// Small number of distinct string values → dimension/category
Category,
/// Numeric values → measure
Measure,
/// Date/time strings → time category
TimeCategory,
/// Many unique strings (IDs, names) → label/identifier
Label,
}
#[derive(Debug, Clone)]
pub struct FieldProposal {
pub field: String,
pub kind: FieldKind,
pub distinct_values: Vec<String>,
pub accepted: bool,
}
impl FieldProposal {
pub fn kind_label(&self) -> &'static str {
match self.kind {
FieldKind::Category => "Category (dimension)",
FieldKind::Measure => "Measure (numeric)",
FieldKind::TimeCategory => "Time Category",
FieldKind::Label => "Label/Identifier (skip)",
}
}
}
const CATEGORY_THRESHOLD: usize = 20;
const LABEL_THRESHOLD: usize = 50;
pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
if records.is_empty() {
return vec![];
}
// Collect all field names
let mut fields: Vec<String> = Vec::new();
for record in records {
if let Value::Object(map) = record {
for key in map.keys() {
if !fields.contains(key) {
fields.push(key.clone());
}
}
}
}
fields.into_iter().map(|field| {
let values: Vec<&Value> = records.iter()
.filter_map(|r| r.get(&field))
.collect();
let all_numeric = values.iter().all(|v| v.is_number());
let all_string = values.iter().all(|v| v.is_string());
if all_numeric {
return FieldProposal {
field,
kind: FieldKind::Measure,
distinct_values: vec![],
accepted: true,
};
}
if all_string {
let distinct: HashSet<&str> = values.iter()
.filter_map(|v| v.as_str())
.collect();
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
let n = distinct_vec.len();
let total = values.len();
// Check if looks like date
let looks_like_date = distinct_vec.iter().any(|s| {
s.contains('-') && s.len() >= 8
|| s.starts_with("Q") && s.len() == 2
|| ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
.iter().any(|m| s.starts_with(m))
});
if looks_like_date {
return FieldProposal {
field,
kind: FieldKind::TimeCategory,
distinct_values: distinct_vec,
accepted: true,
};
}
if n <= CATEGORY_THRESHOLD {
return FieldProposal {
field,
kind: FieldKind::Category,
distinct_values: distinct_vec,
accepted: true,
};
}
return FieldProposal {
field,
kind: FieldKind::Label,
distinct_values: distinct_vec,
accepted: false,
};
}
// Mixed or other: treat as label
FieldProposal {
field,
kind: FieldKind::Label,
distinct_values: vec![],
accepted: false,
}
}).collect()
}
/// Extract nested array from JSON by dot-path
pub fn extract_array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Vec<Value>> {
if path.is_empty() {
return value.as_array();
}
let mut current = value;
for part in path.split('.') {
current = current.get(part)?;
}
current.as_array()
}
/// Find candidate paths to arrays in JSON
pub fn find_array_paths(value: &Value) -> Vec<String> {
let mut paths = Vec::new();
find_array_paths_inner(value, "", &mut paths);
paths
}
fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>) {
match value {
Value::Array(_) => {
paths.push(prefix.to_string());
}
Value::Object(map) => {
for (key, val) in map {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
find_array_paths_inner(val, &path, paths);
}
}
_ => {}
}
}

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

@ -0,0 +1,5 @@
pub mod wizard;
pub mod analyzer;
pub use wizard::{ImportWizard, WizardState};
pub use analyzer::{FieldKind, FieldProposal, analyze_records};

246
src/import/wizard.rs Normal file
View File

@ -0,0 +1,246 @@
use serde_json::Value;
use anyhow::{anyhow, Result};
use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_at_path, find_array_paths};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
#[derive(Debug, Clone, PartialEq)]
pub enum WizardState {
Preview,
SelectArrayPath,
ReviewProposals,
NameModel,
Done,
}
#[derive(Debug)]
pub struct ImportWizard {
pub state: WizardState,
pub raw: Value,
pub array_paths: Vec<String>,
pub selected_path: String,
pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>,
pub model_name: String,
pub cursor: usize,
/// Message to display
pub message: Option<String>,
}
impl ImportWizard {
pub fn new(raw: Value) -> Self {
let array_paths = find_array_paths(&raw);
let state = if raw.is_array() {
WizardState::ReviewProposals
} else if array_paths.len() == 1 {
WizardState::ReviewProposals
} else {
WizardState::SelectArrayPath
};
let mut wizard = Self {
state: WizardState::Preview,
raw: raw.clone(),
array_paths: array_paths.clone(),
selected_path: String::new(),
records: vec![],
proposals: vec![],
model_name: "Imported Model".to_string(),
cursor: 0,
message: None,
};
// Auto-select if array at root or single path
if raw.is_array() {
wizard.select_path("");
} else if array_paths.len() == 1 {
let path = array_paths[0].clone();
wizard.select_path(&path);
}
wizard.state = if wizard.records.is_empty() && raw.is_object() {
WizardState::SelectArrayPath
} else {
wizard.advance();
return wizard;
};
wizard
}
fn select_path(&mut self, path: &str) {
self.selected_path = path.to_string();
if let Some(arr) = extract_array_at_path(&self.raw, path) {
self.records = arr.clone();
self.proposals = analyze_records(&self.records);
}
}
pub fn advance(&mut self) {
self.state = match self.state {
WizardState::Preview => {
if self.array_paths.len() <= 1 {
WizardState::ReviewProposals
} else {
WizardState::SelectArrayPath
}
}
WizardState::SelectArrayPath => WizardState::ReviewProposals,
WizardState::ReviewProposals => WizardState::NameModel,
WizardState::NameModel => WizardState::Done,
WizardState::Done => WizardState::Done,
};
self.cursor = 0;
self.message = None;
}
pub fn confirm_path(&mut self) {
if self.cursor < self.array_paths.len() {
let path = self.array_paths[self.cursor].clone();
self.select_path(&path);
self.advance();
}
}
pub fn toggle_proposal(&mut self) {
if self.cursor < self.proposals.len() {
self.proposals[self.cursor].accepted = !self.proposals[self.cursor].accepted;
}
}
pub fn cycle_proposal_kind(&mut self) {
if self.cursor < self.proposals.len() {
let p = &mut self.proposals[self.cursor];
p.kind = match p.kind {
FieldKind::Category => FieldKind::Measure,
FieldKind::Measure => FieldKind::TimeCategory,
FieldKind::TimeCategory => FieldKind::Label,
FieldKind::Label => FieldKind::Category,
};
}
}
pub fn move_cursor(&mut self, delta: i32) {
let len = match self.state {
WizardState::SelectArrayPath => self.array_paths.len(),
WizardState::ReviewProposals => self.proposals.len(),
_ => 0,
};
if len == 0 { return; }
if delta > 0 {
self.cursor = (self.cursor + 1).min(len - 1);
} else if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn push_name_char(&mut self, c: char) {
self.model_name.push(c);
}
pub fn pop_name_char(&mut self) {
self.model_name.pop();
}
pub fn build_model(&self) -> Result<Model> {
let mut model = Model::new(self.model_name.clone());
// Collect categories and measures from accepted proposals
let categories: Vec<&FieldProposal> = self.proposals.iter()
.filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory))
.collect();
let measures: Vec<&FieldProposal> = self.proposals.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
.collect();
if categories.is_empty() {
return Err(anyhow!("At least one category must be accepted"));
}
// Add categories
for cat_proposal in &categories {
model.add_category(&cat_proposal.field)?;
if let Some(cat) = model.category_mut(&cat_proposal.field) {
for val in &cat_proposal.distinct_values {
cat.add_item(val);
}
}
}
// If there are measures, add a "Measure" category
if !measures.is_empty() {
model.add_category("Measure")?;
if let Some(cat) = model.category_mut("Measure") {
for m in &measures {
cat.add_item(&m.field);
}
}
}
// Import records as cells
for record in &self.records {
if let Value::Object(map) = record {
// Build base coordinate from category fields
let mut coords: Vec<(String, String)> = vec![];
let mut valid = true;
for cat_proposal in &categories {
let val = map.get(&cat_proposal.field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
if let Some(v) = val {
// Ensure item exists
if let Some(cat) = model.category_mut(&cat_proposal.field) {
cat.add_item(&v);
}
coords.push((cat_proposal.field.clone(), v));
} else {
valid = false;
break;
}
}
if !valid { continue; }
// Add each measure as a cell
for measure in &measures {
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
let mut cell_coords = coords.clone();
if !measures.is_empty() {
cell_coords.push(("Measure".to_string(), measure.field.clone()));
}
let key = CellKey::new(cell_coords);
model.set_cell(key, CellValue::Number(val));
}
}
}
}
Ok(model)
}
pub fn preview_summary(&self) -> String {
match &self.raw {
Value::Array(arr) => {
format!(
"Array of {} records. Sample keys: {}",
arr.len(),
arr.first()
.and_then(|r| r.as_object())
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default()
)
}
Value::Object(map) => {
format!(
"Object with {} top-level keys: {}",
map.len(),
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
)
}
_ => "Unknown JSON structure".to_string(),
}
}
}

380
src/main.rs Normal file
View File

@ -0,0 +1,380 @@
mod model;
mod formula;
mod view;
mod ui;
mod import;
mod persistence;
mod command;
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use ui::app::{App, AppMode};
use ui::grid::GridWidget;
use ui::tile_bar::TileBar;
use ui::formula_panel::FormulaPanel;
use ui::category_panel::CategoryPanel;
use ui::view_panel::ViewPanel;
use ui::help::HelpWidget;
use ui::import_wizard_ui::ImportWizardWidget;
use model::Model;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut file_path: Option<PathBuf> = None;
let mut headless_cmds: Vec<String> = Vec::new();
let mut headless_script: Option<PathBuf> = None;
let mut import_path: Option<PathBuf> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--cmd" | "-c" => {
i += 1;
if let Some(cmd) = args.get(i).cloned() {
headless_cmds.push(cmd);
}
}
"--script" | "-s" => {
i += 1;
headless_script = args.get(i).map(PathBuf::from);
}
"--import" => {
i += 1;
import_path = args.get(i).map(PathBuf::from);
}
"--help" | "-h" => {
print_usage();
return Ok(());
}
arg if !arg.starts_with('-') => {
file_path = Some(PathBuf::from(arg));
}
_ => {}
}
i += 1;
}
// Load or create model
let mut model = if let Some(ref path) = file_path {
if path.exists() {
persistence::load(path).with_context(|| format!("Failed to load {}", path.display()))?
} else {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("New Model")
.to_string();
Model::new(name)
}
} else {
Model::new("New Model")
};
// Headless mode: run command(s) and print results
if !headless_cmds.is_empty() || headless_script.is_some() {
return run_headless(&mut model, file_path, headless_cmds, headless_script);
}
// Import mode before TUI
if let Some(ref path) = import_path {
let content = std::fs::read_to_string(path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
let cmd = command::Command::ImportJson {
path: path.to_string_lossy().to_string(),
model_name: None,
array_path: None,
};
let result = command::dispatch(&mut model, &cmd);
if !result.ok {
eprintln!("Import error: {}", result.message.unwrap_or_default());
}
}
// TUI mode
run_tui(model, file_path)
}
fn run_headless(
model: &mut Model,
file_path: Option<PathBuf>,
inline_cmds: Vec<String>,
script: Option<PathBuf>,
) -> Result<()> {
let mut cmds: Vec<String> = inline_cmds;
if let Some(script_path) = script {
let content = std::fs::read_to_string(&script_path)?;
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#') {
cmds.push(trimmed.to_string());
}
}
}
let mut exit_code = 0;
for raw_cmd in &cmds {
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
Ok(c) => c,
Err(e) => {
let result = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&result)?);
exit_code = 1;
continue;
}
};
let result = command::dispatch(model, &parsed);
if !result.ok { exit_code = 1; }
println!("{}", serde_json::to_string(&result)?);
}
// Auto-save if we have a file path and model was potentially modified
if let Some(path) = file_path {
persistence::save(model, &path)?;
}
std::process::exit(exit_code);
}
fn run_tui(model: Model, file_path: Option<PathBuf>) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(model, file_path);
loop {
terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
// Main layout: title bar + content + status bar
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status bar
])
.split(size);
// Title bar
draw_title(f, main_chunks[0], app);
// Content area: grid + optional panels
let content_area = main_chunks[1];
draw_content(f, content_area, app);
// Tile bar
draw_tile_bar(f, main_chunks[2], app);
// Status bar
draw_status(f, main_chunks[3], app);
// Overlays
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget, size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard {
f.render_widget(ImportWizardWidget::new(wizard), size);
}
}
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
draw_export_prompt(f, size, app);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [*]" } else { "" };
let title = format!(" Improvise | Model: {}{} ", app.model.name, dirty);
let help_hint = " [F1 Help] [Ctrl+Q Quit] ";
let padding = " ".repeat(
(area.width as usize).saturating_sub(title.len() + help_hint.len())
);
let full = format!("{title}{padding}{help_hint}");
let style = Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD);
f.render_widget(Paragraph::new(full).style(style), area);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let has_formula = app.formula_panel_open;
let has_category = app.category_panel_open;
let has_view = app.view_panel_open;
let side_open = has_formula || has_category || has_view;
if side_open {
let side_width = 30u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(40),
Constraint::Length(side_width),
])
.split(area);
// Grid
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
chunks[0],
);
// Side panels stacked
let side_area = chunks[1];
let panel_count = [has_formula, has_category, has_view].iter().filter(|&&b| b).count();
let panel_height = side_area.height / panel_count.max(1) as u16;
let mut y = side_area.y;
if has_formula {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a);
y += panel_height;
}
if has_category {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a);
y += panel_height;
}
if has_view {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), a);
}
} else {
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
area,
);
}
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode), area);
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let mode_str = match &app.mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "EDIT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULA PANEL",
AppMode::CategoryPanel => "CATEGORY PANEL",
AppMode::ViewPanel => "VIEW PANEL",
AppMode::TileSelect { .. } => "TILE SELECT",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
};
let search_part = if app.search_mode {
format!(" [Search: {}]", app.search_query)
} else { String::new() };
let panels = format!(
"{}{}{}",
if app.formula_panel_open { " [F]" } else { "" },
if app.category_panel_open { " [C]" } else { "" },
if app.view_panel_open { " [V]" } else { "" },
);
let status = format!(
" {mode_str}{search_part}{panels} | {} | {}",
app.model.active_view,
if app.status_msg.is_empty() {
"Ctrl+F:formulas Ctrl+C:categories Ctrl+V:views Ctrl+S:save".to_string()
} else {
app.status_msg.clone()
}
);
let style = Style::default().fg(Color::Black).bg(Color::DarkGray);
f.render_widget(
Paragraph::new(status).style(style),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { return };
let popup_w = 60u16.min(area.width);
let popup_h = 3u16;
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
use ratatui::widgets::Clear;
f.render_widget(Clear, popup_area);
let block = Block::default().borders(Borders::ALL).title(" Export CSV — enter path (Esc cancel) ");
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
f.render_widget(
Paragraph::new(format!("> {buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn print_usage() {
println!("improvise — multi-dimensional data modeling TUI");
println!();
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model file");
println!(" improvise --import data.json Import JSON, then open TUI");
println!(" improvise --cmd '{{...}}' Run a single JSON command (headless)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!();
println!("HEADLESS COMMANDS (JSON object with 'op' field):");
println!(" {{\"op\":\"AddCategory\",\"name\":\"Region\"}}");
println!(" {{\"op\":\"AddItem\",\"category\":\"Region\",\"item\":\"East\"}}");
println!(" {{\"op\":\"SetCell\",\"coords\":[[\"Region\",\"East\"],[\"Measure\",\"Revenue\"]],\"number\":1200}}");
println!(" {{\"op\":\"AddFormula\",\"raw\":\"Profit = Revenue - Cost\",\"target_category\":\"Measure\"}}");
println!(" {{\"op\":\"Save\",\"path\":\"model.improv\"}}");
println!(" {{\"op\":\"ImportJson\",\"path\":\"data.json\"}}");
println!();
println!("TUI SHORTCUTS:");
println!(" F1 Help");
println!(" Ctrl+Q Quit");
println!(" Ctrl+S Save");
println!(" Ctrl+F Formula panel");
println!(" Ctrl+C Category panel");
println!(" Ctrl+V View panel");
println!(" Enter Edit cell");
println!(" Ctrl+Arrow Tile select mode");
println!(" [ / ] Prev/next page item");
}

121
src/model/category.rs Normal file
View 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
View 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
View 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
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,
}
}
}

137
src/persistence/mod.rs Normal file
View File

@ -0,0 +1,137 @@
use std::io::{Read, Write, BufReader, BufWriter};
use std::path::Path;
use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::model::Model;
const MAGIC: &str = ".improv";
const COMPRESSED_EXT: &str = ".improv.gz";
pub fn save(model: &Model, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(model)
.context("Failed to serialize model")?;
if path.extension().and_then(|e| e.to_str()) == Some("gz")
|| path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false)
{
let file = std::fs::File::create(path)
.with_context(|| format!("Cannot create {}", path.display()))?;
let mut encoder = GzEncoder::new(BufWriter::new(file), Compression::default());
encoder.write_all(json.as_bytes())?;
encoder.finish()?;
} else {
std::fs::write(path, &json)
.with_context(|| format!("Cannot write {}", path.display()))?;
}
Ok(())
}
pub fn load(path: &Path) -> Result<Model> {
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open {}", path.display()))?;
let json = if path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) {
let mut decoder = GzDecoder::new(BufReader::new(file));
let mut s = String::new();
decoder.read_to_string(&mut s)?;
s
} else {
let mut s = String::new();
BufReader::new(file).read_to_string(&mut s)?;
s
};
serde_json::from_str(&json)
.context("Failed to deserialize model")
}
pub fn autosave_path(path: &Path) -> std::path::PathBuf {
let mut p = path.to_path_buf();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("model");
p.set_file_name(format!(".{name}.autosave"));
p
}
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
use crate::view::Axis;
let view = model.views.get(view_name)
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
let page_cats: Vec<String> = view.categories_on(Axis::Page).into_iter().map(String::from).collect();
// Build page-axis coords from current selections (or first item)
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = 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.clone(), sel)
}).collect();
let row_items: Vec<String> = if row_cats.is_empty() {
vec![]
} else {
model.category(&row_cats[0])
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default()
};
let col_items: Vec<String> = if col_cats.is_empty() {
vec![]
} else {
model.category(&col_cats[0])
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default()
};
let mut out = String::new();
// Header row
let row_label = if row_cats.is_empty() { String::new() } else { row_cats.join("/") };
let page_label: Vec<String> = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect();
let header_prefix = if page_label.is_empty() { row_label } else {
format!("{} ({})", row_label, page_label.join(", "))
};
if !header_prefix.is_empty() {
out.push_str(&header_prefix);
out.push(',');
}
out.push_str(&col_items.join(","));
out.push('\n');
// Data rows
let effective_row_items: Vec<String> = if row_items.is_empty() { vec!["".to_string()] } else { row_items };
let effective_col_items: Vec<String> = if col_items.is_empty() { vec!["".to_string()] } else { col_items };
for ri in &effective_row_items {
if !ri.is_empty() {
out.push_str(ri);
out.push(',');
}
let row_values: Vec<String> = effective_col_items.iter().map(|ci| {
let mut coords = page_coords.clone();
if !row_cats.is_empty() && !ri.is_empty() {
coords.push((row_cats[0].clone(), ri.clone()));
}
if !col_cats.is_empty() && !ci.is_empty() {
coords.push((col_cats[0].clone(), ci.clone()));
}
let key = crate::model::CellKey::new(coords);
model.evaluate(&key).to_string()
}).collect();
out.push_str(&row_values.join(","));
out.push('\n');
}
std::fs::write(path, out)?;
Ok(())
}

657
src/ui/app.rs Normal file
View File

@ -0,0 +1,657 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula;
use crate::import::wizard::{ImportWizard, WizardState};
use crate::persistence;
use crate::view::Axis;
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
Normal,
Editing { buffer: String },
FormulaEdit { buffer: String },
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect { cat_idx: usize },
ImportWizard,
ExportPrompt { buffer: String },
Help,
Quit,
}
pub struct App {
pub model: Model,
pub file_path: Option<PathBuf>,
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
pub last_autosave: Instant,
/// Input buffer for command-line style input
pub input_buffer: String,
/// Search query
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
/// Category panel cursor
pub cat_panel_cursor: usize,
/// View panel cursor
pub view_panel_cursor: usize,
/// Formula panel cursor
pub formula_cursor: usize,
pub dirty: bool,
}
impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
Self {
model,
file_path,
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
last_autosave: Instant::now(),
input_buffer: String::new(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
dirty: false,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
match &self.mode.clone() {
AppMode::Quit => {}
AppMode::Help => {
self.mode = AppMode::Normal;
}
AppMode::ImportWizard => {
self.handle_wizard_key(key)?;
}
AppMode::Editing { buffer } => {
self.handle_edit_key(key)?;
}
AppMode::FormulaEdit { buffer } => {
self.handle_formula_edit_key(key)?;
}
AppMode::FormulaPanel => {
self.handle_formula_panel_key(key)?;
}
AppMode::CategoryPanel => {
self.handle_category_panel_key(key)?;
}
AppMode::ViewPanel => {
self.handle_view_panel_key(key)?;
}
AppMode::TileSelect { cat_idx } => {
self.handle_tile_select_key(key)?;
}
AppMode::ExportPrompt { buffer } => {
self.handle_export_key(key)?;
}
AppMode::Normal => {
self.handle_normal_key(key)?;
}
}
Ok(())
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> {
if self.search_mode {
return self.handle_search_key(key);
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
self.mode = AppMode::Quit;
}
(KeyCode::F(1), _) => {
self.mode = AppMode::Help;
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
self.save()?;
}
(KeyCode::Char('f'), KeyModifiers::CONTROL) => {
self.formula_panel_open = !self.formula_panel_open;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.category_panel_open = !self.category_panel_open;
}
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
self.view_panel_open = !self.view_panel_open;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.mode = AppMode::ExportPrompt { buffer: String::new() };
}
// Navigation
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.move_selection(-1, 0);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
self.move_selection(1, 0);
}
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
self.move_selection(0, -1);
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
self.move_selection(0, 1);
}
(KeyCode::Enter, _) => {
let cell_key = self.selected_cell_key();
let current = cell_key.as_ref().map(|k| {
self.model.get_cell(k).to_string()
}).unwrap_or_default();
self.mode = AppMode::Editing { buffer: current };
}
(KeyCode::Char('/'), _) => {
self.search_mode = true;
self.search_query.clear();
}
// Tab cycles panels
(KeyCode::Tab, _) => {
if self.formula_panel_open {
self.mode = AppMode::FormulaPanel;
} else if self.category_panel_open {
self.mode = AppMode::CategoryPanel;
} else if self.view_panel_open {
self.mode = AppMode::ViewPanel;
}
}
// Tile movement with Ctrl+Arrow
(KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL)
| (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => {
let cat_names: Vec<String> = self.model.category_names()
.into_iter().map(String::from).collect();
if !cat_names.is_empty() {
self.mode = AppMode::TileSelect { cat_idx: 0 };
}
}
// Page axis navigation with [ ]
(KeyCode::Char('['), _) => {
self.page_prev();
}
(KeyCode::Char(']'), _) => {
self.page_next();
}
// Formula panel shortcut
(KeyCode::Char('F'), KeyModifiers::NONE) => {
self.formula_panel_open = true;
self.mode = AppMode::FormulaPanel;
}
_ => {}
}
Ok(())
}
fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.search_mode = false; }
KeyCode::Enter => { self.search_mode = false; }
KeyCode::Char(c) => { self.search_query.push(c); }
KeyCode::Backspace => { self.search_query.pop(); }
_ => {}
}
Ok(())
}
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::Editing { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => {
self.mode = AppMode::Normal;
}
KeyCode::Enter => {
// Commit value
if let Some(key) = self.selected_cell_key() {
let value = if buf.is_empty() {
CellValue::Empty
} else if let Ok(n) = buf.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(buf.clone())
};
self.model.set_cell(key, value);
self.dirty = true;
}
self.mode = AppMode::Normal;
self.move_selection(1, 0);
}
KeyCode::Char(c) => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::FormulaEdit { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => { self.mode = AppMode::FormulaPanel; }
KeyCode::Enter => {
// Try to parse and add formula
let first_cat = self.model.category_names().into_iter().next().map(String::from);
if let Some(cat) = first_cat {
match parse_formula(&buf, &cat) {
Ok(formula) => {
self.model.add_formula(formula);
self.status_msg = "Formula added".to_string();
self.dirty = true;
}
Err(e) => {
self.status_msg = format!("Formula error: {e}");
}
}
}
self.mode = AppMode::FormulaPanel;
}
KeyCode::Char(c) => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Char('a') | KeyCode::Char('n') => {
self.mode = AppMode::FormulaEdit { buffer: String::new() };
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.formula_cursor < self.model.formulas.len() {
let target = self.model.formulas[self.formula_cursor].target.clone();
self.model.remove_formula(&target);
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
self.dirty = true;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.formula_cursor + 1 < self.model.formulas.len() {
self.formula_cursor += 1;
}
}
_ => {}
}
Ok(())
}
fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.cat_panel_cursor + 1 < cat_names.len() {
self.cat_panel_cursor += 1;
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
// Cycle axis for selected category
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(cat_name);
}
}
}
_ => {}
}
Ok(())
}
fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let view_names: Vec<String> = self.model.views.keys().cloned().collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.view_panel_cursor + 1 < view_names.len() {
self.view_panel_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.switch_view(name);
self.mode = AppMode::Normal;
}
}
KeyCode::Char('n') => {
let new_name = format!("View {}", self.model.views.len() + 1);
self.model.create_view(&new_name);
let _ = self.model.switch_view(&new_name);
self.dirty = true;
self.mode = AppMode::Normal;
}
KeyCode::Delete | KeyCode::Char('d') => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.delete_view(name);
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
self.dirty = true;
}
}
_ => {}
}
Ok(())
}
fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 };
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Left | KeyCode::Char('h') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx > 0 { *cat_idx -= 1; }
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; }
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(name);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('r') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Row);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('c') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Column);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('p') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Page);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
_ => {}
}
Ok(())
}
fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Enter => {
let buf = if let AppMode::ExportPrompt { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
let path = PathBuf::from(buf);
let view_name = self.model.active_view.clone();
match persistence::export_csv(&self.model, &view_name, &path) {
Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); }
Err(e) => { self.status_msg = format!("Export error: {e}"); }
}
self.mode = AppMode::Normal;
}
KeyCode::Char(c) => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
if let Some(wizard) = &mut self.wizard {
match &wizard.state.clone() {
WizardState::Preview => {
match key.code {
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::SelectArrayPath => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Enter => wizard.confirm_path(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::ReviewProposals => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Char(' ') => wizard.toggle_proposal(),
KeyCode::Char('c') => wizard.cycle_proposal_kind(),
KeyCode::Enter => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::NameModel => {
match key.code {
KeyCode::Char(c) => wizard.push_name_char(c),
KeyCode::Backspace => wizard.pop_name_char(),
KeyCode::Enter => {
let result = wizard.build_model();
match result {
Ok(model) => {
self.model = model;
self.dirty = true;
self.status_msg = "Import successful!".to_string();
self.mode = AppMode::Normal;
self.wizard = None;
}
Err(e) => {
if let Some(w) = &mut self.wizard {
w.message = Some(format!("Error: {e}"));
}
}
}
}
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::Done => {
self.mode = AppMode::Normal;
self.wizard = None;
}
}
}
Ok(())
}
fn move_selection(&mut self, dr: i32, dc: i32) {
if let Some(view) = self.model.active_view_mut() {
let (r, c) = view.selected;
let new_r = (r as i32 + dr).max(0) as usize;
let new_c = (c as i32 + dc).max(0) as usize;
view.selected = (new_r, new_c);
}
}
fn page_next(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let next_idx = (idx + 1).min(items.len() - 1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[next_idx]);
}
break;
}
}
fn page_prev(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let prev_idx = idx.saturating_sub(1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[prev_idx]);
}
break;
}
}
pub fn selected_cell_key(&self) -> Option<CellKey> {
let view = self.model.active_view()?;
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);
let (sel_row, sel_col) = view.selected;
let mut coords = vec![];
// Page coords
for cat_name in &page_cats {
let items = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())?;
coords.push((cat_name.to_string(), sel));
}
// Row coords
for (i, cat_name) in row_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_row)?.clone();
coords.push((cat_name.to_string(), item));
}
// Col coords
for (i, cat_name) in col_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_col)?.clone();
coords.push((cat_name.to_string(), item));
}
Some(CellKey::new(coords))
}
pub fn save(&mut self) -> Result<()> {
if let Some(path) = &self.file_path.clone() {
persistence::save(&self.model, path)?;
self.dirty = false;
self.status_msg = format!("Saved to {}", path.display());
} else {
self.status_msg = "No file path set. Use Ctrl+E to export.".to_string();
}
Ok(())
}
pub fn autosave_if_needed(&mut self) {
if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) {
if let Some(path) = &self.file_path.clone() {
let autosave_path = persistence::autosave_path(path);
let _ = persistence::save(&self.model, &autosave_path);
self.last_autosave = Instant::now();
}
}
}
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
self.wizard = Some(ImportWizard::new(json));
self.mode = AppMode::ImportWizard;
}
}

98
src/ui/category_panel.rs Normal file
View File

@ -0,0 +1,98 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
pub struct CategoryPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> CategoryPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for CategoryPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::CategoryPanel);
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Categories [Enter] cycle axis ");
let inner = block.inner(area);
block.render(area, buf);
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let cat_names: Vec<&str> = self.model.category_names();
if cat_names.is_empty() {
buf.set_string(inner.x, inner.y,
"(no categories)",
Style::default().fg(Color::DarkGray));
return;
}
for (i, cat_name) in cat_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let axis = view.axis_of(cat_name);
let axis_str = match axis {
Axis::Row => "Row ↕",
Axis::Column => "Col ↔",
Axis::Page => "Page ☰",
Axis::Unassigned => "none",
};
let axis_color = match axis {
Axis::Row => Color::Green,
Axis::Column => Color::Blue,
Axis::Page => Color::Magenta,
Axis::Unassigned => Color::DarkGray,
};
let cat = self.model.category(cat_name);
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
let is_selected = i == self.cursor && is_active;
let base_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Background fill for selected row
if is_selected {
let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, inner.y + i as u16, &fill, base_style);
}
let name_part = format!(" {cat_name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
let available = inner.width as usize;
buf.set_string(inner.x, inner.y + i as u16, &name_part, base_style);
if name_part.len() + axis_part.len() < available {
let axis_x = inner.x + name_part.len() as u16;
buf.set_string(axis_x, inner.y + i as u16, &axis_part,
if is_selected { base_style } else { Style::default().fg(axis_color) });
}
}
}
}

76
src/ui/formula_panel.rs Normal file
View File

@ -0,0 +1,76 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
pub struct FormulaPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> FormulaPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for FormulaPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. });
let border_style = if is_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Formulas [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let formulas = &self.model.formulas;
if formulas.is_empty() {
buf.set_string(inner.x, inner.y,
"(no formulas — press 'n' to add)",
Style::default().fg(Color::DarkGray));
return;
}
for (i, formula) in formulas.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let is_selected = i == self.cursor && is_active;
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let text = format!(" {} = {:?}", formula.target, formula.raw);
let truncated = if text.len() > inner.width as usize {
format!("{}", &text[..inner.width as usize - 1])
} else {
text
};
buf.set_string(inner.x, inner.y + i as u16, &truncated, style);
}
// Formula edit mode
if let AppMode::FormulaEdit { buffer } = self.mode {
let y = inner.y + inner.height.saturating_sub(2);
buf.set_string(inner.x, y,
"┄ Enter formula (Name = expr): ",
Style::default().fg(Color::Yellow));
let y = y + 1;
let prompt = format!("> {buffer}");
buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green));
}
}
}

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()
}
}

77
src/ui/help.rs Normal file
View File

@ -0,0 +1,77 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
pub struct HelpWidget;
impl Widget for HelpWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
// Center popup
let popup_w = 60u16.min(area.width);
let popup_h = 30u16.min(area.height);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help — Improvise ")
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let help_text = [
("Navigation", ""),
(" ↑/↓/←/→ or hjkl", "Move cursor"),
(" Enter", "Edit selected cell"),
(" /", "Search in grid"),
(" [ / ]", "Prev/next page item"),
("", ""),
("Panels", ""),
(" Ctrl+F", "Toggle formula panel"),
(" Ctrl+C", "Toggle category panel"),
(" Ctrl+V", "Toggle view panel"),
(" Tab", "Focus next open panel"),
("", ""),
("Tiles / Pivot", ""),
(" Ctrl+Arrow", "Enter tile select mode"),
(" Enter/Space", "Cycle axis (Row→Col→Page)"),
(" r / c / p", "Set axis to Row/Col/Page"),
("", ""),
("File", ""),
(" Ctrl+S", "Save model"),
(" Ctrl+E", "Export CSV"),
("", ""),
("Headless / Batch", ""),
(" --cmd '{...}'", "Run a single JSON command"),
(" --script file", "Run commands from file"),
("", ""),
(" F1", "This help"),
(" Ctrl+Q", "Quit"),
("", ""),
(" Any key to close", ""),
];
for (i, (key, desc)) in help_text.iter().enumerate() {
if i >= inner.height as usize { break; }
let y = inner.y + i as u16;
if key.is_empty() {
continue;
}
if desc.is_empty() {
buf.set_string(inner.x, y, key, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
} else {
buf.set_string(inner.x, y, key, Style::default().fg(Color::Cyan));
let desc_x = inner.x + 26;
if desc_x < inner.x + inner.width {
buf.set_string(desc_x, y, desc, Style::default());
}
}
}
}
}

147
src/ui/import_wizard_ui.rs Normal file
View File

@ -0,0 +1,147 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::wizard::{ImportWizard, WizardState};
use crate::import::analyzer::FieldKind;
pub struct ImportWizardWidget<'a> {
pub wizard: &'a ImportWizard,
}
impl<'a> ImportWizardWidget<'a> {
pub fn new(wizard: &'a ImportWizard) -> Self {
Self { wizard }
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
let popup_h = area.height.min(30);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let title = match self.wizard.state {
WizardState::Preview => " Import Wizard — Step 1: Preview ",
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardState::NameModel => " Import Wizard — Step 4: Name Model ",
WizardState::Done => " Import Wizard — Done ",
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(title);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut y = inner.y;
let x = inner.x;
let w = inner.width as usize;
match &self.wizard.state {
WizardState::Preview => {
let summary = self.wizard.preview_summary();
buf.set_string(x, y, truncate(&summary, w), Style::default());
y += 2;
buf.set_string(x, y,
"Press Enter to continue…",
Style::default().fg(Color::Yellow));
}
WizardState::SelectArrayPath => {
buf.set_string(x, y,
"Select the path containing records:",
Style::default().fg(Color::Yellow));
y += 1;
for (i, path) in self.wizard.array_paths.iter().enumerate() {
if y >= inner.y + inner.height { break; }
let is_sel = i == self.wizard.cursor;
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = format!(" {}", if path.is_empty() { "(root)" } else { path });
buf.set_string(x, y, truncate(&label, w), style);
y += 1;
}
y += 1;
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray));
}
WizardState::ReviewProposals => {
buf.set_string(x, y,
"Review field proposals (Space toggle, c cycle kind):",
Style::default().fg(Color::Yellow));
y += 1;
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
y += 1;
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
if y >= inner.y + inner.height - 2 { break; }
let is_sel = i == self.wizard.cursor;
let kind_color = match proposal.kind {
FieldKind::Category => Color::Green,
FieldKind::Measure => Color::Cyan,
FieldKind::TimeCategory => Color::Magenta,
FieldKind::Label => Color::DarkGray,
};
let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" };
let row = format!(" {:<20} {:<22} {}",
truncate(&proposal.field, 20),
truncate(proposal.kind_label(), 22),
accept_str);
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if proposal.accepted {
Style::default().fg(kind_color)
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(x, y, truncate(&row, w), style);
y += 1;
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
Style::default().fg(Color::DarkGray));
}
WizardState::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;
let name_str = format!("> {}", self.wizard.model_name);
buf.set_string(x, y, truncate(&name_str, w),
Style::default().fg(Color::Green));
y += 2;
buf.set_string(x, y, "Enter to import, Esc to cancel",
Style::default().fg(Color::DarkGray));
if let Some(msg) = &self.wizard.message {
let msg_y = inner.y + inner.height - 1;
buf.set_string(x, msg_y, truncate(msg, w),
Style::default().fg(Color::Red));
}
}
WizardState::Done => {
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
}
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() }
else if max > 1 { format!("{}", &s[..max-1]) }
else { s[..max].to_string() }
}

10
src/ui/mod.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod app;
pub mod grid;
pub mod formula_panel;
pub mod category_panel;
pub mod view_panel;
pub mod tile_bar;
pub mod import_wizard_ui;
pub mod help;
pub use app::{App, AppMode};

82
src/ui/tile_bar.rs Normal file
View File

@ -0,0 +1,82 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
}
}
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
} else {
None
};
let mut x = area.x + 1;
buf.set_string(area.x, area.y, " Tiles: ", Style::default().fg(Color::Gray));
x += 8;
let cat_names: Vec<&str> = self.model.category_names();
for (i, cat_name) in cat_names.iter().enumerate() {
let axis = view.axis_of(cat_name);
let axis_symbol = match axis {
Axis::Row => "",
Axis::Column => "",
Axis::Page => "",
Axis::Unassigned => "",
};
let label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
match axis {
Axis::Row => Style::default().fg(Color::Green),
Axis::Column => Style::default().fg(Color::Blue),
Axis::Page => Style::default().fg(Color::Magenta),
Axis::Unassigned => Style::default().fg(Color::DarkGray),
}
};
if x + label.len() as u16 > area.x + area.width { break; }
buf.set_string(x, area.y, &label, style);
x += label.len() as u16;
}
// Hint
if matches!(self.mode, AppMode::TileSelect { .. }) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}
}
}

62
src/ui/view_panel.rs Normal file
View File

@ -0,0 +1,62 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
pub struct ViewPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> ViewPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for ViewPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::ViewPanel);
let border_style = if is_active {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Views [Enter] switch [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect();
let active = &self.model.active_view;
for (i, view_name) in view_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let is_selected = i == self.cursor && is_active;
let is_active_view = *view_name == active.as_str();
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD)
} else if is_active_view {
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_active_view { "" } else { " " };
buf.set_string(inner.x, inner.y + i as u16,
format!("{prefix}{view_name}"),
style);
}
}
}

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;
}
}

22
tests/smoke.jsonl Normal file
View File

@ -0,0 +1,22 @@
# Smoke test — one JSON command per line
{"op":"AddCategory","name":"Region"}
{"op":"AddCategory","name":"Product"}
{"op":"AddCategory","name":"Measure"}
{"op":"AddItem","category":"Region","item":"East"}
{"op":"AddItem","category":"Region","item":"West"}
{"op":"AddItem","category":"Product","item":"Shirts"}
{"op":"AddItem","category":"Product","item":"Pants"}
{"op":"AddItem","category":"Measure","item":"Revenue"}
{"op":"AddItem","category":"Measure","item":"Cost"}
{"op":"SetCell","coords":[["Region","East"],["Product","Shirts"],["Measure","Revenue"]],"number":1200}
{"op":"SetCell","coords":[["Region","East"],["Product","Shirts"],["Measure","Cost"]],"number":400}
{"op":"SetCell","coords":[["Region","East"],["Product","Pants"],["Measure","Revenue"]],"number":800}
{"op":"SetCell","coords":[["Region","East"],["Product","Pants"],["Measure","Cost"]],"number":300}
{"op":"SetCell","coords":[["Region","West"],["Product","Shirts"],["Measure","Revenue"]],"number":950}
{"op":"SetCell","coords":[["Region","West"],["Product","Shirts"],["Measure","Cost"]],"number":320}
{"op":"AddFormula","raw":"Profit = Revenue - Cost","target_category":"Measure"}
{"op":"CreateView","name":"By Region"}
{"op":"SetAxis","category":"Region","axis":"column"}
{"op":"SetAxis","category":"Product","axis":"row"}
{"op":"SetAxis","category":"Measure","axis":"page"}
{"op":"Save","path":"/tmp/smoke_test.improv"}