Compare commits
75 Commits
experiment
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94bc3ca282 | |||
| f56ca2c66a | |||
| 78df3a4949 | |||
| 19645a34cf | |||
| 67041dd4a5 | |||
| b2d633eb7d | |||
| 401a63f544 | |||
| 3d11daca18 | |||
| ab5f3a5a86 | |||
| 377d417e5a | |||
| 872c4c6c5d | |||
| d3a1a57c78 | |||
| 82ad459c4e | |||
| 6c211e5421 | |||
| 0f1de6ba58 | |||
| 89fdb27d6c | |||
| e2ff9cf98e | |||
| c6c8ac2c69 | |||
| 35946afc91 | |||
| 649d80cb35 | |||
| a45390b7a9 | |||
| 830869d91c | |||
| 32716ebc16 | |||
| 00c62d85b7 | |||
| 64ab352490 | |||
| 909c20bcbd | |||
| 1e8bc7a135 | |||
| 2be1eeae5d | |||
| b8cff2488c | |||
| 4941b6f44c | |||
| 630367a9b0 | |||
| 3c561adf05 | |||
| 0db89b1e3a | |||
| d8f7d9a501 | |||
| ebe8df89ee | |||
| 5cd3cf3c18 | |||
| e976b3c49a | |||
| b7e4316cef | |||
| 56839b81d2 | |||
| 67fca18200 | |||
| 387190c9f7 | |||
| dfae4a882d | |||
| 9afa13f78a | |||
| bfc30cb7b2 | |||
| c188ce3f9d | |||
| f2bb8ec2a7 | |||
| 038c99c473 | |||
| f7436e73ba | |||
| 0c751b7b8b | |||
| 9421d01da5 | |||
| 567ca341f7 | |||
| 6647be30fa | |||
| ac0c538c98 | |||
| 9f5b7f602a | |||
| 4525753109 | |||
| 4233d3fbf4 | |||
| a73fe160c7 | |||
| 5a251a1cbe | |||
| dd728ccac8 | |||
| 77b33b7a85 | |||
| e831648b18 | |||
| be277f43c2 | |||
| edd6431444 | |||
| 5136aadd86 | |||
| b9da06c55f | |||
| edd33d6dee | |||
| 2c9d9c7de7 | |||
| 368b303eac | |||
| fe74cc5fcb | |||
| b9818204a4 | |||
| 1d5edd2c09 | |||
| da93145de5 | |||
| fcfdc09732 | |||
| 23e26f0e06 | |||
| 2cf1123bcb |
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,3 +3,8 @@ target/
|
||||
.DS_Store
|
||||
/result
|
||||
.direnv
|
||||
[#]*
|
||||
symbols.json
|
||||
profile.json
|
||||
profile.json.gz
|
||||
bench/*.txt
|
||||
|
||||
138
Cargo.lock
generated
138
Cargo.lock
generated
@ -23,6 +23,56 @@ 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"
|
||||
@ -107,6 +157,52 @@ 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"
|
||||
@ -161,6 +257,27 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||
dependencies = [
|
||||
"csv-core",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@ -373,7 +490,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"csv",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
@ -381,6 +500,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
@ -419,6 +539,12 @@ 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"
|
||||
@ -544,6 +670,12 @@ 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"
|
||||
@ -1017,6 +1149,12 @@ 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"
|
||||
|
||||
@ -21,12 +21,20 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
unicode-width = "0.2"
|
||||
dirs = "5"
|
||||
csv = "1"
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
strip = false
|
||||
debug = 2
|
||||
|
||||
83
bench/gen_workload.py
Normal file
83
bench/gen_workload.py
Normal file
@ -0,0 +1,83 @@
|
||||
#!/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
260
flake.lock
generated
@ -1,5 +1,111 @@
|
||||
{
|
||||
"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"
|
||||
@ -18,7 +124,128 @@
|
||||
"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=",
|
||||
@ -34,7 +261,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1774794121,
|
||||
"narHash": "sha256-gih24b728CK8twDNU7VX9vVYK2tLEXvy9gm/GKq2VeE=",
|
||||
@ -50,16 +277,43 @@
|
||||
"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",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774926780,
|
||||
|
||||
77
flake.nix
77
flake.nix
@ -7,6 +7,7 @@
|
||||
url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
crate2nix.url = "github:nix-community/crate2nix";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
@ -14,65 +15,37 @@
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
flake-utils,
|
||||
crate2nix,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
overlays = [(import rust-overlay)];
|
||||
pkgs = import nixpkgs {inherit system overlays;};
|
||||
isLinux = pkgs.lib.hasInfix "linux" system;
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [(import rust-overlay)];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = ["rust-src" "clippy" "rustfmt"];
|
||||
targets = pkgs.lib.optionals isLinux ["x86_64-unknown-linux-musl"];
|
||||
};
|
||||
|
||||
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
|
||||
name = "improvise";
|
||||
src = ./.;
|
||||
};
|
||||
|
||||
cargoNix = import generatedCargoNix {
|
||||
pkgs = pkgs;
|
||||
};
|
||||
in {
|
||||
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
|
||||
];
|
||||
devShells.default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
rustToolchain
|
||||
pkgs.pkg-config
|
||||
pkgs.rust-analyzer
|
||||
crate2nix.packages.${system}.default
|
||||
];
|
||||
RUST_BACKTRACE = "1";
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
packages.default = cargoNix.rootCrate.build;
|
||||
});
|
||||
}
|
||||
|
||||
2907
src/command/cmd.rs
Normal file
2907
src/command/cmd.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,230 +0,0 @@
|
||||
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_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_json_headless(
|
||||
model: &mut Model,
|
||||
path: &str,
|
||||
model_name: Option<&str>,
|
||||
array_path: Option<&str>,
|
||||
) -> CommandResult {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
|
||||
};
|
||||
let value: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
|
||||
};
|
||||
|
||||
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
|
||||
match extract_array_at_path(&value, ap) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return CommandResult::err(format!("No array at path '{ap}'")),
|
||||
}
|
||||
} else if let Some(arr) = value.as_array() {
|
||||
arr.clone()
|
||||
} else {
|
||||
// Find first array
|
||||
let paths = crate::import::analyzer::find_array_paths(&value);
|
||||
if let Some(first) = paths.first() {
|
||||
match extract_array_at_path(&value, first) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return CommandResult::err("Could not extract records array"),
|
||||
}
|
||||
} else {
|
||||
return CommandResult::err("No array found in JSON");
|
||||
}
|
||||
};
|
||||
|
||||
let proposals = analyze_records(&records);
|
||||
|
||||
// Auto-accept all and build via ImportPipeline
|
||||
let pipeline = crate::import::wizard::ImportPipeline {
|
||||
raw: value,
|
||||
array_paths: vec![],
|
||||
selected_path: array_path.unwrap_or("").to_string(),
|
||||
records,
|
||||
proposals: proposals
|
||||
.into_iter()
|
||||
.map(|mut p| {
|
||||
p.accepted = p.kind != FieldKind::Label;
|
||||
p
|
||||
})
|
||||
.collect(),
|
||||
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
||||
};
|
||||
|
||||
match pipeline.build_model() {
|
||||
Ok(new_model) => {
|
||||
*model = new_model;
|
||||
CommandResult::ok_msg("JSON imported successfully")
|
||||
}
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
612
src/command/keymap.rs
Normal file
612
src/command/keymap.rs
Normal file
@ -0,0 +1,612 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
//! Command layer — all model mutations go through this layer so they can be
|
||||
//! replayed, scripted, and tested without the TUI.
|
||||
//!
|
||||
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
|
||||
//! The headless CLI (--cmd / --script) routes through here, and the TUI
|
||||
//! App also calls dispatch() for every user action that mutates state.
|
||||
//! 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.
|
||||
|
||||
pub mod dispatch;
|
||||
pub mod types;
|
||||
pub mod cmd;
|
||||
pub mod keymap;
|
||||
pub mod parse;
|
||||
|
||||
pub use dispatch::dispatch;
|
||||
pub use types::{Command, CommandResult};
|
||||
pub use parse::parse_line;
|
||||
|
||||
184
src/command/parse.rs
Normal file
184
src/command/parse.rs
Normal file
@ -0,0 +1,184 @@
|
||||
//! 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(®istry, 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());
|
||||
}
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
400
src/draw.rs
Normal file
400
src/draw.rs
Normal file
@ -0,0 +1,400 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(|w| parse_where(w)).transpose()?;
|
||||
let filter = filter.map(parse_where).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.to_ascii_uppercase() == "WHERE" {
|
||||
if kw.eq_ignore_ascii_case("WHERE") {
|
||||
*pos += 1;
|
||||
let cat = match &tokens[*pos] {
|
||||
Token::Ident(s) => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@ -13,12 +14,24 @@ 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 {
|
||||
@ -32,6 +45,55 @@ 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> {
|
||||
@ -65,6 +127,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
kind: FieldKind::Measure,
|
||||
distinct_values: vec![],
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
};
|
||||
}
|
||||
|
||||
@ -72,26 +136,19 @@ 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();
|
||||
|
||||
// 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))
|
||||
});
|
||||
// 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);
|
||||
|
||||
if looks_like_date {
|
||||
if date_format.is_some() {
|
||||
return FieldProposal {
|
||||
field,
|
||||
kind: FieldKind::TimeCategory,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: true,
|
||||
date_format,
|
||||
date_components: vec![],
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,6 +158,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
kind: FieldKind::Category,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
};
|
||||
}
|
||||
|
||||
@ -109,6 +168,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
kind: FieldKind::Label,
|
||||
distinct_values: distinct_vec,
|
||||
accepted: false,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
};
|
||||
}
|
||||
|
||||
@ -118,6 +179,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
kind: FieldKind::Label,
|
||||
distinct_values: vec![],
|
||||
accepted: false,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@ -160,3 +223,70 @@ 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()));
|
||||
}
|
||||
}
|
||||
|
||||
244
src/import/csv_parser.rs
Normal file
244
src/import/csv_parser.rs
Normal file
@ -0,0 +1,244 @@
|
||||
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>> {
|
||||
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()))?;
|
||||
|
||||
// Detect if first row looks like headers (strings) or data (mixed)
|
||||
let has_headers = reader.headers().is_ok();
|
||||
|
||||
let mut records = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
|
||||
if has_headers {
|
||||
headers = reader
|
||||
.headers()
|
||||
.with_context(|| "Failed to read CSV headers")?
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for result in reader.records() {
|
||||
let record = result.with_context(|| "Failed to read CSV record")?;
|
||||
let mut map = serde_json::Map::new();
|
||||
|
||||
for (i, field) in record.iter().enumerate() {
|
||||
let json_value: Value = parse_csv_field(field);
|
||||
if has_headers {
|
||||
if let Some(header) = headers.get(i) {
|
||||
map.insert(header.clone(), json_value);
|
||||
}
|
||||
} else {
|
||||
map.insert(i.to_string(), json_value);
|
||||
}
|
||||
}
|
||||
|
||||
if !map.is_empty() {
|
||||
records.push(Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Try to parse as number (integer or float)
|
||||
if let Ok(num) = field.parse::<i64>() {
|
||||
return Value::Number(serde_json::Number::from(num));
|
||||
}
|
||||
|
||||
if let Ok(num) = field.parse::<f64>() {
|
||||
return Value::Number(
|
||||
serde_json::Number::from_f64(num).unwrap_or(serde_json::Number::from(0)),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as string
|
||||
Value::String(field.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, path::PathBuf};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_temp_csv(content: &str) -> (PathBuf, tempfile::TempDir) {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("test.csv");
|
||||
fs::write(&path, content).unwrap();
|
||||
(path, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_simple_csv() {
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_floats() {
|
||||
let (path, _dir) =
|
||||
create_temp_csv("Region,Revenue,Cost\nEast,1000.50,600.25\nWest,800.75,500.00");
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_quoted_fields() {
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_empty_values() {
|
||||
let (path, _dir) = create_temp_csv("Region,Product,Revenue\nEast,,1000\nWest,Shirts,");
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Product"], Value::Null);
|
||||
assert_eq!(records[1]["Revenue"], Value::Null);
|
||||
}
|
||||
|
||||
#[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 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!(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
|
||||
let (path, _dir) = create_temp_csv(
|
||||
"Date,Amount,Flag,CheckNo,Description\n\
|
||||
\"03/31/2026\",\"-50.00\",\"*\",\"\",\"VENMO PAYMENT 260331\"\n\
|
||||
\"03/31/2026\",\"-240.00\",\"*\",\"\",\"ROBINHOOD DEBITS XXXXX3795\"",
|
||||
);
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
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]["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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod analyzer;
|
||||
pub mod csv_parser;
|
||||
pub mod wizard;
|
||||
|
||||
@ -2,8 +2,10 @@ use anyhow::{anyhow, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::analyzer::{
|
||||
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
|
||||
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
|
||||
DateComponent, FieldKind, FieldProposal,
|
||||
};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
|
||||
@ -19,6 +21,8 @@ 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 {
|
||||
@ -31,6 +35,7 @@ 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.
|
||||
@ -94,6 +99,30 @@ 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 {
|
||||
@ -105,6 +134,11 @@ 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") {
|
||||
@ -130,7 +164,19 @@ impl ImportPipeline {
|
||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
||||
cat.add_item(&v);
|
||||
}
|
||||
coords.push((cat_proposal.field.clone(), 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
valid = false;
|
||||
break;
|
||||
@ -151,6 +197,24 @@ 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)
|
||||
}
|
||||
}
|
||||
@ -162,6 +226,8 @@ pub enum WizardStep {
|
||||
Preview,
|
||||
SelectArrayPath,
|
||||
ReviewProposals,
|
||||
ConfigureDates,
|
||||
DefineFormulas,
|
||||
NameModel,
|
||||
Done,
|
||||
}
|
||||
@ -177,6 +243,10 @@ 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 {
|
||||
@ -196,6 +266,8 @@ impl ImportWizard {
|
||||
step,
|
||||
cursor: 0,
|
||||
message: None,
|
||||
formula_editing: false,
|
||||
formula_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,7 +283,15 @@ impl ImportWizard {
|
||||
}
|
||||
}
|
||||
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
|
||||
WizardStep::ReviewProposals => WizardStep::NameModel,
|
||||
WizardStep::ReviewProposals => {
|
||||
if self.has_time_categories() {
|
||||
WizardStep::ConfigureDates
|
||||
} else {
|
||||
WizardStep::DefineFormulas
|
||||
}
|
||||
}
|
||||
WizardStep::ConfigureDates => WizardStep::DefineFormulas,
|
||||
WizardStep::DefineFormulas => WizardStep::NameModel,
|
||||
WizardStep::NameModel => WizardStep::Done,
|
||||
WizardStep::Done => WizardStep::Done,
|
||||
};
|
||||
@ -219,6 +299,22 @@ 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();
|
||||
@ -233,6 +329,8 @@ 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 {
|
||||
@ -275,6 +373,130 @@ 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> {
|
||||
@ -410,4 +632,70 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
859
src/main.rs
859
src/main.rs
@ -1,4 +1,5 @@
|
||||
mod command;
|
||||
mod draw;
|
||||
mod formula;
|
||||
mod import;
|
||||
mod model;
|
||||
@ -6,200 +7,350 @@ mod persistence;
|
||||
mod ui;
|
||||
mod view;
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
use crate::import::csv_parser::csv_path_p;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, 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 clap::{Parser, Subcommand};
|
||||
|
||||
use draw::run_tui;
|
||||
use model::Model;
|
||||
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;
|
||||
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>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let arg_config = parse_args(args);
|
||||
arg_config.run()
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
|
||||
trait Runnable {
|
||||
fn run(self: Box<Self>) -> Result<()>;
|
||||
}
|
||||
match cli.command {
|
||||
None => {
|
||||
let model = get_initial_model(&cli.file)?;
|
||||
run_tui(model, cli.file, None)
|
||||
}
|
||||
|
||||
struct CmdLineArgs {
|
||||
file_path: Option<PathBuf>,
|
||||
import_path: Option<PathBuf>,
|
||||
}
|
||||
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"))?
|
||||
};
|
||||
|
||||
impl Runnable for CmdLineArgs {
|
||||
fn run(self: Box<Self>) -> Result<()> {
|
||||
// Load or create model
|
||||
let model = get_initial_model(&self.file_path)?;
|
||||
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,
|
||||
};
|
||||
|
||||
// Pre-TUI import: parse JSON and open wizard
|
||||
let import_json = 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) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Err(e) => {
|
||||
eprintln!("JSON parse error: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(json) => Some(json),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
run_tui(model, self.file_path, import_json)
|
||||
}
|
||||
}
|
||||
|
||||
struct HeadlessArgs {
|
||||
file_path: Option<PathBuf>,
|
||||
commands: Vec<String>,
|
||||
script: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if no_wizard {
|
||||
run_headless_import(import_value, &config, output, cli.file)
|
||||
} else {
|
||||
run_wizard_import(import_value, &config, cli.file)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
Some(Commands::Cmd { json, file }) => run_headless_commands(&json, &file),
|
||||
|
||||
Some(Commands::Script { path, file }) => run_headless_script(&path, &file),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, config: &ImportConfig) {
|
||||
use import::analyzer::{DateComponent, FieldKind};
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(&content) {
|
||||
Err(e) => {
|
||||
eprintln!("JSON parse error: {e}");
|
||||
None
|
||||
}
|
||||
Ok(json) => Some(json),
|
||||
}
|
||||
}
|
||||
};
|
||||
let result = command::dispatch(&mut model, &parsed);
|
||||
if !result.ok {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Headless command execution ───────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Parse error: {e}");
|
||||
exit_code = 1;
|
||||
}
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
}
|
||||
|
||||
if let Some(path) = self.file_path {
|
||||
persistence::save(&mut model, &path)?;
|
||||
}
|
||||
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
if let Some(path) = file {
|
||||
persistence::save(&app.model, path)?;
|
||||
}
|
||||
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
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 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(())
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
|
||||
if let Some(ref path) = file_path {
|
||||
@ -220,369 +371,3 @@ 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_json: 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_json {
|
||||
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 draw(f: &mut Frame, app: &App) {
|
||||
let size = f.area();
|
||||
|
||||
let is_cmd_mode = matches!(app.mode, AppMode::CommandMode { .. });
|
||||
|
||||
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);
|
||||
|
||||
if is_cmd_mode {
|
||||
draw_command_bar(f, main_chunks[3], app);
|
||||
} else {
|
||||
draw_status(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], app);
|
||||
}
|
||||
}
|
||||
|
||||
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 pad = " ".repeat((area.width as usize).saturating_sub(title.len() + right.len()));
|
||||
let line = format!("{title}{pad}{right}");
|
||||
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;
|
||||
|
||||
if side_open {
|
||||
let side_w = 32u16;
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
GridWidget::new(&app.model, &app.mode, &app.search_query),
|
||||
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 {
|
||||
f.render_widget(
|
||||
GridWidget::new(&app.model, &app.mode, &app.search_query),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
f.render_widget(TileBar::new(&app.model, &app.mode), area);
|
||||
}
|
||||
|
||||
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
let mode_badge = match &app.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",
|
||||
};
|
||||
|
||||
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!(" {mode_badge}{search_part} {msg}");
|
||||
let right = view_badge;
|
||||
let pad = " ".repeat((area.width as usize).saturating_sub(left.len() + right.len()));
|
||||
let line = format!("{left}{pad}{right}");
|
||||
|
||||
let badge_style = match &app.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),
|
||||
};
|
||||
|
||||
f.render_widget(Paragraph::new(line).style(badge_style), area);
|
||||
}
|
||||
|
||||
fn draw_command_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
let buf = if let AppMode::CommandMode { buffer } = &app.mode {
|
||||
buffer.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let line = format!(":{buf}▌");
|
||||
f.render_widget(
|
||||
Paragraph::new(line).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_w = 64u16.min(area.width);
|
||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||
let y = area.y + area.height / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, 3);
|
||||
|
||||
f.render_widget(Clear, popup_area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title(" Export CSV — path (Esc cancel) ");
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
|
||||
let w = 58u16.min(area.width.saturating_sub(4));
|
||||
let h = 20u16.min(area.height.saturating_sub(2));
|
||||
let x = area.x + area.width.saturating_sub(w) / 2;
|
||||
let y = area.y + area.height.saturating_sub(h) / 2;
|
||||
let popup = Rect::new(x, y, w, h);
|
||||
|
||||
f.render_widget(Clear, popup);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Blue))
|
||||
.title(" Welcome to improvise ");
|
||||
let inner = block.inner(popup);
|
||||
f.render_widget(block, popup);
|
||||
|
||||
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.json> Import a JSON 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,25 @@ 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,
|
||||
@ -58,6 +77,9 @@ 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 {
|
||||
@ -68,9 +90,15 @@ 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) {
|
||||
@ -105,31 +133,10 @@ 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)]
|
||||
@ -185,30 +192,6 @@ 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();
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::symbol::{Symbol, SymbolTable};
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
/// Sorted by category name for canonical form.
|
||||
@ -41,6 +43,7 @@ impl CellKey {
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||
partial
|
||||
.iter()
|
||||
@ -85,11 +88,22 @@ 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 {
|
||||
cells: HashMap<CellKey, CellValue>,
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl Serialize for DataStore {
|
||||
@ -97,7 +111,8 @@ impl Serialize for DataStore {
|
||||
use serde::ser::SerializeSeq;
|
||||
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||
for (k, v) in &self.cells {
|
||||
seq.serialize_element(&(k, v))?;
|
||||
let cell_key = self.to_cell_key(k);
|
||||
seq.serialize_element(&(cell_key, v))?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
@ -106,8 +121,11 @@ 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 cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
|
||||
Ok(DataStore { cells })
|
||||
let mut store = DataStore::default();
|
||||
for (key, value) in pairs {
|
||||
store.set(key, value);
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,27 +134,145 @@ 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) {
|
||||
self.cells.insert(key, value);
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
|
||||
self.cells.get(key)
|
||||
let ikey = self.lookup_key(key)?;
|
||||
self.cells.get(&ikey)
|
||||
}
|
||||
|
||||
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
|
||||
&self.cells
|
||||
/// 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 remove(&mut self, key: &CellKey) {
|
||||
self.cells.remove(key);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All cells where partial coords match
|
||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||
self.cells
|
||||
/// 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
|
||||
.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.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))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@ -285,7 +421,7 @@ mod data_store {
|
||||
let k = key(&[("Region", "East")]);
|
||||
store.set(k.clone(), CellValue::Number(5.0));
|
||||
store.remove(&k);
|
||||
assert!(store.cells().is_empty());
|
||||
assert!(store.iter_cells().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod model;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
pub use model::Model;
|
||||
pub use types::Model;
|
||||
|
||||
79
src/model/symbol.rs
Normal file
79
src/model/symbol.rs
Normal file
@ -0,0 +1,79 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
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::Formula;
|
||||
use crate::formula::{AggFunc, Formula};
|
||||
use crate::view::View;
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
@ -18,28 +20,56 @@ 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);
|
||||
Self {
|
||||
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 {
|
||||
name,
|
||||
categories: IndexMap::new(),
|
||||
categories,
|
||||
data: DataStore::new(),
|
||||
formulas: Vec::new(),
|
||||
views,
|
||||
active_view: "Default".to_string(),
|
||||
next_category_id: 0,
|
||||
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");
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||
let name = name.into();
|
||||
if self.categories.len() >= MAX_CATEGORIES {
|
||||
// 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 {
|
||||
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
|
||||
}
|
||||
if self.categories.contains_key(&name) {
|
||||
@ -150,6 +180,7 @@ 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()
|
||||
}
|
||||
@ -172,6 +203,59 @@ 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};
|
||||
|
||||
@ -243,9 +327,9 @@ impl Model {
|
||||
}
|
||||
let values: Vec<f64> = model
|
||||
.data
|
||||
.matching_cells(&partial.0)
|
||||
.matching_values(&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()),
|
||||
@ -339,7 +423,8 @@ mod model_tests {
|
||||
let id1 = m.add_category("Region").unwrap();
|
||||
let id2 = m.add_category("Region").unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
assert_eq!(m.categories.len(), 1);
|
||||
// Region + 2 virtuals (_Index, _Dim)
|
||||
assert_eq!(m.category_names().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -490,6 +575,79 @@ 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)]
|
||||
@ -1234,12 +1392,14 @@ mod five_category {
|
||||
#[test]
|
||||
fn five_categories_well_within_limit() {
|
||||
let m = build_model();
|
||||
assert_eq!(m.categories.len(), 5);
|
||||
// 5 regular + 2 virtual (_Index, _Dim)
|
||||
assert_eq!(m.category_names().len(), 7);
|
||||
let mut m2 = build_model();
|
||||
for i in 0..7 {
|
||||
m2.add_category(format!("Extra{i}")).unwrap();
|
||||
}
|
||||
assert_eq!(m2.categories.len(), 12);
|
||||
// 12 regular + 2 virtuals = 14
|
||||
assert_eq!(m2.category_names().len(), 14);
|
||||
assert!(m2.add_category("OneMore").is_err());
|
||||
}
|
||||
}
|
||||
@ -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.cells().iter().collect();
|
||||
let mut cells: Vec<_> = model.data.iter_cells().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,6 +117,7 @@ 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() {
|
||||
@ -315,6 +316,7 @@ 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));
|
||||
@ -457,11 +459,15 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||
}
|
||||
let row_values: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| {
|
||||
layout
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|key| model.evaluate(&key))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
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()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&row_values.join(","));
|
||||
|
||||
1664
src/ui/app.rs
1664
src/ui/app.rs
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
817
src/ui/effect.rs
Normal file
817
src/ui/effect.rs
Normal file
@ -0,0 +1,817 @@
|
||||
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))
|
||||
}
|
||||
262
src/ui/grid.rs
262
src/ui/grid.rs
@ -13,6 +13,10 @@ 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 = "▶";
|
||||
|
||||
@ -20,21 +24,44 @@ 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) -> Self {
|
||||
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 {
|
||||
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 layout = GridLayout::new(self.model, view);
|
||||
let frozen = self.drill_state.map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
let col_offset = view.col_offset;
|
||||
@ -43,6 +70,37 @@ 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)
|
||||
@ -79,23 +137,39 @@ impl<'a> GridWidget<'a> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 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()),
|
||||
}
|
||||
}
|
||||
groups
|
||||
};
|
||||
let has_col_groups = col_groups.iter().any(|g| g.is_some());
|
||||
let has_col_groups = layout
|
||||
.col_items
|
||||
.iter()
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
|
||||
|
||||
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());
|
||||
// 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;
|
||||
}
|
||||
acc += w;
|
||||
last = ci + 1;
|
||||
}
|
||||
let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count());
|
||||
|
||||
// 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 _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
||||
|
||||
@ -116,30 +190,37 @@ impl<'a> GridWidget<'a> {
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
Style::default(),
|
||||
);
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
let mut prev_group: Option<&str> = None;
|
||||
let mut prev_group: Option<String> = None;
|
||||
for ci in visible_col_range.clone() {
|
||||
let x = col_x_at(ci);
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
let group = col_groups[ci].as_deref();
|
||||
let label = if group != prev_group {
|
||||
group.unwrap_or("")
|
||||
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(),
|
||||
}
|
||||
} else {
|
||||
""
|
||||
String::new()
|
||||
};
|
||||
prev_group = group;
|
||||
prev_group = group_name;
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!(
|
||||
"{:<width$}",
|
||||
truncate(label, COL_WIDTH as usize),
|
||||
width = COL_WIDTH as usize
|
||||
),
|
||||
format!("{:<width$}", truncate(&label, cw), width = cw),
|
||||
group_style,
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
@ -155,8 +236,12 @@ 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 {
|
||||
@ -175,17 +260,9 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!(
|
||||
"{:>width$}",
|
||||
truncate(&label, COL_WIDTH as usize),
|
||||
width = COL_WIDTH as usize
|
||||
),
|
||||
format!("{:>width$}", truncate(&label, cw), width = cw),
|
||||
styled,
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
@ -229,29 +306,47 @@ impl<'a> GridWidget<'a> {
|
||||
),
|
||||
group_header_style,
|
||||
);
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
while x < area.x + area.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;
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!("{:─<width$}", "", width = COL_WIDTH as usize),
|
||||
format!("{:─<width$}", "", width = cw),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
let ri = data_row_idx;
|
||||
data_row_idx += 1;
|
||||
|
||||
let row_style = if ri == sel_row {
|
||||
let is_sel_row = ri == sel_row;
|
||||
let row_style = if is_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 {
|
||||
@ -276,22 +371,31 @@ 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 key = match layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
x += COL_WIDTH;
|
||||
continue;
|
||||
}
|
||||
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 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
|
||||
@ -305,6 +409,13 @@ 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 {
|
||||
@ -314,29 +425,21 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!(
|
||||
"{:>width$}",
|
||||
truncate(&cell_str, COL_WIDTH as usize),
|
||||
width = COL_WIDTH as usize
|
||||
),
|
||||
format!("{:>width$}", truncate(&cell_str, cw), width = cw),
|
||||
cell_style,
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
|
||||
// Edit indicator
|
||||
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
||||
if let AppMode::Editing { buffer } = self.mode {
|
||||
let edit_x = area.x
|
||||
+ ROW_HEADER_WIDTH
|
||||
+ (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
||||
{
|
||||
let 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;
|
||||
buf.set_string(
|
||||
edit_x,
|
||||
y,
|
||||
truncate(
|
||||
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
|
||||
COL_WIDTH as usize,
|
||||
),
|
||||
truncate(&format!("{:<width$}", buffer, width = cw), cw),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
@ -348,8 +451,8 @@ impl<'a> GridWidget<'a> {
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Total row
|
||||
if layout.row_count() > 0 && layout.col_count() > 0 {
|
||||
// Total row — numeric aggregation, only meaningful in pivot mode.
|
||||
if !layout.is_records_mode() && layout.row_count() > 0 && layout.col_count() > 0 {
|
||||
if y < area.y + area.height {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
@ -369,29 +472,25 @@ 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_f64(&key))
|
||||
.map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
||||
buf.set_string(
|
||||
x,
|
||||
y,
|
||||
format!(
|
||||
"{:>width$}",
|
||||
truncate(&total_str, COL_WIDTH as usize),
|
||||
width = COL_WIDTH as usize
|
||||
),
|
||||
format!("{:>width$}", truncate(&total_str, cw), width = cw),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -520,7 +619,8 @@ 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);
|
||||
GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf);
|
||||
let bufs = std::collections::HashMap::new();
|
||||
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
use crate::import::analyzer::FieldKind;
|
||||
use crate::import::analyzer::{DateComponent, FieldKind};
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
|
||||
pub struct ImportWizardWidget<'a> {
|
||||
@ -29,10 +29,12 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let title = match self.wizard.step {
|
||||
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::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::Done => " Import Wizard — Done ",
|
||||
};
|
||||
|
||||
@ -158,6 +160,152 @@ 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;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod category_panel;
|
||||
pub mod effect;
|
||||
pub mod formula_panel;
|
||||
pub mod grid;
|
||||
pub mod help;
|
||||
|
||||
@ -14,17 +14,23 @@ 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) -> Self {
|
||||
Self { model, mode }
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
tile_cat_idx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,8 +38,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 let AppMode::TileSelect { cat_idx } = self.mode {
|
||||
Some(*cat_idx)
|
||||
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
|
||||
Some(self.tile_cat_idx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@ -65,7 +71,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));
|
||||
|
||||
@ -6,6 +6,7 @@ pub enum Axis {
|
||||
Row,
|
||||
Column,
|
||||
Page,
|
||||
None,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Axis {
|
||||
@ -14,6 +15,7 @@ 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 ∅"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
@ -27,9 +27,35 @@ 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)
|
||||
@ -46,6 +72,11 @@ 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()
|
||||
@ -68,18 +99,107 @@ impl GridLayout {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let row_items = cross_product(model, view, &row_cats);
|
||||
let col_items = cross_product(model, view, &col_cats);
|
||||
// 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()]));
|
||||
|
||||
Self {
|
||||
row_cats,
|
||||
col_cats,
|
||||
row_cats: vec!["_Index".to_string()],
|
||||
col_cats: vec!["_Dim".to_string()],
|
||||
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
|
||||
@ -128,7 +248,17 @@ 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()
|
||||
@ -188,6 +318,40 @@ 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.
|
||||
@ -223,7 +387,7 @@ fn expand_category(
|
||||
}
|
||||
|
||||
// Skip the data item if its group is collapsed.
|
||||
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
|
||||
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -260,6 +424,79 @@ 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(
|
||||
@ -483,4 +720,91 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
pub mod axis;
|
||||
pub mod layout;
|
||||
pub mod view;
|
||||
pub mod types;
|
||||
|
||||
pub use axis::Axis;
|
||||
pub use layout::{AxisEntry, GridLayout};
|
||||
pub use view::View;
|
||||
pub use types::View;
|
||||
|
||||
@ -41,15 +41,20 @@ impl View {
|
||||
|
||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||
if !self.category_axes.contains_key(cat_name) {
|
||||
// Auto-assign: first → Row, second → Column, rest → Page
|
||||
let rows = self.categories_on(Axis::Row).len();
|
||||
let cols = self.categories_on(Axis::Column).len();
|
||||
let axis = if rows == 0 {
|
||||
Axis::Row
|
||||
} else if cols == 0 {
|
||||
Axis::Column
|
||||
// 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
|
||||
} else {
|
||||
Axis::Page
|
||||
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
|
||||
}
|
||||
};
|
||||
self.category_axes.insert(cat_name.to_string(), axis);
|
||||
}
|
||||
@ -148,12 +153,13 @@ impl View {
|
||||
self.col_offset = 0;
|
||||
}
|
||||
|
||||
/// Cycle axis for a category: Row → Column → Page → Row
|
||||
/// Cycle axis for a category: Row → Column → Page → None → 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::Row,
|
||||
Axis::Page => Axis::None,
|
||||
Axis::None => Axis::Row,
|
||||
};
|
||||
self.set_axis(cat_name, next);
|
||||
self.selected = (0, 0);
|
||||
@ -302,9 +308,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_page_to_row() {
|
||||
fn cycle_axis_page_to_none() {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -351,7 +365,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];
|
||||
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
|
||||
for c in &cats {
|
||||
let count = all_axes.iter()
|
||||
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
||||
@ -377,7 +391,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)],
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
@ -392,7 +406,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)],
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
Reference in New Issue
Block a user