15 Commits

Author SHA1 Message Date
ef79a39721 Add CSV import functionality
- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support

All 201 tests pass.
2026-04-01 01:32:19 -07:00
9fc3f0b5d6 refactor: synthesize previous refactors 2026-04-01 01:01:19 -07:00
3f84ba03cb Revert "refactor: mystery model 3"
This reverts commit 4b721f7543.
2026-04-01 00:46:55 -07:00
4b721f7543 refactor: mystery model 3 2026-04-01 00:46:25 -07:00
6d88de3020 Revert "refactor: mystery model #2"
This reverts commit 87fd6a1620.
2026-04-01 00:41:25 -07:00
87fd6a1620 refactor: mystery model #2 2026-04-01 00:40:22 -07:00
a57d3ed294 Revert "refactor: mystery model #1"
This reverts commit bbebc3344c.
2026-04-01 00:32:12 -07:00
bbebc3344c refactor: mystery model #1 2026-04-01 00:32:07 -07:00
ff08e3c2c2 chore: Revert refactors to give claude a clean slate 2026-04-01 00:26:55 -07:00
8c84256ebc refactor: merge using claude sonnet 2026-04-01 00:25:19 -07:00
d915908354 refactor: unsloth/Qwen3-Coder-Next-GGUF:Q5_K_M refactors the drawing helper 2026-04-01 00:20:19 -07:00
7731c7ceab Revert "refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M"
This reverts commit 98d151f345.
2026-03-31 23:11:21 -07:00
98d151f345 refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M 2026-03-31 23:10:52 -07:00
f1e6e61bca Revert "test: use gpt-oss-20b to do some minor refactoring"
This reverts commit bbd1f48b78.
2026-03-31 22:50:10 -07:00
bbd1f48b78 test: use gpt-oss-20b to do some minor refactoring 2026-03-31 22:50:07 -07:00
37 changed files with 15470 additions and 7602 deletions

1
.envrc
View File

@ -1,2 +1 @@
use flake
unset TMPDIR

5
.gitignore vendored
View File

@ -3,8 +3,3 @@ target/
.DS_Store
/result
.direnv
[#]*
symbols.json
profile.json
profile.json.gz
bench/*.txt

115
Cargo.lock generated
View File

@ -23,56 +23,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@ -157,52 +107,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
@ -490,7 +394,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"crossterm",
"csv",
"dirs",
@ -539,12 +442,6 @@ dependencies = [
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@ -670,12 +567,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -1149,12 +1040,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wait-timeout"
version = "0.2.1"

View File

@ -22,7 +22,6 @@ flate2 = "1"
unicode-width = "0.2"
dirs = "5"
csv = "1"
clap = { version = "4.6.0", features = ["derive"] }
[dev-dependencies]
proptest = "1"
@ -33,8 +32,3 @@ opt-level = 3
lto = true
codegen-units = 1
strip = true
[profile.profiling]
inherits = "release"
strip = false
debug = 2

View File

@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Generate a profiling workload script for improvise.
Usage:
python3 bench/gen_workload.py [--scale N] > bench/large_workload.txt
cargo build --release
time ./target/release/improvise script bench/large_workload.txt
For flamegraph profiling:
samply record ./target/release/improvise script bench/large_workload.txt
"""
import argparse
import random
parser = argparse.ArgumentParser()
parser.add_argument("--scale", type=int, default=1,
help="Scale factor (1=small, 5=medium, 10=large)")
parser.add_argument("--density", type=float, default=0.3,
help="Cell density (0.0-1.0)")
parser.add_argument("--exports", type=int, default=0,
help="Number of export passes (0 = one per month)")
args = parser.parse_args()
random.seed(42)
S = args.scale
n_regions = 5 * S
n_products = 8 * S
n_months = 12
n_channels = 4 + S
measures = ["Revenue", "Cost", "Units"]
regions = [f"R{i:03d}" for i in range(n_regions)]
products = [f"P{i:03d}" for i in range(n_products)]
months = [f"M{i:02d}" for i in range(1, n_months + 1)]
channels = [f"Ch{i:02d}" for i in range(n_channels)]
potential = n_regions * n_products * n_months * n_channels * len(measures)
print(f"# Scale={S}, Density={args.density}")
print(f"# {n_regions} regions × {n_products} products × {n_months} months × {n_channels} channels × {len(measures)} measures")
print(f"# Potential cells: {potential}, Expected: ~{int(potential * args.density)}")
print()
for cat in ["Region", "Product", "Month", "Channel", "Measure"]:
print(f"add-category {cat}")
for items, cat in [(regions, "Region"), (products, "Product"),
(months, "Month"), (channels, "Channel"),
(measures, "Measure")]:
for item in items:
print(f"add-item {cat} {item}")
print("set-axis Region row")
print("set-axis Product column")
print("set-axis Month page")
print("set-axis Channel none")
print("set-axis Measure none")
n = 0
for r in regions:
for p in products:
for m in months:
for c in channels:
if random.random() < args.density:
rev = random.randint(100, 10000)
cost = random.randint(50, rev)
units = random.randint(1, 500)
print(f"set-cell {rev} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Revenue")
print(f"set-cell {cost} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Cost")
print(f"set-cell {units} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Units")
n += 3
print(f"# Total cells: {n}")
print('add-formula Measure "Profit = Revenue - Cost"')
print('add-formula Measure "Margin = Profit / Revenue"')
print('add-formula Measure "AvgPrice = Revenue / Units"')
n_exports = args.exports if args.exports > 0 else n_months
for i, m in enumerate(months[:n_exports]):
print(f"set-page Month {m} . export-csv /tmp/improvise_bench_{i:02d}.csv")
print("# Done")

260
flake.lock generated
View File

@ -1,111 +1,5 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"crate2nix"
],
"flake-compat": [
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix",
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": "nixpkgs_2",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1774369503,
"narHash": "sha256-YeCF4iBhlvTqkn4mihjZgixnDcEVgfyQlNeBsbLYUgQ=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "b873ca53dd64e12340416f0fd5e3b33792b9c17b",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -124,128 +18,7 @@
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"crate2nix",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"crate2nix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1769433173,
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1774709303,
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
@ -261,7 +34,7 @@
"type": "github"
}
},
"nixpkgs_4": {
"nixpkgs_2": {
"locked": {
"lastModified": 1774794121,
"narHash": "sha256-gih24b728CK8twDNU7VX9vVYK2tLEXvy9gm/GKq2VeE=",
@ -277,43 +50,16 @@
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_2",
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"crate2nix": "crate2nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_3",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_4"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1774926780,

View File

@ -7,7 +7,6 @@
url = "github:oxalica/rust-overlay";
};
flake-utils.url = "github:numtide/flake-utils";
crate2nix.url = "github:nix-community/crate2nix";
};
outputs = {
@ -15,37 +14,65 @@
nixpkgs,
rust-overlay,
flake-utils,
crate2nix,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;};
isLinux = pkgs.lib.hasInfix "linux" system;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "clippy" "rustfmt"];
};
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
name = "improvise";
src = ./.;
};
cargoNix = import generatedCargoNix {
pkgs = pkgs;
targets = pkgs.lib.optionals isLinux ["x86_64-unknown-linux-musl"];
};
in {
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
pkgs.rust-analyzer
crate2nix.packages.${system}.default
];
RUST_BACKTRACE = "1";
};
devShells.default = pkgs.mkShell ({
nativeBuildInputs =
[
rustToolchain
pkgs.pkg-config
pkgs.rust-analyzer
]
++ pkgs.lib.optionals isLinux [
# 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.
pkgs.pkgsMusl.stdenv.cc
];
packages.default = cargoNix.rootCrate.build;
RUST_BACKTRACE = "1";
}
// pkgs.lib.optionalAttrs isLinux {
# 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 = "${pkgs.pkgsMusl.stdenv.cc}/bin/cc";
# Default build target: static musl binary.
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
});
packages.default =
if isLinux
then
(pkgs.pkgsMusl.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
}).buildRustPackage {
pname = "improvise";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
}
else
(pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
}).buildRustPackage {
pname = "improvise";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
};
});
}

12789
llama-server.log Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

249
src/command/dispatch.rs Normal file
View File

@ -0,0 +1,249 @@
use super::types::{CellValueArg, Command, CommandResult};
use crate::formula::parse_formula;
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::persistence;
/// 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();
// Validate all categories exist before mutating anything
for (cat_name, _) in &kv {
if model.category(cat_name).is_none() {
return CommandResult::err(format!("Category '{cat_name}' not found"));
}
}
// Ensure items exist within their categories
for (cat_name, item_name) in &kv {
model.category_mut(cat_name).unwrap().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.clear_cell(&key);
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,
target_category,
} => {
model.remove_formula(target, target_category);
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 } => {
model.active_view_mut().set_axis(category, *axis);
CommandResult::ok()
}
Command::SetPageSelection { category, item } => {
model.active_view_mut().set_page_selection(category, item);
CommandResult::ok()
}
Command::ToggleGroup { category, group } => {
model
.active_view_mut()
.toggle_group_collapse(category, group);
CommandResult::ok()
}
Command::HideItem { category, item } => {
model.active_view_mut().hide_item(category, item);
CommandResult::ok()
}
Command::ShowItem { category, item } => {
model.active_view_mut().show_item(category, item);
CommandResult::ok()
}
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(mut loaded) => {
loaded.normalize_view_state();
*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_headless(model, path, model_name.as_deref(), array_path.as_deref()),
}
}
fn import_headless(
model: &mut Model,
path: &str,
model_name: Option<&str>,
array_path: Option<&str>,
) -> CommandResult {
let is_csv = path.ends_with(".csv");
let records = if is_csv {
// Parse CSV file
match crate::import::csv_parser::parse_csv(path) {
Ok(recs) => recs,
Err(e) => return CommandResult::err(e.to_string()),
}
} else {
// Parse JSON file
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}")),
};
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 {
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);
// Build via ImportPipeline
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
// For JSON, we need the original parsed value
// Re-read and parse to get it (or pass it up from above)
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = crate::import::wizard::ImportPipeline {
raw,
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(),
};
match pipeline.build_model() {
Ok(new_model) => {
*model = new_model;
CommandResult::ok_msg("Imported successfully")
}
Err(e) => CommandResult::err(e.to_string()),
}
}

View File

@ -1,612 +0,0 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyModifiers};
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use super::cmd::{self, CmdContext, CmdRegistry};
// `cmd` module imported for `default_registry()` in default_keymaps()
/// A key pattern that can be matched against a KeyEvent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyPattern {
/// Single key with modifiers
Key(KeyCode, KeyModifiers),
/// Matches any Char key (for text-entry modes).
AnyChar,
/// Matches any key at all (lowest priority fallback).
Any,
}
/// Identifies which mode a binding applies to.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey {
Normal,
Help,
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
Editing,
FormulaEdit,
CategoryAdd,
ItemAdd,
ExportPrompt,
CommandMode,
SearchMode,
ImportWizard,
}
impl ModeKey {
pub fn from_app_mode(mode: &AppMode, search_mode: bool) -> Option<Self> {
match mode {
AppMode::Normal if search_mode => Some(ModeKey::SearchMode),
AppMode::Normal => Some(ModeKey::Normal),
AppMode::Help => Some(ModeKey::Help),
AppMode::FormulaPanel => Some(ModeKey::FormulaPanel),
AppMode::CategoryPanel => Some(ModeKey::CategoryPanel),
AppMode::ViewPanel => Some(ModeKey::ViewPanel),
AppMode::TileSelect => Some(ModeKey::TileSelect),
AppMode::Editing { .. } => Some(ModeKey::Editing),
AppMode::FormulaEdit { .. } => Some(ModeKey::FormulaEdit),
AppMode::CategoryAdd { .. } => Some(ModeKey::CategoryAdd),
AppMode::ItemAdd { .. } => Some(ModeKey::ItemAdd),
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
_ => None,
}
}
}
/// What a key binding resolves to.
#[derive(Debug, Clone)]
pub enum Binding {
/// A command name + arguments, looked up in the registry at dispatch time.
Cmd {
name: &'static str,
args: Vec<String>,
},
/// A prefix sub-keymap (Emacs-style).
Prefix(Arc<Keymap>),
}
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
#[derive(Default)]
pub struct Keymap {
bindings: HashMap<KeyPattern, Binding>,
}
impl fmt::Debug for Keymap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Keymap")
.field("binding_count", &self.bindings.len())
.finish()
}
}
impl Keymap {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
/// Bind a key to a command name (no args).
pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, name: &'static str) {
self.bindings.insert(
KeyPattern::Key(key, mods),
Binding::Cmd { name, args: vec![] },
);
}
/// Bind a key to a command name with arguments.
pub fn bind_args(
&mut self,
key: KeyCode,
mods: KeyModifiers,
name: &'static str,
args: Vec<String>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Cmd { name, args });
}
/// Bind a prefix key that activates a sub-keymap.
pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
}
/// Bind a catch-all for any Char key.
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
self.bindings
.insert(KeyPattern::AnyChar, Binding::Cmd { name, args });
}
/// Bind a catch-all for any key at all.
pub fn bind_any(&mut self, name: &'static str) {
self.bindings
.insert(KeyPattern::Any, Binding::Cmd { name, args: vec![] });
}
/// Look up the binding for a key.
/// For Char keys, if exact (key, mods) match fails, retries with NONE
/// modifiers since terminals vary in whether they send SHIFT for
/// uppercase/symbol characters.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
.get(&KeyPattern::Key(key, mods))
.or_else(|| {
// Retry Char keys without modifiers (shift is implicit in the char)
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
self.bindings
.get(&KeyPattern::Key(key, KeyModifiers::NONE))
} else {
None
}
})
.or_else(|| {
if matches!(key, KeyCode::Char(_)) {
self.bindings.get(&KeyPattern::AnyChar)
} else {
None
}
})
.or_else(|| self.bindings.get(&KeyPattern::Any))
}
/// Dispatch a key: look up binding, resolve through registry, return effects.
pub fn dispatch(
&self,
registry: &CmdRegistry,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let binding = self.lookup(key, mods)?;
match binding {
Binding::Cmd { name, args } => {
let cmd = registry.interactive(name, args, ctx).ok()?;
Some(cmd.execute(ctx))
}
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
}
}
}
/// Effect that sets the transient keymap on the App.
#[derive(Debug)]
pub struct SetTransientKeymap(pub Arc<Keymap>);
impl Effect for SetTransientKeymap {
fn apply(&self, app: &mut crate::ui::app::App) {
app.transient_keymap = Some(self.0.clone());
}
}
/// Maps modes to their root keymaps + owns the command registry.
pub struct KeymapSet {
mode_maps: HashMap<ModeKey, Arc<Keymap>>,
registry: CmdRegistry,
}
impl KeymapSet {
pub fn new(registry: CmdRegistry) -> Self {
Self {
mode_maps: HashMap::new(),
registry,
}
}
pub fn insert(&mut self, mode: ModeKey, keymap: Arc<Keymap>) {
self.mode_maps.insert(mode, keymap);
}
/// Dispatch a key event: returns effects if a binding matched.
pub fn dispatch(
&self,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let mode_key = ModeKey::from_app_mode(ctx.mode, ctx.search_mode)?;
let keymap = self.mode_maps.get(&mode_key)?;
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Dispatch against a specific keymap (for transient/prefix keymaps).
pub fn dispatch_transient(
&self,
keymap: &Keymap,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Build the default keymap set with all bindings.
pub fn default_keymaps() -> Self {
let registry = cmd::default_registry();
let mut set = Self::new(registry);
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
// ── Normal mode ──────────────────────────────────────────────────
let mut normal = Keymap::new();
// Navigation
for (key, dr, dc) in [
(KeyCode::Up, -1, 0),
(KeyCode::Down, 1, 0),
(KeyCode::Left, 0, -1),
(KeyCode::Right, 0, 1),
] {
normal.bind_args(
key,
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
for (ch, dr, dc) in [('k', -1, 0), ('j', 1, 0), ('h', 0, -1), ('l', 0, 1)] {
normal.bind_args(
KeyCode::Char(ch),
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
// Jump to boundaries
normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-col");
// Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
// Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell");
normal.bind(KeyCode::Char('p'), none, "paste");
// View
normal.bind(KeyCode::Char('t'), none, "transpose");
// Mode changes
normal.bind(KeyCode::Char('q'), ctrl, "force-quit");
normal.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
normal.bind(KeyCode::Char('/'), none, "search");
normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_args(KeyCode::F(1), none, "enter-mode", vec!["help".into()]);
normal.bind_args(KeyCode::Char('?'), none, "enter-mode", vec!["help".into()]);
// Panel toggles
normal.bind_args(
KeyCode::Char('F'),
none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('V'),
none,
"toggle-panel-and-focus",
vec!["view".into()],
);
normal.bind_args(
KeyCode::Char('f'),
ctrl,
"toggle-panel-visibility",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('c'),
ctrl,
"toggle-panel-visibility",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('v'),
ctrl,
"toggle-panel-visibility",
vec!["view".into()],
);
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Editing entry
normal.bind(KeyCode::Char('i'), none, "enter-edit-mode");
normal.bind(KeyCode::Char('a'), none, "enter-edit-mode");
normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
// Search / category add
normal.bind_args(
KeyCode::Char('n'),
none,
"search-navigate",
vec!["forward".into()],
);
normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
// Page navigation
normal.bind(KeyCode::Char(']'), none, "page-next");
normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
// Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
normal.bind(KeyCode::Left, ctrl, "enter-tile-select");
normal.bind(KeyCode::Right, ctrl, "enter-tile-select");
normal.bind(KeyCode::Up, ctrl, "enter-tile-select");
normal.bind(KeyCode::Down, ctrl, "enter-tile-select");
// Prefix keys
let mut g_map = Keymap::new();
g_map.bind(KeyCode::Char('g'), none, "jump-first-row");
g_map.bind(KeyCode::Char('z'), none, "toggle-col-group-under-cursor");
normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map));
let mut y_map = Keymap::new();
y_map.bind(KeyCode::Char('y'), none, "yank");
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
let mut z_map = Keymap::new();
z_map.bind(KeyCode::Char('Z'), none, "save-and-quit");
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
set.insert(ModeKey::Normal, Arc::new(normal));
// ── Help mode ────────────────────────────────────────────────────
let mut help = Keymap::new();
help.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
help.bind_args(
KeyCode::Char('q'),
none,
"enter-mode",
vec!["normal".into()],
);
set.insert(ModeKey::Help, Arc::new(help));
// ── Formula panel ────────────────────────────────────────────────
let mut fp = Keymap::new();
fp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
fp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
fp.bind_args(
key,
none,
"move-panel-cursor",
vec!["formula".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
fp.bind_args(
key,
none,
"move-panel-cursor",
vec!["formula".into(), "1".into()],
);
}
fp.bind(KeyCode::Char('a'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('n'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel ───────────────────────────────────────────────
let mut cp = Keymap::new();
cp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
cp.bind_args(
key,
none,
"move-panel-cursor",
vec!["category".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
cp.bind_args(
key,
none,
"move-panel-cursor",
vec!["category".into(), "1".into()],
);
}
cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
cp.bind_args(
KeyCode::Char('n'),
none,
"enter-mode",
vec!["category-add".into()],
);
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
// ── View panel ───────────────────────────────────────────────────
let mut vp = Keymap::new();
vp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
vp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
vp.bind_args(
key,
none,
"move-panel-cursor",
vec!["view".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
vp.bind_args(
key,
none,
"move-panel-cursor",
vec!["view".into(), "1".into()],
);
}
vp.bind(KeyCode::Enter, none, "switch-view-at-cursor");
vp.bind(KeyCode::Char('n'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::new();
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_args(
KeyCode::Char('h'),
none,
"move-tile-cursor",
vec!["-1".into()],
);
ts.bind_args(KeyCode::Right, none, "move-tile-cursor", vec!["1".into()]);
ts.bind_args(
KeyCode::Char('l'),
none,
"move-tile-cursor",
vec!["1".into()],
);
ts.bind(KeyCode::Enter, none, "cycle-axis-for-tile");
ts.bind(KeyCode::Char(' '), none, "cycle-axis-for-tile");
ts.bind_args(
KeyCode::Char('r'),
none,
"set-axis-for-tile",
vec!["row".into()],
);
ts.bind_args(
KeyCode::Char('c'),
none,
"set-axis-for-tile",
vec!["column".into()],
);
ts.bind_args(
KeyCode::Char('p'),
none,
"set-axis-for-tile",
vec!["page".into()],
);
ts.bind_args(
KeyCode::Char('n'),
none,
"set-axis-for-tile",
vec!["none".into()],
);
set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Editing mode ─────────────────────────────────────────────────
let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind(KeyCode::Enter, none, "commit-cell-edit");
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed));
// ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new();
fe.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["formula-panel".into()],
);
fe.bind(KeyCode::Enter, none, "commit-formula");
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
fe.bind_any_char("append-char", vec!["formula".into()]);
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new();
ca.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["category-panel".into()],
);
ca.bind(KeyCode::Enter, none, "commit-category-add");
ca.bind(KeyCode::Tab, none, "commit-category-add");
ca.bind_args(
KeyCode::Backspace,
none,
"pop-char",
vec!["category".into()],
);
ca.bind_any_char("append-char", vec!["category".into()]);
set.insert(ModeKey::CategoryAdd, Arc::new(ca));
// ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new();
ia.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["category-panel".into()],
);
ia.bind(KeyCode::Enter, none, "commit-item-add");
ia.bind(KeyCode::Tab, none, "commit-item-add");
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
ia.bind_any_char("append-char", vec!["item".into()]);
set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new();
ep.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ep.bind(KeyCode::Enter, none, "commit-export");
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
ep.bind_any_char("append-char", vec!["export".into()]);
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new();
cm.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cm.bind(KeyCode::Enter, none, "execute-command");
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char("append-char", vec!["command".into()]);
set.insert(ModeKey::CommandMode, Arc::new(cm));
// ── Search mode ──────────────────────────────────────────────────
let mut sm = Keymap::new();
sm.bind(KeyCode::Esc, none, "exit-search-mode");
sm.bind(KeyCode::Enter, none, "exit-search-mode");
sm.bind(KeyCode::Backspace, none, "search-pop-char");
sm.bind_any_char("search-append-char", vec![]);
set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard ────────────────────────────────────────────────
let mut wiz = Keymap::new();
wiz.bind_any("handle-wizard-key");
set.insert(ModeKey::ImportWizard, Arc::new(wiz));
set
}
}

View File

@ -1,12 +1,12 @@
//! Command layer — all model mutations go through this layer so they can be
//! replayed, scripted, and tested without the TUI.
//!
//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`).
//! The headless CLI (--cmd / --script) parses quasi-lisp text into effects
//! and applies them directly.
//! 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 cmd;
pub mod keymap;
pub mod parse;
pub mod dispatch;
pub mod types;
pub use parse::parse_line;
pub use dispatch::dispatch;
pub use types::{Command, CommandResult};

View File

@ -1,184 +0,0 @@
//! Quasi-lisp prefix command parser.
//!
//! Syntax: `word arg1 arg2 ...`
//! Multiple commands on one line separated by `.`
//! Coordinate pairs use `/`: `Category/Item`
//! Quoted strings supported: `"Profit = Revenue - Cost"`
use super::cmd::{default_registry, Cmd, CmdRegistry};
/// Parse a line into commands using the default registry.
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let registry = default_registry();
parse_line_with(&registry, line)
}
/// Parse a line into commands using a given registry.
pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
return Ok(vec![]);
}
let mut commands = Vec::new();
for segment in split_on_dot(line) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let tokens = tokenize(segment);
if tokens.is_empty() {
continue;
}
let word = &tokens[0];
let args = &tokens[1..];
commands.push(registry.parse(word, args)?);
}
Ok(commands)
}
/// Split a line on ` . ` separators (dot must be a standalone word,
/// surrounded by whitespace or at line boundaries). Respects quoted strings.
fn split_on_dot(line: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let mut in_quote = false;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
match c {
'"' => in_quote = !in_quote,
'.' if !in_quote => {
let before_ws = i == 0 || bytes[i - 1].is_ascii_whitespace();
let after_ws = i + 1 >= bytes.len() || bytes[i + 1].is_ascii_whitespace();
if before_ws && after_ws {
segments.push(&line[start..i]);
start = i + 1;
}
}
_ => {}
}
}
segments.push(&line[start..]);
segments
}
/// Tokenize a command segment into words, handling quoted strings.
fn tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
chars.next(); // consume opening quote
let mut s = String::new();
for ch in chars.by_ref() {
if ch == '"' {
break;
}
s.push(ch);
}
tokens.push(s);
} else {
let mut s = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
break;
}
s.push(ch);
chars.next();
}
tokens.push(s);
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_add_category() {
let cmds = parse_line("add-category Region").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-category");
}
#[test]
fn parse_add_item() {
let cmds = parse_line("add-item Region East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-item");
}
#[test]
fn parse_set_cell_number() {
let cmds = parse_line("set-cell 100 Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_set_cell_text() {
let cmds = parse_line("set-cell hello Region/East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_multiple_commands_dot_separated() {
let cmds = parse_line("add-category Region . add-item Region East").unwrap();
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name(), "add-category");
assert_eq!(cmds[1].name(), "add-item");
}
#[test]
fn parse_quoted_string() {
let cmds = parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-formula");
}
#[test]
fn parse_set_axis() {
let cmds = parse_line("set-axis Payee row").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_set_axis_none() {
let cmds = parse_line("set-axis Date none").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_clear_cell() {
let cmds = parse_line("clear-cell Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "clear-cell");
}
#[test]
fn parse_comments_and_blank_lines() {
assert!(parse_line("").unwrap().is_empty());
assert!(parse_line("# comment").unwrap().is_empty());
assert!(parse_line("// comment").unwrap().is_empty());
}
#[test]
fn parse_unknown_command_errors() {
assert!(parse_line("frobnicate foo").is_err());
}
#[test]
fn parse_missing_args_errors() {
assert!(parse_line("add-category").is_err());
assert!(parse_line("set-cell 100").is_err());
}
}

124
src/command/types.rs Normal file
View File

@ -0,0 +1,124 @@
use crate::view::Axis;
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 and category.
RemoveFormula {
target: String,
target_category: 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.
SetAxis { category: String, axis: Axis },
/// 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 },
/// Hide an item in the active view.
HideItem { category: String, item: String },
/// Show (un-hide) an item in the active view.
ShowItem { category: String, item: 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()),
}
}
}

View File

@ -1,400 +0,0 @@
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use crate::model::Model;
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::CategoryPanel;
use crate::ui::formula_panel::FormulaPanel;
use crate::ui::grid::GridWidget;
use crate::ui::help::HelpWidget;
use crate::ui::import_wizard_ui::ImportWizardWidget;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewPanel;
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn fill_line(left: String, right: &str, width: u16) -> String {
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
format!("{left}{pad}{right}")
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w, h)
}
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup);
f.render_widget(block, popup);
inner
}
fn mode_name(mode: &AppMode) -> &'static str {
match mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
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 / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
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);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let file = app
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let grid_area;
if side_open {
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
grid_area = chunks[0];
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
a,
);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
a,
);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
a,
);
}
} else {
grid_area = area;
}
f.render_widget(
GridWidget::new(
&app.model,
&app.mode,
&app.search_query,
&app.buffers,
app.drill_state.as_ref(),
),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode, app.tile_cat_idx), area);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
match app.mode {
AppMode::CommandMode { .. } => {
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
draw_command_bar(f, area, buf);
}
_ => draw_status(f, area, app),
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
f.render_widget(
Paragraph::new(format!(":{buffer}"))
.style(Style::default().fg(Color::White).bg(Color::Black)),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let popup = centered_popup(area, 64, 3);
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -16,7 +16,7 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(parse_where).transpose()?;
let filter = filter.map(|w| parse_where(w)).transpose()?;
let expr = parse_expr(expr_str.trim())?;
@ -299,7 +299,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.eq_ignore_ascii_case("WHERE") {
if kw.to_ascii_uppercase() == "WHERE" {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => {

View File

@ -1,4 +1,3 @@
use chrono::{Datelike, NaiveDate};
use serde_json::Value;
use std::collections::HashSet;
@ -14,24 +13,12 @@ pub enum FieldKind {
Label,
}
/// Date components that can be extracted from a date field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateComponent {
Year,
Month,
Quarter,
}
#[derive(Debug, Clone)]
pub struct FieldProposal {
pub field: String,
pub kind: FieldKind,
pub distinct_values: Vec<String>,
pub accepted: bool,
/// Detected chrono format string (e.g., "%m/%d/%Y"). Only set for TimeCategory.
pub date_format: Option<String>,
/// Which date components to extract as new categories.
pub date_components: Vec<DateComponent>,
}
impl FieldProposal {
@ -45,55 +32,6 @@ impl FieldProposal {
}
}
/// Common date formats to try, in order of preference.
const DATE_FORMATS: &[&str] = &[
"%Y-%m-%d", // 2025-04-02
"%m/%d/%Y", // 04/02/2025
"%m/%d/%y", // 04/02/25
"%d/%m/%Y", // 02/04/2025
"%Y%m%d", // 20250402
"%b %d, %Y", // Apr 02, 2025
"%B %d, %Y", // April 02, 2025
"%d-%b-%Y", // 02-Apr-2025
];
/// Try to detect a chrono date format from sample values.
/// Returns the first format that successfully parses all non-empty samples.
pub fn detect_date_format(samples: &[&str]) -> Option<String> {
let samples: Vec<&str> = samples.iter().copied().filter(|s| !s.is_empty()).collect();
if samples.is_empty() {
return None;
}
// Try up to 10 samples for efficiency
let test_samples: Vec<&str> = samples.into_iter().take(10).collect();
for fmt in DATE_FORMATS {
if test_samples
.iter()
.all(|s| NaiveDate::parse_from_str(s, fmt).is_ok())
{
return Some(fmt.to_string());
}
}
None
}
/// Parse a date string and extract a component value.
pub fn extract_date_component(
value: &str,
format: &str,
component: DateComponent,
) -> Option<String> {
let date = NaiveDate::parse_from_str(value, format).ok()?;
Some(match component {
DateComponent::Year => format!("{}", date.format("%Y")),
DateComponent::Month => format!("{}", date.format("%Y-%m")),
DateComponent::Quarter => {
let q = (date.month0() / 3) + 1;
format!("{}-Q{}", date.format("%Y"), q)
}
})
}
const CATEGORY_THRESHOLD: usize = 20;
pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
@ -127,8 +65,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Measure,
distinct_values: vec![],
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -136,19 +72,26 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
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();
// Try chrono-based date detection
let samples: Vec<&str> = distinct_vec.iter().map(|s| s.as_str()).collect();
let date_format = detect_date_format(&samples);
// 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 date_format.is_some() {
if looks_like_date {
return FieldProposal {
field,
kind: FieldKind::TimeCategory,
distinct_values: distinct_vec,
accepted: true,
date_format,
date_components: vec![],
};
}
@ -158,8 +101,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Category,
distinct_values: distinct_vec,
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -168,8 +109,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Label,
distinct_values: distinct_vec,
accepted: false,
date_format: None,
date_components: vec![],
};
}
@ -179,8 +118,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Label,
distinct_values: vec![],
accepted: false,
date_format: None,
date_components: vec![],
}
})
.collect()
@ -223,70 +160,3 @@ fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>)
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_iso_date_format() {
let samples = vec!["2025-01-15", "2025-02-28", "2024-12-01"];
assert_eq!(detect_date_format(&samples), Some("%Y-%m-%d".to_string()));
}
#[test]
fn detect_us_date_format() {
let samples = vec!["03/31/2026", "01/15/2025", "12/25/2024"];
assert_eq!(detect_date_format(&samples), Some("%m/%d/%Y".to_string()));
}
#[test]
fn detect_short_year_format() {
// Two-digit years are ambiguous with four-digit format, so %m/%d/%Y
// matches first. This is expected — the user can override in the wizard.
let samples = vec!["03/31/26", "01/15/25"];
assert!(detect_date_format(&samples).is_some());
}
#[test]
fn detect_no_date_format() {
let samples = vec!["hello", "world"];
assert_eq!(detect_date_format(&samples), None);
}
#[test]
fn extract_year_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Year);
assert_eq!(result, Some("2026".to_string()));
}
#[test]
fn extract_month_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Month);
assert_eq!(result, Some("2026-03".to_string()));
}
#[test]
fn extract_quarter_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2026-Q1".to_string()));
}
#[test]
fn extract_quarter_q4() {
let result = extract_date_component("12/15/2025", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2025-Q4".to_string()));
}
#[test]
fn analyze_detects_time_category_with_format() {
let records: Vec<Value> = vec![
serde_json::json!({"Date": "01/15/2025", "Amount": 100}),
serde_json::json!({"Date": "02/20/2025", "Amount": 200}),
];
let proposals = analyze_records(&records);
let date_prop = proposals.iter().find(|p| p.field == "Date").unwrap();
assert_eq!(date_prop.kind, FieldKind::TimeCategory);
assert_eq!(date_prop.date_format, Some("%m/%d/%Y".to_string()));
}
}

View File

@ -1,22 +1,15 @@
use std::path::Path;
use anyhow::{Context, Result};
use csv::ReaderBuilder;
use serde_json::Value;
pub fn csv_path_p(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"))
}
/// Parse a CSV file and return records as serde_json::Value array
pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
pub fn parse_csv(path: &str) -> Result<Vec<Value>> {
let mut reader = ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.trim(csv::Trim::All)
.from_path(path)
.with_context(|| format!("Failed to open CSV file: {}", path.display()))?;
.with_context(|| format!("Failed to open CSV file: {path}"))?;
// Detect if first row looks like headers (strings) or data (mixed)
let has_headers = reader.headers().is_ok();
@ -56,28 +49,6 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
Ok(records)
}
/// Parse multiple CSV files and merge into a single JSON array.
/// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv").
pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<Vec<Value>> {
let mut all_records = Vec::new();
for path in paths {
let path = path.as_ref();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let records = parse_csv(path)?;
for mut record in records {
if let Value::Object(ref mut map) = record {
map.insert("File".to_string(), Value::String(stem.clone()));
}
all_records.push(record);
}
}
Ok(all_records)
}
fn parse_csv_field(field: &str) -> Value {
if field.is_empty() {
return Value::Null;
@ -101,29 +72,25 @@ fn parse_csv_field(field: &str) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, path::PathBuf};
use std::fs;
use tempfile::tempdir;
fn create_temp_csv(content: &str) -> (PathBuf, tempfile::TempDir) {
fn create_temp_csv(content: &str) -> (String, tempfile::TempDir) {
let dir = tempdir().unwrap();
let path = dir.path().join("test.csv");
fs::write(&path, content).unwrap();
(path, dir)
(path.to_string_lossy().to_string(), dir)
}
#[test]
fn parse_simple_csv() {
let (path, _dir) =
create_temp_csv("Region,Product,Revenue\nEast,Shirts,1000\nWest,Shirts,800");
let (path, _dir) = create_temp_csv("Region,Product,Revenue\nEast,Shirts,1000\nWest,Shirts,800");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
assert_eq!(records[0]["Product"], Value::String("Shirts".to_string()));
assert_eq!(
records[0]["Revenue"],
Value::Number(serde_json::Number::from(1000))
);
assert_eq!(records[0]["Revenue"], Value::Number(serde_json::Number::from(1000)));
}
#[test]
@ -134,24 +101,17 @@ mod tests {
assert_eq!(records.len(), 2);
assert!(records[0]["Revenue"].is_f64());
assert_eq!(
records[0]["Revenue"],
Value::Number(serde_json::Number::from_f64(1000.50).unwrap())
);
assert_eq!(records[0]["Revenue"], Value::Number(serde_json::Number::from_f64(1000.50).unwrap()));
}
#[test]
fn parse_csv_with_quoted_fields() {
let (path, _dir) =
create_temp_csv("Product,Description,Price\n\"Shirts\",\"A nice shirt\",10.00");
let (path, _dir) = create_temp_csv("Product,Description,Price\n\"Shirts\",\"A nice shirt\",10.00");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["Product"], Value::String("Shirts".to_string()));
assert_eq!(
records[0]["Description"],
Value::String("A nice shirt".to_string())
);
assert_eq!(records[0]["Description"], Value::String("A nice shirt".to_string()));
}
#[test]
@ -166,54 +126,18 @@ mod tests {
#[test]
fn parse_csv_mixed_types() {
let (path, _dir) =
create_temp_csv("Name,Count,Price,Active\nWidget,5,9.99,true\nGadget,3,19.99,false");
let (path, _dir) = create_temp_csv(
"Name,Count,Price,Active\nWidget,5,9.99,true\nGadget,3,19.99,false",
);
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Name"], Value::String("Widget".to_string()));
assert_eq!(
records[0]["Count"],
Value::Number(serde_json::Number::from(5))
);
assert_eq!(records[0]["Count"], Value::Number(serde_json::Number::from(5)));
assert!(records[0]["Price"].is_f64());
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
}
#[test]
fn merge_csvs_adds_file_field_from_stem() {
let dir = tempdir().unwrap();
let sales = dir.path().join("sales.csv");
let expenses = dir.path().join("expenses.csv");
fs::write(&sales, "Region,Revenue\nEast,100\nWest,200").unwrap();
fs::write(&expenses, "Region,Revenue\nEast,50\nWest,75").unwrap();
let records = merge_csvs(&[sales, expenses]).unwrap();
assert_eq!(records.len(), 4);
assert_eq!(records[0]["File"], Value::String("sales".to_string()));
assert_eq!(records[1]["File"], Value::String("sales".to_string()));
assert_eq!(records[2]["File"], Value::String("expenses".to_string()));
assert_eq!(records[3]["File"], Value::String("expenses".to_string()));
// Original fields preserved
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
assert_eq!(
records[2]["Revenue"],
Value::Number(serde_json::Number::from(50))
);
}
#[test]
fn merge_csvs_single_file_works() {
let dir = tempdir().unwrap();
let path = dir.path().join("data.csv");
fs::write(&path, "Name,Value\nA,1").unwrap();
let records = merge_csvs(&[path]).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["File"], Value::String("data".to_string()));
assert_eq!(records[0]["Name"], Value::String("A".to_string()));
}
#[test]
fn parse_checking_csv_format() {
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
@ -226,19 +150,10 @@ mod tests {
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Date"], Value::String("03/31/2026".to_string()));
assert_eq!(
records[0]["Amount"],
Value::Number(serde_json::Number::from_f64(-50.00).unwrap())
);
assert_eq!(records[0]["Amount"], Value::Number(serde_json::Number::from_f64(-50.00).unwrap()));
assert_eq!(records[0]["Flag"], Value::String("*".to_string()));
assert_eq!(records[0]["CheckNo"], Value::Null);
assert_eq!(
records[0]["Description"],
Value::String("VENMO PAYMENT 260331".to_string())
);
assert_eq!(
records[1]["Amount"],
Value::Number(serde_json::Number::from_f64(-240.00).unwrap())
);
assert_eq!(records[0]["Description"], Value::String("VENMO PAYMENT 260331".to_string()));
assert_eq!(records[1]["Amount"], Value::Number(serde_json::Number::from_f64(-240.00).unwrap()));
}
}

View File

@ -2,10 +2,8 @@ use anyhow::{anyhow, Result};
use serde_json::Value;
use super::analyzer::{
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
DateComponent, FieldKind, FieldProposal,
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
@ -21,8 +19,6 @@ pub struct ImportPipeline {
pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>,
pub model_name: String,
/// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost").
pub formulas: Vec<String>,
}
impl ImportPipeline {
@ -35,7 +31,6 @@ impl ImportPipeline {
records: vec![],
proposals: vec![],
model_name: "Imported Model".to_string(),
formulas: vec![],
};
// Auto-select if root is an array or there is exactly one candidate path.
@ -99,30 +94,6 @@ impl ImportPipeline {
return Err(anyhow!("At least one category must be accepted"));
}
// Collect date component extractions: (field_name, format, component, derived_cat_name)
let date_extractions: Vec<(&str, &str, DateComponent, String)> = self
.proposals
.iter()
.filter(|p| {
p.accepted
&& p.kind == FieldKind::TimeCategory
&& p.date_format.is_some()
&& !p.date_components.is_empty()
})
.flat_map(|p| {
let fmt = p.date_format.as_deref().unwrap();
p.date_components.iter().map(move |comp| {
let suffix = match comp {
DateComponent::Year => "Year",
DateComponent::Month => "Month",
DateComponent::Quarter => "Quarter",
};
let derived_name = format!("{}_{}", p.field, suffix);
(p.field.as_str(), fmt, *comp, derived_name)
})
})
.collect();
let mut model = Model::new(&self.model_name);
for cat_proposal in &categories {
@ -134,11 +105,6 @@ impl ImportPipeline {
}
}
// Create derived date-component categories
for (_, _, _, ref derived_name) in &date_extractions {
model.add_category(derived_name)?;
}
if !measures.is_empty() {
model.add_category("Measure")?;
if let Some(cat) = model.category_mut("Measure") {
@ -164,19 +130,7 @@ impl ImportPipeline {
if let Some(cat) = model.category_mut(&cat_proposal.field) {
cat.add_item(&v);
}
coords.push((cat_proposal.field.clone(), v.clone()));
// Extract date components from this field's value
for (field, fmt, comp, ref derived_name) in &date_extractions {
if *field == cat_proposal.field {
if let Some(derived_val) = extract_date_component(&v, fmt, *comp) {
if let Some(cat) = model.category_mut(derived_name) {
cat.add_item(&derived_val);
}
coords.push((derived_name.clone(), derived_val));
}
}
}
coords.push((cat_proposal.field.clone(), v));
} else {
valid = false;
break;
@ -197,24 +151,6 @@ impl ImportPipeline {
}
}
// Parse and add formulas
// Formulas target the "Measure" category by default.
let formula_cat: String = if model.category("Measure").is_some() {
"Measure".to_string()
} else {
model
.categories
.keys()
.next()
.cloned()
.unwrap_or_else(|| "Measure".to_string())
};
for raw in &self.formulas {
if let Ok(formula) = parse_formula(raw, &formula_cat) {
model.add_formula(formula);
}
}
Ok(model)
}
}
@ -226,8 +162,6 @@ pub enum WizardStep {
Preview,
SelectArrayPath,
ReviewProposals,
ConfigureDates,
DefineFormulas,
NameModel,
Done,
}
@ -243,10 +177,6 @@ pub struct ImportWizard {
pub cursor: usize,
/// One-line message to display at the bottom of the wizard panel.
pub message: Option<String>,
/// Whether we're in formula text-input mode.
pub formula_editing: bool,
/// Buffer for the formula being typed.
pub formula_buffer: String,
}
impl ImportWizard {
@ -266,8 +196,6 @@ impl ImportWizard {
step,
cursor: 0,
message: None,
formula_editing: false,
formula_buffer: String::new(),
}
}
@ -283,15 +211,7 @@ impl ImportWizard {
}
}
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
WizardStep::ReviewProposals => {
if self.has_time_categories() {
WizardStep::ConfigureDates
} else {
WizardStep::DefineFormulas
}
}
WizardStep::ConfigureDates => WizardStep::DefineFormulas,
WizardStep::DefineFormulas => WizardStep::NameModel,
WizardStep::ReviewProposals => WizardStep::NameModel,
WizardStep::NameModel => WizardStep::Done,
WizardStep::Done => WizardStep::Done,
};
@ -299,22 +219,6 @@ impl ImportWizard {
self.message = None;
}
fn has_time_categories(&self) -> bool {
self.pipeline
.proposals
.iter()
.any(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
}
/// Get accepted TimeCategory proposals (for ConfigureDates step).
pub fn time_category_proposals(&self) -> Vec<&FieldProposal> {
self.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
.collect()
}
pub fn confirm_path(&mut self) {
if self.cursor < self.pipeline.array_paths.len() {
let path = self.pipeline.array_paths[self.cursor].clone();
@ -329,8 +233,6 @@ impl ImportWizard {
let len = match self.step {
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
WizardStep::ConfigureDates => self.date_config_item_count(),
WizardStep::DefineFormulas => self.pipeline.formulas.len(),
_ => 0,
};
if len == 0 {
@ -373,130 +275,6 @@ impl ImportWizard {
self.pipeline.model_name.pop();
}
// ── Date config ────────────────────────────────────────────────────────────
/// Total number of items in the ConfigureDates list.
/// Each TimeCategory field gets 3 rows (Year, Month, Quarter).
fn date_config_item_count(&self) -> usize {
self.time_category_proposals().len() * 3
}
/// Get the (field_index, component) for the current cursor position.
pub fn date_config_at_cursor(&self) -> Option<(usize, DateComponent)> {
let tc_indices = self.time_category_indices();
if tc_indices.is_empty() {
return None;
}
let field_idx = self.cursor / 3;
let comp_idx = self.cursor % 3;
let component = match comp_idx {
0 => DateComponent::Year,
1 => DateComponent::Month,
_ => DateComponent::Quarter,
};
tc_indices.get(field_idx).map(|&pi| (pi, component))
}
/// Indices into pipeline.proposals for accepted TimeCategory fields.
fn time_category_indices(&self) -> Vec<usize> {
self.pipeline
.proposals
.iter()
.enumerate()
.filter(|(_, p)| {
p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some()
})
.map(|(i, _)| i)
.collect()
}
/// Toggle a date component for the field at the current cursor.
pub fn toggle_date_component(&mut self) {
if let Some((pi, component)) = self.date_config_at_cursor() {
let proposal = &mut self.pipeline.proposals[pi];
if let Some(pos) = proposal
.date_components
.iter()
.position(|c| *c == component)
{
proposal.date_components.remove(pos);
} else {
proposal.date_components.push(component);
}
}
}
// ── Formula editing ────────────────────────────────────────────────────────
/// Buffer for typing a new formula in the DefineFormulas step.
pub fn push_formula_char(&mut self, c: char) {
if !self.formula_editing {
self.formula_editing = true;
self.formula_buffer.clear();
}
self.formula_buffer.push(c);
}
pub fn pop_formula_char(&mut self) {
self.formula_buffer.pop();
}
/// Commit the current formula buffer to the pipeline's formula list.
pub fn confirm_formula(&mut self) {
let text = self.formula_buffer.trim().to_string();
if !text.is_empty() {
self.pipeline.formulas.push(text);
}
self.formula_buffer.clear();
self.formula_editing = false;
self.cursor = self.pipeline.formulas.len().saturating_sub(1);
}
/// Delete the formula at the current cursor position.
pub fn delete_formula(&mut self) {
if self.cursor < self.pipeline.formulas.len() {
self.pipeline.formulas.remove(self.cursor);
if self.cursor > 0 && self.cursor >= self.pipeline.formulas.len() {
self.cursor -= 1;
}
}
}
/// Start editing a new formula.
pub fn start_formula_edit(&mut self) {
self.formula_editing = true;
self.formula_buffer.clear();
}
/// Cancel formula editing.
pub fn cancel_formula_edit(&mut self) {
self.formula_editing = false;
self.formula_buffer.clear();
}
/// Generate sample formulas based on accepted measures.
pub fn sample_formulas(&self) -> Vec<String> {
let measures: Vec<&str> = self
.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
.map(|p| p.field.as_str())
.collect();
let mut samples = Vec::new();
if measures.len() >= 2 {
samples.push(format!("Diff = {} - {}", measures[0], measures[1]));
}
if !measures.is_empty() {
samples.push(format!("Total = SUM({})", measures[0]));
}
if measures.len() >= 2 {
samples.push(format!("Ratio = {} / {}", measures[0], measures[1]));
}
samples
}
// ── Delegate build to pipeline ────────────────────────────────────────────
pub fn build_model(&self) -> Result<Model> {
@ -632,70 +410,4 @@ mod tests {
let p = ImportPipeline::new(raw);
assert_eq!(p.model_name, "Imported Model");
}
#[test]
fn build_model_adds_formulas_from_pipeline() {
let raw = json!([
{"region": "East", "revenue": 100.0, "cost": 40.0},
{"region": "West", "revenue": 200.0, "cost": 80.0},
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Profit = revenue - cost".to_string());
let model = p.build_model().unwrap();
// The formula should produce Profit = 60 for East (100-40)
use crate::model::cell::CellKey;
let key = CellKey::new(vec![
("Measure".to_string(), "Profit".to_string()),
("region".to_string(), "East".to_string()),
]);
let val = model.evaluate(&key).and_then(|v| v.as_f64());
assert_eq!(val, Some(60.0));
}
#[test]
fn build_model_extracts_date_month_component() {
use crate::import::analyzer::DateComponent;
let raw = json!([
{"Date": "01/15/2025", "Amount": 100.0},
{"Date": "01/20/2025", "Amount": 50.0},
{"Date": "02/05/2025", "Amount": 200.0},
]);
let mut p = ImportPipeline::new(raw);
// Enable Month extraction on the Date field
for prop in &mut p.proposals {
if prop.field == "Date" && prop.kind == FieldKind::TimeCategory {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
assert!(model.category("Date_Month").is_some());
let cat = model.category("Date_Month").unwrap();
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
assert!(items.contains(&"2025-01"));
assert!(items.contains(&"2025-02"));
}
#[test]
fn build_model_date_components_appear_in_cell_keys() {
use crate::import::analyzer::DateComponent;
use crate::model::cell::CellKey;
let raw = json!([
{"Date": "03/31/2026", "Amount": 100.0},
]);
let mut p = ImportPipeline::new(raw);
for prop in &mut p.proposals {
if prop.field == "Date" {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
let key = CellKey::new(vec![
("Date".to_string(), "03/31/2026".to_string()),
("Date_Month".to_string(), "2026-03".to_string()),
("Measure".to_string(), "Amount".to_string()),
]);
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
}
}

View File

@ -1,5 +1,4 @@
mod command;
mod draw;
mod formula;
mod import;
mod model;
@ -7,350 +6,214 @@ mod persistence;
mod ui;
mod view;
use crate::import::csv_parser::csv_path_p;
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use draw::run_tui;
use model::Model;
use serde_json::Value;
#[derive(Parser)]
#[command(name = "improvise", about = "Multi-dimensional data modeling TUI")]
struct Cli {
/// Model file to open or create
file: Option<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Import JSON or CSV data, then open TUI (or save with --output)
Import {
/// Files to import (multiple CSVs merge with a "File" category)
files: Vec<PathBuf>,
/// Mark field as category dimension (repeatable)
#[arg(long)]
category: Vec<String>,
/// Mark field as numeric measure (repeatable)
#[arg(long)]
measure: Vec<String>,
/// Mark field as time/date category (repeatable)
#[arg(long)]
time: Vec<String>,
/// Skip/exclude a field from import (repeatable)
#[arg(long)]
skip: Vec<String>,
/// Extract date component, e.g. "Date:Month" (repeatable)
#[arg(long)]
extract: Vec<String>,
/// Set category axis, e.g. "Payee:row" (repeatable)
#[arg(long)]
axis: Vec<String>,
/// Add formula, e.g. "Profit = Revenue - Cost" (repeatable)
#[arg(long)]
formula: Vec<String>,
/// Model name (default: "Imported Model")
#[arg(long)]
name: Option<String>,
/// Skip the interactive wizard
#[arg(long)]
no_wizard: bool,
/// Save to file instead of opening TUI
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Run a JSON command headless (repeatable)
Cmd {
/// JSON command strings
json: Vec<String>,
/// Model file to load/save
#[arg(short, long)]
file: Option<PathBuf>,
},
/// Run commands from a script file headless
Script {
/// Script file (one JSON command per line, # comments)
path: PathBuf,
/// Model file to load/save
#[arg(short, long)]
file: Option<PathBuf>,
},
}
use ui::app::{App, AppMode};
use ui::category_panel::CategoryPanel;
use ui::formula_panel::FormulaPanel;
use ui::grid::GridWidget;
use ui::help::HelpWidget;
use ui::import_wizard_ui::ImportWizardWidget;
use ui::tile_bar::TileBar;
use ui::view_panel::ViewPanel;
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
None => {
let model = get_initial_model(&cli.file)?;
run_tui(model, cli.file, None)
}
Some(Commands::Import {
files,
category,
measure,
time,
skip,
extract,
axis,
formula,
name,
no_wizard,
output,
}) => {
let import_value = if files.is_empty() {
anyhow::bail!("No files specified for import");
} else {
get_import_data(&files)
.ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))?
};
let config = ImportConfig {
categories: category,
measures: measure,
time_fields: time,
skip_fields: skip,
extractions: parse_colon_pairs(&extract),
axes: parse_colon_pairs(&axis),
formulas: formula,
name,
};
if no_wizard {
run_headless_import(import_value, &config, output, cli.file)
} else {
run_wizard_import(import_value, &config, cli.file)
}
}
Some(Commands::Cmd { json, file }) => run_headless_commands(&json, &file),
Some(Commands::Script { path, file }) => run_headless_script(&path, &file),
}
let args: Vec<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
// ── Import config ────────────────────────────────────────────────────────────
struct ImportConfig {
categories: Vec<String>,
measures: Vec<String>,
time_fields: Vec<String>,
skip_fields: Vec<String>,
extractions: Vec<(String, String)>,
axes: Vec<(String, String)>,
formulas: Vec<String>,
name: Option<String>,
trait Runnable {
fn run(self: Box<Self>) -> Result<()>;
}
fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> {
args.iter()
.filter_map(|s| {
let (a, b) = s.split_once(':')?;
Some((a.to_string(), b.to_string()))
})
.collect()
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_path: Option<PathBuf>,
}
fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, config: &ImportConfig) {
use import::analyzer::{DateComponent, FieldKind};
impl Runnable for CmdLineArgs {
fn run(self: Box<Self>) -> Result<()> {
// Load or create model
let model = get_initial_model(&self.file_path)?;
// Override field kinds
for p in &mut pipeline.proposals {
if config.categories.contains(&p.field) {
p.kind = FieldKind::Category;
p.accepted = true;
} else if config.measures.contains(&p.field) {
p.kind = FieldKind::Measure;
p.accepted = true;
} else if config.time_fields.contains(&p.field) {
p.kind = FieldKind::TimeCategory;
p.accepted = true;
} else if config.skip_fields.contains(&p.field) {
p.accepted = false;
}
}
// Apply date component extractions
for (field, comp_str) in &config.extractions {
let component = match comp_str.to_lowercase().as_str() {
"year" => DateComponent::Year,
"month" => DateComponent::Month,
"quarter" => DateComponent::Quarter,
_ => continue,
};
for p in &mut pipeline.proposals {
if p.field == *field && !p.date_components.contains(&component) {
p.date_components.push(component);
}
}
}
// Set formulas
pipeline.formulas = config.formulas.clone();
// Set model name
if let Some(ref name) = config.name {
pipeline.model_name = name.clone();
}
}
fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) {
use view::Axis;
let view = model.active_view_mut();
for (cat, axis_str) in axes {
let axis = match axis_str.to_lowercase().as_str() {
"row" => Axis::Row,
"column" | "col" => Axis::Column,
"page" => Axis::Page,
"none" => Axis::None,
_ => continue,
};
view.set_axis(cat, axis);
}
}
fn run_headless_import(
import_value: Value,
config: &ImportConfig,
output: Option<PathBuf>,
model_file: Option<PathBuf>,
) -> Result<()> {
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
apply_config_to_pipeline(&mut pipeline, config);
let mut model = pipeline.build_model()?;
model.normalize_view_state();
apply_axis_overrides(&mut model, &config.axes);
if let Some(path) = output.or(model_file) {
persistence::save(&model, &path)?;
eprintln!("Saved to {}", path.display());
} else {
eprintln!("No output path specified; use -o <path> or provide a model file");
}
Ok(())
}
fn run_wizard_import(
import_value: Value,
_config: &ImportConfig,
model_file: Option<PathBuf>,
) -> Result<()> {
let model = get_initial_model(&model_file)?;
// Pre-configure will happen inside the TUI via the wizard
// For now, pass import_value and let the wizard handle it
// TODO: pass config to wizard for pre-population
run_tui(model, model_file, Some(import_value))
}
// ── Import data loading ──────────────────────────────────────────────────────
fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
let all_csv = paths.iter().all(|p| csv_path_p(p));
if paths.len() > 1 {
if !all_csv {
eprintln!("Multi-file import only supports CSV files");
return None;
}
match crate::import::csv_parser::merge_csvs(paths) {
Ok(records) => Some(Value::Array(records)),
Err(e) => {
eprintln!("CSV merge error: {e}");
None
}
}
} else {
let path = &paths[0];
match std::fs::read_to_string(path) {
Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display());
None
}
Ok(content) => {
if csv_path_p(path) {
match crate::import::csv_parser::parse_csv(path) {
Ok(records) => Some(Value::Array(records)),
Err(e) => {
eprintln!("CSV parse error: {e}");
None
// Pre-TUI import: parse JSON or CSV and open wizard
let import_value = if let Some(ref path) = self.import_path {
match std::fs::read_to_string(path) {
Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display());
return Ok(());
}
Ok(content) => {
if path.to_string_lossy().ends_with(".csv") {
// Parse CSV and wrap as JSON array
match crate::import::csv_parser::parse_csv(&path.to_string_lossy()) {
Ok(records) => Some(serde_json::Value::Array(records)),
Err(e) => {
eprintln!("CSV parse error: {e}");
return Ok(());
}
}
}
} else {
match serde_json::from_str::<Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
None
} else {
// Parse JSON
match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
return Ok(());
}
Ok(json) => Some(json),
}
Ok(json) => Some(json),
}
}
}
}
} else {
None
};
run_tui(model, self.file_path, import_value)
}
}
// ── Headless command execution ───────────────────────────────────────────────
struct HeadlessArgs {
file_path: Option<PathBuf>,
commands: Vec<String>,
script: Option<PathBuf>,
}
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
use crossterm::event::{KeyCode, KeyModifiers};
let model = get_initial_model(file)?;
let mut app = ui::app::App::new(model, file.clone());
let mut exit_code = 0;
for line in cmds {
match command::parse_line(line) {
Ok(parsed_cmds) => {
for cmd in &parsed_cmds {
let effects = {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
cmd.execute(&ctx)
};
app.apply_effects(effects);
impl Runnable for HeadlessArgs {
fn run(self: Box<Self>) -> Result<()> {
let mut model = get_initial_model(&self.file_path)?;
let mut cmds: Vec<String> = self.commands;
if let Some(script_path) = self.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());
}
}
Err(e) => {
eprintln!("Parse error: {e}");
}
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 r = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&r)?);
exit_code = 1;
continue;
}
};
let result = command::dispatch(&mut model, &parsed);
if !result.ok {
exit_code = 1;
}
println!("{}", serde_json::to_string(&result)?);
}
}
if let Some(path) = file {
persistence::save(&app.model, path)?;
}
if let Some(path) = self.file_path {
persistence::save(&mut model, &path)?;
}
std::process::exit(exit_code);
std::process::exit(exit_code);
}
}
fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<()> {
let content = std::fs::read_to_string(script_path)?;
let lines: Vec<String> = content.lines().map(String::from).collect();
run_headless_commands(&lines, file)
struct HelpArgs;
impl Runnable for HelpArgs {
fn run(self: Box<Self>) -> Result<()> {
println!("improvise — multi-dimensional data modeling TUI\n");
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model");
println!(" improvise --import data.json Import JSON (or CSV) then open TUI");
println!(" improvise --cmd '{{...}}' Run a JSON command (headless, repeatable)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!("\nTUI KEYS (vim-style):");
println!(" : Command mode (:q :w :import :add-cat :formula …)");
println!(" hjkl / ↑↓←→ Navigate grid");
println!(" i / Enter Edit cell (Insert mode)");
println!(" Esc Return to Normal mode");
println!(" x Clear cell");
println!(" yy / p Yank / paste cell value");
println!(" gg / G First / last row");
println!(" 0 / $ First / last column");
println!(" Ctrl+D/U Scroll half-page down / up");
println!(" / n N Search / next / prev");
println!(" [ ] Cycle page-axis filter");
println!(" T Tile-select (pivot) mode");
println!(" F C V Toggle Formulas / Categories / Views panel");
println!(" ZZ Save and quit");
println!(" ? Help");
Ok(())
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
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" => {
return Box::new(HelpArgs);
}
arg if !arg.starts_with('-') => {
file_path = Some(PathBuf::from(arg));
}
_ => {}
}
i += 1;
}
if !headless_cmds.is_empty() || headless_script.is_some() {
Box::new(HeadlessArgs {
file_path,
commands: headless_cmds,
script: headless_script,
})
} else {
Box::new(CmdLineArgs {
file_path,
import_path,
})
}
}
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
if let Some(ref path) = file_path {
@ -371,3 +234,367 @@ fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
Ok(Model::new("New Model"))
}
}
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn fill_line(left: String, right: &str, width: u16) -> String {
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
format!("{left}{pad}{right}")
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w, h)
}
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup);
f.render_widget(block, popup);
inner
}
fn mode_name(mode: &AppMode) -> &'static str {
match mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
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 / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
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);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let file = app
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let grid_area;
if side_open {
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
grid_area = chunks[0];
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
a,
);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
a,
);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
a,
);
}
} else {
grid_area = area;
}
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode), area);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
match app.mode {
AppMode::CommandMode { ref buffer } => draw_command_bar(f, area, buffer),
_ => draw_status(f, area, app),
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
f.render_widget(
Paragraph::new(format!(":{buffer}"))
.style(Style::default().fg(Color::White).bg(Color::Black)),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let popup = centered_popup(area, 64, 3);
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -48,25 +48,6 @@ impl Group {
}
}
/// What kind of category this is.
/// Regular categories store their items explicitly. Virtual categories
/// are synthesized at query time by the layout layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CategoryKind {
#[default]
Regular,
/// Items are "0", "1", ... N where N = number of matching cells.
VirtualIndex,
/// Items are the names of all regular categories + "Value".
VirtualDim,
}
impl CategoryKind {
pub fn is_virtual(&self) -> bool {
!matches!(self, CategoryKind::Regular)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Category {
pub id: CategoryId,
@ -77,9 +58,6 @@ pub struct Category {
pub groups: Vec<Group>,
/// Next item id counter
next_item_id: ItemId,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
impl Category {
@ -90,15 +68,9 @@ impl Category {
items: IndexMap::new(),
groups: Vec::new(),
next_item_id: 0,
kind: CategoryKind::Regular,
}
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
}
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
let name = name.into();
if let Some(item) = self.items.get(&name) {
@ -133,10 +105,31 @@ impl Category {
}
}
// 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 in insertion order, derived from item.group fields.
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
}
}
#[cfg(test)]
@ -192,6 +185,30 @@ mod tests {
assert_eq!(c.groups.len(), 1);
}
#[test]
fn top_level_groups_returns_unique_groups_in_insertion_order() {
let mut c = cat();
c.add_item_in_group("Jan", "Q1");
c.add_item_in_group("Feb", "Q1");
c.add_item_in_group("Apr", "Q2");
assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]);
}
#[test]
fn top_level_groups_empty_for_ungrouped_category() {
let mut c = cat();
c.add_item("East");
c.add_item("West");
assert!(c.top_level_groups().is_empty());
}
#[test]
fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() {
let mut c = cat();
c.add_group(Group::new("Orphan"));
assert!(c.top_level_groups().is_empty());
}
#[test]
fn item_index_reflects_insertion_order() {
let mut c = cat();

View File

@ -1,7 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::symbol::{Symbol, SymbolTable};
use std::collections::HashMap;
/// A cell key is a sorted vector of (category_name, item_name) pairs.
/// Sorted by category name for canonical form.
@ -43,7 +41,6 @@ impl CellKey {
)
}
#[allow(dead_code)]
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
partial
.iter()
@ -88,22 +85,11 @@ impl std::fmt::Display for CellValue {
}
}
/// Interned representation of a CellKey — cheap to hash and compare.
/// Sorted by first element (category Symbol) for canonical form.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
/// 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 {
/// Primary storage — interned keys for O(1) hash/compare.
cells: HashMap<InternedKey, CellValue>,
/// String interner — all category/item names are interned here.
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
cells: HashMap<CellKey, CellValue>,
}
impl Serialize for DataStore {
@ -111,8 +97,7 @@ impl Serialize for DataStore {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
for (k, v) in &self.cells {
let cell_key = self.to_cell_key(k);
seq.serialize_element(&(cell_key, v))?;
seq.serialize_element(&(k, v))?;
}
seq.end()
}
@ -121,11 +106,8 @@ impl Serialize for DataStore {
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 mut store = DataStore::default();
for (key, value) in pairs {
store.set(key, value);
}
Ok(store)
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
Ok(DataStore { cells })
}
}
@ -134,145 +116,27 @@ impl DataStore {
Self::default()
}
/// Intern a CellKey into an InternedKey.
pub fn intern_key(&mut self, key: &CellKey) -> InternedKey {
InternedKey(self.symbols.intern_coords(&key.0))
}
/// Convert an InternedKey back to a CellKey (string form).
pub fn to_cell_key(&self, ikey: &InternedKey) -> CellKey {
CellKey(
ikey.0
.iter()
.map(|(c, i)| {
(
self.symbols.resolve(*c).to_string(),
self.symbols.resolve(*i).to_string(),
)
})
.collect(),
)
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
let ikey = self.intern_key(&key);
// Update index for each coordinate pair
for pair in &ikey.0 {
self.index.entry(*pair).or_default().insert(ikey.clone());
}
self.cells.insert(ikey, value);
self.cells.insert(key, value);
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
let ikey = self.lookup_key(key)?;
self.cells.get(&ikey)
self.cells.get(key)
}
/// Look up an InternedKey for a CellKey without interning new symbols.
fn lookup_key(&self, key: &CellKey) -> Option<InternedKey> {
let pairs: Option<Vec<(Symbol, Symbol)>> = key
.0
.iter()
.map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
pairs.map(InternedKey)
}
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
self.cells
.iter()
.map(|(k, v)| (self.to_cell_key(k), v))
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
&self.cells
}
pub fn remove(&mut self, key: &CellKey) {
let Some(ikey) = self.lookup_key(key) else {
return;
};
if self.cells.remove(&ikey).is_some() {
for pair in &ikey.0 {
if let Some(set) = self.index.get_mut(pair) {
set.remove(&ikey);
}
}
}
self.cells.remove(key);
}
/// Values of all cells where every coordinate in `partial` matches.
/// Hot path: avoids allocating CellKey for each result.
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
if partial.is_empty() {
return self.cells.values().collect();
}
// Intern the partial key (lookup only, no new symbols)
let interned_partial: Vec<(Symbol, Symbol)> = partial
/// All cells where partial coords match
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
self.cells
.iter()
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| self.cells.get(ikey))
.collect()
}
/// All cells where every coordinate in `partial` matches.
/// Allocates CellKey strings for each match — use `matching_values`
/// if you only need values.
#[allow(dead_code)]
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(CellKey, &CellValue)> {
if partial.is_empty() {
return self.iter_cells().collect();
}
let interned_partial: Vec<(Symbol, Symbol)> = partial
.iter()
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| {
let value = self.cells.get(ikey)?;
Some((self.to_cell_key(ikey), value))
})
.filter(|(key, _)| key.matches_partial(partial))
.collect()
}
}
@ -421,7 +285,7 @@ mod data_store {
let k = key(&[("Region", "East")]);
store.set(k.clone(), CellValue::Number(5.0));
store.remove(&k);
assert!(store.iter_cells().next().is_none());
assert!(store.cells().is_empty());
}
#[test]

View File

@ -1,6 +1,5 @@
pub mod category;
pub mod cell;
pub mod symbol;
pub mod types;
pub mod model;
pub use types::Model;
pub use model::Model;

View File

@ -1,12 +1,10 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::category::{Category, CategoryId};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::{AggFunc, Formula};
use crate::formula::Formula;
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
@ -20,56 +18,28 @@ pub struct Model {
pub views: IndexMap<String, View>,
pub active_view: String,
next_category_id: CategoryId,
/// Per-measure aggregation function (measure item name → agg func).
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
#[serde(default)]
pub measure_agg: HashMap<String, AggFunc>,
}
impl Model {
pub fn new(name: impl Into<String>) -> Self {
use crate::model::category::CategoryKind;
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
let mut categories = IndexMap::new();
// Virtual categories — always present, default to Axis::None
categories.insert(
"_Index".to_string(),
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
);
categories.insert(
"_Dim".to_string(),
Category::new(1, "_Dim").with_kind(CategoryKind::VirtualDim),
);
let mut m = Self {
Self {
name,
categories,
categories: IndexMap::new(),
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 2,
measure_agg: HashMap::new(),
};
// Add virtuals to existing views (default view)
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
next_category_id: 0,
}
m
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
// Virtuals don't count against the regular category limit
let regular_count = self
.categories
.values()
.filter(|c| !c.kind.is_virtual())
.count();
if regular_count >= MAX_CATEGORIES {
if self.categories.len() >= MAX_CATEGORIES {
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
}
if self.categories.contains_key(&name) {
@ -180,7 +150,6 @@ impl Model {
}
/// Return all category names
/// Names of all categories (including virtual ones).
pub fn category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
}
@ -203,59 +172,6 @@ impl Model {
self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0)
}
/// Evaluate a cell, aggregating over any hidden (None-axis) categories.
/// When `none_cats` is empty, delegates to `evaluate`.
/// Otherwise, uses `matching_cells` with the partial key and aggregates
/// using the measure's agg function (default SUM).
pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
if none_cats.is_empty() {
return self.evaluate(key);
}
// Check formulas first — they handle their own aggregation
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);
}
}
}
// Aggregate raw data across all None-axis categories
let values: Vec<f64> = self
.data
.matching_values(&key.0)
.into_iter()
.filter_map(|v| v.as_f64())
.collect();
if values.is_empty() {
return None;
}
// Determine agg func from measure_agg map, defaulting to SUM
let agg = key
.get("Measure")
.and_then(|m| self.measure_agg.get(m))
.unwrap_or(&AggFunc::Sum);
let result = match agg {
AggFunc::Sum => values.iter().sum(),
AggFunc::Avg => 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 => values.len() as f64,
};
Some(CellValue::Number(result))
}
/// Evaluate aggregated as f64, returning 0.0 for empty cells.
pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 {
self.evaluate_aggregated(key, none_cats)
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
}
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
use crate::formula::{AggFunc, Expr};
@ -327,9 +243,9 @@ impl Model {
}
let values: Vec<f64> = model
.data
.matching_values(&partial.0)
.matching_cells(&partial.0)
.into_iter()
.filter_map(|v| v.as_f64())
.filter_map(|(_, v)| v.as_f64())
.collect();
match func {
AggFunc::Sum => Some(values.iter().sum()),
@ -423,8 +339,7 @@ mod model_tests {
let id1 = m.add_category("Region").unwrap();
let id2 = m.add_category("Region").unwrap();
assert_eq!(id1, id2);
// Region + 2 virtuals (_Index, _Dim)
assert_eq!(m.category_names().len(), 3);
assert_eq!(m.categories.len(), 1);
}
#[test]
@ -575,79 +490,6 @@ mod model_tests {
m.set_cell(k.clone(), CellValue::Number(77.0));
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
}
#[test]
fn evaluate_aggregated_sums_over_hidden_dimension() {
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("Jan-01");
m.category_mut("Date").unwrap().add_item("Jan-02");
m.category_mut("Measure").unwrap().add_item("Amount");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]),
CellValue::Number(100.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]),
CellValue::Number(50.0),
);
// Without hidden dims, returns None for partial key
let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]);
assert_eq!(m.evaluate(&partial_key), None);
// With Date as hidden dimension, aggregates to SUM
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&partial_key, &none_cats);
assert_eq!(result, Some(CellValue::Number(150.0)));
}
#[test]
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
m.category_mut("Region").unwrap().add_item("East");
m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0));
let key = coord(&[("Region", "East")]);
assert_eq!(
m.evaluate_aggregated(&key, &[]),
Some(CellValue::Number(42.0))
);
}
#[test]
fn evaluate_aggregated_respects_measure_agg() {
use crate::formula::AggFunc;
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("D1");
m.category_mut("Date").unwrap().add_item("D2");
m.category_mut("Measure").unwrap().add_item("Price");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]),
CellValue::Number(10.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]),
CellValue::Number(30.0),
);
m.measure_agg.insert("Price".to_string(), AggFunc::Avg);
let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]);
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&key, &none_cats);
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
}
}
#[cfg(test)]
@ -1392,14 +1234,12 @@ mod five_category {
#[test]
fn five_categories_well_within_limit() {
let m = build_model();
// 5 regular + 2 virtual (_Index, _Dim)
assert_eq!(m.category_names().len(), 7);
assert_eq!(m.categories.len(), 5);
let mut m2 = build_model();
for i in 0..7 {
m2.add_category(format!("Extra{i}")).unwrap();
}
// 12 regular + 2 virtuals = 14
assert_eq!(m2.category_names().len(), 14);
assert_eq!(m2.categories.len(), 12);
assert!(m2.add_category("OneMore").is_err());
}
}

View File

@ -1,79 +0,0 @@
use std::collections::HashMap;
/// An interned string identifier. Copy-cheap, O(1) hash and equality.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Symbol(u64);
/// Bidirectional string ↔ Symbol mapping.
#[derive(Debug, Clone, Default)]
pub struct SymbolTable {
to_id: HashMap<String, Symbol>,
to_str: Vec<String>,
}
impl SymbolTable {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
/// Intern a string, returning its Symbol. Returns existing Symbol if
/// already interned.
pub fn intern(&mut self, s: &str) -> Symbol {
if let Some(&id) = self.to_id.get(s) {
return id;
}
let id = Symbol(self.to_str.len() as u64);
self.to_str.push(s.to_string());
self.to_id.insert(s.to_string(), id);
id
}
/// Look up the Symbol for a string without interning.
pub fn get(&self, s: &str) -> Option<Symbol> {
self.to_id.get(s).copied()
}
/// Resolve a Symbol back to its string.
pub fn resolve(&self, sym: Symbol) -> &str {
&self.to_str[sym.0 as usize]
}
/// Intern a (category, item) pair.
pub fn intern_pair(&mut self, cat: &str, item: &str) -> (Symbol, Symbol) {
(self.intern(cat), self.intern(item))
}
/// Intern a full coordinate list.
pub fn intern_coords(&mut self, coords: &[(String, String)]) -> Vec<(Symbol, Symbol)> {
coords.iter().map(|(c, i)| self.intern_pair(c, i)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intern_returns_same_id() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("hello");
assert_eq!(a, b);
}
#[test]
fn different_strings_different_ids() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("world");
assert_ne!(a, b);
}
#[test]
fn resolve_roundtrips() {
let mut t = SymbolTable::new();
let s = t.intern("test");
assert_eq!(t.resolve(s), "test");
}
}

View File

@ -88,7 +88,7 @@ pub fn format_md(model: &Model) -> String {
}
// Data — sorted by coordinate string for deterministic diffs
let mut cells: Vec<_> = model.data.iter_cells().collect();
let mut cells: Vec<_> = model.data.cells().iter().collect();
cells.sort_by_key(|(k, _)| coord_str(k));
if !cells.is_empty() {
writeln!(out, "\n## Data").unwrap();
@ -97,7 +97,7 @@ pub fn format_md(model: &Model) -> String {
CellValue::Number(_) => value.to_string(),
CellValue::Text(s) => format!("\"{}\"", s),
};
writeln!(out, "{} = {}", coord_str(&key), val_str).unwrap();
writeln!(out, "{} = {}", coord_str(key), val_str).unwrap();
}
}
@ -117,7 +117,6 @@ pub fn format_md(model: &Model) -> String {
Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(),
None => writeln!(out, "{}: page", cat).unwrap(),
},
Axis::None => writeln!(out, "{}: none", cat).unwrap(),
}
}
if !view.number_format.is_empty() {
@ -316,7 +315,6 @@ pub fn parse_md(text: &str) -> Result<Model> {
let axis = match rest {
"row" => Axis::Row,
"column" => Axis::Column,
"none" => Axis::None,
_ => continue,
};
view.axes.push((cat.to_string(), axis));
@ -459,15 +457,11 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
}
let row_values: Vec<String> = (0..layout.col_count())
.map(|ci| {
if layout.is_records_mode() {
layout.records_display(ri, ci).unwrap_or_default()
} else {
layout
.cell_key(ri, ci)
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
.map(|v| v.to_string())
.unwrap_or_default()
}
layout
.cell_key(ri, ci)
.and_then(|key| model.evaluate(&key))
.map(|v| v.to_string())
.unwrap_or_default()
})
.collect();
out.push_str(&row_values.join(","));

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,6 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
Axis::Row => ("Row ↕", Color::Green),
Axis::Column => ("Col ↔", Color::Blue),
Axis::Page => ("Page ☰", Color::Magenta),
Axis::None => ("None ∅", Color::DarkGray),
}
}

View File

@ -1,817 +0,0 @@
use std::fmt::Debug;
use std::path::PathBuf;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use super::app::{App, AppMode};
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
}
// ── Model mutations ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct AddCategory(pub String);
impl Effect for AddCategory {
fn apply(&self, app: &mut App) {
let _ = app.model.add_category(&self.0);
}
}
#[derive(Debug)]
pub struct AddItem {
pub category: String,
pub item: String,
}
impl Effect for AddItem {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item(&self.item);
}
}
}
#[derive(Debug)]
pub struct AddItemInGroup {
pub category: String,
pub item: String,
pub group: String,
}
impl Effect for AddItemInGroup {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group);
}
}
}
#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
fn apply(&self, app: &mut App) {
app.model.set_cell(self.0.clone(), self.1.clone());
}
}
#[derive(Debug)]
pub struct ClearCell(pub CellKey);
impl Effect for ClearCell {
fn apply(&self, app: &mut App) {
app.model.clear_cell(&self.0);
}
}
#[derive(Debug)]
pub struct AddFormula {
pub raw: String,
pub target_category: String,
}
impl Effect for AddFormula {
fn apply(&self, app: &mut App) {
if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) {
app.model.add_formula(formula);
}
}
}
#[derive(Debug)]
pub struct RemoveFormula {
pub target: String,
pub target_category: String,
}
impl Effect for RemoveFormula {
fn apply(&self, app: &mut App) {
app.model
.remove_formula(&self.target, &self.target_category);
}
}
// ── View mutations ───────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct CreateView(pub String);
impl Effect for CreateView {
fn apply(&self, app: &mut App) {
app.model.create_view(&self.0);
}
}
#[derive(Debug)]
pub struct DeleteView(pub String);
impl Effect for DeleteView {
fn apply(&self, app: &mut App) {
let _ = app.model.delete_view(&self.0);
}
}
#[derive(Debug)]
pub struct SwitchView(pub String);
impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
}
}
/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
}
}
}
/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
}
}
}
#[derive(Debug)]
pub struct SetAxis {
pub category: String,
pub axis: Axis,
}
impl Effect for SetAxis {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_axis(&self.category, self.axis);
}
}
#[derive(Debug)]
pub struct SetPageSelection {
pub category: String,
pub item: String,
}
impl Effect for SetPageSelection {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_page_selection(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ToggleGroup {
pub category: String,
pub group: String,
}
impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.toggle_group_collapse(&self.category, &self.group);
}
}
#[derive(Debug)]
pub struct HideItem {
pub category: String,
pub item: String,
}
impl Effect for HideItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.hide_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ShowItem {
pub category: String,
pub item: String,
}
impl Effect for ShowItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.show_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct TransposeAxes;
impl Effect for TransposeAxes {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().transpose_axes();
}
}
#[derive(Debug)]
pub struct CycleAxis(pub String);
impl Effect for CycleAxis {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().cycle_axis(&self.0);
}
}
#[derive(Debug)]
pub struct SetNumberFormat(pub String);
impl Effect for SetNumberFormat {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().number_format = self.0.clone();
}
}
// ── Navigation ───────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct SetSelected(pub usize, pub usize);
impl Effect for SetSelected {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().selected = (self.0, self.1);
}
}
#[derive(Debug)]
pub struct SetRowOffset(pub usize);
impl Effect for SetRowOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().row_offset = self.0;
}
}
#[derive(Debug)]
pub struct SetColOffset(pub usize);
impl Effect for SetColOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().col_offset = self.0;
}
}
// ── App state ────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
fn apply(&self, app: &mut App) {
app.mode = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetStatus(pub String);
impl Effect for SetStatus {
fn apply(&self, app: &mut App) {
app.status_msg = self.0.clone();
}
}
#[derive(Debug)]
pub struct MarkDirty;
impl Effect for MarkDirty {
fn apply(&self, app: &mut App) {
app.dirty = true;
}
}
#[derive(Debug)]
pub struct SetYanked(pub Option<CellValue>);
impl Effect for SetYanked {
fn apply(&self, app: &mut App) {
app.yanked = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchQuery(pub String);
impl Effect for SetSearchQuery {
fn apply(&self, app: &mut App) {
app.search_query = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchMode(pub bool);
impl Effect for SetSearchMode {
fn apply(&self, app: &mut App) {
app.search_mode = self.0;
}
}
/// Set a named buffer's contents.
#[derive(Debug)]
pub struct SetBuffer {
pub name: String,
pub value: String,
}
impl Effect for SetBuffer {
fn apply(&self, app: &mut App) {
// "search" is special — it writes to search_query for backward compat
if self.name == "search" {
app.search_query = self.value.clone();
} else {
app.buffers.insert(self.name.clone(), self.value.clone());
}
}
}
#[derive(Debug)]
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
fn apply(&self, app: &mut App) {
app.tile_cat_idx = self.0;
}
}
/// Populate the drill state with a frozen snapshot of records.
/// Clears any previous drill state.
#[derive(Debug)]
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill {
fn apply(&self, app: &mut App) {
app.drill_state = Some(super::app::DrillState {
records: self.0.clone(),
pending_edits: std::collections::HashMap::new(),
});
}
}
/// Apply any pending edits to the model and clear the drill state.
#[derive(Debug)]
pub struct ApplyAndClearDrill;
impl Effect for ApplyAndClearDrill {
fn apply(&self, app: &mut App) {
let Some(drill) = app.drill_state.take() else {
return;
};
// For each pending edit, update the cell
for ((record_idx, col_name), new_value) in &drill.pending_edits {
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
continue;
};
if col_name == "Value" {
// Update the cell's value
let value = if new_value.is_empty() {
app.model.clear_cell(orig_key);
continue;
} else if let Ok(n) = new_value.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(new_value.clone())
};
app.model.set_cell(orig_key.clone(), value);
} else {
// Rename a coordinate: remove old cell, insert new with updated coord
let value = match app.model.get_cell(orig_key) {
Some(v) => v.clone(),
None => continue,
};
app.model.clear_cell(orig_key);
// Build new key by replacing the coord
let new_coords: Vec<(String, String)> = orig_key
.0
.iter()
.map(|(c, i)| {
if c == col_name {
(c.clone(), new_value.clone())
} else {
(c.clone(), i.clone())
}
})
.collect();
let new_key = CellKey::new(new_coords);
// Ensure the new item exists in that category
if let Some(cat) = app.model.category_mut(col_name) {
cat.add_item(new_value.clone());
}
app.model.set_cell(new_key, value);
}
}
app.dirty = true;
}
}
/// Stage a pending edit in the drill state.
#[derive(Debug)]
pub struct SetDrillPendingEdit {
pub record_idx: usize,
pub col_name: String,
pub new_value: String,
}
impl Effect for SetDrillPendingEdit {
fn apply(&self, app: &mut App) {
if let Some(drill) = &mut app.drill_state {
drill
.pending_edits
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
}
}
}
// ── Side effects ─────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct Save;
impl Effect for Save {
fn apply(&self, app: &mut App) {
if let Some(ref path) = app.file_path {
match crate::persistence::save(&app.model, path) {
Ok(()) => {
app.dirty = false;
app.status_msg = format!("Saved to {}", path.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
} else {
app.status_msg = "No file path — use :w <path>".to_string();
}
}
}
#[derive(Debug)]
pub struct SaveAs(pub PathBuf);
impl Effect for SaveAs {
fn apply(&self, app: &mut App) {
match crate::persistence::save(&app.model, &self.0) {
Ok(()) => {
app.file_path = Some(self.0.clone());
app.dirty = false;
app.status_msg = format!("Saved to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
}
}
/// Dispatch a key event to the import wizard.
/// The wizard has its own internal state machine; this effect handles
/// all wizard key interactions and App-level side effects.
#[derive(Debug)]
pub struct WizardKey {
pub key_code: crossterm::event::KeyCode,
}
impl Effect for WizardKey {
fn apply(&self, app: &mut App) {
use crate::import::wizard::WizardStep;
let Some(wizard) = &mut app.wizard else {
return;
};
match &wizard.step.clone() {
WizardStep::Preview => match self.key_code {
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
wizard.advance()
}
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::SelectArrayPath => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ReviewProposals => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ConfigureDates => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::DefineFormulas => {
if wizard.formula_editing {
match self.key_code {
crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
_ => {}
}
} else {
match self.key_code {
crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
}
}
}
WizardStep::NameModel => match self.key_code {
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
crossterm::event::KeyCode::Enter => match wizard.build_model() {
Ok(mut model) => {
model.normalize_view_state();
app.model = model;
app.formula_cursor = 0;
app.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.mode = AppMode::Normal;
app.wizard = None;
}
Err(e) => {
if let Some(w) = &mut app.wizard {
w.message = Some(format!("Error: {e}"));
}
}
},
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::Done => {
app.mode = AppMode::Normal;
app.wizard = None;
}
}
}
}
/// Start the import wizard from a JSON file path.
#[derive(Debug)]
pub struct StartImportWizard(pub String);
impl Effect for StartImportWizard {
fn apply(&self, app: &mut App) {
match std::fs::read_to_string(&self.0) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.mode = AppMode::ImportWizard;
}
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
}
},
Err(e) => {
app.status_msg = format!("Cannot read file: {e}");
}
}
}
}
#[derive(Debug)]
pub struct ExportCsv(pub PathBuf);
impl Effect for ExportCsv {
fn apply(&self, app: &mut App) {
let view_name = app.model.active_view.clone();
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
Ok(()) => {
app.status_msg = format!("Exported to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Export error: {e}");
}
}
}
}
/// Load a model from a file, replacing the current one.
#[derive(Debug)]
pub struct LoadModel(pub PathBuf);
impl Effect for LoadModel {
fn apply(&self, app: &mut App) {
match crate::persistence::load(&self.0) {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Load error: {e}");
}
}
}
}
/// Headless JSON/CSV import: read file, analyze, build model, replace current.
#[derive(Debug)]
pub struct ImportJsonHeadless {
pub path: PathBuf,
pub model_name: Option<String>,
pub array_path: Option<String>,
}
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
};
use crate::import::wizard::ImportPipeline;
let is_csv = self
.path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
let records = if is_csv {
match crate::import::csv_parser::parse_csv(&self.path) {
Ok(recs) => recs,
Err(e) => {
app.status_msg = format!("CSV error: {e}");
return;
}
}
} else {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) => {
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
return;
}
};
if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => {
app.status_msg = format!("No array at path '{ap}'");
return;
}
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
let paths = find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => {
app.status_msg = "Could not extract records array".to_string();
return;
}
}
} else {
app.status_msg = "No array found in JSON".to_string();
return;
}
}
};
let proposals = analyze_records(&records);
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = ImportPipeline {
raw,
array_paths: vec![],
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
records,
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: self
.model_name
.as_deref()
.unwrap_or("Imported Model")
.to_string(),
formulas: vec![],
};
match pipeline.build_model() {
Ok(new_model) => {
app.model = new_model;
app.status_msg = "Imported successfully".to_string();
}
Err(e) => {
app.status_msg = format!("Import error: {e}");
}
}
}
}
#[derive(Debug)]
pub struct SetPanelOpen {
pub panel: Panel,
pub open: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum Panel {
Formula,
Category,
View,
}
impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_panel_open = self.open,
Panel::Category => app.category_panel_open = self.open,
Panel::View => app.view_panel_open = self.open,
}
}
}
#[derive(Debug)]
pub struct SetPanelCursor {
pub panel: Panel,
pub cursor: usize,
}
impl Effect for SetPanelCursor {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_cursor = self.cursor,
Panel::Category => app.cat_panel_cursor = self.cursor,
Panel::View => app.view_panel_cursor = self.cursor,
}
}
}
// ── Convenience constructors ─────────────────────────────────────────────────
pub fn mark_dirty() -> Box<dyn Effect> {
Box::new(MarkDirty)
}
pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
Box::new(SetStatus(msg.into()))
}
pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
Box::new(ChangeMode(mode))
}
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
Box::new(SetSelected(row, col))
}

View File

@ -13,10 +13,6 @@ use crate::view::{AxisEntry, GridLayout};
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const MIN_COL_WIDTH: u16 = 6;
const MAX_COL_WIDTH: u16 = 32;
/// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const GROUP_EXPANDED: &str = "";
const GROUP_COLLAPSED: &str = "";
@ -24,44 +20,21 @@ pub struct GridWidget<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>,
pub drill_state: Option<&'a crate::ui::app::DrillState>,
}
impl<'a> GridWidget<'a> {
pub fn new(
model: &'a Model,
mode: &'a AppMode,
search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>,
drill_state: Option<&'a crate::ui::app::DrillState>,
) -> Self {
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
Self {
model,
mode,
search_query,
buffers,
drill_state,
}
}
/// In records mode, get the display text for (row, col): pending edit if
/// staged, otherwise the underlying record's value for that column.
fn records_cell_text(&self, layout: &GridLayout, row: usize, col: usize) -> String {
let col_name = layout.col_label(col);
let pending = self
.drill_state
.and_then(|s| s.pending_edits.get(&(row, col_name.clone())).cloned());
pending
.or_else(|| layout.records_display(row, col))
.unwrap_or_default()
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view();
let frozen = self.drill_state.map(|s| s.records.clone());
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
let layout = GridLayout::new(self.model, view);
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
@ -70,37 +43,6 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// Per-column widths. In records mode, size each column to its widest
// content (pending edit → record value → header label). Otherwise use
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX.
let col_widths: Vec<u16> = if layout.is_records_mode() {
let n = layout.col_count();
let mut widths = vec![MIN_COL_WIDTH; n];
for ci in 0..n {
let header = layout.col_label(ci);
let w = header.width() as u16;
if w > widths[ci] {
widths[ci] = w;
}
}
for ri in 0..layout.row_count() {
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let s = self.records_cell_text(&layout, ri, ci);
let w = s.width() as u16;
if w > *wref {
*wref = w;
}
}
}
// Add 2 cells of right-padding; cap at MAX_COL_WIDTH.
widths
.into_iter()
.map(|w| (w + 2).min(MAX_COL_WIDTH))
.collect()
} else {
vec![COL_WIDTH; layout.col_count()]
};
// Sub-column widths for row header area
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
let sub_widths: Vec<u16> = (0..n_row_levels)
@ -137,39 +79,23 @@ impl<'a> GridWidget<'a> {
})
.collect();
let has_col_groups = layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
// Compute how many columns fit starting from col_offset.
let data_area_width = area.width.saturating_sub(ROW_HEADER_WIDTH);
let mut acc = 0u16;
let mut last = col_offset;
for ci in col_offset..layout.col_count() {
let w = *col_widths.get(ci).unwrap_or(&COL_WIDTH);
if acc + w > data_area_width {
break;
// Map each data-col index to its group name (None if ungrouped)
let col_groups: Vec<Option<String>> = {
let mut groups = Vec::new();
let mut current: Option<String> = None;
for entry in &layout.col_items {
match entry {
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
AxisEntry::DataItem(_) => groups.push(current.clone()),
}
}
acc += w;
last = ci + 1;
}
let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count());
groups
};
let has_col_groups = col_groups.iter().any(|g| g.is_some());
// x offset (relative to the data area start) for each column index.
let col_x: Vec<u16> = {
let mut v = vec![0u16; layout.col_count() + 1];
for ci in 0..layout.col_count() {
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&COL_WIDTH);
}
v
};
let col_x_at = |ci: usize| -> u16 {
area.x
+ ROW_HEADER_WIDTH
+ col_x[ci].saturating_sub(col_x[col_offset])
};
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&COL_WIDTH) };
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_range =
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
@ -190,37 +116,30 @@ impl<'a> GridWidget<'a> {
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut prev_group: Option<String> = None;
let mut x = area.x + ROW_HEADER_WIDTH;
let mut prev_group: Option<&str> = None;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let col_group = layout.col_group_for(ci);
let group_name = col_group.as_ref().map(|(_, g)| g.clone());
let label = if group_name != prev_group {
match &col_group {
Some((cat, g)) => {
let indicator = if view.is_group_collapsed(cat, g) {
GROUP_COLLAPSED
} else {
GROUP_EXPANDED
};
format!("{indicator} {g}")
}
None => String::new(),
}
let group = col_groups[ci].as_deref();
let label = if group != prev_group {
group.unwrap_or("")
} else {
String::new()
""
};
prev_group = group_name;
prev_group = group;
buf.set_string(
x,
y,
format!("{:<width$}", truncate(&label, cw), width = cw),
format!(
"{:<width$}",
truncate(label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
group_style,
);
x += COL_WIDTH;
}
y += 1;
}
@ -236,12 +155,8 @@ impl<'a> GridWidget<'a> {
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let label = if layout.col_cats.is_empty() {
layout.col_label(ci)
} else {
@ -260,9 +175,17 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!("{:>width$}", truncate(&label, cw), width = cw),
format!(
"{:>width$}",
truncate(&label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
styled,
);
x += COL_WIDTH;
if x >= area.x + area.width {
break;
}
}
y += 1;
}
@ -306,47 +229,29 @@ impl<'a> GridWidget<'a> {
),
group_header_style,
);
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let mut x = area.x + ROW_HEADER_WIDTH;
while x < area.x + area.width {
buf.set_string(
x,
y,
format!("{:─<width$}", "", width = cw),
format!("{:─<width$}", "", width = COL_WIDTH as usize),
Style::default().fg(Color::DarkGray),
);
x += COL_WIDTH;
}
}
AxisEntry::DataItem(_) => {
let ri = data_row_idx;
data_row_idx += 1;
let is_sel_row = ri == sel_row;
let row_style = if is_sel_row {
let row_style = if ri == sel_row {
Style::default()
.fg(Color::Cyan)
.bg(ROW_HIGHLIGHT_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Paint row-highlight background across the entire row
// (data area + any trailing space) so gaps between columns
// and the margin after the last column share the highlight.
if is_sel_row {
let row_w = (area.x + area.width).saturating_sub(area.x);
buf.set_string(
area.x + ROW_HEADER_WIDTH,
y,
" ".repeat(row_w.saturating_sub(ROW_HEADER_WIDTH) as usize),
Style::default().bg(ROW_HIGHLIGHT_BG),
);
}
// Multi-level row header — one sub-column per row category
let mut hx = area.x;
for d in 0..n_row_levels {
@ -371,31 +276,22 @@ impl<'a> GridWidget<'a> {
hx += sub_widths[d];
}
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let (cell_str, value) = if layout.is_records_mode() {
let s = self.records_cell_text(&layout, ri, ci);
// In records mode the value is a string, not aggregated
let v = if !s.is_empty() {
Some(crate::model::cell::CellValue::Text(s.clone()))
} else {
None
};
(s, v)
} else {
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => continue,
};
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
(s, value)
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => {
x += COL_WIDTH;
continue;
}
};
let value = self.model.evaluate(&key);
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str
@ -409,13 +305,6 @@ impl<'a> GridWidget<'a> {
.add_modifier(Modifier::BOLD)
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row {
let fg = if value.is_none() {
Color::DarkGray
} else {
Color::White
};
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if value.is_none() {
Style::default().fg(Color::DarkGray)
} else {
@ -425,21 +314,29 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!("{:>width$}", truncate(&cell_str, cw), width = cw),
format!(
"{:>width$}",
truncate(&cell_str, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
cell_style,
);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
{
let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
let edit_x = col_x_at(sel_col);
let cw = col_w_at(sel_col) as usize;
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;
buf.set_string(
edit_x,
y,
truncate(&format!("{:<width$}", buffer, width = cw), cw),
truncate(
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
COL_WIDTH as usize,
),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::UNDERLINED),
@ -451,8 +348,8 @@ impl<'a> GridWidget<'a> {
y += 1;
}
// Total row — numeric aggregation, only meaningful in pivot mode.
if !layout.is_records_mode() && layout.row_count() > 0 && layout.col_count() > 0 {
// Total row
if layout.row_count() > 0 && layout.col_count() > 0 {
if y < area.y + area.height {
buf.set_string(
area.x,
@ -472,25 +369,29 @@ impl<'a> GridWidget<'a> {
.add_modifier(Modifier::BOLD),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
.map(|key| self.model.evaluate_f64(&key))
.sum();
let total_str = format_f64(total, fmt_comma, fmt_decimals);
buf.set_string(
x,
y,
format!("{:>width$}", truncate(&total_str, cw), width = cw),
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;
}
}
}
@ -619,8 +520,7 @@ mod tests {
fn render(model: &Model, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let bufs = std::collections::HashMap::new();
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf);
buf
}

View File

@ -5,7 +5,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::analyzer::{DateComponent, FieldKind};
use crate::import::analyzer::FieldKind;
use crate::import::wizard::{ImportWizard, WizardStep};
pub struct ImportWizardWidget<'a> {
@ -29,12 +29,10 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Clear.render(popup_area, buf);
let title = match self.wizard.step {
WizardStep::Preview => " Import Wizard — Preview ",
WizardStep::SelectArrayPath => " Import Wizard — Select Array ",
WizardStep::ReviewProposals => " Import Wizard — Review Fields ",
WizardStep::ConfigureDates => " Import Wizard — Date Components ",
WizardStep::DefineFormulas => " Import Wizard — Formulas ",
WizardStep::NameModel => " Import Wizard — Name Model ",
WizardStep::Preview => " Import Wizard — Step 1: Preview ",
WizardStep::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardStep::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardStep::NameModel => " Import Wizard — Step 4: Name Model ",
WizardStep::Done => " Import Wizard — Done ",
};
@ -160,152 +158,6 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Style::default().fg(Color::DarkGray),
);
}
WizardStep::ConfigureDates => {
buf.set_string(
x,
y,
"Select date components to extract (Space toggle):",
Style::default().fg(Color::Yellow),
);
y += 1;
let tc_proposals = self.wizard.time_category_proposals();
let mut item_idx = 0;
for proposal in &tc_proposals {
if y >= inner.y + inner.height - 2 {
break;
}
let fmt_str = proposal.date_format.as_deref().unwrap_or("?");
let header = format!(" {} (format: {})", proposal.field, fmt_str);
buf.set_string(
x,
y,
truncate(&header, w),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
y += 1;
for component in &[
DateComponent::Year,
DateComponent::Month,
DateComponent::Quarter,
] {
if y >= inner.y + inner.height - 2 {
break;
}
let enabled = proposal.date_components.contains(component);
let check = if enabled { "[\u{2713}]" } else { "[ ]" };
let label = match component {
DateComponent::Year => "Year",
DateComponent::Month => "Month",
DateComponent::Quarter => "Quarter",
};
let row = format!(" {} {}", check, label);
let is_sel = item_idx == self.wizard.cursor;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if enabled {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(x, y, truncate(&row, w), style);
y += 1;
item_idx += 1;
}
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(
x,
hint_y,
"Space: toggle Enter: next Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStep::DefineFormulas => {
buf.set_string(
x,
y,
"Define formulas (optional):",
Style::default().fg(Color::Yellow),
);
y += 1;
// Show existing formulas
if self.wizard.pipeline.formulas.is_empty() && !self.wizard.formula_editing {
buf.set_string(
x,
y,
" (no formulas yet)",
Style::default().fg(Color::DarkGray),
);
y += 1;
}
for (i, formula) in self.wizard.pipeline.formulas.iter().enumerate() {
if y >= inner.y + inner.height - 5 {
break;
}
let is_sel = i == self.wizard.cursor && !self.wizard.formula_editing;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
buf.set_string(x, y, truncate(&format!(" {}", formula), w), style);
y += 1;
}
// Formula input area
if self.wizard.formula_editing {
y += 1;
buf.set_string(
x,
y,
"Formula (e.g., Profit = Revenue - Cost):",
Style::default().fg(Color::Yellow),
);
y += 1;
let input = format!("> {}\u{2588}", self.wizard.formula_buffer);
buf.set_string(x, y, truncate(&input, w), Style::default().fg(Color::Green));
y += 1;
}
// Sample formulas
let samples = self.wizard.sample_formulas();
if !samples.is_empty() {
y += 1;
buf.set_string(x, y, "Examples:", Style::default().fg(Color::DarkGray));
y += 1;
for sample in &samples {
if y >= inner.y + inner.height - 1 {
break;
}
buf.set_string(
x,
y,
truncate(&format!(" {}", sample), w),
Style::default().fg(Color::DarkGray),
);
y += 1;
}
}
let hint_y = inner.y + inner.height - 1;
let hint = if self.wizard.formula_editing {
"Enter: add Esc: cancel"
} else {
"n: new formula d: delete Enter: next Esc: cancel"
};
buf.set_string(x, hint_y, hint, Style::default().fg(Color::DarkGray));
}
WizardStep::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;

View File

@ -1,6 +1,5 @@
pub mod app;
pub mod category_panel;
pub mod effect;
pub mod formula_panel;
pub mod grid;
pub mod help;

View File

@ -14,23 +14,17 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
Axis::Row => ("", Color::Green),
Axis::Column => ("", Color::Blue),
Axis::Page => ("", Color::Magenta),
Axis::None => ("", Color::DarkGray),
}
}
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub tile_cat_idx: usize,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
Self {
model,
mode,
tile_cat_idx,
}
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
}
}
@ -38,8 +32,8 @@ impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view();
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
Some(self.tile_cat_idx)
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
} else {
None
};
@ -71,7 +65,7 @@ impl<'a> Widget for TileBar<'a> {
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
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));

View File

@ -6,7 +6,6 @@ pub enum Axis {
Row,
Column,
Page,
None,
}
impl std::fmt::Display for Axis {
@ -15,7 +14,6 @@ impl std::fmt::Display for Axis {
Axis::Row => write!(f, "Row ↕"),
Axis::Column => write!(f, "Col ↔"),
Axis::Page => write!(f, "Page ☰"),
Axis::None => write!(f, "None ∅"),
}
}
}

View File

@ -1,4 +1,4 @@
use crate::model::cell::{CellKey, CellValue};
use crate::model::cell::CellKey;
use crate::model::Model;
use crate::view::{Axis, View};
@ -27,35 +27,9 @@ pub struct GridLayout {
pub page_coords: Vec<(String, String)>,
pub row_items: Vec<AxisEntry>,
pub col_items: Vec<AxisEntry>,
/// Categories on `Axis::None` — hidden, implicitly aggregated.
pub none_cats: Vec<String>,
/// In records mode: the filtered cell list, one per row.
/// None for normal pivot views.
pub records: Option<Vec<(CellKey, CellValue)>>,
}
impl GridLayout {
/// Build a layout. When records-mode is active and `frozen_records`
/// is provided, use that snapshot instead of re-querying the store.
pub fn with_frozen_records(
model: &Model,
view: &View,
frozen_records: Option<Vec<(CellKey, CellValue)>>,
) -> Self {
let mut layout = Self::new(model, view);
if layout.is_records_mode() {
if let Some(records) = frozen_records {
// Re-build with the frozen records instead
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
layout.row_items = row_items;
layout.records = Some(records);
}
}
layout
}
pub fn new(model: &Model, view: &View) -> Self {
let row_cats: Vec<String> = view
.categories_on(Axis::Row)
@ -72,11 +46,6 @@ impl GridLayout {
.into_iter()
.map(String::from)
.collect();
let none_cats: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
let page_coords = page_cats
.iter()
@ -99,107 +68,18 @@ impl GridLayout {
})
.collect();
// Detect records mode: _Index on Row and _Dim on Col
let is_records_mode =
row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");
if is_records_mode {
Self::build_records_mode(model, view, page_coords, none_cats)
} else {
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
Self {
row_cats,
col_cats,
page_coords,
row_items,
col_items,
none_cats,
records: None,
}
}
}
/// Build a records-mode layout: rows are individual cells, columns are
/// category names + "Value". Cells matching the page filter are enumerated.
fn build_records_mode(
model: &Model,
_view: &View,
page_coords: Vec<(String, String)>,
none_cats: Vec<String>,
) -> Self {
// Filter cells by page_coords
let partial: Vec<(String, String)> = page_coords.clone();
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
model
.data
.matching_cells(&partial)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
// Sort for deterministic ordering
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
// Synthesize row items: one per record, labeled with its index
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
// Synthesize col items: one per category + "Value"
let cat_names: Vec<String> = model
.category_names()
.into_iter()
.map(String::from)
.collect();
let mut col_items: Vec<AxisEntry> = cat_names
.iter()
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
.collect();
col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
Self {
row_cats: vec!["_Index".to_string()],
col_cats: vec!["_Dim".to_string()],
row_cats,
col_cats,
page_coords,
row_items,
col_items,
none_cats,
records: Some(records),
}
}
/// Get the display string for the cell at (row, col) in records mode.
/// Returns None for normal (non-records) layouts.
pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
let records = self.records.as_ref()?;
let record = records.get(row)?;
let col_item = self.col_label(col);
if col_item == "Value" {
Some(record.1.to_string())
} else {
// col_item is a category name
let found = record
.0
.0
.iter()
.find(|(c, _)| c == &col_item)
.map(|(_, v)| v.clone());
Some(found.unwrap_or_default())
}
}
/// Whether this layout is in records mode.
pub fn is_records_mode(&self) -> bool {
self.records.is_some()
}
/// Number of data rows (group headers excluded).
pub fn row_count(&self) -> usize {
self.row_items
@ -248,17 +128,7 @@ impl GridLayout {
/// Build the CellKey for the data cell at (row, col), including the active
/// page-axis filter. Returns None if row or col is out of bounds.
/// In records mode: returns the real underlying CellKey when the column
/// is "Value" (editable); returns None for coord columns (read-only).
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
if let Some(records) = &self.records {
// Records mode: only the Value column maps to a real, editable cell.
if self.col_label(col) == "Value" {
return records.get(row).map(|(k, _)| k.clone());
} else {
return None;
}
}
let row_item = self
.row_items
.iter()
@ -318,40 +188,6 @@ impl GridLayout {
}
None
}
/// Find the group containing the Nth data row.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
let vi = self.data_row_to_visual(data_row)?;
self.row_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
/// Find the group containing the Nth data column.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
let vi = self.data_col_to_visual(data_col)?;
self.col_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
}
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
@ -387,7 +223,7 @@ fn expand_category(
}
// Skip the data item if its group is collapsed.
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
continue;
}
@ -424,79 +260,6 @@ mod tests {
use super::{AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
fn records_model() -> Model {
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("Measure").unwrap().add_item("Revenue");
m.category_mut("Measure").unwrap().add_item("Cost");
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("Measure".into(), "Revenue".into()),
]),
CellValue::Number(100.0),
);
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("Measure".into(), "Cost".into()),
]),
CellValue::Number(50.0),
);
m
}
#[test]
fn records_mode_activated_when_index_and_dim_on_axes() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode());
assert_eq!(layout.row_count(), 2); // 2 cells
}
#[test]
fn records_mode_cell_key_editable_for_value_column() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode());
// Find the "Value" column index
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
let value_col = cols.iter().position(|c| c == "Value").unwrap();
// cell_key should be Some for Value column
let key = layout.cell_key(0, value_col);
assert!(key.is_some(), "Value column should be editable");
// cell_key should be None for coord columns
let region_col = cols.iter().position(|c| c == "Region").unwrap();
assert!(
layout.cell_key(0, region_col).is_none(),
"Region column should not be editable"
);
}
#[test]
fn records_mode_cell_key_maps_to_real_cell() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
let value_col = cols.iter().position(|c| c == "Value").unwrap();
// The CellKey at (0, Value) should look up a real cell value
let key = layout.cell_key(0, value_col).unwrap();
let val = m.evaluate(&key);
assert!(val.is_some(), "cell_key should resolve to a real cell");
}
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
@ -720,91 +483,4 @@ mod tests {
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
assert_eq!(layout.data_row_to_visual(2), None);
}
#[test]
fn data_col_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
assert_eq!(layout.data_col_to_visual(1), Some(3));
assert_eq!(layout.data_col_to_visual(2), None);
}
#[test]
fn row_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.row_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.row_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn row_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.row_group_for(0), None);
}
#[test]
fn col_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.col_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.col_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn col_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.col_group_for(0), None);
}
}

View File

@ -1,7 +1,7 @@
pub mod axis;
pub mod layout;
pub mod types;
pub mod view;
pub use axis::Axis;
pub use layout::{AxisEntry, GridLayout};
pub use types::View;
pub use view::View;

View File

@ -41,20 +41,15 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Virtual categories (names starting with `_`) default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
let axis = if cat_name.starts_with('_') {
Axis::None
// 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 {
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
} else {
Axis::Page
}
Axis::Page
};
self.category_axes.insert(cat_name.to_string(), axis);
}
@ -153,13 +148,12 @@ impl View {
self.col_offset = 0;
}
/// Cycle axis for a category: Row → Column → Page → None → Row
/// Cycle axis for a category: Row → Column → Page → Row
pub fn cycle_axis(&mut self, cat_name: &str) {
let next = match self.axis_of(cat_name) {
Axis::Row => Axis::Column,
Axis::Column => Axis::Page,
Axis::Page => Axis::None,
Axis::None => Axis::Row,
Axis::Page => Axis::Row,
};
self.set_axis(cat_name, next);
self.selected = (0, 0);
@ -308,17 +302,9 @@ mod tests {
}
#[test]
fn cycle_axis_page_to_none() {
fn cycle_axis_page_to_row() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::None);
}
#[test]
fn cycle_axis_none_to_row() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.set_axis("Time", Axis::None);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::Row);
}
@ -365,7 +351,7 @@ mod prop_tests {
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
let all_axes = [Axis::Row, Axis::Column, Axis::Page];
for c in &cats {
let count = all_axes.iter()
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
@ -391,7 +377,7 @@ mod prop_tests {
fn set_axis_updates_axis_of(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
@ -406,7 +392,7 @@ mod prop_tests {
fn set_axis_exclusive(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }