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:
15
.cargo/config.toml
Normal file
15
.cargo/config.toml
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
*.autosave
|
||||||
|
.DS_Store
|
||||||
1077
Cargo.lock
generated
Normal file
1077
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal 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
277
SPEC.md
Normal 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
82
flake.lock
generated
Normal 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
62
flake.nix
Normal 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
219
src/command/dispatch.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::formula::parse_formula;
|
||||||
|
use crate::view::Axis;
|
||||||
|
use crate::persistence;
|
||||||
|
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
|
||||||
|
use super::types::{CellValueArg, Command, CommandResult};
|
||||||
|
|
||||||
|
/// Execute a command against the model, returning a result.
|
||||||
|
/// This is the single authoritative mutation path used by both the TUI and headless modes.
|
||||||
|
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
||||||
|
match cmd {
|
||||||
|
Command::AddCategory { name } => {
|
||||||
|
match model.add_category(name) {
|
||||||
|
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::AddItem { category, item } => {
|
||||||
|
match model.category_mut(category) {
|
||||||
|
Some(cat) => { cat.add_item(item); CommandResult::ok() }
|
||||||
|
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::AddItemInGroup { category, item, group } => {
|
||||||
|
match model.category_mut(category) {
|
||||||
|
Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() }
|
||||||
|
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SetCell { coords, value } => {
|
||||||
|
let kv: Vec<(String, String)> = coords.iter()
|
||||||
|
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||||
|
.collect();
|
||||||
|
// Ensure items exist
|
||||||
|
for (cat_name, item_name) in &kv {
|
||||||
|
if let Some(cat) = model.category_mut(cat_name) {
|
||||||
|
cat.add_item(item_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = CellKey::new(kv);
|
||||||
|
let cell_value = match value {
|
||||||
|
CellValueArg::Number { number } => CellValue::Number(*number),
|
||||||
|
CellValueArg::Text { text } => CellValue::Text(text.clone()),
|
||||||
|
};
|
||||||
|
model.set_cell(key, cell_value);
|
||||||
|
CommandResult::ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ClearCell { coords } => {
|
||||||
|
let kv: Vec<(String, String)> = coords.iter()
|
||||||
|
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||||
|
.collect();
|
||||||
|
let key = CellKey::new(kv);
|
||||||
|
model.set_cell(key, CellValue::Empty);
|
||||||
|
CommandResult::ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::AddFormula { raw, target_category } => {
|
||||||
|
match parse_formula(raw, target_category) {
|
||||||
|
Ok(formula) => {
|
||||||
|
// Ensure the target item exists in the target category
|
||||||
|
let target = formula.target.clone();
|
||||||
|
let cat_name = formula.target_category.clone();
|
||||||
|
if let Some(cat) = model.category_mut(&cat_name) {
|
||||||
|
cat.add_item(&target);
|
||||||
|
}
|
||||||
|
model.add_formula(formula);
|
||||||
|
CommandResult::ok_msg(format!("Formula '{raw}' added"))
|
||||||
|
}
|
||||||
|
Err(e) => CommandResult::err(format!("Parse error: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::RemoveFormula { target } => {
|
||||||
|
model.remove_formula(target);
|
||||||
|
CommandResult::ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::CreateView { name } => {
|
||||||
|
model.create_view(name);
|
||||||
|
CommandResult::ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::DeleteView { name } => {
|
||||||
|
match model.delete_view(name) {
|
||||||
|
Ok(_) => CommandResult::ok(),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SwitchView { name } => {
|
||||||
|
match model.switch_view(name) {
|
||||||
|
Ok(_) => CommandResult::ok(),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SetAxis { category, axis } => {
|
||||||
|
let ax = match axis.to_lowercase().as_str() {
|
||||||
|
"row" | "rows" => Axis::Row,
|
||||||
|
"column" | "col" | "columns" => Axis::Column,
|
||||||
|
"page" | "pages" | "filter" => Axis::Page,
|
||||||
|
other => return CommandResult::err(format!("Unknown axis '{other}'")),
|
||||||
|
};
|
||||||
|
match model.active_view_mut() {
|
||||||
|
Some(view) => { view.set_axis(category, ax); CommandResult::ok() }
|
||||||
|
None => CommandResult::err("No active view"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SetPageSelection { category, item } => {
|
||||||
|
match model.active_view_mut() {
|
||||||
|
Some(view) => { view.set_page_selection(category, item); CommandResult::ok() }
|
||||||
|
None => CommandResult::err("No active view"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ToggleGroup { category, group } => {
|
||||||
|
match model.active_view_mut() {
|
||||||
|
Some(view) => { view.toggle_group_collapse(category, group); CommandResult::ok() }
|
||||||
|
None => CommandResult::err("No active view"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Save { path } => {
|
||||||
|
match persistence::save(model, std::path::Path::new(path)) {
|
||||||
|
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Load { path } => {
|
||||||
|
match persistence::load(std::path::Path::new(path)) {
|
||||||
|
Ok(loaded) => {
|
||||||
|
*model = loaded;
|
||||||
|
CommandResult::ok_msg(format!("Loaded from {path}"))
|
||||||
|
}
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ExportCsv { path } => {
|
||||||
|
let view_name = model.active_view.clone();
|
||||||
|
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
|
||||||
|
Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ImportJson { path, model_name, array_path } => {
|
||||||
|
import_json_headless(model, path, model_name.as_deref(), array_path.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_json_headless(
|
||||||
|
model: &mut Model,
|
||||||
|
path: &str,
|
||||||
|
model_name: Option<&str>,
|
||||||
|
array_path: Option<&str>,
|
||||||
|
) -> CommandResult {
|
||||||
|
let content = match std::fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
|
||||||
|
};
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
|
||||||
|
match extract_array_at_path(&value, ap) {
|
||||||
|
Some(arr) => arr.clone(),
|
||||||
|
None => return CommandResult::err(format!("No array at path '{ap}'")),
|
||||||
|
}
|
||||||
|
} else if let Some(arr) = value.as_array() {
|
||||||
|
arr.clone()
|
||||||
|
} else {
|
||||||
|
// Find first array
|
||||||
|
let paths = crate::import::analyzer::find_array_paths(&value);
|
||||||
|
if let Some(first) = paths.first() {
|
||||||
|
match extract_array_at_path(&value, first) {
|
||||||
|
Some(arr) => arr.clone(),
|
||||||
|
None => return CommandResult::err("Could not extract records array"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CommandResult::err("No array found in JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let proposals = analyze_records(&records);
|
||||||
|
|
||||||
|
// Auto-accept all and build
|
||||||
|
let wizard = crate::import::wizard::ImportWizard {
|
||||||
|
state: crate::import::wizard::WizardState::NameModel,
|
||||||
|
raw: value,
|
||||||
|
array_paths: vec![],
|
||||||
|
selected_path: array_path.unwrap_or("").to_string(),
|
||||||
|
records,
|
||||||
|
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
|
||||||
|
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
||||||
|
cursor: 0,
|
||||||
|
message: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match wizard.build_model() {
|
||||||
|
Ok(new_model) => {
|
||||||
|
*model = new_model;
|
||||||
|
CommandResult::ok_msg("JSON imported successfully")
|
||||||
|
}
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/command/mod.rs
Normal file
12
src/command/mod.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//! Command layer — all model mutations go through this layer so they can be
|
||||||
|
//! replayed, scripted, and tested without the TUI.
|
||||||
|
//!
|
||||||
|
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
|
||||||
|
//! The headless CLI (--cmd / --script) routes through here, and the TUI
|
||||||
|
//! App also calls dispatch() for every user action that mutates state.
|
||||||
|
|
||||||
|
pub mod dispatch;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use types::{Command, CommandResult};
|
||||||
|
pub use dispatch::dispatch;
|
||||||
99
src/command/types.rs
Normal file
99
src/command/types.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// All commands that can mutate a Model.
|
||||||
|
///
|
||||||
|
/// Serialized as `{"op": "<variant>", ...rest}` where `rest` contains
|
||||||
|
/// the variant's fields flattened into the same JSON object.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "op")]
|
||||||
|
pub enum Command {
|
||||||
|
/// Add a category (dimension).
|
||||||
|
AddCategory { name: String },
|
||||||
|
|
||||||
|
/// Add an item to a category.
|
||||||
|
AddItem { category: String, item: String },
|
||||||
|
|
||||||
|
/// Add an item inside a named group.
|
||||||
|
AddItemInGroup { category: String, item: String, group: String },
|
||||||
|
|
||||||
|
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
|
||||||
|
SetCell {
|
||||||
|
coords: Vec<[String; 2]>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
value: CellValueArg,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Clear a cell.
|
||||||
|
ClearCell { coords: Vec<[String; 2]> },
|
||||||
|
|
||||||
|
/// Add or replace a formula.
|
||||||
|
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
|
||||||
|
/// `target_category` names the category that owns the formula target.
|
||||||
|
AddFormula { raw: String, target_category: String },
|
||||||
|
|
||||||
|
/// Remove a formula by its target name.
|
||||||
|
RemoveFormula { target: String },
|
||||||
|
|
||||||
|
/// Create a new view.
|
||||||
|
CreateView { name: String },
|
||||||
|
|
||||||
|
/// Delete a view.
|
||||||
|
DeleteView { name: String },
|
||||||
|
|
||||||
|
/// Switch the active view.
|
||||||
|
SwitchView { name: String },
|
||||||
|
|
||||||
|
/// Set the axis of a category in the active view.
|
||||||
|
/// axis: "row" | "column" | "page"
|
||||||
|
SetAxis { category: String, axis: String },
|
||||||
|
|
||||||
|
/// Set the page-axis selection for a category.
|
||||||
|
SetPageSelection { category: String, item: String },
|
||||||
|
|
||||||
|
/// Toggle collapse of a group in the active view.
|
||||||
|
ToggleGroup { category: String, group: String },
|
||||||
|
|
||||||
|
/// Save the model to a file path.
|
||||||
|
Save { path: String },
|
||||||
|
|
||||||
|
/// Load a model from a file path (replaces current model).
|
||||||
|
Load { path: String },
|
||||||
|
|
||||||
|
/// Export the active view to CSV.
|
||||||
|
ExportCsv { path: String },
|
||||||
|
|
||||||
|
/// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals).
|
||||||
|
ImportJson {
|
||||||
|
path: String,
|
||||||
|
model_name: Option<String>,
|
||||||
|
/// Dot-path to the records array (empty = root)
|
||||||
|
array_path: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline value for SetCell
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum CellValueArg {
|
||||||
|
Number { number: f64 },
|
||||||
|
Text { text: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommandResult {
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandResult {
|
||||||
|
pub fn ok() -> Self {
|
||||||
|
Self { ok: true, message: None }
|
||||||
|
}
|
||||||
|
pub fn ok_msg(msg: impl Into<String>) -> Self {
|
||||||
|
Self { ok: true, message: Some(msg.into()) }
|
||||||
|
}
|
||||||
|
pub fn err(msg: impl Into<String>) -> Self {
|
||||||
|
Self { ok: false, message: Some(msg.into()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/formula/ast.rs
Normal file
58
src/formula/ast.rs
Normal 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
5
src/formula/mod.rs
Normal 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
306
src/formula/parser.rs
Normal 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
160
src/import/analyzer.rs
Normal 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
5
src/import/mod.rs
Normal 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
246
src/import/wizard.rs
Normal 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
380
src/main.rs
Normal 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
121
src/model/category.rs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub type CategoryId = usize;
|
||||||
|
pub type ItemId = usize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub name: String,
|
||||||
|
/// Parent group name, if any
|
||||||
|
pub group: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||||
|
Self { id, name: name.into(), group: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||||
|
self.group = Some(group.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Group {
|
||||||
|
pub name: String,
|
||||||
|
/// Parent group name for nested hierarchies
|
||||||
|
pub parent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self { name: name.into(), parent: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_parent(mut self, parent: impl Into<String>) -> Self {
|
||||||
|
self.parent = Some(parent.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Category {
|
||||||
|
pub id: CategoryId,
|
||||||
|
pub name: String,
|
||||||
|
/// Items in insertion order
|
||||||
|
pub items: IndexMap<String, Item>,
|
||||||
|
/// Named groups (hierarchy nodes)
|
||||||
|
pub groups: Vec<Group>,
|
||||||
|
/// Next item id counter
|
||||||
|
next_item_id: ItemId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Category {
|
||||||
|
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
items: IndexMap::new(),
|
||||||
|
groups: Vec::new(),
|
||||||
|
next_item_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
|
||||||
|
let name = name.into();
|
||||||
|
if let Some(item) = self.items.get(&name) {
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
let id = self.next_item_id;
|
||||||
|
self.next_item_id += 1;
|
||||||
|
self.items.insert(name.clone(), Item::new(id, name));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_item_in_group(&mut self, name: impl Into<String>, group: impl Into<String>) -> ItemId {
|
||||||
|
let name = name.into();
|
||||||
|
let group = group.into();
|
||||||
|
if let Some(item) = self.items.get(&name) {
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
let id = self.next_item_id;
|
||||||
|
self.next_item_id += 1;
|
||||||
|
self.items.insert(name.clone(), Item::new(id, name).with_group(group));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_group(&mut self, group: Group) {
|
||||||
|
if !self.groups.iter().any(|g| g.name == group.name) {
|
||||||
|
self.groups.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_by_name(&self, name: &str) -> Option<&Item> {
|
||||||
|
self.items.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_index(&self, name: &str) -> Option<usize> {
|
||||||
|
self.items.get_index_of(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns item names in order, grouped hierarchically
|
||||||
|
pub fn ordered_item_names(&self) -> Vec<&str> {
|
||||||
|
self.items.keys().map(|s| s.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns unique group names at the top level
|
||||||
|
pub fn top_level_groups(&self) -> Vec<&str> {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for item in self.items.values() {
|
||||||
|
if let Some(g) = &item.group {
|
||||||
|
if !seen.contains(&g.as_str()) {
|
||||||
|
seen.push(g.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/model/cell.rs
Normal file
158
src/model/cell.rs
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||||
|
/// Sorted by category name for canonical form.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct CellKey(pub Vec<(String, String)>);
|
||||||
|
|
||||||
|
impl CellKey {
|
||||||
|
pub fn new(mut coords: Vec<(String, String)>) -> Self {
|
||||||
|
coords.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
Self(coords)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, category: &str) -> Option<&str> {
|
||||||
|
self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with(mut self, category: impl Into<String>, item: impl Into<String>) -> Self {
|
||||||
|
let cat = category.into();
|
||||||
|
let itm = item.into();
|
||||||
|
if let Some(pos) = self.0.iter().position(|(c, _)| c == &cat) {
|
||||||
|
self.0[pos].1 = itm;
|
||||||
|
} else {
|
||||||
|
self.0.push((cat, itm));
|
||||||
|
self.0.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn without(&self, category: &str) -> Self {
|
||||||
|
Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||||
|
partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CellKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let parts: Vec<_> = self.0.iter().map(|(c, v)| format!("{c}={v}")).collect();
|
||||||
|
write!(f, "{{{}}}", parts.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum CellValue {
|
||||||
|
Number(f64),
|
||||||
|
Text(String),
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CellValue {
|
||||||
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
CellValue::Number(n) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
matches!(self, CellValue::Empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CellValue {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
CellValue::Number(n) => {
|
||||||
|
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||||
|
write!(f, "{}", *n as i64)
|
||||||
|
} else {
|
||||||
|
write!(f, "{n:.4}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CellValue::Text(s) => write!(f, "{s}"),
|
||||||
|
CellValue::Empty => write!(f, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CellValue {
|
||||||
|
fn default() -> Self {
|
||||||
|
CellValue::Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||||
|
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DataStore {
|
||||||
|
cells: HashMap<CellKey, CellValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for DataStore {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
use serde::ser::SerializeSeq;
|
||||||
|
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||||
|
for (k, v) in &self.cells {
|
||||||
|
seq.serialize_element(&(k, v))?;
|
||||||
|
}
|
||||||
|
seq.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for DataStore {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
||||||
|
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
|
||||||
|
Ok(DataStore { cells })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
||||||
|
if value.is_empty() {
|
||||||
|
self.cells.remove(&key);
|
||||||
|
} else {
|
||||||
|
self.cells.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &CellKey) -> &CellValue {
|
||||||
|
self.cells.get(key).unwrap_or(&CellValue::Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
||||||
|
self.cells.get_mut(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
|
||||||
|
&self.cells
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, key: &CellKey) {
|
||||||
|
self.cells.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum all cells matching partial coordinates
|
||||||
|
pub fn sum_matching(&self, partial: &[(String, String)]) -> f64 {
|
||||||
|
self.cells.iter()
|
||||||
|
.filter(|(key, _)| key.matches_partial(partial))
|
||||||
|
.filter_map(|(_, v)| v.as_f64())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All cells where partial coords match
|
||||||
|
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||||
|
self.cells.iter()
|
||||||
|
.filter(|(key, _)| key.matches_partial(partial))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/model/mod.rs
Normal file
7
src/model/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pub mod category;
|
||||||
|
pub mod cell;
|
||||||
|
pub mod model;
|
||||||
|
|
||||||
|
pub use category::{Category, CategoryId, Group, Item, ItemId};
|
||||||
|
pub use cell::{CellKey, CellValue, DataStore};
|
||||||
|
pub use model::Model;
|
||||||
250
src/model/model.rs
Normal file
250
src/model/model.rs
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
|
use super::category::{Category, CategoryId};
|
||||||
|
use super::cell::{CellKey, CellValue, DataStore};
|
||||||
|
use crate::formula::Formula;
|
||||||
|
use crate::view::View;
|
||||||
|
|
||||||
|
const MAX_CATEGORIES: usize = 12;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Model {
|
||||||
|
pub name: String,
|
||||||
|
pub categories: IndexMap<String, Category>,
|
||||||
|
pub data: DataStore,
|
||||||
|
pub formulas: Vec<Formula>,
|
||||||
|
pub views: IndexMap<String, View>,
|
||||||
|
pub active_view: String,
|
||||||
|
next_category_id: CategoryId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
let name = name.into();
|
||||||
|
let default_view = View::new("Default");
|
||||||
|
let mut views = IndexMap::new();
|
||||||
|
views.insert("Default".to_string(), default_view);
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
categories: IndexMap::new(),
|
||||||
|
data: DataStore::new(),
|
||||||
|
formulas: Vec::new(),
|
||||||
|
views,
|
||||||
|
active_view: "Default".to_string(),
|
||||||
|
next_category_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
|
let name = name.into();
|
||||||
|
if self.categories.len() >= MAX_CATEGORIES {
|
||||||
|
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
|
||||||
|
}
|
||||||
|
if self.categories.contains_key(&name) {
|
||||||
|
return Ok(self.categories[&name].id);
|
||||||
|
}
|
||||||
|
let id = self.next_category_id;
|
||||||
|
self.next_category_id += 1;
|
||||||
|
self.categories.insert(name.clone(), Category::new(id, name.clone()));
|
||||||
|
// Add to all views
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.on_category_added(&name);
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
|
||||||
|
self.categories.get_mut(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category(&self, name: &str) -> Option<&Category> {
|
||||||
|
self.categories.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cell(&mut self, key: CellKey, value: CellValue) {
|
||||||
|
self.data.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cell(&self, key: &CellKey) -> &CellValue {
|
||||||
|
self.data.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_formula(&mut self, formula: Formula) {
|
||||||
|
// Replace if same target
|
||||||
|
if let Some(pos) = self.formulas.iter().position(|f| f.target == formula.target) {
|
||||||
|
self.formulas[pos] = formula;
|
||||||
|
} else {
|
||||||
|
self.formulas.push(formula);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_formula(&mut self, target: &str) {
|
||||||
|
self.formulas.retain(|f| f.target != target);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_view(&self) -> Option<&View> {
|
||||||
|
self.views.get(&self.active_view)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_view_mut(&mut self) -> Option<&mut View> {
|
||||||
|
self.views.get_mut(&self.active_view)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
|
||||||
|
let name = name.into();
|
||||||
|
let mut view = View::new(name.clone());
|
||||||
|
// Copy category assignments from default if any
|
||||||
|
for cat_name in self.categories.keys() {
|
||||||
|
view.on_category_added(cat_name);
|
||||||
|
}
|
||||||
|
self.views.insert(name.clone(), view);
|
||||||
|
self.views.get_mut(&name).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch_view(&mut self, name: &str) -> Result<()> {
|
||||||
|
if self.views.contains_key(name) {
|
||||||
|
self.active_view = name.to_string();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("View '{name}' not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_view(&mut self, name: &str) -> Result<()> {
|
||||||
|
if self.views.len() <= 1 {
|
||||||
|
return Err(anyhow!("Cannot delete the last view"));
|
||||||
|
}
|
||||||
|
self.views.shift_remove(name);
|
||||||
|
if self.active_view == name {
|
||||||
|
self.active_view = self.views.keys().next().unwrap().clone();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all category names
|
||||||
|
pub fn category_names(&self) -> Vec<&str> {
|
||||||
|
self.categories.keys().map(|s| s.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a computed value at a given key, considering formulas
|
||||||
|
pub fn evaluate(&self, key: &CellKey) -> CellValue {
|
||||||
|
// Check if the last category dimension in the key corresponds to a formula target
|
||||||
|
for formula in &self.formulas {
|
||||||
|
if let Some(item_val) = key.get(&formula.target_category) {
|
||||||
|
if item_val == formula.target {
|
||||||
|
return self.eval_formula(formula, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.data.get(key).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> CellValue {
|
||||||
|
use crate::formula::{Expr, AggFunc};
|
||||||
|
|
||||||
|
// Check WHERE filter first
|
||||||
|
if let Some(filter) = &formula.filter {
|
||||||
|
if let Some(item_val) = context.get(&filter.category) {
|
||||||
|
if item_val != filter.item.as_str() {
|
||||||
|
return self.data.get(context).clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_item_category<'a>(model: &'a Model, item_name: &str) -> Option<&'a str> {
|
||||||
|
for (cat_name, cat) in &model.categories {
|
||||||
|
if cat.items.contains_key(item_name) {
|
||||||
|
return Some(cat_name.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_expr(
|
||||||
|
expr: &Expr,
|
||||||
|
context: &CellKey,
|
||||||
|
model: &Model,
|
||||||
|
target_category: &str,
|
||||||
|
) -> Option<f64> {
|
||||||
|
match expr {
|
||||||
|
Expr::Number(n) => Some(*n),
|
||||||
|
Expr::Ref(name) => {
|
||||||
|
let cat = find_item_category(model, name).unwrap_or(name);
|
||||||
|
let new_key = context.clone().with(cat, name);
|
||||||
|
model.evaluate(&new_key).as_f64()
|
||||||
|
}
|
||||||
|
Expr::BinOp(op, l, r) => {
|
||||||
|
let lv = eval_expr(l, context, model, target_category)?;
|
||||||
|
let rv = eval_expr(r, context, model, target_category)?;
|
||||||
|
Some(match op.as_str() {
|
||||||
|
"+" => lv + rv,
|
||||||
|
"-" => lv - rv,
|
||||||
|
"*" => lv * rv,
|
||||||
|
"/" => if rv == 0.0 { 0.0 } else { lv / rv },
|
||||||
|
"^" => lv.powf(rv),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?),
|
||||||
|
Expr::Agg(func, _inner, _filter) => {
|
||||||
|
let partial = context.without(target_category);
|
||||||
|
let values: Vec<f64> = model.data.matching_cells(&partial.0)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, v)| v.as_f64())
|
||||||
|
.collect();
|
||||||
|
match func {
|
||||||
|
AggFunc::Sum => Some(values.iter().sum()),
|
||||||
|
AggFunc::Avg => {
|
||||||
|
if values.is_empty() { None }
|
||||||
|
else { Some(values.iter().sum::<f64>() / values.len() as f64) }
|
||||||
|
}
|
||||||
|
AggFunc::Min => values.iter().cloned().reduce(f64::min),
|
||||||
|
AggFunc::Max => values.iter().cloned().reduce(f64::max),
|
||||||
|
AggFunc::Count => Some(values.len() as f64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::If(cond, then, else_) => {
|
||||||
|
let cv = eval_bool(cond, context, model, target_category)?;
|
||||||
|
if cv {
|
||||||
|
eval_expr(then, context, model, target_category)
|
||||||
|
} else {
|
||||||
|
eval_expr(else_, context, model, target_category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_bool(
|
||||||
|
expr: &Expr,
|
||||||
|
context: &CellKey,
|
||||||
|
model: &Model,
|
||||||
|
target_category: &str,
|
||||||
|
) -> Option<bool> {
|
||||||
|
match expr {
|
||||||
|
Expr::BinOp(op, l, r) => {
|
||||||
|
let lv = eval_expr(l, context, model, target_category)?;
|
||||||
|
let rv = eval_expr(r, context, model, target_category)?;
|
||||||
|
Some(match op.as_str() {
|
||||||
|
"=" | "==" => (lv - rv).abs() < 1e-10,
|
||||||
|
"!=" => (lv - rv).abs() >= 1e-10,
|
||||||
|
"<" => lv < rv,
|
||||||
|
">" => lv > rv,
|
||||||
|
"<=" => lv <= rv,
|
||||||
|
">=" => lv >= rv,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match eval_expr(&formula.expr, context, self, &formula.target_category) {
|
||||||
|
Some(n) => CellValue::Number(n),
|
||||||
|
None => CellValue::Empty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
137
src/persistence/mod.rs
Normal file
137
src/persistence/mod.rs
Normal 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
657
src/ui/app.rs
Normal 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 == ¤t).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 == ¤t).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
98
src/ui/category_panel.rs
Normal 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
76
src/ui/formula_panel.rs
Normal 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
321
src/ui/grid.rs
Normal 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
77
src/ui/help.rs
Normal 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
147
src/ui/import_wizard_ui.rs
Normal 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
10
src/ui/mod.rs
Normal 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
82
src/ui/tile_bar.rs
Normal 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
62
src/ui/view_panel.rs
Normal 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
21
src/view/axis.rs
Normal 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
5
src/view/mod.rs
Normal 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
127
src/view/view.rs
Normal 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
22
tests/smoke.jsonl
Normal 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"}
|
||||||
Reference in New Issue
Block a user