diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d684ec8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,15 @@ +[build] +# Always build a fully-static musl binary by default. +# The devShell sets CARGO_BUILD_TARGET and CARGO_TARGET_*_LINKER, +# so the linker is resolved automatically via the environment. +target = "x86_64-unknown-linux-musl" + +[target.x86_64-unknown-linux-gnu] +# Use gcc (not rust-lld) for host-target build scripts on NixOS. +# rust-lld bundled with rust-overlay can't resolve glibc symbols on NixOS. +linker = "gcc" + +[target.x86_64-unknown-linux-musl] +# Linker is provided via CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER +# in the nix devShell. Outside nix, musl-gcc must be in PATH. +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97dec79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +*.autosave +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..30203df --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1077 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "improvise" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "crossterm", + "dirs", + "flate2", + "indexmap", + "ratatui", + "serde", + "serde_json", + "thiserror", + "unicode-width 0.2.0", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0e9daef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "improvise" +version = "0.1.0" +edition = "2021" +description = "Multi-dimensional data modeling terminal application" +license = "MIT" + +[[bin]] +name = "improvise" +path = "src/main.rs" + +[dependencies] +ratatui = "0.29" +crossterm = "0.28" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "1" +indexmap = { version = "2", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } +flate2 = "1" +unicode-width = "0.2" +dirs = "5" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e9910c6 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,277 @@ +# Improvise — Multi-Dimensional Data Modeling Terminal Application + +## Context + +Traditional spreadsheets conflate data, formulas, and presentation into a single flat grid addressed by opaque cell references (A1, B7). This makes models fragile, hard to audit, and impossible to rearrange without rewriting formulas. We are building a terminal application that treats data as a multi-dimensional, semantically labeled structure — separating data, computation, and views into independent layers. The result is a tool where formulas reference meaningful names, views can be rearranged instantly, and the same dataset can be explored from multiple perspectives simultaneously. + +The application compiles to a single static binary (`x86_64-unknown-linux-musl`) and provides a rich TUI experience. + +--- + +## 1. Core Data Model + +### 1.1 Categories and Items +- Data is organized into **categories** (dimensions) and **items** (members of a dimension). + - Example: Category "Region" contains items "North", "South", "East", "West". + - Example: Category "Time" contains items "Q1", "Q2", "Q3", "Q4". +- Items within a category can be organized into **groups** forming a hierarchy. + - Example: Items "Jan", "Feb", "Mar" grouped under "Q1"; quarters grouped under "2025". +- Groups are collapsible/expandable for drill-down. +- A model supports up to **12 categories**. + +### 1.2 Data Cells +- Each data cell is identified by the intersection of one item from each active category — not by grid coordinates. +- Cells hold numeric values, text, or empty/null. +- The underlying storage is a sparse multi-dimensional array (`HashMap`). + +### 1.3 Models +- A **model** is the top-level container: it holds all categories, items, groups, data cells, formulas, and views. +- Models are saved to and loaded from a single `.improv` file (JSON format). + +--- + +## 2. Formula System + +### 2.1 Named Formulas +- Formulas reference categories and items by name, not by cell address. + - Example: `Profit = Revenue - Cost` + - Example: `Tax = Revenue * 0.08` + - Example: `Margin = Profit / Revenue` +- A formula applies uniformly across all intersections of the referenced categories. No copying or dragging. + +### 2.2 Formula Panel +- Formulas are defined in a **dedicated formula panel**, separate from the data grid. +- All formulas are visible in one place for easy auditing. +- Formulas cannot be accidentally overwritten by data entry. + +### 2.3 Scoped Formulas (WHERE clause) +- A formula can be scoped to a subset of items: + - Example: `Discount = 0.10 * Price WHERE Region = "West"` + +### 2.4 Aggregation +- Built-in aggregation functions: `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`. + +### 2.5 Formula Language +- Expression-based (not Turing-complete). +- Operators: `+`, `-`, `*`, `/`, `^`, unary `-`. +- Comparisons: `=`, `!=`, `<`, `>`, `<=`, `>=`. +- Conditionals: `IF(condition, then, else)`. +- `WHERE` clause for filtering: `SUM(Sales WHERE Region = "East")`. +- Parentheses for grouping. +- Literal numbers and quoted strings. + +--- + +## 3. View System + +### 3.1 Views as First-Class Objects +- A **view** is a named configuration specifying: + - Which categories are assigned to **rows**, **columns**, and **pages** (filters/slicers). + - Which items/groups are visible vs. hidden. + - Sort order (future). + - Number formatting. +- Multiple views can exist per model. Each is independent. +- Editing data in any view updates the underlying model; all other views reflect the change. + +### 3.2 Category Tiles +- Each category is represented as a **tile** displayed in the tile bar. +- The user can move tiles between row, column, and page axes to instantly pivot/rearrange the view. +- Moving a tile triggers an instant recalculation and re-render of the grid. + +### 3.3 Page Axis (Slicing) +- Categories assigned to the page axis act as filters. +- The user selects a single item from a paged category using `[` and `]`. + +### 3.4 Collapsing and Expanding +- Groups can be collapsed/expanded per-view (future: keyboard shortcut in grid). + +--- + +## 4. JSON Import Wizard + +### 4.1 Purpose +- Users can import arbitrary JSON files to bootstrap a model. + +### 4.2 Wizard Flow (interactive TUI) + +**Step 1: Preview** — Structural summary of the JSON. + +**Step 2: Select Array Path** — If the JSON is not a flat array, the user selects which key path contains the primary record array. + +**Step 3: Review Proposals** — Fields are analyzed and proposed as: + - Category (small number of distinct string values) + - Measure (numeric) + - Time Category (date-like strings) + - Label/Identifier (skip) + +**Step 4: Name the Model** — User names the model and confirms. + +### 4.3 Headless Import +``` +improvise --cmd '{"op":"ImportJson","path":"data.json"}' +``` + +--- + +## 5. Terminal UI + +### 5.1 Layout +``` ++---------------------------------------------------------------+ +| Improvise | Model: Sales 2025 [*] [F1 Help] [Ctrl+Q] | ++---------------------------------------------------------------+ +| [Page: Region = East] | +| | Q1 | Q2 | Q3 | Q4 | | +|--------------+---------+---------+---------+---------+--------| +| Shirts | 1,200 | 1,450 | 1,100 | 1,800 | | +| Pants | 800 | 920 | 750 | 1,200 | | +| ... | +|--------------+---------+---------+---------+---------+--------| +| Total | 4,100 | 4,670 | 3,750 | 5,800 | | ++---------------------------------------------------------------+ +| Tiles: [Time ↔] [Product ↕] [Region ☰] Ctrl+↑↓←→ tiles | ++---------------------------------------------------------------+ +| NORMAL | Default | Ctrl+F:formulas Ctrl+C:categories ... | ++---------------------------------------------------------------+ +``` + +### 5.2 Panels +- **Grid panel** (main): Scrollable table of the current view. +- **Tile bar**: Category tiles with axis symbols. `Ctrl+Arrow` enters tile-select mode. +- **Formula panel**: `Ctrl+F` — list and edit formulas. +- **Category panel**: `Ctrl+C` — manage categories and axis assignments. +- **View panel**: `Ctrl+V` — switch, create, delete views. +- **Status bar**: Mode, active view name, keyboard hints. + +### 5.3 Navigation and Editing +| Key | Action | +|-----|--------| +| ↑↓←→ / hjkl | Move cursor | +| Enter | Edit cell | +| Esc | Cancel edit | +| Tab | Focus next open panel | +| / | Search | +| [ / ] | Page axis prev/next | +| Ctrl+Arrow | Tile select mode | +| Enter/Space (tile) | Cycle axis (Row→Col→Page) | +| r / c / p (tile) | Set axis directly | +| Ctrl+F | Toggle formula panel | +| Ctrl+C | Toggle category panel | +| Ctrl+V | Toggle view panel | +| Ctrl+S | Save | +| Ctrl+E | Export CSV | +| F1 | Help | +| Ctrl+Q | Quit | + +--- + +## 6. Command Layer (Headless Mode) + +All model mutations go through a typed command layer. This enables: +- Scripting without the TUI +- Replay / audit log +- Testing without rendering + +### 6.1 Command Format +JSON object with an `op` field: +```json +{"op": "CommandName", ...args} +``` + +### 6.2 Available Commands + +| op | Required fields | Description | +|----|-----------------|-------------| +| `AddCategory` | `name` | Add a category/dimension | +| `AddItem` | `category`, `item` | Add an item to a category | +| `AddItemInGroup` | `category`, `item`, `group` | Add an item in a named group | +| `SetCell` | `coords: [[cat,item],...]`, `number` or `text` | Set a cell value | +| `ClearCell` | `coords` | Clear a cell | +| `AddFormula` | `raw`, `target_category` | Add/replace a formula | +| `RemoveFormula` | `target` | Remove a formula by target name | +| `CreateView` | `name` | Create a new view | +| `DeleteView` | `name` | Delete a view | +| `SwitchView` | `name` | Switch the active view | +| `SetAxis` | `category`, `axis` (`"row"/"column"/"page"`) | Set category axis | +| `SetPageSelection` | `category`, `item` | Set page-axis filter | +| `ToggleGroup` | `category`, `group` | Toggle group collapse | +| `Save` | `path` | Save model to file | +| `Load` | `path` | Load model from file | +| `ExportCsv` | `path` | Export active view to CSV | +| `ImportJson` | `path`, `model_name?`, `array_path?` | Import JSON file | + +### 6.3 Response Format +```json +{"ok": true, "message": "optional message"} +{"ok": false, "message": "error description"} +``` + +### 6.4 Invocation +```bash +# Single command +improvise model.improv --cmd '{"op":"SetCell","coords":[["Region","East"],["Measure","Revenue"]],"number":1200}' + +# Script file (one JSON object per line, # comments allowed) +improvise model.improv --script setup.jsonl +``` + +--- + +## 7. Persistence + +### 7.1 File Format +Native format: JSON-based `.improv` file containing all categories, items, groups, data cells, formulas, and view definitions. + +Compressed variant: `.improv.gz` (gzip, same JSON payload). + +### 7.2 Export +- `Ctrl+E` in TUI or `ExportCsv` command: exports active view to CSV. + +### 7.3 Autosave +- Periodic autosave (every 30 seconds when dirty) to `.model.improv.autosave`. + +--- + +## 8. Technology + +| Concern | Choice | +|---------|--------| +| Language | Rust (stable) | +| TUI | [Ratatui](https://github.com/ratatui-org/ratatui) + Crossterm | +| Serialization | `serde` + `serde_json` | +| Static binary | `x86_64-unknown-linux-musl` via `musl-gcc` | +| Dev environment | Nix flake with `rust-overlay` | +| No runtime deps | Single binary, no database, no network | + +--- + +## 9. Non-Goals (v1) + +- Scripting/macro language beyond the formula system. +- Collaborative/multi-user editing. +- Live external data sources (databases, APIs). +- Charts or graphical visualization. +- Multi-level undo history. + +--- + +## 10. Verification + +```bash +# Build +nix develop --command cargo build --release +file target/x86_64-unknown-linux-musl/release/improvise # → statically linked + +# Import test +./improvise --cmd '{"op":"ImportJson","path":"sample.json"}' --cmd '{"op":"Save","path":"test.improv"}' + +# Formula test +./improvise test.improv \ + --cmd '{"op":"AddFormula","raw":"Profit = Revenue - Cost","target_category":"Measure"}' + +# Headless script +./improvise new.improv --script tests/setup.jsonl + +# TUI +./improvise model.improv +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3b7ff17 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773821835, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773975983, + "narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a598066 --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "Improvise — Multi-Dimensional Data Modeling Terminal Application"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "clippy" "rustfmt" ]; + targets = [ "x86_64-unknown-linux-musl" ]; + }; + + muslCC = pkgs.pkgsMusl.stdenv.cc; + in + { + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ + rustToolchain + pkgs.pkg-config + # Provide cc (gcc) for building proc-macro / build-script crates + # that target the host (x86_64-unknown-linux-gnu). + pkgs.gcc + # musl-gcc wrapper for the static musl target. + muslCC + ]; + + # Tell Cargo which linker to use for each target so it never + # falls back to rust-lld (which can't find glibc on NixOS). + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = + "${pkgs.gcc}/bin/gcc"; + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER = + "${muslCC}/bin/cc"; + + # Default build target: static musl binary. + CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl"; + + RUST_BACKTRACE = "1"; + }; + + packages.default = + (pkgs.pkgsMusl.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }).buildRustPackage { + pname = "improvise"; + version = "0.1.0"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + }; + }); +} diff --git a/src/command/dispatch.rs b/src/command/dispatch.rs new file mode 100644 index 0000000..7774b40 --- /dev/null +++ b/src/command/dispatch.rs @@ -0,0 +1,219 @@ +use anyhow::Result; + +use crate::model::Model; +use crate::model::cell::{CellKey, CellValue}; +use crate::formula::parse_formula; +use crate::view::Axis; +use crate::persistence; +use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind}; +use super::types::{CellValueArg, Command, CommandResult}; + +/// Execute a command against the model, returning a result. +/// This is the single authoritative mutation path used by both the TUI and headless modes. +pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult { + match cmd { + Command::AddCategory { name } => { + match model.add_category(name) { + Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")), + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::AddItem { category, item } => { + match model.category_mut(category) { + Some(cat) => { cat.add_item(item); CommandResult::ok() } + None => CommandResult::err(format!("Category '{category}' not found")), + } + } + + Command::AddItemInGroup { category, item, group } => { + match model.category_mut(category) { + Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() } + None => CommandResult::err(format!("Category '{category}' not found")), + } + } + + Command::SetCell { coords, value } => { + let kv: Vec<(String, String)> = coords.iter() + .map(|pair| (pair[0].clone(), pair[1].clone())) + .collect(); + // Ensure items exist + for (cat_name, item_name) in &kv { + if let Some(cat) = model.category_mut(cat_name) { + cat.add_item(item_name); + } + } + let key = CellKey::new(kv); + let cell_value = match value { + CellValueArg::Number { number } => CellValue::Number(*number), + CellValueArg::Text { text } => CellValue::Text(text.clone()), + }; + model.set_cell(key, cell_value); + CommandResult::ok() + } + + Command::ClearCell { coords } => { + let kv: Vec<(String, String)> = coords.iter() + .map(|pair| (pair[0].clone(), pair[1].clone())) + .collect(); + let key = CellKey::new(kv); + model.set_cell(key, CellValue::Empty); + CommandResult::ok() + } + + Command::AddFormula { raw, target_category } => { + match parse_formula(raw, target_category) { + Ok(formula) => { + // Ensure the target item exists in the target category + let target = formula.target.clone(); + let cat_name = formula.target_category.clone(); + if let Some(cat) = model.category_mut(&cat_name) { + cat.add_item(&target); + } + model.add_formula(formula); + CommandResult::ok_msg(format!("Formula '{raw}' added")) + } + Err(e) => CommandResult::err(format!("Parse error: {e}")), + } + } + + Command::RemoveFormula { target } => { + model.remove_formula(target); + CommandResult::ok() + } + + Command::CreateView { name } => { + model.create_view(name); + CommandResult::ok() + } + + Command::DeleteView { name } => { + match model.delete_view(name) { + Ok(_) => CommandResult::ok(), + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::SwitchView { name } => { + match model.switch_view(name) { + Ok(_) => CommandResult::ok(), + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::SetAxis { category, axis } => { + let ax = match axis.to_lowercase().as_str() { + "row" | "rows" => Axis::Row, + "column" | "col" | "columns" => Axis::Column, + "page" | "pages" | "filter" => Axis::Page, + other => return CommandResult::err(format!("Unknown axis '{other}'")), + }; + match model.active_view_mut() { + Some(view) => { view.set_axis(category, ax); CommandResult::ok() } + None => CommandResult::err("No active view"), + } + } + + Command::SetPageSelection { category, item } => { + match model.active_view_mut() { + Some(view) => { view.set_page_selection(category, item); CommandResult::ok() } + None => CommandResult::err("No active view"), + } + } + + Command::ToggleGroup { category, group } => { + match model.active_view_mut() { + Some(view) => { view.toggle_group_collapse(category, group); CommandResult::ok() } + None => CommandResult::err("No active view"), + } + } + + Command::Save { path } => { + match persistence::save(model, std::path::Path::new(path)) { + Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")), + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::Load { path } => { + match persistence::load(std::path::Path::new(path)) { + Ok(loaded) => { + *model = loaded; + CommandResult::ok_msg(format!("Loaded from {path}")) + } + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::ExportCsv { path } => { + let view_name = model.active_view.clone(); + match persistence::export_csv(model, &view_name, std::path::Path::new(path)) { + Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")), + Err(e) => CommandResult::err(e.to_string()), + } + } + + Command::ImportJson { path, model_name, array_path } => { + import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()) + } + } +} + +fn import_json_headless( + model: &mut Model, + path: &str, + model_name: Option<&str>, + array_path: Option<&str>, +) -> CommandResult { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")), + }; + let value: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => return CommandResult::err(format!("JSON parse error: {e}")), + }; + + let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) { + match extract_array_at_path(&value, ap) { + Some(arr) => arr.clone(), + None => return CommandResult::err(format!("No array at path '{ap}'")), + } + } else if let Some(arr) = value.as_array() { + arr.clone() + } else { + // Find first array + let paths = crate::import::analyzer::find_array_paths(&value); + if let Some(first) = paths.first() { + match extract_array_at_path(&value, first) { + Some(arr) => arr.clone(), + None => return CommandResult::err("Could not extract records array"), + } + } else { + return CommandResult::err("No array found in JSON"); + } + }; + + let proposals = analyze_records(&records); + + // Auto-accept all and build + let wizard = crate::import::wizard::ImportWizard { + state: crate::import::wizard::WizardState::NameModel, + raw: value, + array_paths: vec![], + selected_path: array_path.unwrap_or("").to_string(), + records, + proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(), + model_name: model_name.unwrap_or("Imported Model").to_string(), + cursor: 0, + message: None, + }; + + match wizard.build_model() { + Ok(new_model) => { + *model = new_model; + CommandResult::ok_msg("JSON imported successfully") + } + Err(e) => CommandResult::err(e.to_string()), + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..9f2917d --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,12 @@ +//! Command layer — all model mutations go through this layer so they can be +//! replayed, scripted, and tested without the TUI. +//! +//! Each command is a JSON object: `{"op": "CommandName", ...args}`. +//! The headless CLI (--cmd / --script) routes through here, and the TUI +//! App also calls dispatch() for every user action that mutates state. + +pub mod dispatch; +pub mod types; + +pub use types::{Command, CommandResult}; +pub use dispatch::dispatch; diff --git a/src/command/types.rs b/src/command/types.rs new file mode 100644 index 0000000..ece8abd --- /dev/null +++ b/src/command/types.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +/// All commands that can mutate a Model. +/// +/// Serialized as `{"op": "", ...rest}` where `rest` contains +/// the variant's fields flattened into the same JSON object. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op")] +pub enum Command { + /// Add a category (dimension). + AddCategory { name: String }, + + /// Add an item to a category. + AddItem { category: String, item: String }, + + /// Add an item inside a named group. + AddItemInGroup { category: String, item: String, group: String }, + + /// Set a cell value. `coords` is a list of `[category, item]` pairs. + SetCell { + coords: Vec<[String; 2]>, + #[serde(flatten)] + value: CellValueArg, + }, + + /// Clear a cell. + ClearCell { coords: Vec<[String; 2]> }, + + /// Add or replace a formula. + /// `raw` is the full formula string, e.g. "Profit = Revenue - Cost". + /// `target_category` names the category that owns the formula target. + AddFormula { raw: String, target_category: String }, + + /// Remove a formula by its target name. + RemoveFormula { target: String }, + + /// Create a new view. + CreateView { name: String }, + + /// Delete a view. + DeleteView { name: String }, + + /// Switch the active view. + SwitchView { name: String }, + + /// Set the axis of a category in the active view. + /// axis: "row" | "column" | "page" + SetAxis { category: String, axis: String }, + + /// Set the page-axis selection for a category. + SetPageSelection { category: String, item: String }, + + /// Toggle collapse of a group in the active view. + ToggleGroup { category: String, group: String }, + + /// Save the model to a file path. + Save { path: String }, + + /// Load a model from a file path (replaces current model). + Load { path: String }, + + /// Export the active view to CSV. + ExportCsv { path: String }, + + /// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals). + ImportJson { + path: String, + model_name: Option, + /// Dot-path to the records array (empty = root) + array_path: Option, + }, +} + +/// Inline value for SetCell +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CellValueArg { + Number { number: f64 }, + Text { text: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandResult { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl CommandResult { + pub fn ok() -> Self { + Self { ok: true, message: None } + } + pub fn ok_msg(msg: impl Into) -> Self { + Self { ok: true, message: Some(msg.into()) } + } + pub fn err(msg: impl Into) -> Self { + Self { ok: false, message: Some(msg.into()) } + } +} diff --git a/src/formula/ast.rs b/src/formula/ast.rs new file mode 100644 index 0000000..4bb680d --- /dev/null +++ b/src/formula/ast.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AggFunc { + Sum, + Avg, + Min, + Max, + Count, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Filter { + pub category: String, + pub item: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Expr { + Number(f64), + Ref(String), + BinOp(String, Box, Box), + UnaryMinus(Box), + Agg(AggFunc, Box, Option), + If(Box, Box, Box), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Formula { + /// The raw formula text, e.g. "Profit = Revenue - Cost" + pub raw: String, + /// The item/dimension name this formula computes, e.g. "Profit" + pub target: String, + /// The category containing the target item + pub target_category: String, + /// The expression to evaluate + pub expr: Expr, + /// Optional WHERE filter + pub filter: Option, +} + +impl Formula { + pub fn new( + raw: impl Into, + target: impl Into, + target_category: impl Into, + expr: Expr, + filter: Option, + ) -> Self { + Self { + raw: raw.into(), + target: target.into(), + target_category: target_category.into(), + expr, + filter, + } + } +} diff --git a/src/formula/mod.rs b/src/formula/mod.rs new file mode 100644 index 0000000..31e8b68 --- /dev/null +++ b/src/formula/mod.rs @@ -0,0 +1,5 @@ +pub mod parser; +pub mod ast; + +pub use ast::{AggFunc, Expr, Filter, Formula}; +pub use parser::parse_formula; diff --git a/src/formula/parser.rs b/src/formula/parser.rs new file mode 100644 index 0000000..d4628d8 --- /dev/null +++ b/src/formula/parser.rs @@ -0,0 +1,306 @@ +use anyhow::{anyhow, Result}; + +use super::ast::{AggFunc, Expr, Filter, Formula}; + +/// Parse a formula string like "Profit = Revenue - Cost" +/// or "Tax = Revenue * 0.08 WHERE Region = \"East\"" +pub fn parse_formula(raw: &str, target_category: &str) -> Result { + let raw = raw.trim(); + + // Split on first `=` to get target = expression + let eq_pos = raw.find('=').ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?; + let target = raw[..eq_pos].trim().to_string(); + let rest = raw[eq_pos + 1..].trim(); + + // Check for WHERE clause at top level + let (expr_str, filter) = split_where(rest); + let filter = filter.map(|w| parse_where(w)).transpose()?; + + let expr = parse_expr(expr_str.trim())?; + + Ok(Formula::new(raw, target, target_category, expr, filter)) +} + +fn split_where(s: &str) -> (&str, Option<&str>) { + // Find WHERE not inside parens or quotes + let bytes = s.as_bytes(); + let mut depth = 0i32; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'(' => depth += 1, + b')' => depth -= 1, + b'"' => { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + } + _ if depth == 0 => { + if s[i..].to_ascii_uppercase().starts_with("WHERE") { + let before = &s[..i]; + let after = &s[i + 5..]; + if before.ends_with(char::is_whitespace) || i == 0 { + return (before.trim(), Some(after.trim())); + } + } + } + _ => {} + } + i += 1; + } + (s, None) +} + +fn parse_where(s: &str) -> Result { + // Format: Category = "Item" or Category = Item + let eq_pos = s.find('=').ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?; + let category = s[..eq_pos].trim().to_string(); + let item_raw = s[eq_pos + 1..].trim(); + let item = item_raw.trim_matches('"').to_string(); + Ok(Filter { category, item }) +} + +/// Parse an expression using recursive descent +pub fn parse_expr(s: &str) -> Result { + let tokens = tokenize(s)?; + let mut pos = 0; + let expr = parse_add_sub(&tokens, &mut pos)?; + if pos < tokens.len() { + return Err(anyhow!("Unexpected token at position {pos}: {:?}", tokens[pos])); + } + Ok(expr) +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + Ident(String), + Str(String), + Plus, + Minus, + Star, + Slash, + Caret, + LParen, + RParen, + Comma, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, +} + +fn tokenize(s: &str) -> Result> { + let mut tokens = Vec::new(); + let chars: Vec = s.chars().collect(); + let mut i = 0; + + while i < chars.len() { + match chars[i] { + ' ' | '\t' | '\n' => i += 1, + '+' => { tokens.push(Token::Plus); i += 1; } + '-' => { tokens.push(Token::Minus); i += 1; } + '*' => { tokens.push(Token::Star); i += 1; } + '/' => { tokens.push(Token::Slash); i += 1; } + '^' => { tokens.push(Token::Caret); i += 1; } + '(' => { tokens.push(Token::LParen); i += 1; } + ')' => { tokens.push(Token::RParen); i += 1; } + ',' => { tokens.push(Token::Comma); i += 1; } + '!' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ne); i += 2; } + '<' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Le); i += 2; } + '>' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ge); i += 2; } + '<' => { tokens.push(Token::Lt); i += 1; } + '>' => { tokens.push(Token::Gt); i += 1; } + '=' => { tokens.push(Token::Eq); i += 1; } + '"' => { + i += 1; + let mut s = String::new(); + while i < chars.len() && chars[i] != '"' { + s.push(chars[i]); + i += 1; + } + if i < chars.len() { i += 1; } + tokens.push(Token::Str(s)); + } + c if c.is_ascii_digit() || c == '.' => { + let mut num = String::new(); + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + num.push(chars[i]); + i += 1; + } + tokens.push(Token::Number(num.parse()?)); + } + c if c.is_alphabetic() || c == '_' => { + let mut ident = String::new(); + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') { + // Don't consume trailing spaces if next non-space is operator + if chars[i] == ' ' { + // Peek ahead + let j = i + 1; + let next_nonspace = chars[j..].iter().find(|&&c| c != ' '); + if matches!(next_nonspace, Some('+') | Some('-') | Some('*') | Some('/') | Some('^') | Some(')') | Some(',') | None) { + break; + } + } + ident.push(chars[i]); + i += 1; + } + let ident = ident.trim_end().to_string(); + tokens.push(Token::Ident(ident)); + } + c => return Err(anyhow!("Unexpected character '{c}' in expression")), + } + } + Ok(tokens) +} + +fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result { + let mut left = parse_mul_div(tokens, pos)?; + while *pos < tokens.len() { + let op = match &tokens[*pos] { + Token::Plus => "+", + Token::Minus => "-", + _ => break, + }; + *pos += 1; + let right = parse_mul_div(tokens, pos)?; + left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)); + } + Ok(left) +} + +fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result { + let mut left = parse_pow(tokens, pos)?; + while *pos < tokens.len() { + let op = match &tokens[*pos] { + Token::Star => "*", + Token::Slash => "/", + _ => break, + }; + *pos += 1; + let right = parse_pow(tokens, pos)?; + left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)); + } + Ok(left) +} + +fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result { + let base = parse_unary(tokens, pos)?; + if *pos < tokens.len() && tokens[*pos] == Token::Caret { + *pos += 1; + let exp = parse_unary(tokens, pos)?; + return Ok(Expr::BinOp("^".to_string(), Box::new(base), Box::new(exp))); + } + Ok(base) +} + +fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result { + if *pos < tokens.len() && tokens[*pos] == Token::Minus { + *pos += 1; + let e = parse_primary(tokens, pos)?; + return Ok(Expr::UnaryMinus(Box::new(e))); + } + parse_primary(tokens, pos) +} + +fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result { + if *pos >= tokens.len() { + return Err(anyhow!("Unexpected end of expression")); + } + match &tokens[*pos].clone() { + Token::Number(n) => { + *pos += 1; + Ok(Expr::Number(*n)) + } + Token::Ident(name) => { + let name = name.clone(); + *pos += 1; + // Check for function call + let upper = name.to_ascii_uppercase(); + match upper.as_str() { + "SUM" | "AVG" | "MIN" | "MAX" | "COUNT" => { + let func = match upper.as_str() { + "SUM" => AggFunc::Sum, + "AVG" => AggFunc::Avg, + "MIN" => AggFunc::Min, + "MAX" => AggFunc::Max, + "COUNT" => AggFunc::Count, + _ => unreachable!(), + }; + if *pos < tokens.len() && tokens[*pos] == Token::LParen { + *pos += 1; + let inner = parse_add_sub(tokens, pos)?; + // Optional WHERE filter + let filter = if *pos < tokens.len() { + if let Token::Ident(kw) = &tokens[*pos] { + if kw.to_ascii_uppercase() == "WHERE" { + *pos += 1; + let cat = match &tokens[*pos] { + Token::Ident(s) => { let s = s.clone(); *pos += 1; s } + t => return Err(anyhow!("Expected category name, got {t:?}")), + }; + // expect = + if *pos < tokens.len() && tokens[*pos] == Token::Eq { *pos += 1; } + let item = match &tokens[*pos] { + Token::Str(s) | Token::Ident(s) => { let s = s.clone(); *pos += 1; s } + t => return Err(anyhow!("Expected item name, got {t:?}")), + }; + Some(Filter { category: cat, item }) + } else { None } + } else { None } + } else { None }; + // expect ) + if *pos < tokens.len() && tokens[*pos] == Token::RParen { + *pos += 1; + } + return Ok(Expr::Agg(func, Box::new(inner), filter)); + } + Ok(Expr::Ref(name)) + } + "IF" => { + if *pos < tokens.len() && tokens[*pos] == Token::LParen { + *pos += 1; + let cond = parse_comparison(tokens, pos)?; + if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; } + let then = parse_add_sub(tokens, pos)?; + if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; } + let else_ = parse_add_sub(tokens, pos)?; + if *pos < tokens.len() && tokens[*pos] == Token::RParen { *pos += 1; } + return Ok(Expr::If(Box::new(cond), Box::new(then), Box::new(else_))); + } + Ok(Expr::Ref(name)) + } + _ => Ok(Expr::Ref(name)), + } + } + Token::LParen => { + *pos += 1; + let e = parse_add_sub(tokens, pos)?; + if *pos < tokens.len() && tokens[*pos] == Token::RParen { + *pos += 1; + } + Ok(e) + } + t => Err(anyhow!("Unexpected token in expression: {t:?}")), + } +} + +fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result { + let left = parse_add_sub(tokens, pos)?; + if *pos >= tokens.len() { return Ok(left); } + let op = match &tokens[*pos] { + Token::Eq => "=", + Token::Ne => "!=", + Token::Lt => "<", + Token::Gt => ">", + Token::Le => "<=", + Token::Ge => ">=", + _ => return Ok(left), + }; + *pos += 1; + let right = parse_add_sub(tokens, pos)?; + Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right))) +} diff --git a/src/import/analyzer.rs b/src/import/analyzer.rs new file mode 100644 index 0000000..3058fc9 --- /dev/null +++ b/src/import/analyzer.rs @@ -0,0 +1,160 @@ +use std::collections::HashSet; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq)] +pub enum FieldKind { + /// Small number of distinct string values → dimension/category + Category, + /// Numeric values → measure + Measure, + /// Date/time strings → time category + TimeCategory, + /// Many unique strings (IDs, names) → label/identifier + Label, +} + +#[derive(Debug, Clone)] +pub struct FieldProposal { + pub field: String, + pub kind: FieldKind, + pub distinct_values: Vec, + pub accepted: bool, +} + +impl FieldProposal { + pub fn kind_label(&self) -> &'static str { + match self.kind { + FieldKind::Category => "Category (dimension)", + FieldKind::Measure => "Measure (numeric)", + FieldKind::TimeCategory => "Time Category", + FieldKind::Label => "Label/Identifier (skip)", + } + } +} + +const CATEGORY_THRESHOLD: usize = 20; +const LABEL_THRESHOLD: usize = 50; + +pub fn analyze_records(records: &[Value]) -> Vec { + if records.is_empty() { + return vec![]; + } + + // Collect all field names + let mut fields: Vec = Vec::new(); + for record in records { + if let Value::Object(map) = record { + for key in map.keys() { + if !fields.contains(key) { + fields.push(key.clone()); + } + } + } + } + + fields.into_iter().map(|field| { + let values: Vec<&Value> = records.iter() + .filter_map(|r| r.get(&field)) + .collect(); + + let all_numeric = values.iter().all(|v| v.is_number()); + let all_string = values.iter().all(|v| v.is_string()); + + if all_numeric { + return FieldProposal { + field, + kind: FieldKind::Measure, + distinct_values: vec![], + accepted: true, + }; + } + + if all_string { + let distinct: HashSet<&str> = values.iter() + .filter_map(|v| v.as_str()) + .collect(); + let distinct_vec: Vec = distinct.into_iter().map(String::from).collect(); + let n = distinct_vec.len(); + let total = values.len(); + + // Check if looks like date + let looks_like_date = distinct_vec.iter().any(|s| { + s.contains('-') && s.len() >= 8 + || s.starts_with("Q") && s.len() == 2 + || ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] + .iter().any(|m| s.starts_with(m)) + }); + + if looks_like_date { + return FieldProposal { + field, + kind: FieldKind::TimeCategory, + distinct_values: distinct_vec, + accepted: true, + }; + } + + if n <= CATEGORY_THRESHOLD { + return FieldProposal { + field, + kind: FieldKind::Category, + distinct_values: distinct_vec, + accepted: true, + }; + } + + return FieldProposal { + field, + kind: FieldKind::Label, + distinct_values: distinct_vec, + accepted: false, + }; + } + + // Mixed or other: treat as label + FieldProposal { + field, + kind: FieldKind::Label, + distinct_values: vec![], + accepted: false, + } + }).collect() +} + +/// Extract nested array from JSON by dot-path +pub fn extract_array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Vec> { + if path.is_empty() { + return value.as_array(); + } + let mut current = value; + for part in path.split('.') { + current = current.get(part)?; + } + current.as_array() +} + +/// Find candidate paths to arrays in JSON +pub fn find_array_paths(value: &Value) -> Vec { + let mut paths = Vec::new(); + find_array_paths_inner(value, "", &mut paths); + paths +} + +fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec) { + match value { + Value::Array(_) => { + paths.push(prefix.to_string()); + } + Value::Object(map) => { + for (key, val) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + find_array_paths_inner(val, &path, paths); + } + } + _ => {} + } +} diff --git a/src/import/mod.rs b/src/import/mod.rs new file mode 100644 index 0000000..c0eb08d --- /dev/null +++ b/src/import/mod.rs @@ -0,0 +1,5 @@ +pub mod wizard; +pub mod analyzer; + +pub use wizard::{ImportWizard, WizardState}; +pub use analyzer::{FieldKind, FieldProposal, analyze_records}; diff --git a/src/import/wizard.rs b/src/import/wizard.rs new file mode 100644 index 0000000..07f34ec --- /dev/null +++ b/src/import/wizard.rs @@ -0,0 +1,246 @@ +use serde_json::Value; +use anyhow::{anyhow, Result}; + +use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_at_path, find_array_paths}; +use crate::model::Model; +use crate::model::cell::{CellKey, CellValue}; + +#[derive(Debug, Clone, PartialEq)] +pub enum WizardState { + Preview, + SelectArrayPath, + ReviewProposals, + NameModel, + Done, +} + +#[derive(Debug)] +pub struct ImportWizard { + pub state: WizardState, + pub raw: Value, + pub array_paths: Vec, + pub selected_path: String, + pub records: Vec, + pub proposals: Vec, + pub model_name: String, + pub cursor: usize, + /// Message to display + pub message: Option, +} + +impl ImportWizard { + pub fn new(raw: Value) -> Self { + let array_paths = find_array_paths(&raw); + let state = if raw.is_array() { + WizardState::ReviewProposals + } else if array_paths.len() == 1 { + WizardState::ReviewProposals + } else { + WizardState::SelectArrayPath + }; + + let mut wizard = Self { + state: WizardState::Preview, + raw: raw.clone(), + array_paths: array_paths.clone(), + selected_path: String::new(), + records: vec![], + proposals: vec![], + model_name: "Imported Model".to_string(), + cursor: 0, + message: None, + }; + + // Auto-select if array at root or single path + if raw.is_array() { + wizard.select_path(""); + } else if array_paths.len() == 1 { + let path = array_paths[0].clone(); + wizard.select_path(&path); + } + + wizard.state = if wizard.records.is_empty() && raw.is_object() { + WizardState::SelectArrayPath + } else { + wizard.advance(); + return wizard; + }; + wizard + } + + fn select_path(&mut self, path: &str) { + self.selected_path = path.to_string(); + if let Some(arr) = extract_array_at_path(&self.raw, path) { + self.records = arr.clone(); + self.proposals = analyze_records(&self.records); + } + } + + pub fn advance(&mut self) { + self.state = match self.state { + WizardState::Preview => { + if self.array_paths.len() <= 1 { + WizardState::ReviewProposals + } else { + WizardState::SelectArrayPath + } + } + WizardState::SelectArrayPath => WizardState::ReviewProposals, + WizardState::ReviewProposals => WizardState::NameModel, + WizardState::NameModel => WizardState::Done, + WizardState::Done => WizardState::Done, + }; + self.cursor = 0; + self.message = None; + } + + pub fn confirm_path(&mut self) { + if self.cursor < self.array_paths.len() { + let path = self.array_paths[self.cursor].clone(); + self.select_path(&path); + self.advance(); + } + } + + pub fn toggle_proposal(&mut self) { + if self.cursor < self.proposals.len() { + self.proposals[self.cursor].accepted = !self.proposals[self.cursor].accepted; + } + } + + pub fn cycle_proposal_kind(&mut self) { + if self.cursor < self.proposals.len() { + let p = &mut self.proposals[self.cursor]; + p.kind = match p.kind { + FieldKind::Category => FieldKind::Measure, + FieldKind::Measure => FieldKind::TimeCategory, + FieldKind::TimeCategory => FieldKind::Label, + FieldKind::Label => FieldKind::Category, + }; + } + } + + pub fn move_cursor(&mut self, delta: i32) { + let len = match self.state { + WizardState::SelectArrayPath => self.array_paths.len(), + WizardState::ReviewProposals => self.proposals.len(), + _ => 0, + }; + if len == 0 { return; } + if delta > 0 { + self.cursor = (self.cursor + 1).min(len - 1); + } else if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn push_name_char(&mut self, c: char) { + self.model_name.push(c); + } + + pub fn pop_name_char(&mut self) { + self.model_name.pop(); + } + + pub fn build_model(&self) -> Result { + let mut model = Model::new(self.model_name.clone()); + + // Collect categories and measures from accepted proposals + let categories: Vec<&FieldProposal> = self.proposals.iter() + .filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)) + .collect(); + let measures: Vec<&FieldProposal> = self.proposals.iter() + .filter(|p| p.accepted && p.kind == FieldKind::Measure) + .collect(); + + if categories.is_empty() { + return Err(anyhow!("At least one category must be accepted")); + } + + // Add categories + for cat_proposal in &categories { + model.add_category(&cat_proposal.field)?; + if let Some(cat) = model.category_mut(&cat_proposal.field) { + for val in &cat_proposal.distinct_values { + cat.add_item(val); + } + } + } + + // If there are measures, add a "Measure" category + if !measures.is_empty() { + model.add_category("Measure")?; + if let Some(cat) = model.category_mut("Measure") { + for m in &measures { + cat.add_item(&m.field); + } + } + } + + // Import records as cells + for record in &self.records { + if let Value::Object(map) = record { + // Build base coordinate from category fields + let mut coords: Vec<(String, String)> = vec![]; + let mut valid = true; + + for cat_proposal in &categories { + let val = map.get(&cat_proposal.field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string())); + + if let Some(v) = val { + // Ensure item exists + if let Some(cat) = model.category_mut(&cat_proposal.field) { + cat.add_item(&v); + } + coords.push((cat_proposal.field.clone(), v)); + } else { + valid = false; + break; + } + } + + if !valid { continue; } + + // Add each measure as a cell + for measure in &measures { + if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { + let mut cell_coords = coords.clone(); + if !measures.is_empty() { + cell_coords.push(("Measure".to_string(), measure.field.clone())); + } + let key = CellKey::new(cell_coords); + model.set_cell(key, CellValue::Number(val)); + } + } + } + } + + Ok(model) + } + + pub fn preview_summary(&self) -> String { + match &self.raw { + Value::Array(arr) => { + format!( + "Array of {} records. Sample keys: {}", + arr.len(), + arr.first() + .and_then(|r| r.as_object()) + .map(|m| m.keys().take(5).cloned().collect::>().join(", ")) + .unwrap_or_default() + ) + } + Value::Object(map) => { + format!( + "Object with {} top-level keys: {}", + map.len(), + map.keys().take(10).cloned().collect::>().join(", ") + ) + } + _ => "Unknown JSON structure".to_string(), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f1cbee0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,380 @@ +mod model; +mod formula; +mod view; +mod ui; +mod import; +mod persistence; +mod command; + +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::{ + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +use ui::app::{App, AppMode}; +use ui::grid::GridWidget; +use ui::tile_bar::TileBar; +use ui::formula_panel::FormulaPanel; +use ui::category_panel::CategoryPanel; +use ui::view_panel::ViewPanel; +use ui::help::HelpWidget; +use ui::import_wizard_ui::ImportWizardWidget; +use model::Model; + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let mut file_path: Option = None; + let mut headless_cmds: Vec = Vec::new(); + let mut headless_script: Option = None; + let mut import_path: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--cmd" | "-c" => { + i += 1; + if let Some(cmd) = args.get(i).cloned() { + headless_cmds.push(cmd); + } + } + "--script" | "-s" => { + i += 1; + headless_script = args.get(i).map(PathBuf::from); + } + "--import" => { + i += 1; + import_path = args.get(i).map(PathBuf::from); + } + "--help" | "-h" => { + print_usage(); + return Ok(()); + } + arg if !arg.starts_with('-') => { + file_path = Some(PathBuf::from(arg)); + } + _ => {} + } + i += 1; + } + + // Load or create model + let mut model = if let Some(ref path) = file_path { + if path.exists() { + persistence::load(path).with_context(|| format!("Failed to load {}", path.display()))? + } else { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("New Model") + .to_string(); + Model::new(name) + } + } else { + Model::new("New Model") + }; + + // Headless mode: run command(s) and print results + if !headless_cmds.is_empty() || headless_script.is_some() { + return run_headless(&mut model, file_path, headless_cmds, headless_script); + } + + // Import mode before TUI + if let Some(ref path) = import_path { + let content = std::fs::read_to_string(path)?; + let json: serde_json::Value = serde_json::from_str(&content)?; + let cmd = command::Command::ImportJson { + path: path.to_string_lossy().to_string(), + model_name: None, + array_path: None, + }; + let result = command::dispatch(&mut model, &cmd); + if !result.ok { + eprintln!("Import error: {}", result.message.unwrap_or_default()); + } + } + + // TUI mode + run_tui(model, file_path) +} + +fn run_headless( + model: &mut Model, + file_path: Option, + inline_cmds: Vec, + script: Option, +) -> Result<()> { + let mut cmds: Vec = inline_cmds; + if let Some(script_path) = script { + let content = std::fs::read_to_string(&script_path)?; + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#') { + cmds.push(trimmed.to_string()); + } + } + } + + let mut exit_code = 0; + for raw_cmd in &cmds { + let parsed: command::Command = match serde_json::from_str(raw_cmd) { + Ok(c) => c, + Err(e) => { + let result = command::CommandResult::err(format!("JSON parse error: {e}")); + println!("{}", serde_json::to_string(&result)?); + exit_code = 1; + continue; + } + }; + let result = command::dispatch(model, &parsed); + if !result.ok { exit_code = 1; } + println!("{}", serde_json::to_string(&result)?); + } + + // Auto-save if we have a file path and model was potentially modified + if let Some(path) = file_path { + persistence::save(model, &path)?; + } + + std::process::exit(exit_code); +} + +fn run_tui(model: Model, file_path: Option) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(model, file_path); + + loop { + terminal.draw(|f| draw(f, &app))?; + + if event::poll(Duration::from_millis(200))? { + if let Event::Key(key) = event::read()? { + app.handle_key(key)?; + } + } + + app.autosave_if_needed(); + + if matches!(app.mode, AppMode::Quit) { + break; + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + Ok(()) +} + +fn draw(f: &mut Frame, app: &App) { + let size = f.area(); + + // Main layout: title bar + content + status bar + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(0), // content + Constraint::Length(1), // tile bar + Constraint::Length(1), // status bar + ]) + .split(size); + + // Title bar + draw_title(f, main_chunks[0], app); + + // Content area: grid + optional panels + let content_area = main_chunks[1]; + draw_content(f, content_area, app); + + // Tile bar + draw_tile_bar(f, main_chunks[2], app); + + // Status bar + draw_status(f, main_chunks[3], app); + + // Overlays + if matches!(app.mode, AppMode::Help) { + f.render_widget(HelpWidget, size); + } + if matches!(app.mode, AppMode::ImportWizard) { + if let Some(wizard) = &app.wizard { + f.render_widget(ImportWizardWidget::new(wizard), size); + } + } + if matches!(app.mode, AppMode::ExportPrompt { .. }) { + draw_export_prompt(f, size, app); + } +} + +fn draw_title(f: &mut Frame, area: Rect, app: &App) { + let dirty = if app.dirty { " [*]" } else { "" }; + let title = format!(" Improvise | Model: {}{} ", app.model.name, dirty); + let help_hint = " [F1 Help] [Ctrl+Q Quit] "; + let padding = " ".repeat( + (area.width as usize).saturating_sub(title.len() + help_hint.len()) + ); + let full = format!("{title}{padding}{help_hint}"); + let style = Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD); + f.render_widget(Paragraph::new(full).style(style), area); +} + +fn draw_content(f: &mut Frame, area: Rect, app: &App) { + let has_formula = app.formula_panel_open; + let has_category = app.category_panel_open; + let has_view = app.view_panel_open; + let side_open = has_formula || has_category || has_view; + + if side_open { + let side_width = 30u16; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(40), + Constraint::Length(side_width), + ]) + .split(area); + + // Grid + f.render_widget( + GridWidget::new(&app.model, &app.mode, &app.search_query), + chunks[0], + ); + + // Side panels stacked + let side_area = chunks[1]; + let panel_count = [has_formula, has_category, has_view].iter().filter(|&&b| b).count(); + let panel_height = side_area.height / panel_count.max(1) as u16; + + let mut y = side_area.y; + if has_formula { + let a = Rect::new(side_area.x, y, side_area.width, panel_height); + f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a); + y += panel_height; + } + if has_category { + let a = Rect::new(side_area.x, y, side_area.width, panel_height); + f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a); + y += panel_height; + } + if has_view { + let a = Rect::new(side_area.x, y, side_area.width, panel_height); + f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), a); + } + } else { + f.render_widget( + GridWidget::new(&app.model, &app.mode, &app.search_query), + area, + ); + } +} + +fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { + f.render_widget(TileBar::new(&app.model, &app.mode), area); +} + +fn draw_status(f: &mut Frame, area: Rect, app: &App) { + let mode_str = match &app.mode { + AppMode::Normal => "NORMAL", + AppMode::Editing { .. } => "EDIT", + AppMode::FormulaEdit { .. } => "FORMULA", + AppMode::FormulaPanel => "FORMULA PANEL", + AppMode::CategoryPanel => "CATEGORY PANEL", + AppMode::ViewPanel => "VIEW PANEL", + AppMode::TileSelect { .. } => "TILE SELECT", + AppMode::ImportWizard => "IMPORT", + AppMode::ExportPrompt { .. } => "EXPORT", + AppMode::Help => "HELP", + AppMode::Quit => "QUIT", + }; + + let search_part = if app.search_mode { + format!(" [Search: {}]", app.search_query) + } else { String::new() }; + + let panels = format!( + "{}{}{}", + if app.formula_panel_open { " [F]" } else { "" }, + if app.category_panel_open { " [C]" } else { "" }, + if app.view_panel_open { " [V]" } else { "" }, + ); + + let status = format!( + " {mode_str}{search_part}{panels} | {} | {}", + app.model.active_view, + if app.status_msg.is_empty() { + "Ctrl+F:formulas Ctrl+C:categories Ctrl+V:views Ctrl+S:save".to_string() + } else { + app.status_msg.clone() + } + ); + + let style = Style::default().fg(Color::Black).bg(Color::DarkGray); + f.render_widget( + Paragraph::new(status).style(style), + area, + ); +} + +fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) { + let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { return }; + let popup_w = 60u16.min(area.width); + let popup_h = 3u16; + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height / 2; + let popup_area = Rect::new(x, y, popup_w, popup_h); + + use ratatui::widgets::Clear; + f.render_widget(Clear, popup_area); + let block = Block::default().borders(Borders::ALL).title(" Export CSV — enter path (Esc cancel) "); + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + f.render_widget( + Paragraph::new(format!("> {buf}█")).style(Style::default().fg(Color::Green)), + inner, + ); +} + +fn print_usage() { + println!("improvise — multi-dimensional data modeling TUI"); + println!(); + println!("USAGE:"); + println!(" improvise [file.improv] Open or create a model file"); + println!(" improvise --import data.json Import JSON, then open TUI"); + println!(" improvise --cmd '{{...}}' Run a single JSON command (headless)"); + println!(" improvise --script cmds.jsonl Run commands from file (headless)"); + println!(); + println!("HEADLESS COMMANDS (JSON object with 'op' field):"); + println!(" {{\"op\":\"AddCategory\",\"name\":\"Region\"}}"); + println!(" {{\"op\":\"AddItem\",\"category\":\"Region\",\"item\":\"East\"}}"); + println!(" {{\"op\":\"SetCell\",\"coords\":[[\"Region\",\"East\"],[\"Measure\",\"Revenue\"]],\"number\":1200}}"); + println!(" {{\"op\":\"AddFormula\",\"raw\":\"Profit = Revenue - Cost\",\"target_category\":\"Measure\"}}"); + println!(" {{\"op\":\"Save\",\"path\":\"model.improv\"}}"); + println!(" {{\"op\":\"ImportJson\",\"path\":\"data.json\"}}"); + println!(); + println!("TUI SHORTCUTS:"); + println!(" F1 Help"); + println!(" Ctrl+Q Quit"); + println!(" Ctrl+S Save"); + println!(" Ctrl+F Formula panel"); + println!(" Ctrl+C Category panel"); + println!(" Ctrl+V View panel"); + println!(" Enter Edit cell"); + println!(" Ctrl+Arrow Tile select mode"); + println!(" [ / ] Prev/next page item"); +} diff --git a/src/model/category.rs b/src/model/category.rs new file mode 100644 index 0000000..9f73f25 --- /dev/null +++ b/src/model/category.rs @@ -0,0 +1,121 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +pub type CategoryId = usize; +pub type ItemId = usize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: ItemId, + pub name: String, + /// Parent group name, if any + pub group: Option, +} + +impl Item { + pub fn new(id: ItemId, name: impl Into) -> Self { + Self { id, name: name.into(), group: None } + } + + pub fn with_group(mut self, group: impl Into) -> Self { + self.group = Some(group.into()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Group { + pub name: String, + /// Parent group name for nested hierarchies + pub parent: Option, +} + +impl Group { + pub fn new(name: impl Into) -> Self { + Self { name: name.into(), parent: None } + } + + pub fn with_parent(mut self, parent: impl Into) -> Self { + self.parent = Some(parent.into()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Category { + pub id: CategoryId, + pub name: String, + /// Items in insertion order + pub items: IndexMap, + /// Named groups (hierarchy nodes) + pub groups: Vec, + /// Next item id counter + next_item_id: ItemId, +} + +impl Category { + pub fn new(id: CategoryId, name: impl Into) -> Self { + Self { + id, + name: name.into(), + items: IndexMap::new(), + groups: Vec::new(), + next_item_id: 0, + } + } + + pub fn add_item(&mut self, name: impl Into) -> ItemId { + let name = name.into(); + if let Some(item) = self.items.get(&name) { + return item.id; + } + let id = self.next_item_id; + self.next_item_id += 1; + self.items.insert(name.clone(), Item::new(id, name)); + id + } + + pub fn add_item_in_group(&mut self, name: impl Into, group: impl Into) -> ItemId { + let name = name.into(); + let group = group.into(); + if let Some(item) = self.items.get(&name) { + return item.id; + } + let id = self.next_item_id; + self.next_item_id += 1; + self.items.insert(name.clone(), Item::new(id, name).with_group(group)); + id + } + + pub fn add_group(&mut self, group: Group) { + if !self.groups.iter().any(|g| g.name == group.name) { + self.groups.push(group); + } + } + + pub fn item_by_name(&self, name: &str) -> Option<&Item> { + self.items.get(name) + } + + pub fn item_index(&self, name: &str) -> Option { + self.items.get_index_of(name) + } + + /// Returns item names in order, grouped hierarchically + pub fn ordered_item_names(&self) -> Vec<&str> { + self.items.keys().map(|s| s.as_str()).collect() + } + + /// Returns unique group names at the top level + pub fn top_level_groups(&self) -> Vec<&str> { + let mut seen = Vec::new(); + for item in self.items.values() { + if let Some(g) = &item.group { + if !seen.contains(&g.as_str()) { + seen.push(g.as_str()); + } + } + } + seen + } +} diff --git a/src/model/cell.rs b/src/model/cell.rs new file mode 100644 index 0000000..917f152 --- /dev/null +++ b/src/model/cell.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +/// A cell key is a sorted vector of (category_name, item_name) pairs. +/// Sorted by category name for canonical form. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CellKey(pub Vec<(String, String)>); + +impl CellKey { + pub fn new(mut coords: Vec<(String, String)>) -> Self { + coords.sort_by(|a, b| a.0.cmp(&b.0)); + Self(coords) + } + + pub fn get(&self, category: &str) -> Option<&str> { + self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str()) + } + + pub fn with(mut self, category: impl Into, item: impl Into) -> Self { + let cat = category.into(); + let itm = item.into(); + if let Some(pos) = self.0.iter().position(|(c, _)| c == &cat) { + self.0[pos].1 = itm; + } else { + self.0.push((cat, itm)); + self.0.sort_by(|a, b| a.0.cmp(&b.0)); + } + self + } + + pub fn without(&self, category: &str) -> Self { + Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect()) + } + + pub fn matches_partial(&self, partial: &[(String, String)]) -> bool { + partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str())) + } +} + +impl std::fmt::Display for CellKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let parts: Vec<_> = self.0.iter().map(|(c, v)| format!("{c}={v}")).collect(); + write!(f, "{{{}}}", parts.join(", ")) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CellValue { + Number(f64), + Text(String), + Empty, +} + +impl CellValue { + pub fn as_f64(&self) -> Option { + match self { + CellValue::Number(n) => Some(*n), + _ => None, + } + } + + pub fn is_empty(&self) -> bool { + matches!(self, CellValue::Empty) + } +} + +impl std::fmt::Display for CellValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CellValue::Number(n) => { + if n.fract() == 0.0 && n.abs() < 1e15 { + write!(f, "{}", *n as i64) + } else { + write!(f, "{n:.4}") + } + } + CellValue::Text(s) => write!(f, "{s}"), + CellValue::Empty => write!(f, ""), + } + } +} + +impl Default for CellValue { + fn default() -> Self { + CellValue::Empty + } +} + +/// Serialized as a list of (key, value) pairs so CellKey doesn't need +/// to implement the `Serialize`-as-string requirement for JSON object keys. +#[derive(Debug, Clone, Default)] +pub struct DataStore { + cells: HashMap, +} + +impl Serialize for DataStore { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(self.cells.len()))?; + for (k, v) in &self.cells { + seq.serialize_element(&(k, v))?; + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for DataStore { + fn deserialize>(d: D) -> Result { + let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?; + let cells: HashMap = pairs.into_iter().collect(); + Ok(DataStore { cells }) + } +} + +impl DataStore { + pub fn new() -> Self { + Self::default() + } + + pub fn set(&mut self, key: CellKey, value: CellValue) { + if value.is_empty() { + self.cells.remove(&key); + } else { + self.cells.insert(key, value); + } + } + + pub fn get(&self, key: &CellKey) -> &CellValue { + self.cells.get(key).unwrap_or(&CellValue::Empty) + } + + pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> { + self.cells.get_mut(key) + } + + pub fn cells(&self) -> &HashMap { + &self.cells + } + + pub fn remove(&mut self, key: &CellKey) { + self.cells.remove(key); + } + + /// Sum all cells matching partial coordinates + pub fn sum_matching(&self, partial: &[(String, String)]) -> f64 { + self.cells.iter() + .filter(|(key, _)| key.matches_partial(partial)) + .filter_map(|(_, v)| v.as_f64()) + .sum() + } + + /// All cells where partial coords match + pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> { + self.cells.iter() + .filter(|(key, _)| key.matches_partial(partial)) + .collect() + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..c8eee44 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,7 @@ +pub mod category; +pub mod cell; +pub mod model; + +pub use category::{Category, CategoryId, Group, Item, ItemId}; +pub use cell::{CellKey, CellValue, DataStore}; +pub use model::Model; diff --git a/src/model/model.rs b/src/model/model.rs new file mode 100644 index 0000000..f900c19 --- /dev/null +++ b/src/model/model.rs @@ -0,0 +1,250 @@ +use std::collections::HashMap; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use anyhow::{anyhow, Result}; + +use super::category::{Category, CategoryId}; +use super::cell::{CellKey, CellValue, DataStore}; +use crate::formula::Formula; +use crate::view::View; + +const MAX_CATEGORIES: usize = 12; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Model { + pub name: String, + pub categories: IndexMap, + pub data: DataStore, + pub formulas: Vec, + pub views: IndexMap, + pub active_view: String, + next_category_id: CategoryId, +} + +impl Model { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + let default_view = View::new("Default"); + let mut views = IndexMap::new(); + views.insert("Default".to_string(), default_view); + Self { + name, + categories: IndexMap::new(), + data: DataStore::new(), + formulas: Vec::new(), + views, + active_view: "Default".to_string(), + next_category_id: 0, + } + } + + pub fn add_category(&mut self, name: impl Into) -> Result { + let name = name.into(); + if self.categories.len() >= MAX_CATEGORIES { + return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached")); + } + if self.categories.contains_key(&name) { + return Ok(self.categories[&name].id); + } + let id = self.next_category_id; + self.next_category_id += 1; + self.categories.insert(name.clone(), Category::new(id, name.clone())); + // Add to all views + for view in self.views.values_mut() { + view.on_category_added(&name); + } + Ok(id) + } + + pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> { + self.categories.get_mut(name) + } + + pub fn category(&self, name: &str) -> Option<&Category> { + self.categories.get(name) + } + + pub fn set_cell(&mut self, key: CellKey, value: CellValue) { + self.data.set(key, value); + } + + pub fn get_cell(&self, key: &CellKey) -> &CellValue { + self.data.get(key) + } + + pub fn add_formula(&mut self, formula: Formula) { + // Replace if same target + if let Some(pos) = self.formulas.iter().position(|f| f.target == formula.target) { + self.formulas[pos] = formula; + } else { + self.formulas.push(formula); + } + } + + pub fn remove_formula(&mut self, target: &str) { + self.formulas.retain(|f| f.target != target); + } + + pub fn active_view(&self) -> Option<&View> { + self.views.get(&self.active_view) + } + + pub fn active_view_mut(&mut self) -> Option<&mut View> { + self.views.get_mut(&self.active_view) + } + + pub fn create_view(&mut self, name: impl Into) -> &mut View { + let name = name.into(); + let mut view = View::new(name.clone()); + // Copy category assignments from default if any + for cat_name in self.categories.keys() { + view.on_category_added(cat_name); + } + self.views.insert(name.clone(), view); + self.views.get_mut(&name).unwrap() + } + + pub fn switch_view(&mut self, name: &str) -> Result<()> { + if self.views.contains_key(name) { + self.active_view = name.to_string(); + Ok(()) + } else { + Err(anyhow!("View '{name}' not found")) + } + } + + pub fn delete_view(&mut self, name: &str) -> Result<()> { + if self.views.len() <= 1 { + return Err(anyhow!("Cannot delete the last view")); + } + self.views.shift_remove(name); + if self.active_view == name { + self.active_view = self.views.keys().next().unwrap().clone(); + } + Ok(()) + } + + /// Return all category names + pub fn category_names(&self) -> Vec<&str> { + self.categories.keys().map(|s| s.as_str()).collect() + } + + /// Evaluate a computed value at a given key, considering formulas + pub fn evaluate(&self, key: &CellKey) -> CellValue { + // Check if the last category dimension in the key corresponds to a formula target + for formula in &self.formulas { + if let Some(item_val) = key.get(&formula.target_category) { + if item_val == formula.target { + return self.eval_formula(formula, key); + } + } + } + self.data.get(key).clone() + } + + fn eval_formula(&self, formula: &Formula, context: &CellKey) -> CellValue { + use crate::formula::{Expr, AggFunc}; + + // Check WHERE filter first + if let Some(filter) = &formula.filter { + if let Some(item_val) = context.get(&filter.category) { + if item_val != filter.item.as_str() { + return self.data.get(context).clone(); + } + } + } + + fn find_item_category<'a>(model: &'a Model, item_name: &str) -> Option<&'a str> { + for (cat_name, cat) in &model.categories { + if cat.items.contains_key(item_name) { + return Some(cat_name.as_str()); + } + } + None + } + + fn eval_expr( + expr: &Expr, + context: &CellKey, + model: &Model, + target_category: &str, + ) -> Option { + match expr { + Expr::Number(n) => Some(*n), + Expr::Ref(name) => { + let cat = find_item_category(model, name).unwrap_or(name); + let new_key = context.clone().with(cat, name); + model.evaluate(&new_key).as_f64() + } + Expr::BinOp(op, l, r) => { + let lv = eval_expr(l, context, model, target_category)?; + let rv = eval_expr(r, context, model, target_category)?; + Some(match op.as_str() { + "+" => lv + rv, + "-" => lv - rv, + "*" => lv * rv, + "/" => if rv == 0.0 { 0.0 } else { lv / rv }, + "^" => lv.powf(rv), + _ => return None, + }) + } + Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), + Expr::Agg(func, _inner, _filter) => { + let partial = context.without(target_category); + let values: Vec = model.data.matching_cells(&partial.0) + .into_iter() + .filter_map(|(_, v)| v.as_f64()) + .collect(); + match func { + AggFunc::Sum => Some(values.iter().sum()), + AggFunc::Avg => { + if values.is_empty() { None } + else { Some(values.iter().sum::() / values.len() as f64) } + } + AggFunc::Min => values.iter().cloned().reduce(f64::min), + AggFunc::Max => values.iter().cloned().reduce(f64::max), + AggFunc::Count => Some(values.len() as f64), + } + } + Expr::If(cond, then, else_) => { + let cv = eval_bool(cond, context, model, target_category)?; + if cv { + eval_expr(then, context, model, target_category) + } else { + eval_expr(else_, context, model, target_category) + } + } + } + } + + fn eval_bool( + expr: &Expr, + context: &CellKey, + model: &Model, + target_category: &str, + ) -> Option { + match expr { + Expr::BinOp(op, l, r) => { + let lv = eval_expr(l, context, model, target_category)?; + let rv = eval_expr(r, context, model, target_category)?; + Some(match op.as_str() { + "=" | "==" => (lv - rv).abs() < 1e-10, + "!=" => (lv - rv).abs() >= 1e-10, + "<" => lv < rv, + ">" => lv > rv, + "<=" => lv <= rv, + ">=" => lv >= rv, + _ => return None, + }) + } + _ => None, + } + } + + match eval_expr(&formula.expr, context, self, &formula.target_category) { + Some(n) => CellValue::Number(n), + None => CellValue::Empty, + } + } +} + diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs new file mode 100644 index 0000000..c0a5a89 --- /dev/null +++ b/src/persistence/mod.rs @@ -0,0 +1,137 @@ +use std::io::{Read, Write, BufReader, BufWriter}; +use std::path::Path; +use anyhow::{Context, Result}; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use flate2::Compression; + +use crate::model::Model; + +const MAGIC: &str = ".improv"; +const COMPRESSED_EXT: &str = ".improv.gz"; + +pub fn save(model: &Model, path: &Path) -> Result<()> { + let json = serde_json::to_string_pretty(model) + .context("Failed to serialize model")?; + + if path.extension().and_then(|e| e.to_str()) == Some("gz") + || path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) + { + let file = std::fs::File::create(path) + .with_context(|| format!("Cannot create {}", path.display()))?; + let mut encoder = GzEncoder::new(BufWriter::new(file), Compression::default()); + encoder.write_all(json.as_bytes())?; + encoder.finish()?; + } else { + std::fs::write(path, &json) + .with_context(|| format!("Cannot write {}", path.display()))?; + } + Ok(()) +} + +pub fn load(path: &Path) -> Result { + let file = std::fs::File::open(path) + .with_context(|| format!("Cannot open {}", path.display()))?; + + let json = if path.to_str().map(|s| s.ends_with(".gz")).unwrap_or(false) { + let mut decoder = GzDecoder::new(BufReader::new(file)); + let mut s = String::new(); + decoder.read_to_string(&mut s)?; + s + } else { + let mut s = String::new(); + BufReader::new(file).read_to_string(&mut s)?; + s + }; + + serde_json::from_str(&json) + .context("Failed to deserialize model") +} + +pub fn autosave_path(path: &Path) -> std::path::PathBuf { + let mut p = path.to_path_buf(); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("model"); + p.set_file_name(format!(".{name}.autosave")); + p +} + +pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { + use crate::view::Axis; + + let view = model.views.get(view_name) + .ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?; + + let row_cats: Vec = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); + let col_cats: Vec = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); + let page_cats: Vec = view.categories_on(Axis::Page).into_iter().map(String::from).collect(); + + // Build page-axis coords from current selections (or first item) + let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| { + let items: Vec = model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + let sel = view.page_selection(cat_name) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + (cat_name.clone(), sel) + }).collect(); + + let row_items: Vec = if row_cats.is_empty() { + vec![] + } else { + model.category(&row_cats[0]) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default() + }; + + let col_items: Vec = if col_cats.is_empty() { + vec![] + } else { + model.category(&col_cats[0]) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default() + }; + + let mut out = String::new(); + + // Header row + let row_label = if row_cats.is_empty() { String::new() } else { row_cats.join("/") }; + let page_label: Vec = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect(); + let header_prefix = if page_label.is_empty() { row_label } else { + format!("{} ({})", row_label, page_label.join(", ")) + }; + if !header_prefix.is_empty() { + out.push_str(&header_prefix); + out.push(','); + } + out.push_str(&col_items.join(",")); + out.push('\n'); + + // Data rows + let effective_row_items: Vec = if row_items.is_empty() { vec!["".to_string()] } else { row_items }; + let effective_col_items: Vec = if col_items.is_empty() { vec!["".to_string()] } else { col_items }; + + for ri in &effective_row_items { + if !ri.is_empty() { + out.push_str(ri); + out.push(','); + } + let row_values: Vec = effective_col_items.iter().map(|ci| { + let mut coords = page_coords.clone(); + if !row_cats.is_empty() && !ri.is_empty() { + coords.push((row_cats[0].clone(), ri.clone())); + } + if !col_cats.is_empty() && !ci.is_empty() { + coords.push((col_cats[0].clone(), ci.clone())); + } + let key = crate::model::CellKey::new(coords); + model.evaluate(&key).to_string() + }).collect(); + out.push_str(&row_values.join(",")); + out.push('\n'); + } + + std::fs::write(path, out)?; + Ok(()) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..a27769f --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,657 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::model::Model; +use crate::model::cell::{CellKey, CellValue}; +use crate::formula::parse_formula; +use crate::import::wizard::{ImportWizard, WizardState}; +use crate::persistence; +use crate::view::Axis; + +#[derive(Debug, Clone, PartialEq)] +pub enum AppMode { + Normal, + Editing { buffer: String }, + FormulaEdit { buffer: String }, + FormulaPanel, + CategoryPanel, + ViewPanel, + TileSelect { cat_idx: usize }, + ImportWizard, + ExportPrompt { buffer: String }, + Help, + Quit, +} + +pub struct App { + pub model: Model, + pub file_path: Option, + pub mode: AppMode, + pub status_msg: String, + pub wizard: Option, + pub last_autosave: Instant, + /// Input buffer for command-line style input + pub input_buffer: String, + /// Search query + pub search_query: String, + pub search_mode: bool, + pub formula_panel_open: bool, + pub category_panel_open: bool, + pub view_panel_open: bool, + /// Category panel cursor + pub cat_panel_cursor: usize, + /// View panel cursor + pub view_panel_cursor: usize, + /// Formula panel cursor + pub formula_cursor: usize, + pub dirty: bool, +} + +impl App { + pub fn new(model: Model, file_path: Option) -> Self { + Self { + model, + file_path, + mode: AppMode::Normal, + status_msg: String::new(), + wizard: None, + last_autosave: Instant::now(), + input_buffer: String::new(), + search_query: String::new(), + search_mode: false, + formula_panel_open: false, + category_panel_open: false, + view_panel_open: false, + cat_panel_cursor: 0, + view_panel_cursor: 0, + formula_cursor: 0, + dirty: false, + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + match &self.mode.clone() { + AppMode::Quit => {} + AppMode::Help => { + self.mode = AppMode::Normal; + } + AppMode::ImportWizard => { + self.handle_wizard_key(key)?; + } + AppMode::Editing { buffer } => { + self.handle_edit_key(key)?; + } + AppMode::FormulaEdit { buffer } => { + self.handle_formula_edit_key(key)?; + } + AppMode::FormulaPanel => { + self.handle_formula_panel_key(key)?; + } + AppMode::CategoryPanel => { + self.handle_category_panel_key(key)?; + } + AppMode::ViewPanel => { + self.handle_view_panel_key(key)?; + } + AppMode::TileSelect { cat_idx } => { + self.handle_tile_select_key(key)?; + } + AppMode::ExportPrompt { buffer } => { + self.handle_export_key(key)?; + } + AppMode::Normal => { + self.handle_normal_key(key)?; + } + } + Ok(()) + } + + fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> { + if self.search_mode { + return self.handle_search_key(key); + } + + match (key.code, key.modifiers) { + (KeyCode::Char('q'), KeyModifiers::CONTROL) => { + self.mode = AppMode::Quit; + } + (KeyCode::F(1), _) => { + self.mode = AppMode::Help; + } + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { + self.save()?; + } + (KeyCode::Char('f'), KeyModifiers::CONTROL) => { + self.formula_panel_open = !self.formula_panel_open; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.category_panel_open = !self.category_panel_open; + } + (KeyCode::Char('v'), KeyModifiers::CONTROL) => { + self.view_panel_open = !self.view_panel_open; + } + (KeyCode::Char('e'), KeyModifiers::CONTROL) => { + self.mode = AppMode::ExportPrompt { buffer: String::new() }; + } + // Navigation + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { + self.move_selection(-1, 0); + } + (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { + self.move_selection(1, 0); + } + (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { + self.move_selection(0, -1); + } + (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + self.move_selection(0, 1); + } + (KeyCode::Enter, _) => { + let cell_key = self.selected_cell_key(); + let current = cell_key.as_ref().map(|k| { + self.model.get_cell(k).to_string() + }).unwrap_or_default(); + self.mode = AppMode::Editing { buffer: current }; + } + (KeyCode::Char('/'), _) => { + self.search_mode = true; + self.search_query.clear(); + } + // Tab cycles panels + (KeyCode::Tab, _) => { + if self.formula_panel_open { + self.mode = AppMode::FormulaPanel; + } else if self.category_panel_open { + self.mode = AppMode::CategoryPanel; + } else if self.view_panel_open { + self.mode = AppMode::ViewPanel; + } + } + // Tile movement with Ctrl+Arrow + (KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL) + | (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => { + let cat_names: Vec = self.model.category_names() + .into_iter().map(String::from).collect(); + if !cat_names.is_empty() { + self.mode = AppMode::TileSelect { cat_idx: 0 }; + } + } + // Page axis navigation with [ ] + (KeyCode::Char('['), _) => { + self.page_prev(); + } + (KeyCode::Char(']'), _) => { + self.page_next(); + } + // Formula panel shortcut + (KeyCode::Char('F'), KeyModifiers::NONE) => { + self.formula_panel_open = true; + self.mode = AppMode::FormulaPanel; + } + _ => {} + } + Ok(()) + } + + fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { self.search_mode = false; } + KeyCode::Enter => { self.search_mode = false; } + KeyCode::Char(c) => { self.search_query.push(c); } + KeyCode::Backspace => { self.search_query.pop(); } + _ => {} + } + Ok(()) + } + + fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> { + let buf = if let AppMode::Editing { buffer } = &self.mode { + buffer.clone() + } else { return Ok(()); }; + + match key.code { + KeyCode::Esc => { + self.mode = AppMode::Normal; + } + KeyCode::Enter => { + // Commit value + if let Some(key) = self.selected_cell_key() { + let value = if buf.is_empty() { + CellValue::Empty + } else if let Ok(n) = buf.parse::() { + CellValue::Number(n) + } else { + CellValue::Text(buf.clone()) + }; + self.model.set_cell(key, value); + self.dirty = true; + } + self.mode = AppMode::Normal; + self.move_selection(1, 0); + } + KeyCode::Char(c) => { + if let AppMode::Editing { buffer } = &mut self.mode { + buffer.push(c); + } + } + KeyCode::Backspace => { + if let AppMode::Editing { buffer } = &mut self.mode { + buffer.pop(); + } + } + _ => {} + } + Ok(()) + } + + fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> { + let buf = if let AppMode::FormulaEdit { buffer } = &self.mode { + buffer.clone() + } else { return Ok(()); }; + + match key.code { + KeyCode::Esc => { self.mode = AppMode::FormulaPanel; } + KeyCode::Enter => { + // Try to parse and add formula + let first_cat = self.model.category_names().into_iter().next().map(String::from); + if let Some(cat) = first_cat { + match parse_formula(&buf, &cat) { + Ok(formula) => { + self.model.add_formula(formula); + self.status_msg = "Formula added".to_string(); + self.dirty = true; + } + Err(e) => { + self.status_msg = format!("Formula error: {e}"); + } + } + } + self.mode = AppMode::FormulaPanel; + } + KeyCode::Char(c) => { + if let AppMode::FormulaEdit { buffer } = &mut self.mode { + buffer.push(c); + } + } + KeyCode::Backspace => { + if let AppMode::FormulaEdit { buffer } = &mut self.mode { + buffer.pop(); + } + } + _ => {} + } + Ok(()) + } + + fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Char('a') | KeyCode::Char('n') => { + self.mode = AppMode::FormulaEdit { buffer: String::new() }; + } + KeyCode::Char('d') | KeyCode::Delete => { + if self.formula_cursor < self.model.formulas.len() { + let target = self.model.formulas[self.formula_cursor].target.clone(); + self.model.remove_formula(&target); + if self.formula_cursor > 0 { self.formula_cursor -= 1; } + self.dirty = true; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if self.formula_cursor > 0 { self.formula_cursor -= 1; } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.formula_cursor + 1 < self.model.formulas.len() { + self.formula_cursor += 1; + } + } + _ => {} + } + Ok(()) + } + + fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> { + let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); + match key.code { + KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Up | KeyCode::Char('k') => { + if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.cat_panel_cursor + 1 < cat_names.len() { + self.cat_panel_cursor += 1; + } + } + KeyCode::Enter | KeyCode::Char(' ') => { + // Cycle axis for selected category + if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) { + if let Some(view) = self.model.active_view_mut() { + view.cycle_axis(cat_name); + } + } + } + _ => {} + } + Ok(()) + } + + fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> { + let view_names: Vec = self.model.views.keys().cloned().collect(); + match key.code { + KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Up | KeyCode::Char('k') => { + if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.view_panel_cursor + 1 < view_names.len() { + self.view_panel_cursor += 1; + } + } + KeyCode::Enter => { + if let Some(name) = view_names.get(self.view_panel_cursor) { + let _ = self.model.switch_view(name); + self.mode = AppMode::Normal; + } + } + KeyCode::Char('n') => { + let new_name = format!("View {}", self.model.views.len() + 1); + self.model.create_view(&new_name); + let _ = self.model.switch_view(&new_name); + self.dirty = true; + self.mode = AppMode::Normal; + } + KeyCode::Delete | KeyCode::Char('d') => { + if let Some(name) = view_names.get(self.view_panel_cursor) { + let _ = self.model.delete_view(name); + if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } + self.dirty = true; + } + } + _ => {} + } + Ok(()) + } + + fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> { + let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); + let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 }; + + match key.code { + KeyCode::Esc => { self.mode = AppMode::Normal; } + KeyCode::Left | KeyCode::Char('h') => { + if let AppMode::TileSelect { ref mut cat_idx } = self.mode { + if *cat_idx > 0 { *cat_idx -= 1; } + } + } + KeyCode::Right | KeyCode::Char('l') => { + if let AppMode::TileSelect { ref mut cat_idx } = self.mode { + if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; } + } + } + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(name) = cat_names.get(cat_idx) { + if let Some(view) = self.model.active_view_mut() { + view.cycle_axis(name); + } + self.dirty = true; + } + self.mode = AppMode::Normal; + } + KeyCode::Char('r') => { + if let Some(name) = cat_names.get(cat_idx) { + if let Some(view) = self.model.active_view_mut() { + view.set_axis(name, Axis::Row); + } + self.dirty = true; + } + self.mode = AppMode::Normal; + } + KeyCode::Char('c') => { + if let Some(name) = cat_names.get(cat_idx) { + if let Some(view) = self.model.active_view_mut() { + view.set_axis(name, Axis::Column); + } + self.dirty = true; + } + self.mode = AppMode::Normal; + } + KeyCode::Char('p') => { + if let Some(name) = cat_names.get(cat_idx) { + if let Some(view) = self.model.active_view_mut() { + view.set_axis(name, Axis::Page); + } + self.dirty = true; + } + self.mode = AppMode::Normal; + } + _ => {} + } + Ok(()) + } + + fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { self.mode = AppMode::Normal; } + KeyCode::Enter => { + let buf = if let AppMode::ExportPrompt { buffer } = &self.mode { + buffer.clone() + } else { return Ok(()); }; + let path = PathBuf::from(buf); + let view_name = self.model.active_view.clone(); + match persistence::export_csv(&self.model, &view_name, &path) { + Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); } + Err(e) => { self.status_msg = format!("Export error: {e}"); } + } + self.mode = AppMode::Normal; + } + KeyCode::Char(c) => { + if let AppMode::ExportPrompt { buffer } = &mut self.mode { + buffer.push(c); + } + } + KeyCode::Backspace => { + if let AppMode::ExportPrompt { buffer } = &mut self.mode { + buffer.pop(); + } + } + _ => {} + } + Ok(()) + } + + fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> { + if let Some(wizard) = &mut self.wizard { + match &wizard.state.clone() { + WizardState::Preview => { + match key.code { + KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(), + KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + _ => {} + } + } + WizardState::SelectArrayPath => { + match key.code { + KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), + KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), + KeyCode::Enter => wizard.confirm_path(), + KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + _ => {} + } + } + WizardState::ReviewProposals => { + match key.code { + KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), + KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), + KeyCode::Char(' ') => wizard.toggle_proposal(), + KeyCode::Char('c') => wizard.cycle_proposal_kind(), + KeyCode::Enter => wizard.advance(), + KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + _ => {} + } + } + WizardState::NameModel => { + match key.code { + KeyCode::Char(c) => wizard.push_name_char(c), + KeyCode::Backspace => wizard.pop_name_char(), + KeyCode::Enter => { + let result = wizard.build_model(); + match result { + Ok(model) => { + self.model = model; + self.dirty = true; + self.status_msg = "Import successful!".to_string(); + self.mode = AppMode::Normal; + self.wizard = None; + } + Err(e) => { + if let Some(w) = &mut self.wizard { + w.message = Some(format!("Error: {e}")); + } + } + } + } + KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + _ => {} + } + } + WizardState::Done => { + self.mode = AppMode::Normal; + self.wizard = None; + } + } + } + Ok(()) + } + + fn move_selection(&mut self, dr: i32, dc: i32) { + if let Some(view) = self.model.active_view_mut() { + let (r, c) = view.selected; + let new_r = (r as i32 + dr).max(0) as usize; + let new_c = (c as i32 + dc).max(0) as usize; + view.selected = (new_r, new_c); + } + } + + fn page_next(&mut self) { + let page_cats: Vec = self.model.active_view() + .map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect()) + .unwrap_or_default(); + + for cat_name in &page_cats { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + if items.is_empty() { continue; } + + let current = self.model.active_view() + .and_then(|v| v.page_selection(cat_name)) + .map(String::from) + .unwrap_or_else(|| items[0].clone()); + + let idx = items.iter().position(|i| i == ¤t).unwrap_or(0); + let next_idx = (idx + 1).min(items.len() - 1); + if let Some(view) = self.model.active_view_mut() { + view.set_page_selection(cat_name, &items[next_idx]); + } + break; + } + } + + fn page_prev(&mut self) { + let page_cats: Vec = self.model.active_view() + .map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect()) + .unwrap_or_default(); + + for cat_name in &page_cats { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + if items.is_empty() { continue; } + + let current = self.model.active_view() + .and_then(|v| v.page_selection(cat_name)) + .map(String::from) + .unwrap_or_else(|| items[0].clone()); + + let idx = items.iter().position(|i| i == ¤t).unwrap_or(0); + let prev_idx = idx.saturating_sub(1); + if let Some(view) = self.model.active_view_mut() { + view.set_page_selection(cat_name, &items[prev_idx]); + } + break; + } + } + + pub fn selected_cell_key(&self) -> Option { + let view = self.model.active_view()?; + let row_cats: Vec<&str> = view.categories_on(Axis::Row); + let col_cats: Vec<&str> = view.categories_on(Axis::Column); + let page_cats: Vec<&str> = view.categories_on(Axis::Page); + + let (sel_row, sel_col) = view.selected; + let mut coords = vec![]; + + // Page coords + for cat_name in &page_cats { + let items = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect::>()) + .unwrap_or_default(); + let sel = view.page_selection(cat_name) + .map(String::from) + .or_else(|| items.first().cloned())?; + coords.push((cat_name.to_string(), sel)); + } + + // Row coords + for (i, cat_name) in row_cats.iter().enumerate() { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter() + .filter(|item| !view.is_hidden(cat_name, item)) + .map(String::from).collect()) + .unwrap_or_default(); + let item = items.get(sel_row)?.clone(); + coords.push((cat_name.to_string(), item)); + } + + // Col coords + for (i, cat_name) in col_cats.iter().enumerate() { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter() + .filter(|item| !view.is_hidden(cat_name, item)) + .map(String::from).collect()) + .unwrap_or_default(); + let item = items.get(sel_col)?.clone(); + coords.push((cat_name.to_string(), item)); + } + + Some(CellKey::new(coords)) + } + + pub fn save(&mut self) -> Result<()> { + if let Some(path) = &self.file_path.clone() { + persistence::save(&self.model, path)?; + self.dirty = false; + self.status_msg = format!("Saved to {}", path.display()); + } else { + self.status_msg = "No file path set. Use Ctrl+E to export.".to_string(); + } + Ok(()) + } + + pub fn autosave_if_needed(&mut self) { + if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) { + if let Some(path) = &self.file_path.clone() { + let autosave_path = persistence::autosave_path(path); + let _ = persistence::save(&self.model, &autosave_path); + self.last_autosave = Instant::now(); + } + } + } + + pub fn start_import_wizard(&mut self, json: serde_json::Value) { + self.wizard = Some(ImportWizard::new(json)); + self.mode = AppMode::ImportWizard; + } +} diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs new file mode 100644 index 0000000..b7f9c99 --- /dev/null +++ b/src/ui/category_panel.rs @@ -0,0 +1,98 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Widget}, +}; + +use crate::model::Model; +use crate::view::Axis; +use crate::ui::app::AppMode; + +pub struct CategoryPanel<'a> { + pub model: &'a Model, + pub mode: &'a AppMode, + pub cursor: usize, +} + +impl<'a> CategoryPanel<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { + Self { model, mode, cursor } + } +} + +impl<'a> Widget for CategoryPanel<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let is_active = matches!(self.mode, AppMode::CategoryPanel); + let border_style = if is_active { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Categories [Enter] cycle axis "); + let inner = block.inner(area); + block.render(area, buf); + + let view = match self.model.active_view() { + Some(v) => v, + None => return, + }; + + let cat_names: Vec<&str> = self.model.category_names(); + if cat_names.is_empty() { + buf.set_string(inner.x, inner.y, + "(no categories)", + Style::default().fg(Color::DarkGray)); + return; + } + + for (i, cat_name) in cat_names.iter().enumerate() { + if inner.y + i as u16 >= inner.y + inner.height { break; } + + let axis = view.axis_of(cat_name); + let axis_str = match axis { + Axis::Row => "Row ↕", + Axis::Column => "Col ↔", + Axis::Page => "Page ☰", + Axis::Unassigned => "none", + }; + let axis_color = match axis { + Axis::Row => Color::Green, + Axis::Column => Color::Blue, + Axis::Page => Color::Magenta, + Axis::Unassigned => Color::DarkGray, + }; + + let cat = self.model.category(cat_name); + let item_count = cat.map(|c| c.items.len()).unwrap_or(0); + + let is_selected = i == self.cursor && is_active; + let base_style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + // Background fill for selected row + if is_selected { + let fill = " ".repeat(inner.width as usize); + buf.set_string(inner.x, inner.y + i as u16, &fill, base_style); + } + + let name_part = format!(" {cat_name} ({item_count})"); + let axis_part = format!(" [{axis_str}]"); + let available = inner.width as usize; + + buf.set_string(inner.x, inner.y + i as u16, &name_part, base_style); + if name_part.len() + axis_part.len() < available { + let axis_x = inner.x + name_part.len() as u16; + buf.set_string(axis_x, inner.y + i as u16, &axis_part, + if is_selected { base_style } else { Style::default().fg(axis_color) }); + } + } + } +} diff --git a/src/ui/formula_panel.rs b/src/ui/formula_panel.rs new file mode 100644 index 0000000..5e92452 --- /dev/null +++ b/src/ui/formula_panel.rs @@ -0,0 +1,76 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Widget}, +}; + +use crate::model::Model; +use crate::ui::app::AppMode; + +pub struct FormulaPanel<'a> { + pub model: &'a Model, + pub mode: &'a AppMode, + pub cursor: usize, +} + +impl<'a> FormulaPanel<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { + Self { model, mode, cursor } + } +} + +impl<'a> Widget for FormulaPanel<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }); + let border_style = if is_active { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Formulas [n]ew [d]elete "); + let inner = block.inner(area); + block.render(area, buf); + + let formulas = &self.model.formulas; + + if formulas.is_empty() { + buf.set_string(inner.x, inner.y, + "(no formulas — press 'n' to add)", + Style::default().fg(Color::DarkGray)); + return; + } + + for (i, formula) in formulas.iter().enumerate() { + if inner.y + i as u16 >= inner.y + inner.height { break; } + let is_selected = i == self.cursor && is_active; + let style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + let text = format!(" {} = {:?}", formula.target, formula.raw); + let truncated = if text.len() > inner.width as usize { + format!("{}…", &text[..inner.width as usize - 1]) + } else { + text + }; + buf.set_string(inner.x, inner.y + i as u16, &truncated, style); + } + + // Formula edit mode + if let AppMode::FormulaEdit { buffer } = self.mode { + let y = inner.y + inner.height.saturating_sub(2); + buf.set_string(inner.x, y, + "┄ Enter formula (Name = expr): ", + Style::default().fg(Color::Yellow)); + let y = y + 1; + let prompt = format!("> {buffer}█"); + buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green)); + } + } +} diff --git a/src/ui/grid.rs b/src/ui/grid.rs new file mode 100644 index 0000000..66f66f4 --- /dev/null +++ b/src/ui/grid.rs @@ -0,0 +1,321 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Widget}, +}; +use unicode_width::UnicodeWidthStr; + +use crate::model::Model; +use crate::model::cell::{CellKey, CellValue}; +use crate::view::Axis; +use crate::ui::app::AppMode; + +const ROW_HEADER_WIDTH: u16 = 16; +const COL_WIDTH: u16 = 10; +const MIN_COL_WIDTH: u16 = 6; + +pub struct GridWidget<'a> { + pub model: &'a Model, + pub mode: &'a AppMode, + pub search_query: &'a str, +} + +impl<'a> GridWidget<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self { + Self { model, mode, search_query } + } + + fn render_grid(&self, area: Rect, buf: &mut Buffer) { + let view = match self.model.active_view() { + Some(v) => v, + None => return, + }; + + let row_cats: Vec<&str> = view.categories_on(Axis::Row); + let col_cats: Vec<&str> = view.categories_on(Axis::Column); + let page_cats: Vec<&str> = view.categories_on(Axis::Page); + + // Gather row items + let row_items: Vec> = if row_cats.is_empty() { + vec![vec![]] + } else { + let cat_name = row_cats[0]; + let cat = match self.model.category(cat_name) { + Some(c) => c, + None => return, + }; + cat.ordered_item_names().into_iter() + .filter(|item| !view.is_hidden(cat_name, item)) + .map(|item| vec![item.to_string()]) + .collect() + }; + + // Gather col items + let col_items: Vec> = if col_cats.is_empty() { + vec![vec![]] + } else { + let cat_name = col_cats[0]; + let cat = match self.model.category(cat_name) { + Some(c) => c, + None => return, + }; + cat.ordered_item_names().into_iter() + .filter(|item| !view.is_hidden(cat_name, item)) + .map(|item| vec![item.to_string()]) + .collect() + }; + + // Page filter coords + let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + let sel = view.page_selection(cat_name) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + (cat_name.to_string(), sel) + }).collect(); + + let (sel_row, sel_col) = view.selected; + let row_offset = view.row_offset; + let col_offset = view.col_offset; + + // Available cols + let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize; + let visible_col_items: Vec<_> = col_items.iter() + .skip(col_offset) + .take(available_cols.max(1)) + .collect(); + + let available_rows = area.height.saturating_sub(2) as usize; // header + border + let visible_row_items: Vec<_> = row_items.iter() + .skip(row_offset) + .take(available_rows.max(1)) + .collect(); + + let mut y = area.y; + + // Column headers + let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let row_header_col = format!("{:width$}", truncated, width = COL_WIDTH as usize), styled); + x += COL_WIDTH; + if x >= area.x + area.width { break; } + } + y += 1; + + // Separator + let sep = "─".repeat(area.width as usize); + buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray)); + y += 1; + + // Data rows + for (ri, row_item) in visible_row_items.iter().enumerate() { + let abs_ri = ri + row_offset; + if y >= area.y + area.height { break; } + + let row_label = row_item.join("/"); + let row_style = if abs_ri == sel_row { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1); + buf.set_string(area.x, y, + format!("{:= area.x + area.width { break; } + + let mut coords = page_coords.clone(); + for (cat, item) in row_cats.iter().zip(row_item.iter()) { + coords.push((cat.to_string(), item.clone())); + } + for (cat, item) in col_cats.iter().zip(col_item.iter()) { + coords.push((cat.to_string(), item.clone())); + } + let key = CellKey::new(coords); + let value = self.model.evaluate(&key); + + let cell_str = format_value(&value); + let is_selected = abs_ri == sel_row && abs_ci == sel_col; + let is_search_match = !self.search_query.is_empty() + && cell_str.to_lowercase().contains(&self.search_query.to_lowercase()); + + let cell_style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + } else if is_search_match { + Style::default().fg(Color::Black).bg(Color::Yellow) + } else if matches!(value, CellValue::Empty) { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + + let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize); + buf.set_string(x, y, formatted, cell_style); + x += COL_WIDTH; + } + + // Edit indicator + if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row { + if let AppMode::Editing { buffer } = self.mode { + let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH; + let edit_str = format!("{:= area.x + area.width { break; } + let mut coords = page_coords.clone(); + for (cat, item) in col_cats.iter().zip(col_item.iter()) { + coords.push((cat.to_string(), item.clone())); + } + let total: f64 = row_items.iter().map(|ri| { + let mut c = coords.clone(); + for (cat, item) in row_cats.iter().zip(ri.iter()) { + c.push((cat.to_string(), item.clone())); + } + let key = CellKey::new(c); + self.model.evaluate(&key).as_f64().unwrap_or(0.0) + }).sum(); + + let total_str = format_f64(total); + buf.set_string(x, y, + format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + x += COL_WIDTH; + } + } + } + } +} + +impl<'a> Widget for GridWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let view_name = self.model.active_view + .clone(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" View: {} ", view_name)); + let inner = block.inner(area); + block.render(area, buf); + + // Page axis bar + let view = self.model.active_view(); + if let Some(view) = view { + let page_cats: Vec<&str> = view.categories_on(Axis::Page); + if !page_cats.is_empty() && inner.height > 0 { + let page_info: Vec = page_cats.iter().map(|cat_name| { + let items: Vec = self.model.category(cat_name) + .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + let sel = view.page_selection(cat_name) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_else(|| "(none)".to_string()); + format!("{cat_name} = {sel}") + }).collect(); + let page_str = format!(" [{}] ", page_info.join(" | ")); + buf.set_string(inner.x, inner.y, + &page_str, + Style::default().fg(Color::Magenta)); + + let grid_area = Rect { + y: inner.y + 1, + height: inner.height.saturating_sub(1), + ..inner + }; + self.render_grid(grid_area, buf); + } else { + self.render_grid(inner, buf); + } + } + } +} + +fn format_value(v: &CellValue) -> String { + match v { + CellValue::Number(n) => format_f64(*n), + CellValue::Text(s) => s.clone(), + CellValue::Empty => String::new(), + } +} + +fn format_f64(n: f64) -> String { + if n == 0.0 { + return "0".to_string(); + } + if n.fract() == 0.0 && n.abs() < 1e12 { + // Integer with comma formatting + let i = n as i64; + let s = i.to_string(); + let is_neg = s.starts_with('-'); + let digits = if is_neg { &s[1..] } else { &s[..] }; + let mut result = String::new(); + for (idx, c) in digits.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { result.push(','); } + result.push(c); + } + if is_neg { result.push('-'); } + result.chars().rev().collect() + } else { + format!("{n:.2}") + } +} + +fn truncate(s: &str, max_width: usize) -> String { + let w = s.width(); + if w <= max_width { + s.to_string() + } else if max_width > 1 { + let mut result = String::new(); + let mut width = 0; + for c in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); + if width + cw + 1 > max_width { break; } + result.push(c); + width += cw; + } + result.push('…'); + result + } else { + s.chars().take(max_width).collect() + } +} diff --git a/src/ui/help.rs b/src/ui/help.rs new file mode 100644 index 0000000..1d29be2 --- /dev/null +++ b/src/ui/help.rs @@ -0,0 +1,77 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, Widget}, +}; + +pub struct HelpWidget; + +impl Widget for HelpWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + // Center popup + let popup_w = 60u16.min(area.width); + let popup_h = 30u16.min(area.height); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup_area = Rect::new(x, y, popup_w, popup_h); + + Clear.render(popup_area, buf); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Help — Improvise ") + .border_style(Style::default().fg(Color::Yellow)); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let help_text = [ + ("Navigation", ""), + (" ↑/↓/←/→ or hjkl", "Move cursor"), + (" Enter", "Edit selected cell"), + (" /", "Search in grid"), + (" [ / ]", "Prev/next page item"), + ("", ""), + ("Panels", ""), + (" Ctrl+F", "Toggle formula panel"), + (" Ctrl+C", "Toggle category panel"), + (" Ctrl+V", "Toggle view panel"), + (" Tab", "Focus next open panel"), + ("", ""), + ("Tiles / Pivot", ""), + (" Ctrl+Arrow", "Enter tile select mode"), + (" Enter/Space", "Cycle axis (Row→Col→Page)"), + (" r / c / p", "Set axis to Row/Col/Page"), + ("", ""), + ("File", ""), + (" Ctrl+S", "Save model"), + (" Ctrl+E", "Export CSV"), + ("", ""), + ("Headless / Batch", ""), + (" --cmd '{...}'", "Run a single JSON command"), + (" --script file", "Run commands from file"), + ("", ""), + (" F1", "This help"), + (" Ctrl+Q", "Quit"), + ("", ""), + (" Any key to close", ""), + ]; + + for (i, (key, desc)) in help_text.iter().enumerate() { + if i >= inner.height as usize { break; } + let y = inner.y + i as u16; + if key.is_empty() { + continue; + } + if desc.is_empty() { + buf.set_string(inner.x, y, key, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + } else { + buf.set_string(inner.x, y, key, Style::default().fg(Color::Cyan)); + let desc_x = inner.x + 26; + if desc_x < inner.x + inner.width { + buf.set_string(desc_x, y, desc, Style::default()); + } + } + } + } +} diff --git a/src/ui/import_wizard_ui.rs b/src/ui/import_wizard_ui.rs new file mode 100644 index 0000000..2c68b0f --- /dev/null +++ b/src/ui/import_wizard_ui.rs @@ -0,0 +1,147 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, Widget}, +}; + +use crate::import::wizard::{ImportWizard, WizardState}; +use crate::import::analyzer::FieldKind; + +pub struct ImportWizardWidget<'a> { + pub wizard: &'a ImportWizard, +} + +impl<'a> ImportWizardWidget<'a> { + pub fn new(wizard: &'a ImportWizard) -> Self { + Self { wizard } + } +} + +impl<'a> Widget for ImportWizardWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let popup_w = area.width.min(80); + let popup_h = area.height.min(30); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup_area = Rect::new(x, y, popup_w, popup_h); + + Clear.render(popup_area, buf); + + let title = match self.wizard.state { + WizardState::Preview => " Import Wizard — Step 1: Preview ", + WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ", + WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ", + WizardState::NameModel => " Import Wizard — Step 4: Name Model ", + WizardState::Done => " Import Wizard — Done ", + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(title); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let mut y = inner.y; + let x = inner.x; + let w = inner.width as usize; + + match &self.wizard.state { + WizardState::Preview => { + let summary = self.wizard.preview_summary(); + buf.set_string(x, y, truncate(&summary, w), Style::default()); + y += 2; + buf.set_string(x, y, + "Press Enter to continue…", + Style::default().fg(Color::Yellow)); + } + WizardState::SelectArrayPath => { + buf.set_string(x, y, + "Select the path containing records:", + Style::default().fg(Color::Yellow)); + y += 1; + for (i, path) in self.wizard.array_paths.iter().enumerate() { + if y >= inner.y + inner.height { break; } + let is_sel = i == self.wizard.cursor; + let style = if is_sel { + Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let label = format!(" {}", if path.is_empty() { "(root)" } else { path }); + buf.set_string(x, y, truncate(&label, w), style); + y += 1; + } + y += 1; + buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray)); + } + WizardState::ReviewProposals => { + buf.set_string(x, y, + "Review field proposals (Space toggle, c cycle kind):", + Style::default().fg(Color::Yellow)); + y += 1; + let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept"); + buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED)); + y += 1; + + for (i, proposal) in self.wizard.proposals.iter().enumerate() { + if y >= inner.y + inner.height - 2 { break; } + let is_sel = i == self.wizard.cursor; + + let kind_color = match proposal.kind { + FieldKind::Category => Color::Green, + FieldKind::Measure => Color::Cyan, + FieldKind::TimeCategory => Color::Magenta, + FieldKind::Label => Color::DarkGray, + }; + + let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" }; + let row = format!(" {:<20} {:<22} {}", + truncate(&proposal.field, 20), + truncate(proposal.kind_label(), 22), + accept_str); + + let style = if is_sel { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + } else if proposal.accepted { + Style::default().fg(kind_color) + } else { + Style::default().fg(Color::DarkGray) + }; + + buf.set_string(x, y, truncate(&row, w), style); + y += 1; + } + let hint_y = inner.y + inner.height - 1; + buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel", + Style::default().fg(Color::DarkGray)); + } + WizardState::NameModel => { + buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow)); + y += 1; + let name_str = format!("> {}█", self.wizard.model_name); + buf.set_string(x, y, truncate(&name_str, w), + Style::default().fg(Color::Green)); + y += 2; + buf.set_string(x, y, "Enter to import, Esc to cancel", + Style::default().fg(Color::DarkGray)); + + if let Some(msg) = &self.wizard.message { + let msg_y = inner.y + inner.height - 1; + buf.set_string(x, msg_y, truncate(msg, w), + Style::default().fg(Color::Red)); + } + } + WizardState::Done => { + buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green)); + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { s.to_string() } + else if max > 1 { format!("{}…", &s[..max-1]) } + else { s[..max].to_string() } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..6113508 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,10 @@ +pub mod app; +pub mod grid; +pub mod formula_panel; +pub mod category_panel; +pub mod view_panel; +pub mod tile_bar; +pub mod import_wizard_ui; +pub mod help; + +pub use app::{App, AppMode}; diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs new file mode 100644 index 0000000..d7523e5 --- /dev/null +++ b/src/ui/tile_bar.rs @@ -0,0 +1,82 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::Widget, +}; + +use crate::model::Model; +use crate::view::Axis; +use crate::ui::app::AppMode; + +pub struct TileBar<'a> { + pub model: &'a Model, + pub mode: &'a AppMode, +} + +impl<'a> TileBar<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode) -> Self { + Self { model, mode } + } +} + +impl<'a> Widget for TileBar<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let view = match self.model.active_view() { + Some(v) => v, + None => return, + }; + + let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { + Some(*cat_idx) + } else { + None + }; + + let mut x = area.x + 1; + buf.set_string(area.x, area.y, " Tiles: ", Style::default().fg(Color::Gray)); + x += 8; + + let cat_names: Vec<&str> = self.model.category_names(); + for (i, cat_name) in cat_names.iter().enumerate() { + let axis = view.axis_of(cat_name); + let axis_symbol = match axis { + Axis::Row => "↕", + Axis::Column => "↔", + Axis::Page => "☰", + Axis::Unassigned => "─", + }; + + let label = format!(" [{cat_name} {axis_symbol}] "); + let is_selected = selected_cat_idx == Some(i); + + let style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + match axis { + Axis::Row => Style::default().fg(Color::Green), + Axis::Column => Style::default().fg(Color::Blue), + Axis::Page => Style::default().fg(Color::Magenta), + Axis::Unassigned => Style::default().fg(Color::DarkGray), + } + }; + + if x + label.len() as u16 > area.x + area.width { break; } + buf.set_string(x, area.y, &label, style); + x += label.len() as u16; + } + + // Hint + if matches!(self.mode, AppMode::TileSelect { .. }) { + let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel"; + if x + hint.len() as u16 <= area.x + area.width { + buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray)); + } + } else { + let hint = " Ctrl+↑↓←→ to move tiles"; + if x + hint.len() as u16 <= area.x + area.width { + buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray)); + } + } + } +} diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs new file mode 100644 index 0000000..8ee5516 --- /dev/null +++ b/src/ui/view_panel.rs @@ -0,0 +1,62 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Widget}, +}; + +use crate::model::Model; +use crate::ui::app::AppMode; + +pub struct ViewPanel<'a> { + pub model: &'a Model, + pub mode: &'a AppMode, + pub cursor: usize, +} + +impl<'a> ViewPanel<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { + Self { model, mode, cursor } + } +} + +impl<'a> Widget for ViewPanel<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let is_active = matches!(self.mode, AppMode::ViewPanel); + let border_style = if is_active { + Style::default().fg(Color::Blue) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Views [Enter] switch [n]ew [d]elete "); + let inner = block.inner(area); + block.render(area, buf); + + let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect(); + let active = &self.model.active_view; + + for (i, view_name) in view_names.iter().enumerate() { + if inner.y + i as u16 >= inner.y + inner.height { break; } + + let is_selected = i == self.cursor && is_active; + let is_active_view = *view_name == active.as_str(); + + let style = if is_selected { + Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD) + } else if is_active_view { + Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let prefix = if is_active_view { "▶ " } else { " " }; + buf.set_string(inner.x, inner.y + i as u16, + format!("{prefix}{view_name}"), + style); + } + } +} diff --git a/src/view/axis.rs b/src/view/axis.rs new file mode 100644 index 0000000..369a4ac --- /dev/null +++ b/src/view/axis.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Axis { + Row, + Column, + Page, + /// Not yet assigned + Unassigned, +} + +impl std::fmt::Display for Axis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Axis::Row => write!(f, "Row ↕"), + Axis::Column => write!(f, "Col ↔"), + Axis::Page => write!(f, "Page ☰"), + Axis::Unassigned => write!(f, "─"), + } + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..5e7a36a --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,5 @@ +pub mod view; +pub mod axis; + +pub use view::View; +pub use axis::Axis; diff --git a/src/view/view.rs b/src/view/view.rs new file mode 100644 index 0000000..ec5f6b6 --- /dev/null +++ b/src/view/view.rs @@ -0,0 +1,127 @@ +use std::collections::{HashMap, HashSet}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use super::axis::Axis; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct View { + pub name: String, + /// Axis assignment for each category + pub category_axes: IndexMap, + /// For page axis: selected item per category + pub page_selections: HashMap, + /// Hidden items per category + pub hidden_items: HashMap>, + /// Collapsed groups per category + pub collapsed_groups: HashMap>, + /// Number format string (e.g. ",.0f" for comma-separated integer) + pub number_format: String, + /// Scroll offset for grid + pub row_offset: usize, + pub col_offset: usize, + /// Selected cell (row_idx, col_idx) + pub selected: (usize, usize), +} + +impl View { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + category_axes: IndexMap::new(), + page_selections: HashMap::new(), + hidden_items: HashMap::new(), + collapsed_groups: HashMap::new(), + number_format: ",.0".to_string(), + row_offset: 0, + col_offset: 0, + selected: (0, 0), + } + } + + pub fn on_category_added(&mut self, cat_name: &str) { + if !self.category_axes.contains_key(cat_name) { + // Auto-assign: first → Row, second → Column, rest → Page + let rows = self.categories_on(Axis::Row).len(); + let cols = self.categories_on(Axis::Column).len(); + let axis = if rows == 0 { + Axis::Row + } else if cols == 0 { + Axis::Column + } else { + Axis::Page + }; + self.category_axes.insert(cat_name.to_string(), axis); + } + } + + pub fn set_axis(&mut self, cat_name: &str, axis: Axis) { + if let Some(a) = self.category_axes.get_mut(cat_name) { + *a = axis; + } + } + + pub fn axis_of(&self, cat_name: &str) -> Axis { + self.category_axes.get(cat_name).copied().unwrap_or(Axis::Unassigned) + } + + pub fn categories_on(&self, axis: Axis) -> Vec<&str> { + self.category_axes.iter() + .filter(|(_, &a)| a == axis) + .map(|(n, _)| n.as_str()) + .collect() + } + + pub fn set_page_selection(&mut self, cat_name: &str, item: &str) { + self.page_selections.insert(cat_name.to_string(), item.to_string()); + } + + pub fn page_selection(&self, cat_name: &str) -> Option<&str> { + self.page_selections.get(cat_name).map(|s| s.as_str()) + } + + pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { + let set = self.collapsed_groups.entry(cat_name.to_string()).or_default(); + if set.contains(group_name) { + set.remove(group_name); + } else { + set.insert(group_name.to_string()); + } + } + + pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { + self.collapsed_groups + .get(cat_name) + .map(|s| s.contains(group_name)) + .unwrap_or(false) + } + + pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { + self.hidden_items.entry(cat_name.to_string()).or_default().insert(item_name.to_string()); + } + + pub fn show_item(&mut self, cat_name: &str, item_name: &str) { + if let Some(set) = self.hidden_items.get_mut(cat_name) { + set.remove(item_name); + } + } + + pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool { + self.hidden_items.get(cat_name).map(|s| s.contains(item_name)).unwrap_or(false) + } + + /// Cycle axis for a category: Row → Column → Page → Row + pub fn cycle_axis(&mut self, cat_name: &str) { + let current = self.axis_of(cat_name); + let next = match current { + Axis::Row => Axis::Column, + Axis::Column => Axis::Page, + Axis::Page => Axis::Row, + Axis::Unassigned => Axis::Row, + }; + self.set_axis(cat_name, next); + self.selected = (0, 0); + self.row_offset = 0; + self.col_offset = 0; + } +} diff --git a/tests/smoke.jsonl b/tests/smoke.jsonl new file mode 100644 index 0000000..476745f --- /dev/null +++ b/tests/smoke.jsonl @@ -0,0 +1,22 @@ +# Smoke test — one JSON command per line +{"op":"AddCategory","name":"Region"} +{"op":"AddCategory","name":"Product"} +{"op":"AddCategory","name":"Measure"} +{"op":"AddItem","category":"Region","item":"East"} +{"op":"AddItem","category":"Region","item":"West"} +{"op":"AddItem","category":"Product","item":"Shirts"} +{"op":"AddItem","category":"Product","item":"Pants"} +{"op":"AddItem","category":"Measure","item":"Revenue"} +{"op":"AddItem","category":"Measure","item":"Cost"} +{"op":"SetCell","coords":[["Region","East"],["Product","Shirts"],["Measure","Revenue"]],"number":1200} +{"op":"SetCell","coords":[["Region","East"],["Product","Shirts"],["Measure","Cost"]],"number":400} +{"op":"SetCell","coords":[["Region","East"],["Product","Pants"],["Measure","Revenue"]],"number":800} +{"op":"SetCell","coords":[["Region","East"],["Product","Pants"],["Measure","Cost"]],"number":300} +{"op":"SetCell","coords":[["Region","West"],["Product","Shirts"],["Measure","Revenue"]],"number":950} +{"op":"SetCell","coords":[["Region","West"],["Product","Shirts"],["Measure","Cost"]],"number":320} +{"op":"AddFormula","raw":"Profit = Revenue - Cost","target_category":"Measure"} +{"op":"CreateView","name":"By Region"} +{"op":"SetAxis","category":"Region","axis":"column"} +{"op":"SetAxis","category":"Product","axis":"row"} +{"op":"SetAxis","category":"Measure","axis":"page"} +{"op":"Save","path":"/tmp/smoke_test.improv"}