Compare commits
16 Commits
1181ffd0ab
...
341816227b
| Author | SHA1 | Date | |
|---|---|---|---|
| 341816227b | |||
| 9634c95687 | |||
| df9a02b2a9 | |||
| 9e02245f37 | |||
| 47ad9e6032 | |||
| 77a5124d16 | |||
| f0b9227d8f | |||
| f04fe517ae | |||
| 45bfe2c4c7 | |||
| 0fe04de53e | |||
| ecb524c947 | |||
| 62d18ffe7c | |||
| 7249facf94 | |||
| 9ad8abd8a5 | |||
| 917b928759 | |||
| f11d79f700 |
@@ -78,7 +78,7 @@ bd close <id> # Complete work
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git merge
|
||||
git merge origin/main
|
||||
bd dolt push
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
|
||||
Generated
+195
-91
@@ -88,6 +88,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
@@ -99,9 +108,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
@@ -147,9 +156,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -162,9 +171,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
@@ -183,9 +198,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -205,9 +220,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -265,9 +280,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
@@ -310,13 +325,19 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
@@ -491,9 +512,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "enum_dispatch"
|
||||
@@ -542,6 +563,12 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-srgb8"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -605,6 +632,30 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -673,9 +724,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -803,7 +859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -853,11 +909,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -897,10 +954,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -911,7 +974,7 @@ version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -937,17 +1000,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -962,9 +1025,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memmem"
|
||||
@@ -999,9 +1062,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1015,7 +1078,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1034,9 +1097,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
@@ -1094,6 +1157,30 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"fast-srgb8",
|
||||
"libm",
|
||||
"palette_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_derive"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
|
||||
dependencies = [
|
||||
"by_address",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1212,6 +1299,12 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -1260,7 +1353,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
@@ -1353,9 +1446,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
|
||||
checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
@@ -1363,21 +1456,25 @@ dependencies = [
|
||||
"ratatui-macros",
|
||||
"ratatui-termwiz",
|
||||
"ratatui-widgets",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"compact_str",
|
||||
"hashbrown 0.16.1",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-segmentation",
|
||||
@@ -1387,9 +1484,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
|
||||
checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
@@ -1399,9 +1496,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-macros"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
|
||||
checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"ratatui-widgets",
|
||||
@@ -1409,9 +1506,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-termwiz"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
|
||||
checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"termwiz",
|
||||
@@ -1419,17 +1516,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
|
||||
checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.16.1",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"serde",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
@@ -1442,7 +1540,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1458,9 +1556,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -1481,9 +1579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
@@ -1500,7 +1598,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -1575,9 +1673,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -1599,9 +1697,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
@@ -1642,9 +1740,15 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
@@ -1666,18 +1770,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -1749,7 +1853,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"fancy-regex",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
@@ -1846,9 +1950,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
@@ -1870,9 +1974,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
@@ -1905,9 +2009,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.2",
|
||||
@@ -1965,9 +2069,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -1978,9 +2082,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -1988,9 +2092,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -2001,9 +2105,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -2036,7 +2140,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -2268,7 +2372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -2300,18 +2404,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+22
-22
@@ -83,9 +83,9 @@ pub trait Effect: Debug {
|
||||
|
||||
**GridLayout** (`view/layout.rs`): pure function of `Model + View`. `cell_key(r,c)`, `cell_value(r,c)`, `drill_records(r,c)`. **Records mode** auto-detects when `_Index` is on Row and `_Dim` is on Column.
|
||||
|
||||
**AppMode** (`ui/app.rs`): 15 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, ImportWizard, Help, Quit). `SearchMode` is Normal + `search_mode: bool`, not its own variant.
|
||||
**AppMode** (`ui/app.rs`): 16 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Text-entry variants carry a `MinibufferConfig`. `SearchMode` is Normal + `search_mode: bool`, not its own variant. `App` state is split into `ModelState` (workbook, file_path, dirty) and `ViewState` (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0.
|
||||
|
||||
**Keymap** (`command/keymap.rs`): `Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 14 mode keymaps built by `KeymapSet::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`.
|
||||
**Keymap** (`command/keymap.rs`): `Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 16 mode keymaps built by `KeymapSet::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -158,53 +158,53 @@ model/symbol.rs 79 / 3t Symbol interning
|
||||
view/layout.rs 1140 / 24t GridLayout, drill, records mode
|
||||
view/types.rs 531 / 28t View config (axes, pages, hidden, collapsed, format)
|
||||
view/axis.rs 21 Axis enum
|
||||
workbook.rs 259 / 11t Workbook: Model + cross-view ops
|
||||
workbook.rs 266 / 11t Workbook: Model + cross-view ops
|
||||
format.rs 229 / 29t format_f64, parse_number_format (display only)
|
||||
```
|
||||
|
||||
### `improvise-formula` (`crates/improvise-formula/src/`)
|
||||
```
|
||||
parser.rs 776 / 65t pest grammar + tokenizer → Formula AST
|
||||
parser.rs 1080 / 65t pest grammar + tokenizer → Formula AST
|
||||
ast.rs 77 Expr, BinOp, AggFunc, Formula, Filter
|
||||
```
|
||||
|
||||
### `improvise-io` (`crates/improvise-io/src/`)
|
||||
```
|
||||
persistence/improv.pest 124 PEG grammar — single source of truth
|
||||
persistence/mod.rs 2410 / 83t save/load/gzip/legacy-JSON, CSV export
|
||||
import/wizard.rs 1117 / 38t ImportPipeline + ImportWizard
|
||||
persistence/mod.rs 2402 / 83t save/load/gzip/legacy-JSON, CSV export
|
||||
import/wizard.rs 1121 / 38t ImportPipeline + ImportWizard
|
||||
import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/Skip)
|
||||
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
|
||||
```
|
||||
|
||||
### Command layer (`src/command/`)
|
||||
```
|
||||
cmd/core.rs 297 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||
cmd/registry.rs 586 / 0t default_registry() — all registrations (no tests yet)
|
||||
cmd/navigation.rs 475 / 10t Move, EnterAdvance, Page*
|
||||
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||
cmd/registry.rs 601 / 0t default_registry() — all registrations (no tests yet)
|
||||
cmd/navigation.rs 501 / 10t Move, EnterAdvance, Page*
|
||||
cmd/cell.rs 198 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
|
||||
cmd/commit.rs 330 / 7t CommitFormula, CommitCategoryAdd/ItemAdd, CommitExport
|
||||
cmd/effect_cmds.rs 437 / 5t effect_cmd! macro, 25+ simple wrappers
|
||||
cmd/grid.rs 409 / 7t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
|
||||
cmd/mode.rs 308 / 8t EnterMode, Quit, EditOrDrill, EnterTileSelect
|
||||
cmd/commit.rs 455 / 7t CommitFormula, CommitCategoryAdd/ItemAdd, CommitExport
|
||||
cmd/effect_cmds.rs 466 / 5t effect_cmd! macro, 25+ simple wrappers
|
||||
cmd/grid.rs 494 / 7t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
|
||||
cmd/mode.rs 354 / 8t EnterMode, Quit, EditOrDrill, EnterTileSelect
|
||||
cmd/panel.rs 587 / 13t Panel toggle/cycle/cursor, formula/category/view panels
|
||||
cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
|
||||
cmd/text_buffer.rs 256 / 7t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
|
||||
cmd/tile.rs 160 / 5t MoveTileCursor, TileAxisOp
|
||||
keymap.rs 1066 / 22t KeyPattern, Binding, Keymap, ModeKey, 14 mode keymaps
|
||||
keymap.rs 1248 / 22t KeyPattern, Binding, Keymap, ModeKey, 16 mode keymaps
|
||||
parse.rs 236 / 19t Script/command-line parser (prefix syntax)
|
||||
```
|
||||
|
||||
### UI, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
|
||||
```
|
||||
ui/effect.rs 942 / 41t Effect trait, 50+ effect types
|
||||
ui/app.rs 914 / 30t App state, AppMode (15), handle_key, autosave
|
||||
ui/grid.rs 1036 / 13t GridWidget (ratatui), column widths
|
||||
ui/help.rs 617 5-page help overlay (render only)
|
||||
ui/effect.rs 1860 / 41t Effect trait, 50+ effect types
|
||||
ui/app.rs 1475 / 30t App, ModelState, ViewState, AppMode (16), handle_key, autosave
|
||||
ui/grid.rs 1063 / 13t GridWidget (ratatui), column widths
|
||||
ui/help.rs 603 5-page help overlay (render only)
|
||||
ui/import_wizard_ui.rs 347 Import wizard rendering
|
||||
ui/cat_tree.rs 165 / 6t Category tree flattener for panel
|
||||
draw.rs 400 TUI event loop, frame composition
|
||||
main.rs 391 CLI entry (clap): open, import, cmd, script
|
||||
ui/cat_tree.rs 156 / 6t Category tree flattener for panel
|
||||
draw.rs 422 TUI event loop, frame composition
|
||||
main.rs 388 CLI entry (clap): open, import, cmd, script
|
||||
# other ui/*.rs are small panel renderers — skip unless changing layout/style
|
||||
```
|
||||
|
||||
@@ -251,4 +251,4 @@ Full guidance lives in `context/design-principles.md` §6. Quick reminders:
|
||||
|
||||
## Key dependencies
|
||||
|
||||
ratatui 0.30, crossterm 0.28, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.
|
||||
ratatui 0.30, crossterm 0.29, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use indexmap::IndexMap;
|
||||
use indexmap::{IndexMap, IndexSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::category::{Category, CategoryId};
|
||||
@@ -10,6 +10,15 @@ use crate::formula::{AggFunc, Formula};
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
|
||||
/// Tolerance for float comparison in formula evaluation. The `=`/`!=`
|
||||
/// operators and the division-by-zero guard share this epsilon so the
|
||||
/// semantics agree: any value that `x = 0` treats as zero is also rejected
|
||||
/// as a divisor (`div/0`). Otherwise `IF(x = 0, a, y / x)` could take the
|
||||
/// else branch's division with an x its own condition called zero.
|
||||
/// 1e-10 is far below typical data magnitudes but absorbs accumulated
|
||||
/// f64 rounding noise from chained formulas.
|
||||
const FLOAT_EQ_EPSILON: f64 = 1e-10;
|
||||
|
||||
/// Pure-data document model: categories, cells, and formulas.
|
||||
///
|
||||
/// `Model` intentionally does **not** know about views. The view axes and
|
||||
@@ -342,8 +351,9 @@ impl Model {
|
||||
.map(|f| (f.target_category.clone(), f.target.clone()))
|
||||
.collect();
|
||||
|
||||
// Gather all unique partial keys (stems) for each formula category
|
||||
let mut stems: Vec<CellKey> = Vec::new();
|
||||
// Gather all unique partial keys (stems) for each formula category.
|
||||
// IndexSet dedupes in O(1) per cell while preserving insertion order.
|
||||
let mut stems: IndexSet<CellKey> = IndexSet::new();
|
||||
for (target_cat, _) in &formula_cats {
|
||||
for (key, _) in self.data.iter_cells() {
|
||||
let stem = key.without(target_cat);
|
||||
@@ -353,9 +363,7 @@ impl Model {
|
||||
for nc in none_cats {
|
||||
stripped = stripped.without(nc);
|
||||
}
|
||||
if !stems.contains(&stripped) {
|
||||
stems.push(stripped);
|
||||
}
|
||||
stems.insert(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +478,7 @@ impl Model {
|
||||
BinOp::Sub => Ok(lv - rv),
|
||||
BinOp::Mul => Ok(lv * rv),
|
||||
BinOp::Div => {
|
||||
if rv == 0.0 {
|
||||
if rv.abs() < FLOAT_EQ_EPSILON {
|
||||
Err("div/0".into())
|
||||
} else {
|
||||
Ok(lv / rv)
|
||||
@@ -552,8 +560,8 @@ impl Model {
|
||||
let lv = eval_expr_cached(l, context, model, target_category, none_cats)?;
|
||||
let rv = eval_expr_cached(r, context, model, target_category, none_cats)?;
|
||||
match op {
|
||||
BinOp::Eq => Ok((lv - rv).abs() < 1e-10),
|
||||
BinOp::Ne => Ok((lv - rv).abs() >= 1e-10),
|
||||
BinOp::Eq => Ok((lv - rv).abs() < FLOAT_EQ_EPSILON),
|
||||
BinOp::Ne => Ok((lv - rv).abs() >= FLOAT_EQ_EPSILON),
|
||||
BinOp::Lt => Ok(lv < rv),
|
||||
BinOp::Gt => Ok(lv > rv),
|
||||
BinOp::Le => Ok(lv <= rv),
|
||||
@@ -671,7 +679,7 @@ impl Model {
|
||||
BinOp::Sub => Ok(lv - rv),
|
||||
BinOp::Mul => Ok(lv * rv),
|
||||
BinOp::Div => {
|
||||
if rv == 0.0 {
|
||||
if rv.abs() < FLOAT_EQ_EPSILON {
|
||||
Err("div/0".into())
|
||||
} else {
|
||||
Ok(lv / rv)
|
||||
@@ -746,8 +754,8 @@ impl Model {
|
||||
let lv = eval_expr(l, context, model, target_category, depth)?;
|
||||
let rv = eval_expr(r, context, model, target_category, depth)?;
|
||||
match op {
|
||||
BinOp::Eq => Ok((lv - rv).abs() < 1e-10),
|
||||
BinOp::Ne => Ok((lv - rv).abs() >= 1e-10),
|
||||
BinOp::Eq => Ok((lv - rv).abs() < FLOAT_EQ_EPSILON),
|
||||
BinOp::Ne => Ok((lv - rv).abs() >= FLOAT_EQ_EPSILON),
|
||||
BinOp::Lt => Ok(lv < rv),
|
||||
BinOp::Gt => Ok(lv > rv),
|
||||
BinOp::Le => Ok(lv <= rv),
|
||||
@@ -1534,6 +1542,113 @@ mod formula_tests {
|
||||
Some(CellValue::Number(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug (improvise-0bf): `=`/`!=` compare with a 1e-10 epsilon, but the
|
||||
/// division-by-zero guard checked `rv == 0.0` exactly. With X = 1e-11
|
||||
/// the comparison `X = 0` is true, yet `100 / X` happily divided —
|
||||
/// incoherent semantics for `IF(X = 0, a, y / X)`. Any value the
|
||||
/// equality operator treats as zero must also trigger div/0.
|
||||
/// This exercises the direct eval path (`eval_expr` in
|
||||
/// `eval_formula_depth`).
|
||||
#[test]
|
||||
fn near_zero_divisor_consistent_with_equality_epsilon() {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("_Measure").unwrap();
|
||||
if let Some(cat) = m.category_mut("_Measure") {
|
||||
cat.add_item("X");
|
||||
cat.add_item("IsZero");
|
||||
cat.add_item("Ratio");
|
||||
}
|
||||
m.set_cell(coord(&[("_Measure", "X")]), CellValue::Number(1e-11));
|
||||
m.add_formula(parse_formula("IsZero = IF(X = 0, 1, 0)", "_Measure").unwrap());
|
||||
m.add_formula(parse_formula("Ratio = 100 / X", "_Measure").unwrap());
|
||||
// Equality says X is zero…
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("_Measure", "IsZero")])),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
// …so dividing by X must be div/0, not 1e13.
|
||||
// Bug: returns Number(1e13) — guard uses exact == 0.0.
|
||||
assert_eq!(
|
||||
m.evaluate(&coord(&[("_Measure", "Ratio")])),
|
||||
Some(CellValue::Error("div/0".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Same bug (improvise-0bf), fixed-point/cached eval path
|
||||
/// (`eval_expr_cached` in `eval_formula_with_cache`): its division
|
||||
/// guard also used exact `== 0.0` while `=` used the epsilon.
|
||||
#[test]
|
||||
fn near_zero_divisor_consistent_in_cached_eval_path() {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("_Measure").unwrap();
|
||||
m.add_category("Region").unwrap();
|
||||
if let Some(cat) = m.category_mut("_Measure") {
|
||||
cat.add_item("X");
|
||||
cat.add_item("IsZero");
|
||||
cat.add_item("Ratio");
|
||||
}
|
||||
if let Some(cat) = m.category_mut("Region") {
|
||||
cat.add_item("East");
|
||||
}
|
||||
m.set_cell(
|
||||
coord(&[("_Measure", "X"), ("Region", "East")]),
|
||||
CellValue::Number(1e-11),
|
||||
);
|
||||
m.add_formula(parse_formula("IsZero = IF(X = 0, 1, 0)", "_Measure").unwrap());
|
||||
m.add_formula(parse_formula("Ratio = 100 / X", "_Measure").unwrap());
|
||||
let none_cats = vec!["Region".to_string()];
|
||||
m.recompute_formulas(&none_cats);
|
||||
// Equality says X is zero…
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&coord(&[("_Measure", "IsZero")]), &none_cats),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
// …so dividing by X must be div/0 in the cached path too.
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&coord(&[("_Measure", "Ratio")]), &none_cats),
|
||||
Some(CellValue::Error("div/0".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard for improvise-6os: stem collection in
|
||||
/// `recompute_formulas` deduplicated with `Vec::contains` (O(n²) over
|
||||
/// all data cells per formula category). Replaced with an `IndexSet` —
|
||||
/// a pure perf change. This pins the observable contract: recomputed
|
||||
/// values for a multi-stem model (chained formulas, duplicate stems
|
||||
/// from multiple measures per stem) are unchanged and stable across
|
||||
/// repeated recomputes.
|
||||
#[test]
|
||||
fn recompute_formulas_multi_stem_values_stable() {
|
||||
let mut m = revenue_cost_model();
|
||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||
m.add_formula(parse_formula("Margin = Profit / Revenue", "_Measure").unwrap());
|
||||
m.recompute_formulas(&[]);
|
||||
|
||||
let cases = [
|
||||
(("Profit", "East"), 400.0),
|
||||
(("Profit", "West"), 300.0),
|
||||
(("Margin", "East"), 0.4),
|
||||
(("Margin", "West"), 0.375),
|
||||
];
|
||||
for ((target, region), expected) in cases {
|
||||
let key = coord(&[("_Measure", target), ("Region", region)]);
|
||||
let val = m
|
||||
.formula_cache
|
||||
.get(&key)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or_else(|| panic!("missing cache entry for {target}/{region}"));
|
||||
assert!(
|
||||
approx_eq(val, expected),
|
||||
"{target}/{region}: expected {expected}, got {val}"
|
||||
);
|
||||
}
|
||||
|
||||
// Recomputing again reproduces the exact same cache (determinism).
|
||||
let snapshot = m.formula_cache.clone();
|
||||
m.recompute_formulas(&[]);
|
||||
assert_eq!(m.formula_cache, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -182,11 +182,15 @@ impl GridLayout {
|
||||
}
|
||||
|
||||
/// Get the display string for the cell at (row, col) in records mode.
|
||||
/// Returns None for normal (non-records) layouts.
|
||||
/// Returns None for normal (non-records) layouts and for out-of-bounds
|
||||
/// rows/columns (symmetric with `cell_key`).
|
||||
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.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if col_item == "Value" {
|
||||
Some(record.1.to_string())
|
||||
} else {
|
||||
@@ -806,6 +810,23 @@ mod tests {
|
||||
assert_eq!(last_value, "999", "new record should be the last row");
|
||||
}
|
||||
|
||||
/// Regression test for improvise-byz: `records_display` for an
|
||||
/// out-of-bounds column called `col_label(col)`, which returns `""`;
|
||||
/// `""` fails the `== "Value"` check, gets looked up as a category name
|
||||
/// in the record (never found), and the function returned `Some("")` —
|
||||
/// masking view/data mismatches. It must return `None`, symmetric with
|
||||
/// `cell_key`, which explicitly returns `None` for an empty col label.
|
||||
#[test]
|
||||
fn records_display_out_of_bounds_col_returns_none() {
|
||||
let mut wb = records_workbook();
|
||||
let v = wb.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
assert_eq!(layout.records_display(0, layout.col_count()), None);
|
||||
}
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(
|
||||
pairs
|
||||
|
||||
@@ -111,13 +111,28 @@ impl View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis assignment for a registered category.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `cat_name` is not registered with this view. Registration
|
||||
/// is guaranteed for every model category: `Workbook::new`,
|
||||
/// `Workbook::add_category`, `Workbook::add_label_category`, and
|
||||
/// `Workbook::create_view` all call `View::on_category_added` for every
|
||||
/// view, so any name obtained from `Model::categories` or from this
|
||||
/// view's own `category_axes` is safe. Use [`View::try_axis_of`] when
|
||||
/// the name comes from anywhere else.
|
||||
pub fn axis_of(&self, cat_name: &str) -> Axis {
|
||||
*self
|
||||
.category_axes
|
||||
.get(cat_name)
|
||||
self.try_axis_of(cat_name)
|
||||
.expect("axis_of called for category not registered with this view")
|
||||
}
|
||||
|
||||
/// Non-panicking variant of [`View::axis_of`]: returns `None` when the
|
||||
/// category is not registered with this view.
|
||||
pub fn try_axis_of(&self, cat_name: &str) -> Option<Axis> {
|
||||
self.category_axes.get(cat_name).copied()
|
||||
}
|
||||
|
||||
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
||||
self.category_axes
|
||||
.iter()
|
||||
@@ -208,18 +223,21 @@ impl View {
|
||||
self.col_offset = 0;
|
||||
}
|
||||
|
||||
/// Cycle axis for a category: Row → Column → Page → None → Row
|
||||
/// Cycle axis for a category: Row → Column → Page → None → Row.
|
||||
/// No-op for an unregistered category, consistent with `set_axis`.
|
||||
pub fn cycle_axis(&mut self, cat_name: &str) {
|
||||
let next = match self.axis_of(cat_name) {
|
||||
Axis::Row => Axis::Column,
|
||||
Axis::Column => Axis::Page,
|
||||
Axis::Page => Axis::None,
|
||||
Axis::None => Axis::Row,
|
||||
};
|
||||
self.set_axis(cat_name, next);
|
||||
self.selected = (0, 0);
|
||||
self.row_offset = 0;
|
||||
self.col_offset = 0;
|
||||
if let Some(current) = self.try_axis_of(cat_name) {
|
||||
let next = match current {
|
||||
Axis::Row => Axis::Column,
|
||||
Axis::Column => Axis::Page,
|
||||
Axis::Page => Axis::None,
|
||||
Axis::None => Axis::Row,
|
||||
};
|
||||
self.set_axis(cat_name, next);
|
||||
self.selected = (0, 0);
|
||||
self.row_offset = 0;
|
||||
self.col_offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +323,31 @@ mod tests {
|
||||
v.axis_of("Ghost");
|
||||
}
|
||||
|
||||
/// Regression test for improvise-t8s: `cycle_axis` panicked (via the
|
||||
/// `expect` in `axis_of`) when given a category not registered with the
|
||||
/// view, while `set_axis` silently no-ops on the same input. `cycle_axis`
|
||||
/// now goes through `try_axis_of` and no-ops on unknown categories,
|
||||
/// consistent with `set_axis`.
|
||||
#[test]
|
||||
fn cycle_axis_unknown_category_is_noop() {
|
||||
let mut v = view_with_cats(&["Region"]);
|
||||
v.cycle_axis("Ghost");
|
||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||
assert_eq!(v.try_axis_of("Ghost"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_axis_of_known_category_returns_some() {
|
||||
let v = view_with_cats(&["Region"]);
|
||||
assert_eq!(v.try_axis_of("Region"), Some(Axis::Row));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_axis_of_unknown_category_returns_none() {
|
||||
let v = View::new("Test");
|
||||
assert_eq!(v.try_axis_of("Ghost"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_selection_set_and_get() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
|
||||
@@ -9,8 +9,22 @@ pub fn csv_path_p(path: &Path) -> bool {
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"))
|
||||
}
|
||||
|
||||
/// Parse a CSV file and return records as serde_json::Value array
|
||||
/// Parse a CSV file and return records as serde_json::Value array.
|
||||
/// Warnings about short rows are printed to stderr; use
|
||||
/// [`parse_csv_with_warnings`] to capture them instead.
|
||||
pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
let (records, warnings) = parse_csv_with_warnings(path)?;
|
||||
for warning in &warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Parse a CSV file, returning records plus warnings.
|
||||
/// Rows shorter than the header keep every header column — missing trailing
|
||||
/// fields are filled with `Value::Null` — and each affected column is
|
||||
/// reported as a warning with the number of rows it was missing from.
|
||||
pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec<Value>, Vec<String>)> {
|
||||
let mut reader = ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.flexible(true)
|
||||
@@ -22,7 +36,7 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
let has_headers = reader.headers().is_ok();
|
||||
|
||||
let mut records = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
let mut headers: Vec<String> = Vec::new();
|
||||
|
||||
if has_headers {
|
||||
headers = reader
|
||||
@@ -33,6 +47,9 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Per-column count of rows that were too short to provide a value.
|
||||
let mut missing_counts = vec![0usize; headers.len()];
|
||||
|
||||
for result in reader.records() {
|
||||
let record = result.with_context(|| "Failed to read CSV record")?;
|
||||
let mut map = serde_json::Map::new();
|
||||
@@ -48,12 +65,28 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Short row: keep the trailing header columns (as null) rather than
|
||||
// silently dropping them, and remember which columns were affected.
|
||||
for (i, header) in headers.iter().enumerate().skip(record.len()) {
|
||||
map.insert(header.clone(), Value::Null);
|
||||
missing_counts[i] += 1;
|
||||
}
|
||||
|
||||
if !map.is_empty() {
|
||||
records.push(Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
let warnings = headers
|
||||
.iter()
|
||||
.zip(&missing_counts)
|
||||
.filter(|&(_, &count)| count > 0)
|
||||
.map(|(header, count)| {
|
||||
format!("column '{header}' missing from {count} row(s); filled with empty values")
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((records, warnings))
|
||||
}
|
||||
|
||||
/// Parse multiple CSV files and merge into a single JSON array.
|
||||
@@ -270,6 +303,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-k8i): the reader is configured with `.flexible(true)`,
|
||||
/// so rows shorter than the header parsed without error — but the missing
|
||||
/// trailing columns were simply absent from the record map. No error, no
|
||||
/// warning, and field-kind analysis saw skewed value counts. Short rows
|
||||
/// must keep every header column (missing ones as Null), never silently
|
||||
/// drop them.
|
||||
#[test]
|
||||
fn short_rows_pad_missing_trailing_columns_with_null() {
|
||||
let (path, _dir) = create_temp_csv("A,B,C,D,E\n1,2,3,4,5\n1,2,3,4\n1,2,3,4");
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 3);
|
||||
let short = records[1].as_object().unwrap();
|
||||
assert!(
|
||||
short.contains_key("E"),
|
||||
"missing trailing column must be present (as null), not silently dropped"
|
||||
);
|
||||
assert_eq!(short["E"], Value::Null);
|
||||
}
|
||||
|
||||
/// Companion to the test above (improvise-k8i): the warning must name the
|
||||
/// affected column and say how many rows were short.
|
||||
#[test]
|
||||
fn short_rows_produce_warning_naming_column_and_count() {
|
||||
let (path, _dir) = create_temp_csv("A,B,C,D,E\n1,2,3,4,5\n1,2,3,4\n1,2,3,4");
|
||||
let (records, warnings) = parse_csv_with_warnings(&path).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 3);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("'E'"), "warning names the column: {}", warnings[0]);
|
||||
assert!(warnings[0].contains('2'), "warning counts affected rows: {}", warnings[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_rows_produce_no_warnings() {
|
||||
let (path, _dir) = create_temp_csv("A,B\n1,2\n3,4");
|
||||
let (_, warnings) = parse_csv_with_warnings(&path).unwrap();
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_checking_csv_format() {
|
||||
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
|
||||
|
||||
@@ -9,6 +9,11 @@ use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// Mirrors the model's private `MAX_CATEGORIES` limit (regular categories
|
||||
/// only; virtual `_Index`/`_Dim`/`_Measure` don't count). Used to pre-check
|
||||
/// proposals before the wizard's confirm step (improvise-mzv).
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
|
||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||
|
||||
/// Pure data + logic for turning a JSON value into a Model.
|
||||
@@ -80,6 +85,22 @@ impl ImportPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of regular categories `build_model` would create: accepted
|
||||
/// Category/TimeCategory fields plus derived date-component categories.
|
||||
/// (Measures and labels map to non-regular categories and don't count.)
|
||||
pub fn proposed_category_count(&self) -> usize {
|
||||
self.proposals
|
||||
.iter()
|
||||
.filter(|p| p.accepted)
|
||||
.map(|p| match p.kind {
|
||||
FieldKind::Category => 1,
|
||||
FieldKind::TimeCategory if p.date_format.is_some() => 1 + p.date_components.len(),
|
||||
FieldKind::TimeCategory => 1,
|
||||
FieldKind::Measure | FieldKind::Label => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Build a Workbook from the current proposals. Pure — no side effects.
|
||||
pub fn build_model(&self) -> Result<Workbook> {
|
||||
let categories: Vec<&FieldProposal> = self
|
||||
@@ -297,6 +318,17 @@ impl ImportWizard {
|
||||
// ── Step transitions ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn advance(&mut self) {
|
||||
// Pre-check the category limit where the user can still fix it
|
||||
// (improvise-mzv): block here instead of failing in build_model()
|
||||
// after the final confirm.
|
||||
if matches!(
|
||||
self.step,
|
||||
WizardStep::ReviewProposals | WizardStep::ConfigureDates
|
||||
) && let Some(msg) = self.category_limit_message()
|
||||
{
|
||||
self.message = Some(msg);
|
||||
return;
|
||||
}
|
||||
self.step = match self.step {
|
||||
WizardStep::Preview => {
|
||||
if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
|
||||
@@ -322,6 +354,18 @@ impl ImportWizard {
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
/// User-facing message when the accepted proposals would exceed the
|
||||
/// model's regular-category limit; `None` when within the limit.
|
||||
fn category_limit_message(&self) -> Option<String> {
|
||||
let count = self.pipeline.proposed_category_count();
|
||||
(count > MAX_CATEGORIES).then(|| {
|
||||
format!(
|
||||
"Too many categories: {count} proposed, max {MAX_CATEGORIES}. \
|
||||
Mark fields as Measure (c) or toggle them off (Space)."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_time_categories(&self) -> bool {
|
||||
self.pipeline
|
||||
.proposals
|
||||
@@ -856,6 +900,73 @@ mod tests {
|
||||
assert!(!w.pipeline.records.is_empty());
|
||||
}
|
||||
|
||||
// ── Category limit pre-check ────────────────────────────────────────
|
||||
|
||||
/// Build records whose fields `c00..cNN` are all low-cardinality strings,
|
||||
/// so the analyzer proposes every field as an accepted Category.
|
||||
fn category_records(n_fields: usize) -> serde_json::Value {
|
||||
let recs: Vec<serde_json::Value> = (0..3)
|
||||
.map(|r| {
|
||||
let mut m = serde_json::Map::new();
|
||||
for i in 0..n_fields {
|
||||
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(recs)
|
||||
}
|
||||
|
||||
/// BUG (improvise-mzv): with more than MAX_CATEGORIES (12) accepted
|
||||
/// Category proposals, the wizard let the user advance through every
|
||||
/// remaining step; build_model() only failed at the final confirm.
|
||||
/// Advancing past ReviewProposals must be blocked with a message telling
|
||||
/// the user to mark fields as Measure or toggle them off.
|
||||
#[test]
|
||||
fn wizard_blocks_advance_when_over_category_limit() {
|
||||
let mut w = ImportWizard::new(category_records(13));
|
||||
assert_eq!(w.step, WizardStep::ReviewProposals);
|
||||
assert_eq!(
|
||||
w.pipeline
|
||||
.proposals
|
||||
.iter()
|
||||
.filter(|p| p.accepted && p.kind == FieldKind::Category)
|
||||
.count(),
|
||||
13,
|
||||
"sanity: all 13 fields proposed as accepted categories"
|
||||
);
|
||||
|
||||
w.advance();
|
||||
assert_eq!(
|
||||
w.step,
|
||||
WizardStep::ReviewProposals,
|
||||
"advance must be blocked while over the category limit"
|
||||
);
|
||||
let msg = w.message.as_deref().expect("a message explaining the block");
|
||||
assert!(msg.contains("Measure"), "message should tell the user how to fix it: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_allows_advance_at_exactly_category_limit() {
|
||||
let mut w = ImportWizard::new(category_records(12));
|
||||
assert_eq!(w.step, WizardStep::ReviewProposals);
|
||||
w.advance();
|
||||
assert_ne!(w.step, WizardStep::ReviewProposals);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_advance_unblocks_after_marking_field_as_measure() {
|
||||
let mut w = ImportWizard::new(category_records(13));
|
||||
w.advance();
|
||||
assert_eq!(w.step, WizardStep::ReviewProposals);
|
||||
|
||||
// Toggle one field off; the wizard should now let the user through.
|
||||
w.cursor = 0;
|
||||
w.toggle_proposal();
|
||||
w.advance();
|
||||
assert_ne!(w.step, WizardStep::ReviewProposals);
|
||||
}
|
||||
|
||||
// ── Formula editing in wizard ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -58,10 +58,17 @@ formulas_section = {
|
||||
formula_line*
|
||||
}
|
||||
|
||||
// `- raw expression [Category]` — the trailing `[Category]` suffix is
|
||||
// optional (defaults to _Measure) and its name follows the usual quoting
|
||||
// rules. `formula_raw` stops where a suffix-then-newline (or bare newline)
|
||||
// begins, so pipe-quoted identifiers containing " [" stay in the raw text.
|
||||
formula_line = {
|
||||
"- " ~ rest_of_line ~ NEWLINE ~ blank_lines
|
||||
"- " ~ formula_raw ~ formula_target? ~ NEWLINE ~ blank_lines
|
||||
}
|
||||
|
||||
formula_target = _{ " "* ~ "[" ~ name ~ "]" }
|
||||
formula_raw = @{ (!(formula_target? ~ NEWLINE) ~ ANY)+ }
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
data_section = {
|
||||
|
||||
@@ -106,6 +106,17 @@ fn format_number(n: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a token matched by the grammar's `number` rule into a cell value.
|
||||
///
|
||||
/// The grammar only admits tokens that `f64::from_str` accepts (overflow
|
||||
/// saturates to ±inf), but if the two grammars ever drift the mismatch must
|
||||
/// surface — never silently store a wrong number.
|
||||
fn parse_number_cell(s: &str) -> CellValue {
|
||||
s.parse()
|
||||
.map(CellValue::Number)
|
||||
.unwrap_or_else(|_| CellValue::Error(format!("invalid number: {s}")))
|
||||
}
|
||||
|
||||
// ── File I/O ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_gzip(path: &Path) -> bool {
|
||||
@@ -213,7 +224,7 @@ pub fn format_md(workbook: &Workbook) -> String {
|
||||
if f.target_category == "_Measure" {
|
||||
w!(out, "- {}", f.raw);
|
||||
} else {
|
||||
w!(out, "- {} [{}]", f.raw, f.target_category);
|
||||
w!(out, "- {} [{}]", f.raw, quote_name(&f.target_category));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,7 +302,17 @@ pub fn parse_md(text: &str) -> Result<Workbook> {
|
||||
use pest::iterators::{Pair, Pairs};
|
||||
|
||||
let file = ImprovParser::parse(Rule::file, text)
|
||||
.map_err(|e| anyhow::anyhow!("Parse error: {e}"))?
|
||||
.map_err(|e| {
|
||||
use pest::error::LineColLocation;
|
||||
let (line, col) = match e.line_col {
|
||||
LineColLocation::Pos(p) | LineColLocation::Span(p, _) => p,
|
||||
};
|
||||
let line_text = text.lines().nth(line - 1).unwrap_or("").trim_end();
|
||||
anyhow::anyhow!(
|
||||
"Parse error at line {line}, column {col}: {line_text}\n ({})",
|
||||
e.variant.message()
|
||||
)
|
||||
})?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Empty parse result"))?;
|
||||
|
||||
@@ -399,19 +420,18 @@ pub fn parse_md(text: &str) -> Result<Workbook> {
|
||||
Rule::formulas_section => {
|
||||
for fl in pair.into_inner() {
|
||||
if fl.as_rule() == Rule::formula_line {
|
||||
let raw = first_str(fl)?;
|
||||
if let Some(i) = raw.rfind(" [")
|
||||
&& raw.ends_with(']')
|
||||
{
|
||||
formulas.push((
|
||||
raw[..i].to_string(),
|
||||
raw[i + 2..raw.len() - 1].to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let mut parts = fl.into_inner();
|
||||
let raw = next(&mut parts, "formula_line")?
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_string();
|
||||
// No [Category] suffix — default to _Measure
|
||||
if !raw.is_empty() && raw.contains('=') {
|
||||
formulas.push((raw, "_Measure".to_string()));
|
||||
let category = match parts.next() {
|
||||
Some(name_pair) => extract_name(name_pair)?,
|
||||
None => "_Measure".to_string(),
|
||||
};
|
||||
if raw.contains('=') {
|
||||
formulas.push((raw, category));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,9 +450,7 @@ pub fn parse_md(text: &str) -> Result<Workbook> {
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
let value = match value_pair.as_rule() {
|
||||
Rule::number => {
|
||||
CellValue::Number(value_pair.as_str().parse().unwrap_or(0.0))
|
||||
}
|
||||
Rule::number => parse_number_cell(value_pair.as_str()),
|
||||
Rule::pipe_quoted => {
|
||||
let inner = next(&mut value_pair.into_inner(), "pipe_quoted")?;
|
||||
CellValue::Text(unescape_pipe(inner.as_str()))
|
||||
@@ -586,8 +604,9 @@ pub fn export_csv(workbook: &Workbook, view_name: &str, path: &Path) -> Result<(
|
||||
|
||||
let layout = GridLayout::new(&workbook.model, view);
|
||||
let model = &workbook.model;
|
||||
let (fmt_comma, fmt_decimals) = crate::format::parse_number_format(&view.number_format);
|
||||
|
||||
let mut out = String::new();
|
||||
let mut wtr = csv::WriterBuilder::new().flexible(true).from_path(path)?;
|
||||
|
||||
// Header row
|
||||
let row_header = layout.row_cats.join("/");
|
||||
@@ -601,31 +620,28 @@ pub fn export_csv(workbook: &Workbook, view_name: &str, path: &Path) -> Result<(
|
||||
} else {
|
||||
format!("{} ({})", row_header, page_label.join(", "))
|
||||
};
|
||||
let mut header: Vec<String> = Vec::new();
|
||||
if !header_prefix.is_empty() {
|
||||
out.push_str(&header_prefix);
|
||||
out.push(',');
|
||||
header.push(header_prefix);
|
||||
}
|
||||
let col_labels: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| layout.col_label(ci))
|
||||
.collect();
|
||||
out.push_str(&col_labels.join(","));
|
||||
out.push('\n');
|
||||
header.extend((0..layout.col_count()).map(|ci| layout.col_label(ci)));
|
||||
wtr.write_record(&header)?;
|
||||
|
||||
// Data rows
|
||||
for ri in 0..layout.row_count() {
|
||||
let mut record: Vec<String> = Vec::new();
|
||||
let row_label = layout.row_label(ri);
|
||||
if !row_label.is_empty() {
|
||||
out.push_str(&row_label);
|
||||
out.push(',');
|
||||
record.push(row_label);
|
||||
}
|
||||
let row_values: Vec<String> = (0..layout.col_count())
|
||||
.map(|ci| layout.display_text(model, ri, ci, false, 0))
|
||||
.collect();
|
||||
out.push_str(&row_values.join(","));
|
||||
out.push('\n');
|
||||
record.extend(
|
||||
(0..layout.col_count())
|
||||
.map(|ci| layout.display_text(model, ri, ci, fmt_comma, fmt_decimals)),
|
||||
);
|
||||
wtr.write_record(&record)?;
|
||||
}
|
||||
|
||||
std::fs::write(path, out)?;
|
||||
wtr.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -897,6 +913,59 @@ mod tests {
|
||||
assert_eq!(f.target_category, "Type");
|
||||
}
|
||||
|
||||
/// Bug improvise-zgb (b): the formulas walker located the `[Category]`
|
||||
/// suffix via `raw.rfind(" [")`, so a hand-written line with no space
|
||||
/// before the bracket (`- Total = Food + Gas[Type]`) was not recognized
|
||||
/// and the formula was silently retargeted to _Measure. The suffix is now
|
||||
/// part of the grammar (`formula_line`) and walked structurally.
|
||||
#[test]
|
||||
fn parse_md_formula_category_suffix_without_space() {
|
||||
let text = "v2025-04-09\n# T\n\
|
||||
## Category: Type\n\
|
||||
- Food, Gas, Total\n\
|
||||
## Formulas\n\
|
||||
- Total = Food + Gas[Type]\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
let f = &m.model.formulas()[0];
|
||||
assert_eq!(f.raw, "Total = Food + Gas");
|
||||
assert_eq!(f.target_category, "Type");
|
||||
}
|
||||
|
||||
/// Bug improvise-zgb (a): `rfind(" [")` mis-split a suffix whose category
|
||||
/// name itself contains " [" — the writer emitted it unquoted
|
||||
/// (`[Margin [%]]`) and the parser then split inside the name, producing
|
||||
/// garbage raw text and category "%]". Category names in the suffix are
|
||||
/// now pipe-quoted on write and parsed via the grammar's `name` rule.
|
||||
#[test]
|
||||
fn parse_md_round_trips_formula_category_with_special_chars() {
|
||||
let mut m = two_cat_model();
|
||||
m.add_category("Margin [%]").unwrap();
|
||||
m.model
|
||||
.category_mut("Margin [%]")
|
||||
.unwrap()
|
||||
.add_item("Total");
|
||||
m.model
|
||||
.add_formula(parse_formula("Total = Food + Gas", "Margin [%]").unwrap());
|
||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||
let f = &m2.model.formulas()[0];
|
||||
assert_eq!(f.raw, "Total = Food + Gas");
|
||||
assert_eq!(f.target_category, "Margin [%]");
|
||||
}
|
||||
|
||||
/// Companion to improvise-zgb: a formula *ending* in a pipe-quoted
|
||||
/// identifier that contains " [" must not have the quoted name mistaken
|
||||
/// for a category suffix — it stays a _Measure formula with intact raw.
|
||||
#[test]
|
||||
fn parse_md_formula_ending_in_bracketed_pipe_name_keeps_raw() {
|
||||
let text = "v2025-04-09\n# T\n\
|
||||
## Formulas\n\
|
||||
- Total = Cost + |Revenue [USD]|\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
let f = &m.model.formulas()[0];
|
||||
assert_eq!(f.raw, "Total = Cost + |Revenue [USD]|");
|
||||
assert_eq!(f.target_category, "_Measure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_md_round_trips_hidden_item() {
|
||||
let _ = two_cat_model();
|
||||
@@ -1195,6 +1264,46 @@ Type=Food = 42
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug improvise-1cz: export_csv joined labels/values with bare commas
|
||||
/// (no RFC 4180 quoting) and ignored the view's number format. With a
|
||||
/// comma-grouping format, a value like 1200 renders as "1,200" and a
|
||||
/// label like "Smith, Jr." splits into two fields, corrupting every row.
|
||||
#[test]
|
||||
fn export_csv_quotes_commas_per_rfc4180() {
|
||||
let mut m = Workbook::new("T");
|
||||
m.add_category("Type").unwrap();
|
||||
m.model.category_mut("Type").unwrap().add_item("Smith, Jr.");
|
||||
m.add_category("Month").unwrap();
|
||||
m.model.category_mut("Month").unwrap().add_item("Jan");
|
||||
m.model.set_cell(
|
||||
coord(&[("Type", "Smith, Jr."), ("Month", "Jan")]),
|
||||
CellValue::Number(1200.0),
|
||||
);
|
||||
m.active_view_mut().number_format = ",.2f".to_string();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("export.csv");
|
||||
super::export_csv(&m, "Default", &path).unwrap();
|
||||
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_path(&path)
|
||||
.unwrap();
|
||||
let records: Vec<csv::StringRecord> =
|
||||
rdr.records().collect::<Result<_, _>>().unwrap();
|
||||
// Header + one data row, each with exactly 2 fields (label + value)
|
||||
assert_eq!(records.len(), 2, "expected header + 1 row: {records:?}");
|
||||
for r in &records {
|
||||
assert_eq!(r.len(), 2, "comma split a field: {r:?}");
|
||||
}
|
||||
assert_eq!(&records[1][0], "Smith, Jr.");
|
||||
assert!(
|
||||
records[1][1].starts_with("1,200"),
|
||||
"expected comma-grouped value, got: {:?}",
|
||||
&records[1][1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_csv_unknown_view_returns_error() {
|
||||
let m = two_cat_model();
|
||||
@@ -1975,6 +2084,70 @@ mod parser_edge_cases {
|
||||
assert_eq!(m.model.name, "MyModel");
|
||||
}
|
||||
|
||||
/// Bug improvise-4yc: the `Rule::number` arm used
|
||||
/// `as_str().parse().unwrap_or(0.0)`, so any numeric token the grammar
|
||||
/// admitted but `f64::from_str` rejected would silently become
|
||||
/// Number(0.0). The conversion must surface a CellValue::Error naming the
|
||||
/// offending text instead of fabricating a zero.
|
||||
#[test]
|
||||
fn unparseable_number_token_yields_error_not_zero() {
|
||||
// No token the current grammar admits actually fails f64 parsing
|
||||
// (overflow saturates to inf), so exercise the helper directly with
|
||||
// the forms the grammar could plausibly drift toward admitting.
|
||||
for bad in ["3.14.15", "1.5e", "--5", ""] {
|
||||
let got = super::parse_number_cell(bad);
|
||||
match got {
|
||||
CellValue::Error(msg) => {
|
||||
assert!(msg.contains(bad) || bad.is_empty(), "error should name {bad:?}: {msg}")
|
||||
}
|
||||
other => panic!("{bad:?} must not produce {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bug improvise-4yc (grammar routing): malformed numeric forms in a data
|
||||
/// line must never silently load as Number(0.0). The grammar routes them
|
||||
/// either to bare_value (Text) or to a parse error that names the text.
|
||||
#[test]
|
||||
fn malformed_numbers_in_data_never_silently_zero() {
|
||||
// "--5" fails the number rule entirely → bare_value → Text
|
||||
let text = "v2025-04-09\n# T\n## Data\nType=Food = --5\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
assert_eq!(
|
||||
m.model.get_cell(&coord(&[("Type", "Food")])),
|
||||
Some(&CellValue::Text("--5".into()))
|
||||
);
|
||||
|
||||
// "3.14.15" — number matches the "3.14" prefix and commits, the rest
|
||||
// fails → whole-file parse error whose message shows the line
|
||||
let text = "v2025-04-09\n# T\n## Data\nType=Food = 3.14.15\n";
|
||||
let err = parse_md(text).unwrap_err().to_string();
|
||||
assert!(err.contains("3.14.15"), "error must name the text: {err}");
|
||||
|
||||
// "1e999" is admitted; f64 parsing saturates to +inf, not 0.0
|
||||
let text = "v2025-04-09\n# T\n## Data\nType=Food = 1e999\n";
|
||||
let m = parse_md(text).unwrap();
|
||||
assert_eq!(
|
||||
m.model.get_cell(&coord(&[("Type", "Food")])),
|
||||
Some(&CellValue::Number(f64::INFINITY))
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug improvise-6kj: parse failures were wrapped as `Parse error: {e}`,
|
||||
/// leaning on pest's raw rendering instead of stating the location in
|
||||
/// plain words. The message must name the line number and quote the
|
||||
/// offending line so a user can find the problem in a hand-edited file.
|
||||
#[test]
|
||||
fn parse_error_names_line_number_and_contents() {
|
||||
let text = "v2025-04-09\n# T\n## Data\nType=Food = 3.14.15\n";
|
||||
let err = parse_md(text).unwrap_err().to_string();
|
||||
assert!(err.contains("line 4"), "missing line number: {err}");
|
||||
assert!(
|
||||
err.contains("Type=Food = 3.14.15"),
|
||||
"missing line contents: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_data_without_value() {
|
||||
// Malformed data line: no " = " separator — pest rejects it
|
||||
|
||||
Generated
+9
-9
@@ -37,11 +37,11 @@
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774369503,
|
||||
"narHash": "sha256-YeCF4iBhlvTqkn4mihjZgixnDcEVgfyQlNeBsbLYUgQ=",
|
||||
"lastModified": 1779717186,
|
||||
"narHash": "sha256-WAmzV4ohaVEqZ4Zs2G0Gb/t9iY3KuQ7bg3rLNcjIhM4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "b873ca53dd64e12340416f0fd5e3b33792b9c17b",
|
||||
"rev": "c994c83963b42b26f127e9cc50a8c86852db67d0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -247,11 +247,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1775710090,
|
||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||
"lastModified": 1780749050,
|
||||
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -316,11 +316,11 @@
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775877051,
|
||||
"narHash": "sha256-wpSQm2PD/w4uRo2wb8utk0b5hOBkkg/CZ1xICY+qB7M=",
|
||||
"lastModified": 1781061510,
|
||||
"narHash": "sha256-tVuGHgt/TsWu1rUAqEL+eWRIJJHtiPE2+yQ63b+/WTU=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "08b4f3633471874c8894632ade1b78d75dbda002",
|
||||
"rev": "d286e9691bb03045febbf8304a658eab1487d1b5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
crate2nix.packages.${system}.default
|
||||
pkgs.cargo-expand
|
||||
pkgs.cargo-llvm-cov
|
||||
pkgs.global
|
||||
pkgs.universal-ctags
|
||||
|
||||
# Demo recording and release tooling
|
||||
pkgs.asciinema
|
||||
|
||||
+125
-13
@@ -80,6 +80,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// From a non-panel mode (Normal), cycling goes to the first open panel.
|
||||
#[test]
|
||||
fn cycle_panel_focus_with_multiple_panels() {
|
||||
let m = two_cat_model();
|
||||
@@ -97,8 +98,106 @@ mod tests {
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"),
|
||||
"Expected panel focus, got: {dbg}"
|
||||
dbg.contains("FormulaPanel"),
|
||||
"Expected first open panel (FormulaPanel) from Normal mode, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug improvise-dqn: CyclePanelFocus always picked the first open panel,
|
||||
/// ignoring the currently focused panel. With formula + category open and
|
||||
/// focus on the formula panel, cycling must move to the category panel.
|
||||
#[test]
|
||||
fn cycle_panel_focus_advances_from_formula_to_category() {
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::FormulaPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("CategoryPanel"),
|
||||
"Expected CategoryPanel after FormulaPanel, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug improvise-dqn: with category + view open and focus on the category
|
||||
/// panel, cycling must advance to the view panel (not stay on category).
|
||||
#[test]
|
||||
fn cycle_panel_focus_advances_from_category_to_view() {
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.category_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: true,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("ViewPanel"),
|
||||
"Expected ViewPanel after CategoryPanel, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// From the last open panel, cycling wraps back to the first open one.
|
||||
#[test]
|
||||
fn cycle_panel_focus_wraps_from_view_to_formula() {
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::ViewPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: false,
|
||||
view_open: true,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("FormulaPanel"),
|
||||
"Expected wrap to FormulaPanel from ViewPanel, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// With exactly one panel open and focus already on it, cycling stays
|
||||
/// there (self-transition back to the same panel mode).
|
||||
#[test]
|
||||
fn cycle_panel_focus_single_panel_stays_put() {
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
effects.is_empty() || dbg.contains("CategoryPanel"),
|
||||
"Expected no-op or self-transition to CategoryPanel, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +388,11 @@ impl Cmd for TogglePanelVisibility {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab through open panels, entering the first open panel's mode.
|
||||
/// Tab through open panels in order Formula → Category → View, wrapping.
|
||||
/// The starting point is the panel currently focused (`ctx.mode`); from a
|
||||
/// non-panel mode (e.g. Normal) focus goes to the first open panel. With a
|
||||
/// single open panel this self-transitions back to it; with none open it is
|
||||
/// a no-op.
|
||||
#[derive(Debug)]
|
||||
pub struct CyclePanelFocus {
|
||||
pub formula_open: bool,
|
||||
@@ -300,16 +403,25 @@ impl Cmd for CyclePanelFocus {
|
||||
fn name(&self) -> &'static str {
|
||||
"cycle-panel-focus"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
if self.formula_open {
|
||||
vec![effect::change_mode(AppMode::FormulaPanel)]
|
||||
} else if self.category_open {
|
||||
vec![effect::change_mode(AppMode::CategoryPanel)]
|
||||
} else if self.view_open {
|
||||
vec![effect::change_mode(AppMode::ViewPanel)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let order = [
|
||||
(self.formula_open, AppMode::FormulaPanel),
|
||||
(self.category_open, AppMode::CategoryPanel),
|
||||
(self.view_open, AppMode::ViewPanel),
|
||||
];
|
||||
// Index of the first candidate: the panel after the focused one,
|
||||
// or the first panel when invoked from a non-panel mode.
|
||||
let start = match ctx.mode {
|
||||
AppMode::FormulaPanel => 1,
|
||||
AppMode::CategoryPanel => 2,
|
||||
AppMode::ViewPanel => 3,
|
||||
_ => 0,
|
||||
};
|
||||
(0..order.len())
|
||||
.map(|i| &order[(start + i) % order.len()])
|
||||
.find(|(open, _)| *open)
|
||||
.map(|(_, mode)| vec![effect::change_mode(mode.clone())])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ 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());
|
||||
app.view_state.transient_keymap = Some(self.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-45
@@ -61,7 +61,7 @@ pub fn run_tui(
|
||||
if let Some(json) = import_value {
|
||||
app.start_import_wizard(json);
|
||||
} else if app.is_empty_model() {
|
||||
app.mode = AppMode::Help;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -82,7 +82,7 @@ pub fn run_tui(
|
||||
|
||||
app.autosave_if_needed();
|
||||
|
||||
if matches!(app.mode, AppMode::Quit) {
|
||||
if matches!(app.view_state.mode, AppMode::Quit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -167,29 +167,30 @@ fn draw(f: &mut Frame, app: &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::new(app.help_page), size);
|
||||
if matches!(app.view_state.mode, AppMode::Help) {
|
||||
f.render_widget(HelpWidget::new(app.view_state.help_page), size);
|
||||
}
|
||||
if matches!(app.mode, AppMode::ImportWizard)
|
||||
&& let Some(wizard) = &app.wizard
|
||||
if matches!(app.view_state.mode, AppMode::ImportWizard)
|
||||
&& let Some(wizard) = &app.view_state.wizard
|
||||
{
|
||||
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||
}
|
||||
// ExportPrompt now uses the minibuffer at the bottom bar.
|
||||
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||
if app.is_empty_model() && matches!(app.view_state.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||
draw_welcome(f, main_chunks[1]);
|
||||
}
|
||||
|
||||
// Which-key popup: show available completions after a prefix key
|
||||
if let Some(ref km) = app.transient_keymap {
|
||||
if let Some(ref km) = app.view_state.transient_keymap {
|
||||
let hints = km.binding_hints();
|
||||
f.render_widget(WhichKeyWidget::new(&hints), size);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
let dirty = if app.dirty { " [+]" } else { "" };
|
||||
let dirty = if app.model_state.dirty { " [+]" } else { "" };
|
||||
let file = app
|
||||
.model_state
|
||||
.file_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
@@ -198,7 +199,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
.unwrap_or_default();
|
||||
let title = format!(
|
||||
" improvise · {}{}{} ",
|
||||
app.workbook.model.name, file, dirty
|
||||
app.model_state.workbook.model.name, file, dirty
|
||||
);
|
||||
let right = " ?:help :q quit ";
|
||||
let line = fill_line(title, right, area.width);
|
||||
@@ -214,7 +215,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
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 side_open = app.view_state.formula_panel_open || app.view_state.category_panel_open || app.view_state.view_panel_open;
|
||||
|
||||
let grid_area;
|
||||
if side_open {
|
||||
@@ -228,9 +229,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
let side = chunks[1];
|
||||
let panel_count = [
|
||||
app.formula_panel_open,
|
||||
app.category_panel_open,
|
||||
app.view_panel_open,
|
||||
app.view_state.formula_panel_open,
|
||||
app.view_state.category_panel_open,
|
||||
app.view_state.view_panel_open,
|
||||
]
|
||||
.iter()
|
||||
.filter(|&&b| b)
|
||||
@@ -238,26 +239,26 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
let ph = side.height / panel_count.max(1);
|
||||
let mut y = side.y;
|
||||
|
||||
if app.formula_panel_open {
|
||||
if app.view_state.formula_panel_open {
|
||||
let a = Rect::new(side.x, y, side.width, ph);
|
||||
let content = FormulaContent::new(&app.workbook.model, &app.mode);
|
||||
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
|
||||
let content = FormulaContent::new(&app.model_state.workbook.model, &app.view_state.mode);
|
||||
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.formula_cursor), a);
|
||||
y += ph;
|
||||
}
|
||||
if app.category_panel_open {
|
||||
if app.view_state.category_panel_open {
|
||||
let a = Rect::new(side.x, y, side.width, ph);
|
||||
let content = CategoryContent::new(
|
||||
&app.workbook.model,
|
||||
app.workbook.active_view(),
|
||||
&app.expanded_cats,
|
||||
&app.model_state.workbook.model,
|
||||
app.model_state.workbook.active_view(),
|
||||
&app.view_state.expanded_cats,
|
||||
);
|
||||
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
|
||||
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.cat_panel_cursor), a);
|
||||
y += ph;
|
||||
}
|
||||
if app.view_panel_open {
|
||||
if app.view_state.view_panel_open {
|
||||
let a = Rect::new(side.x, y, side.width, ph);
|
||||
let content = ViewContent::new(&app.workbook);
|
||||
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
|
||||
let content = ViewContent::new(&app.model_state.workbook);
|
||||
f.render_widget(Panel::new(content, &app.view_state.mode, app.view_state.view_panel_cursor), a);
|
||||
}
|
||||
} else {
|
||||
grid_area = area;
|
||||
@@ -265,14 +266,14 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
f.render_widget(
|
||||
GridWidget::new(
|
||||
&app.workbook.model,
|
||||
app.workbook.active_view(),
|
||||
&app.workbook.active_view,
|
||||
&app.model_state.workbook.model,
|
||||
app.model_state.workbook.active_view(),
|
||||
&app.model_state.workbook.active_view,
|
||||
&app.layout,
|
||||
&app.mode,
|
||||
&app.search_query,
|
||||
&app.buffers,
|
||||
app.drill_state.as_ref(),
|
||||
&app.view_state.mode,
|
||||
&app.view_state.search_query,
|
||||
&app.view_state.buffers,
|
||||
app.view_state.drill_state.as_ref(),
|
||||
),
|
||||
grid_area,
|
||||
);
|
||||
@@ -281,18 +282,19 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
f.render_widget(
|
||||
TileBar::new(
|
||||
&app.workbook.model,
|
||||
app.workbook.active_view(),
|
||||
&app.mode,
|
||||
app.tile_cat_idx,
|
||||
&app.model_state.workbook.model,
|
||||
app.model_state.workbook.active_view(),
|
||||
&app.view_state.mode,
|
||||
app.view_state.tile_cat_idx,
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
if let Some(mb) = app.mode.minibuffer() {
|
||||
if let Some(mb) = app.view_state.mode.minibuffer() {
|
||||
let buf = app
|
||||
.view_state
|
||||
.buffers
|
||||
.get(mb.buffer_key)
|
||||
.map(|s| s.as_str())
|
||||
@@ -313,25 +315,25 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
let search_part = if app.search_mode {
|
||||
format!(" /{}▌", app.search_query)
|
||||
let search_part = if app.view_state.search_mode {
|
||||
format!(" /{}▌", app.view_state.search_query)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let msg = if !app.status_msg.is_empty() {
|
||||
app.status_msg.as_str()
|
||||
let msg = if !app.view_state.status_msg.is_empty() {
|
||||
app.view_state.status_msg.as_str()
|
||||
} else {
|
||||
app.hint_text()
|
||||
};
|
||||
|
||||
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
|
||||
let view_badge = format!(" {}{} ", app.workbook.active_view, yank_indicator);
|
||||
let yank_indicator = if app.view_state.yanked.is_some() { " [yank]" } else { "" };
|
||||
let view_badge = format!(" {}{} ", app.model_state.workbook.active_view, yank_indicator);
|
||||
|
||||
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
|
||||
let left = format!(" {}{search_part} {msg}", mode_name(&app.view_state.mode));
|
||||
let line = fill_line(left, &view_badge, area.width);
|
||||
|
||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.view_state.mode)), area);
|
||||
}
|
||||
|
||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
|
||||
+1
-1
@@ -353,7 +353,7 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
|
||||
}
|
||||
|
||||
if let Some(path) = file {
|
||||
persistence::save(&app.workbook, path)?;
|
||||
persistence::save(&app.model_state.workbook, path)?;
|
||||
}
|
||||
|
||||
std::process::exit(exit_code);
|
||||
|
||||
+286
-176
@@ -185,13 +185,33 @@ impl AppMode {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
/// Document state slice: the workbook and its IO bookkeeping. Distinct from
|
||||
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
|
||||
/// because `file_path` and `dirty` are persistence-layer concerns.
|
||||
#[derive(Debug)]
|
||||
pub struct ModelState {
|
||||
pub workbook: Workbook,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl Default for ModelState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workbook: Workbook::new("Untitled"),
|
||||
file_path: None,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
|
||||
/// and other per-session state that does not persist to disk.
|
||||
#[derive(Debug)]
|
||||
pub struct ViewState {
|
||||
pub mode: AppMode,
|
||||
pub status_msg: String,
|
||||
pub wizard: Option<ImportWizard>,
|
||||
pub last_autosave: Instant,
|
||||
pub search_query: String,
|
||||
pub search_mode: bool,
|
||||
pub formula_panel_open: bool,
|
||||
@@ -200,7 +220,6 @@ pub struct App {
|
||||
pub cat_panel_cursor: usize,
|
||||
pub view_panel_cursor: usize,
|
||||
pub formula_cursor: usize,
|
||||
pub dirty: bool,
|
||||
/// Yanked cell value for `p` paste
|
||||
pub yanked: Option<CellValue>,
|
||||
/// Tile select cursor (which category index is highlighted)
|
||||
@@ -217,24 +236,85 @@ pub struct App {
|
||||
pub drill_state: Option<DrillState>,
|
||||
/// Current page index in the Help screen (0-based).
|
||||
pub help_page: usize,
|
||||
/// Terminal dimensions (updated on resize and at startup).
|
||||
pub term_width: u16,
|
||||
pub term_height: u16,
|
||||
/// Categories expanded in the category panel tree view.
|
||||
pub expanded_cats: std::collections::HashSet<String>,
|
||||
/// Named text buffers for text-entry modes
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
||||
pub transient_keymap: Option<Arc<Keymap>>,
|
||||
/// Current grid layout, derived from model + view + drill_state.
|
||||
/// Rebuilt via `rebuild_layout()` after state changes.
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
wizard: None,
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
formula_cursor: 0,
|
||||
yanked: None,
|
||||
tile_cat_idx: 0,
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
drill_state: None,
|
||||
help_page: 0,
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level TUI application state. Composed of two named slices and a
|
||||
/// residue of fields that don't belong to either:
|
||||
///
|
||||
/// - `model_state` — document state (Workbook + IO bookkeeping). Persists.
|
||||
/// - `view_state` — UI session state (mode, cursors, panels, buffers, …).
|
||||
/// Does not persist.
|
||||
/// - The remaining direct fields are runtime / derived / config — see
|
||||
/// per-field tags below. Each of them is documented as belonging to one
|
||||
/// of these residue categories so the slice boundary stays explicit:
|
||||
///
|
||||
/// - **derived cache**: a pure function of the slices, recomputed on
|
||||
/// demand. Owned by `App` because rebuilding belongs to the host.
|
||||
/// - **runtime metadata**: data the runtime feeds in (terminal dims,
|
||||
/// wall-clock instants). Not part of model or view state.
|
||||
/// - **transient**: a flag whose lifetime is bounded to one method call
|
||||
/// on `App`. Reset on entry, never observed across calls.
|
||||
/// - **config**: data loaded once at startup and read but not written
|
||||
/// during a session.
|
||||
pub struct App {
|
||||
pub model_state: ModelState,
|
||||
pub view_state: ViewState,
|
||||
/// **Runtime metadata**: wall-clock instant of the last autosave. Used
|
||||
/// by `autosave_if_needed` to debounce writes. Not part of either slice
|
||||
/// because it's about IO timing, not document or session state.
|
||||
pub last_autosave: Instant,
|
||||
/// **Runtime metadata**: terminal dimensions, updated on resize events
|
||||
/// and at startup. Fed in by the host loop in `draw.rs`.
|
||||
pub term_width: u16,
|
||||
pub term_height: u16,
|
||||
/// **Derived cache**: pure function of `model_state.workbook`,
|
||||
/// `view_state.drill_state`, and the active view. Rebuilt via
|
||||
/// `rebuild_layout()` after any state change. Owned by `App` because
|
||||
/// it's the host's job to coordinate the rebuild, not the slices'.
|
||||
pub layout: GridLayout,
|
||||
/// When set to true by an effect during `apply_effects`, the remaining
|
||||
/// effects in the batch are skipped. The flag is reset at the start of
|
||||
/// every `apply_effects` call. Use via the `AbortChain` effect — this is
|
||||
/// the mechanism by which e.g. "advance at bottom-right" short-circuits
|
||||
/// the trailing `EnterEditAtCursor` in a `CommitAndAdvance` chain.
|
||||
/// **Transient**: when an effect sets this to `true` during
|
||||
/// `apply_effects`, the remaining effects in the batch are skipped.
|
||||
/// Reset to `false` at the start of every `apply_effects` call. Use
|
||||
/// via the `AbortChain` effect — this is the mechanism by which e.g.
|
||||
/// "advance at bottom-right" short-circuits the trailing
|
||||
/// `EnterEditAtCursor` in a `CommitAndAdvance` chain.
|
||||
pub abort_effects: bool,
|
||||
/// **Config**: keymap configuration assembled at startup by
|
||||
/// `KeymapSet::default_keymaps()`. Read every keypress but never
|
||||
/// mutated during a session.
|
||||
keymap_set: KeymapSet,
|
||||
}
|
||||
|
||||
@@ -251,32 +331,15 @@ impl App {
|
||||
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||
};
|
||||
Self {
|
||||
workbook,
|
||||
file_path,
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
wizard: None,
|
||||
model_state: ModelState {
|
||||
workbook,
|
||||
file_path,
|
||||
dirty: false,
|
||||
},
|
||||
view_state: ViewState::default(),
|
||||
last_autosave: Instant::now(),
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
formula_cursor: 0,
|
||||
dirty: false,
|
||||
yanked: None,
|
||||
tile_cat_idx: 0,
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
drill_state: None,
|
||||
help_page: 0,
|
||||
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
|
||||
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
layout,
|
||||
abort_effects: false,
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
@@ -286,53 +349,53 @@ impl App {
|
||||
/// Rebuild the grid layout from current workbook, active view, and drill
|
||||
/// state. Note: `with_frozen_records` already handles pruning internally.
|
||||
pub fn rebuild_layout(&mut self) {
|
||||
let none_cats = self.workbook.active_view().none_cats();
|
||||
self.workbook.model.recompute_formulas(&none_cats);
|
||||
let view = self.workbook.active_view();
|
||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout = GridLayout::with_frozen_records(&self.workbook.model, view, frozen);
|
||||
let none_cats = self.model_state.workbook.active_view().none_cats();
|
||||
self.model_state.workbook.model.recompute_formulas(&none_cats);
|
||||
let view = self.model_state.workbook.active_view();
|
||||
let frozen = self.view_state.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
|
||||
}
|
||||
|
||||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||||
let view = self.workbook.active_view();
|
||||
let view = self.model_state.workbook.active_view();
|
||||
let layout = &self.layout;
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
CmdContext {
|
||||
model: &self.workbook.model,
|
||||
workbook: &self.workbook,
|
||||
model: &self.model_state.workbook.model,
|
||||
workbook: &self.model_state.workbook,
|
||||
view,
|
||||
layout,
|
||||
registry: self.keymap_set.registry(),
|
||||
mode: &self.mode,
|
||||
mode: &self.view_state.mode,
|
||||
selected: view.selected,
|
||||
row_offset: view.row_offset,
|
||||
col_offset: view.col_offset,
|
||||
search_query: &self.search_query,
|
||||
yanked: &self.yanked,
|
||||
dirty: self.dirty,
|
||||
search_mode: self.search_mode,
|
||||
formula_panel_open: self.formula_panel_open,
|
||||
category_panel_open: self.category_panel_open,
|
||||
view_panel_open: self.view_panel_open,
|
||||
buffers: &self.buffers,
|
||||
formula_cursor: self.formula_cursor,
|
||||
cat_panel_cursor: self.cat_panel_cursor,
|
||||
view_panel_cursor: self.view_panel_cursor,
|
||||
tile_cat_idx: self.tile_cat_idx,
|
||||
view_back_stack: &self.view_back_stack,
|
||||
view_forward_stack: &self.view_forward_stack,
|
||||
has_drill_state: self.drill_state.is_some(),
|
||||
search_query: &self.view_state.search_query,
|
||||
yanked: &self.view_state.yanked,
|
||||
dirty: self.model_state.dirty,
|
||||
search_mode: self.view_state.search_mode,
|
||||
formula_panel_open: self.view_state.formula_panel_open,
|
||||
category_panel_open: self.view_state.category_panel_open,
|
||||
view_panel_open: self.view_state.view_panel_open,
|
||||
buffers: &self.view_state.buffers,
|
||||
formula_cursor: self.view_state.formula_cursor,
|
||||
cat_panel_cursor: self.view_state.cat_panel_cursor,
|
||||
view_panel_cursor: self.view_state.view_panel_cursor,
|
||||
tile_cat_idx: self.view_state.tile_cat_idx,
|
||||
view_back_stack: &self.view_state.view_back_stack,
|
||||
view_forward_stack: &self.view_state.view_forward_stack,
|
||||
has_drill_state: self.view_state.drill_state.is_some(),
|
||||
display_value: {
|
||||
let key = layout.cell_key(sel_row, sel_col);
|
||||
if let Some(k) = &key {
|
||||
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
|
||||
self.drill_state
|
||||
self.view_state.drill_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
|
||||
.or_else(|| layout.resolve_display(k))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.workbook
|
||||
self.model_state.workbook
|
||||
.model
|
||||
.get_cell(k)
|
||||
.map(|v| v.to_string())
|
||||
@@ -346,7 +409,7 @@ impl App {
|
||||
visible_cols: {
|
||||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||
let col_widths =
|
||||
compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals);
|
||||
compute_col_widths(&self.model_state.workbook.model, layout, fmt_comma, fmt_decimals);
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
@@ -355,7 +418,7 @@ impl App {
|
||||
view.col_offset,
|
||||
)
|
||||
},
|
||||
expanded_cats: &self.expanded_cats,
|
||||
expanded_cats: &self.view_state.expanded_cats,
|
||||
key_code: key,
|
||||
}
|
||||
}
|
||||
@@ -379,7 +442,7 @@ impl App {
|
||||
/// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
|
||||
pub fn is_empty_model(&self) -> bool {
|
||||
use crate::model::category::CategoryKind;
|
||||
self.workbook.model.categories.values().all(|c| {
|
||||
self.model_state.workbook.model.categories.values().all(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
CategoryKind::VirtualIndex
|
||||
@@ -393,7 +456,7 @@ impl App {
|
||||
self.rebuild_layout();
|
||||
|
||||
// Transient keymap (prefix key sequence) takes priority
|
||||
if let Some(transient) = self.transient_keymap.take() {
|
||||
if let Some(transient) = self.view_state.transient_keymap.take() {
|
||||
let effects = {
|
||||
let ctx = self.cmd_context(key.code, key.modifiers);
|
||||
self.keymap_set
|
||||
@@ -418,24 +481,24 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn autosave_if_needed(&mut self) {
|
||||
if self.dirty
|
||||
if self.model_state.dirty
|
||||
&& self.last_autosave.elapsed() > Duration::from_secs(30)
|
||||
&& let Some(path) = &self.file_path.clone()
|
||||
&& let Some(path) = &self.model_state.file_path.clone()
|
||||
{
|
||||
let ap = persistence::autosave_path(path);
|
||||
let _ = persistence::save(&self.workbook, &ap);
|
||||
let _ = persistence::save(&self.model_state.workbook, &ap);
|
||||
self.last_autosave = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
|
||||
self.wizard = Some(ImportWizard::new(json));
|
||||
self.mode = AppMode::ImportWizard;
|
||||
self.view_state.wizard = Some(ImportWizard::new(json));
|
||||
self.view_state.mode = AppMode::ImportWizard;
|
||||
}
|
||||
|
||||
/// Hint text for the status bar (context-sensitive)
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
match &self.view_state.mode {
|
||||
AppMode::Normal => {
|
||||
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
|
||||
}
|
||||
@@ -472,6 +535,53 @@ impl App {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// improvise-3vr: ModelState and ViewState are the named slices of App
|
||||
/// state introduced by the vb4 refactor. Step 1 only requires that the
|
||||
/// types exist and are constructible; subsequent steps move fields in.
|
||||
#[test]
|
||||
fn model_state_and_view_state_are_constructible() {
|
||||
let _: ModelState = ModelState::default();
|
||||
let _: ViewState = ViewState::default();
|
||||
}
|
||||
|
||||
/// improvise-x2c: ModelState owns the document slice — workbook,
|
||||
/// file_path, and dirty. App accesses them through model_state.
|
||||
#[test]
|
||||
fn app_model_state_owns_workbook_file_path_and_dirty() {
|
||||
let app = App::new(Workbook::new("T"), Some(PathBuf::from("/tmp/x")));
|
||||
let _: &Workbook = &app.model_state.workbook;
|
||||
let _: &Option<PathBuf> = &app.model_state.file_path;
|
||||
let _: bool = app.model_state.dirty;
|
||||
}
|
||||
|
||||
/// improvise-ew0: ViewState owns the UI session slice — mode, status,
|
||||
/// search, panels, navigation, drill, yanked, buffers, etc. App
|
||||
/// accesses them through view_state.
|
||||
#[test]
|
||||
fn app_view_state_owns_ui_session_fields() {
|
||||
let app = App::new(Workbook::new("T"), None);
|
||||
let _: &AppMode = &app.view_state.mode;
|
||||
let _: &str = &app.view_state.status_msg;
|
||||
let _: &Option<ImportWizard> = &app.view_state.wizard;
|
||||
let _: &str = &app.view_state.search_query;
|
||||
let _: bool = app.view_state.search_mode;
|
||||
let _: bool = app.view_state.formula_panel_open;
|
||||
let _: bool = app.view_state.category_panel_open;
|
||||
let _: bool = app.view_state.view_panel_open;
|
||||
let _: usize = app.view_state.cat_panel_cursor;
|
||||
let _: usize = app.view_state.view_panel_cursor;
|
||||
let _: usize = app.view_state.formula_cursor;
|
||||
let _: &Option<CellValue> = &app.view_state.yanked;
|
||||
let _: usize = app.view_state.tile_cat_idx;
|
||||
let _: &Vec<ViewFrame> = &app.view_state.view_back_stack;
|
||||
let _: &Vec<ViewFrame> = &app.view_state.view_forward_stack;
|
||||
let _: &Option<DrillState> = &app.view_state.drill_state;
|
||||
let _: usize = app.view_state.help_page;
|
||||
let _: &std::collections::HashSet<String> = &app.view_state.expanded_cats;
|
||||
let _: &HashMap<String, String> = &app.view_state.buffers;
|
||||
let _: &Option<Arc<Keymap>> = &app.view_state.transient_keymap;
|
||||
}
|
||||
|
||||
fn two_col_model() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Row").unwrap(); // → Row axis
|
||||
@@ -493,7 +603,7 @@ mod tests {
|
||||
|
||||
fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance {
|
||||
use crate::command::cmd::navigation::CursorState;
|
||||
let view = app.workbook.active_view();
|
||||
let view = app.model_state.workbook.active_view();
|
||||
let cursor = CursorState {
|
||||
row: view.selected.0,
|
||||
col: view.selected.1,
|
||||
@@ -510,29 +620,29 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_advance_moves_down_within_column() {
|
||||
let mut app = two_col_model();
|
||||
app.workbook.active_view_mut().selected = (0, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, 0);
|
||||
let cmd = enter_advance_cmd(&app);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||
assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_advance_wraps_to_top_of_next_column() {
|
||||
let mut app = two_col_model();
|
||||
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
|
||||
app.workbook.active_view_mut().selected = (2, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (2, 0);
|
||||
let cmd = enter_advance_cmd(&app);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.workbook.active_view().selected, (0, 1));
|
||||
assert_eq!(app.model_state.workbook.active_view().selected, (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_advance_stays_at_bottom_right() {
|
||||
let mut app = two_col_model();
|
||||
app.workbook.active_view_mut().selected = (2, 1);
|
||||
app.model_state.workbook.active_view_mut().selected = (2, 1);
|
||||
let cmd = enter_advance_cmd(&app);
|
||||
run_cmd(&mut app, &cmd);
|
||||
assert_eq!(app.workbook.active_view().selected, (2, 1));
|
||||
assert_eq!(app.model_state.workbook.active_view().selected, (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,7 +653,7 @@ mod tests {
|
||||
let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]);
|
||||
app.start_import_wizard(json);
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::ImportWizard),
|
||||
matches!(app.view_state.mode, AppMode::ImportWizard),
|
||||
"mode should be ImportWizard after start_import_wizard"
|
||||
);
|
||||
}
|
||||
@@ -555,7 +665,7 @@ mod tests {
|
||||
app.start_import_wizard(serde_json::json!([{"x": 1}]));
|
||||
// After the command the mode must NOT be reset to Normal
|
||||
assert!(
|
||||
!matches!(app.mode, AppMode::Normal),
|
||||
!matches!(app.view_state.mode, AppMode::Normal),
|
||||
"mode must not be Normal after import wizard is opened"
|
||||
);
|
||||
}
|
||||
@@ -567,13 +677,13 @@ mod tests {
|
||||
// Enter command mode with ':'
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(matches!(app.mode, AppMode::CommandMode { .. }));
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
|
||||
assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. }));
|
||||
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
|
||||
|
||||
// Type 'q'
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
|
||||
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -611,34 +721,34 @@ mod tests {
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.workbook.active_view().selected.1,
|
||||
app.model_state.workbook.active_view().selected.1,
|
||||
3,
|
||||
"cursor should be at column 3"
|
||||
);
|
||||
assert!(
|
||||
app.workbook.active_view().col_offset > 0,
|
||||
app.model_state.workbook.active_view().col_offset > 0,
|
||||
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
|
||||
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
|
||||
app.workbook.active_view().col_offset
|
||||
app.model_state.workbook.active_view().col_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_jumps_to_first_col() {
|
||||
let mut app = two_col_model();
|
||||
app.workbook.active_view_mut().selected = (1, 1);
|
||||
app.model_state.workbook.active_view_mut().selected = (1, 1);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 0));
|
||||
assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_jumps_to_last_col() {
|
||||
let mut app = two_col_model();
|
||||
app.workbook.active_view_mut().selected = (1, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (1, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected, (1, 1));
|
||||
assert_eq!(app.model_state.workbook.active_view().selected, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -646,40 +756,40 @@ mod tests {
|
||||
let mut app = two_col_model();
|
||||
// Add enough rows
|
||||
for i in 0..30 {
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||
app.workbook.active_view_mut().selected = (0, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected.1, 0, "column preserved");
|
||||
assert_eq!(app.model_state.workbook.active_view().selected.1, 0, "column preserved");
|
||||
assert!(
|
||||
app.workbook.active_view().selected.0 > 0,
|
||||
app.model_state.workbook.active_view().selected.0 > 0,
|
||||
"row should advance on PageDown"
|
||||
);
|
||||
// 3/4 of ~20 = 15
|
||||
assert_eq!(app.workbook.active_view().selected.0, 15);
|
||||
assert_eq!(app.model_state.workbook.active_view().selected.0, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_scrolls_backward() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.term_height = 28;
|
||||
app.workbook.active_view_mut().selected = (20, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (20, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected.0, 5);
|
||||
assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -687,22 +797,22 @@ mod tests {
|
||||
let mut app = two_col_model();
|
||||
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
|
||||
for i in 0..10 {
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.workbook.active_view_mut().selected = (0, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, 0);
|
||||
// G jumps to last row (row 12)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
let last = app.workbook.active_view().selected.0;
|
||||
let last = app.model_state.workbook.active_view().selected.0;
|
||||
assert_eq!(last, 12, "should be at last row");
|
||||
// With only ~5 visible rows and 13 rows, offset should scroll.
|
||||
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
|
||||
let offset = app.workbook.active_view().row_offset;
|
||||
let offset = app.model_state.workbook.active_view().row_offset;
|
||||
assert!(
|
||||
offset > 0,
|
||||
"row_offset should scroll when last row is beyond visible area, but is {offset}"
|
||||
@@ -713,38 +823,38 @@ mod tests {
|
||||
fn ctrl_d_scrolls_viewport_with_small_terminal() {
|
||||
let mut app = two_col_model();
|
||||
for i in 0..30 {
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.category_mut("Row")
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.workbook.active_view_mut().selected = (0, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, 0);
|
||||
// Ctrl+d scrolls by 5 rows
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected.0, 5);
|
||||
assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
|
||||
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
|
||||
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
|
||||
.unwrap();
|
||||
assert_eq!(app.workbook.active_view().selected.0, 10);
|
||||
assert_eq!(app.model_state.workbook.active_view().selected.0, 10);
|
||||
assert!(
|
||||
app.workbook.active_view().row_offset > 0,
|
||||
app.model_state.workbook.active_view().row_offset > 0,
|
||||
"row_offset should scroll with small terminal, but is {}",
|
||||
app.workbook.active_view().row_offset
|
||||
app.model_state.workbook.active_view().row_offset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_in_edit_mode_commits_and_moves_right() {
|
||||
let mut app = two_col_model();
|
||||
app.workbook.active_view_mut().selected = (0, 0);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, 0);
|
||||
// Enter edit mode
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(matches!(app.mode, AppMode::Editing { .. }));
|
||||
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
|
||||
// Type a digit
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
@@ -753,12 +863,12 @@ mod tests {
|
||||
.unwrap();
|
||||
// Should be in edit mode on column 1
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::Editing { .. }),
|
||||
matches!(app.view_state.mode, AppMode::Editing { .. }),
|
||||
"should be in edit mode after Tab, but mode is {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
assert_eq!(
|
||||
app.workbook.active_view().selected.1,
|
||||
app.model_state.workbook.active_view().selected.1,
|
||||
1,
|
||||
"should have moved to column 1"
|
||||
);
|
||||
@@ -824,9 +934,9 @@ mod tests {
|
||||
"o should create the first record row in an empty records view"
|
||||
);
|
||||
assert!(
|
||||
app.mode.is_editing(),
|
||||
app.view_state.mode.is_editing(),
|
||||
"o should leave the app in edit mode, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -863,7 +973,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.workbook.model.get_cell(&CellKey::new(vec![(
|
||||
app.model_state.workbook.model.get_cell(&CellKey::new(vec![(
|
||||
"_Measure".to_string(),
|
||||
"Rev".to_string(),
|
||||
)])),
|
||||
@@ -935,11 +1045,11 @@ mod tests {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut app = records_model_with_two_rows();
|
||||
// Simulate Tab-at-bottom-right having produced an empty-key cell.
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.set_cell(CellKey::new(vec![]), CellValue::Number(0.0));
|
||||
assert!(
|
||||
app.workbook
|
||||
app.model_state.workbook
|
||||
.model
|
||||
.data
|
||||
.iter_cells()
|
||||
@@ -954,7 +1064,7 @@ mod tests {
|
||||
"setup: should have left records mode"
|
||||
);
|
||||
assert!(
|
||||
!app.workbook
|
||||
!app.model_state.workbook
|
||||
.model
|
||||
.data
|
||||
.iter_cells()
|
||||
@@ -972,23 +1082,23 @@ mod tests {
|
||||
let mut app = records_model_with_two_rows();
|
||||
let last_row = app.layout.row_count() - 1;
|
||||
let last_col = app.layout.col_count() - 1;
|
||||
app.workbook.active_view_mut().selected = (last_row, last_col);
|
||||
app.model_state.workbook.active_view_mut().selected = (last_row, last_col);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(app.mode.is_editing(), "setup: should be editing");
|
||||
assert!(app.view_state.mode.is_editing(), "setup: should be editing");
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!app.mode.is_editing(),
|
||||
!app.view_state.mode.is_editing(),
|
||||
"Enter at bottom-right should exit editing, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::RecordsNormal),
|
||||
matches!(app.view_state.mode, AppMode::RecordsNormal),
|
||||
"should return to RecordsNormal, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1003,12 +1113,12 @@ mod tests {
|
||||
|
||||
let last_row = initial_rows - 1;
|
||||
let last_col = app.layout.col_count() - 1;
|
||||
app.workbook.active_view_mut().selected = (last_row, last_col);
|
||||
app.model_state.workbook.active_view_mut().selected = (last_row, last_col);
|
||||
|
||||
// Enter edit mode on the bottom-right cell
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(app.mode.is_editing(), "setup: should be editing");
|
||||
assert!(app.view_state.mode.is_editing(), "setup: should be editing");
|
||||
|
||||
// TAB should commit, insert below, move to first cell of new row
|
||||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
@@ -1020,14 +1130,14 @@ mod tests {
|
||||
"TAB on bottom-right should insert a record below"
|
||||
);
|
||||
assert_eq!(
|
||||
app.workbook.active_view().selected,
|
||||
app.model_state.workbook.active_view().selected,
|
||||
(initial_rows, 0),
|
||||
"TAB should move to first cell of the new row"
|
||||
);
|
||||
assert!(
|
||||
app.mode.is_editing(),
|
||||
app.view_state.mode.is_editing(),
|
||||
"should enter edit mode on the new cell, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1052,11 +1162,11 @@ mod tests {
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(app.drill_state.is_some(), "drill should create drill state");
|
||||
assert!(app.view_state.drill_state.is_some(), "drill should create drill state");
|
||||
let value_col = (0..app.layout.col_count())
|
||||
.find(|&col| app.layout.col_label(col) == "Value")
|
||||
.expect("drill view should include a Value column");
|
||||
app.workbook.active_view_mut().selected = (0, value_col);
|
||||
app.model_state.workbook.active_view_mut().selected = (0, value_col);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
|
||||
@@ -1067,12 +1177,12 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.workbook.model.get_cell(&record_key),
|
||||
app.model_state.workbook.model.get_cell(&record_key),
|
||||
Some(&CellValue::Number(1.0)),
|
||||
"drill edit should remain staged until leaving the drill view"
|
||||
);
|
||||
assert_eq!(
|
||||
app.drill_state
|
||||
app.view_state.drill_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))),
|
||||
Some(&"9".to_string()),
|
||||
@@ -1085,7 +1195,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.workbook.model.get_cell(&record_key),
|
||||
app.model_state.workbook.model.get_cell(&record_key),
|
||||
Some(&CellValue::Number(9.0)),
|
||||
"leaving drill view should apply the staged edit"
|
||||
);
|
||||
@@ -1118,7 +1228,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!app.workbook
|
||||
!app.model_state.workbook
|
||||
.model
|
||||
.category("Region")
|
||||
.unwrap()
|
||||
@@ -1137,14 +1247,14 @@ mod tests {
|
||||
.unwrap();
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("x"));
|
||||
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x"));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
|
||||
// Re-enter command mode — buffer should be cleared
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some(""));
|
||||
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
|
||||
}
|
||||
|
||||
// ── is_empty_model ──────────────────────────────────────────────────
|
||||
@@ -1174,34 +1284,34 @@ mod tests {
|
||||
#[test]
|
||||
fn help_page_next_advances_page() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.help_page = 0;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
app.view_state.help_page = 0;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.help_page, 1, "l should advance to page 1");
|
||||
assert_eq!(app.view_state.help_page, 1, "l should advance to page 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_page_prev_goes_back() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.help_page = 2;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
app.view_state.help_page = 2;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.help_page, 1, "h should go back to page 1");
|
||||
assert_eq!(app.view_state.help_page, 1, "h should go back to page 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_page_clamps_at_zero() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.help_page = 0;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
app.view_state.help_page = 0;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(app.help_page, 0, "page should not go below 0");
|
||||
assert_eq!(app.view_state.help_page, 0, "page should not go below 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1209,13 +1319,13 @@ mod tests {
|
||||
use crate::ui::help::HELP_PAGE_COUNT;
|
||||
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.help_page = HELP_PAGE_COUNT - 1;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
app.view_state.help_page = HELP_PAGE_COUNT - 1;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.help_page,
|
||||
app.view_state.help_page,
|
||||
HELP_PAGE_COUNT - 1,
|
||||
"page should not exceed the last page"
|
||||
);
|
||||
@@ -1226,12 +1336,12 @@ mod tests {
|
||||
#[test]
|
||||
fn help_q_returns_to_normal() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::Normal),
|
||||
matches!(app.view_state.mode, AppMode::Normal),
|
||||
"q should return to Normal mode"
|
||||
);
|
||||
}
|
||||
@@ -1239,12 +1349,12 @@ mod tests {
|
||||
#[test]
|
||||
fn help_esc_returns_to_normal() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::Normal),
|
||||
matches!(app.view_state.mode, AppMode::Normal),
|
||||
"Esc should return to Normal mode"
|
||||
);
|
||||
}
|
||||
@@ -1252,14 +1362,14 @@ mod tests {
|
||||
#[test]
|
||||
fn help_colon_enters_command_mode() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.mode = AppMode::Help;
|
||||
app.view_state.mode = AppMode::Help;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||||
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
|
||||
"colon in Help mode should enter CommandMode, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1275,9 +1385,9 @@ mod tests {
|
||||
};
|
||||
effect.apply(&mut app);
|
||||
assert!(
|
||||
app.status_msg.contains("Unknown category"),
|
||||
app.view_state.status_msg.contains("Unknown category"),
|
||||
"should report unknown category, got: {:?}",
|
||||
app.status_msg
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1291,9 +1401,9 @@ mod tests {
|
||||
};
|
||||
effect.apply(&mut app);
|
||||
assert!(
|
||||
app.status_msg.contains("Formula error"),
|
||||
app.view_state.status_msg.contains("Formula error"),
|
||||
"should report formula error, got: {:?}",
|
||||
app.status_msg
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1302,19 +1412,19 @@ mod tests {
|
||||
#[test]
|
||||
fn tile_axis_change_stays_in_tile_select() {
|
||||
let mut app = two_col_model();
|
||||
app.mode = AppMode::TileSelect;
|
||||
app.tile_cat_idx = 0;
|
||||
app.view_state.mode = AppMode::TileSelect;
|
||||
app.view_state.tile_cat_idx = 0;
|
||||
|
||||
// Press 'r' to set axis to Row — should stay in TileSelect
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::TileSelect),
|
||||
matches!(app.view_state.mode, AppMode::TileSelect),
|
||||
"should stay in TileSelect after axis change, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
assert!(
|
||||
!app.status_msg.is_empty(),
|
||||
!app.view_state.status_msg.is_empty(),
|
||||
"should show status feedback after axis change"
|
||||
);
|
||||
}
|
||||
@@ -1324,42 +1434,42 @@ mod tests {
|
||||
#[test]
|
||||
fn category_panel_colon_enters_command_mode() {
|
||||
let mut app = two_col_model();
|
||||
app.mode = AppMode::CategoryPanel;
|
||||
app.view_state.mode = AppMode::CategoryPanel;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||||
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
|
||||
"colon in CategoryPanel should enter CommandMode, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_panel_colon_enters_command_mode() {
|
||||
let mut app = two_col_model();
|
||||
app.mode = AppMode::ViewPanel;
|
||||
app.view_state.mode = AppMode::ViewPanel;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||||
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
|
||||
"colon in ViewPanel should enter CommandMode, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_select_colon_enters_command_mode() {
|
||||
let mut app = two_col_model();
|
||||
app.mode = AppMode::TileSelect;
|
||||
app.view_state.mode = AppMode::TileSelect;
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(app.mode, AppMode::CommandMode { .. }),
|
||||
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
|
||||
"colon in TileSelect should enter CommandMode, got {:?}",
|
||||
app.mode
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+380
-272
File diff suppressed because it is too large
Load Diff
+57
-6
@@ -217,8 +217,7 @@ impl<'a> GridWidget<'a> {
|
||||
let label = if layout.col_cats.is_empty() {
|
||||
layout.col_label(ci)
|
||||
} else {
|
||||
let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
|
||||
if show {
|
||||
if show_sublabel(ci, col_offset, d, &data_col_items) {
|
||||
data_col_items[ci][d].clone()
|
||||
} else {
|
||||
String::new()
|
||||
@@ -340,9 +339,7 @@ impl<'a> GridWidget<'a> {
|
||||
let label = if layout.row_cats.is_empty() {
|
||||
layout.row_label(ri)
|
||||
} else {
|
||||
let show =
|
||||
ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
|
||||
if show {
|
||||
if show_sublabel(ri, row_offset, d, &data_row_items) {
|
||||
data_row_items[ri][d].clone()
|
||||
} else {
|
||||
String::new()
|
||||
@@ -649,6 +646,15 @@ pub fn compute_visible_cols(
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// Decide whether the multi-level header sub-label at depth `d` for the data
|
||||
/// entry `idx` should be rendered, or blanked because it repeats the previous
|
||||
/// visible entry's prefix. `first_rendered` is the index of the first entry
|
||||
/// actually on screen (the row/col scroll offset): that entry always shows its
|
||||
/// full labels so a scrolled viewport keeps its group context.
|
||||
fn show_sublabel(idx: usize, first_rendered: usize, d: usize, items: &[&Vec<String>]) -> bool {
|
||||
idx == first_rendered || items[idx][..=d] != items[idx - 1][..=d]
|
||||
}
|
||||
|
||||
// Re-export shared formatting functions
|
||||
pub use crate::format::{format_f64, parse_number_format};
|
||||
|
||||
@@ -678,7 +684,7 @@ fn truncate(s: &str, max_width: usize) -> String {
|
||||
mod tests {
|
||||
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||
|
||||
use super::GridWidget;
|
||||
use super::{show_sublabel, GridWidget};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
@@ -1060,4 +1066,49 @@ mod tests {
|
||||
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
||||
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
||||
}
|
||||
|
||||
// ── Multi-level header repeat suppression ─────────────────────────────────
|
||||
|
||||
/// Items: [A,x], [A,y], [B,z] — multi-level header tuples.
|
||||
fn sublabel_items() -> Vec<Vec<String>> {
|
||||
[["A", "x"], ["A", "y"], ["B", "z"]]
|
||||
.iter()
|
||||
.map(|t| t.iter().map(|s| s.to_string()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bug (improvise-jjx): suppression compared against the entry at
|
||||
/// `idx - 1` even when that entry is scrolled off-screen. The first
|
||||
/// RENDERED entry must always show its full labels, otherwise scrolling
|
||||
/// blanks the group label at the top of the viewport.
|
||||
#[test]
|
||||
fn first_rendered_entry_always_shows_sublabel_when_scrolled() {
|
||||
let items = sublabel_items();
|
||||
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||
// Scrolled so index 1 ([A,y]) is the first visible entry. Its level-0
|
||||
// prefix matches off-screen index 0 ([A,x]) — it must still show "A".
|
||||
assert!(
|
||||
show_sublabel(1, 1, 0, &refs),
|
||||
"first rendered entry must show its label even when its prefix matches the off-screen entry above"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_prefix_below_first_rendered_entry_is_suppressed() {
|
||||
let items = sublabel_items();
|
||||
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||
// Unscrolled: index 1 repeats index 0's level-0 prefix → suppressed.
|
||||
assert!(!show_sublabel(1, 0, 0, &refs));
|
||||
// Leaf level differs → shown.
|
||||
assert!(show_sublabel(1, 0, 1, &refs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_prefix_is_always_shown() {
|
||||
let items = sublabel_items();
|
||||
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||
assert!(show_sublabel(0, 0, 0, &refs));
|
||||
assert!(show_sublabel(2, 0, 0, &refs)); // B != A
|
||||
assert!(show_sublabel(2, 1, 0, &refs)); // scrolled, still differs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,16 +323,18 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
"Enter to import, Esc to cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
);
|
||||
|
||||
if let Some(msg) = &self.wizard.message {
|
||||
let msg_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, msg_y, truncate(msg, w), Style::default().fg(Color::Red));
|
||||
}
|
||||
}
|
||||
WizardStep::Done => {
|
||||
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
|
||||
}
|
||||
}
|
||||
|
||||
// Wizard message (errors, blocked-step explanations) takes the bottom
|
||||
// line on every step, overriding the key hint while present.
|
||||
if let Some(msg) = &self.wizard.message {
|
||||
let msg_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, msg_y, truncate(msg, w), Style::default().fg(Color::Red));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user