16 Commits

Author SHA1 Message Date
Edward Langley 341816227b fix(persistence): CSV export quoting, number errors, formula suffix, parse locations
- export_csv writes through csv::Writer (RFC 4180) and honors the view's
  number format (improvise-1cz)
- malformed numbers yield CellValue::Error naming the text instead of
  silent 0.0 (improvise-4yc)
- formula [Category] suffix moved into the pest grammar; walker extracts
  it structurally instead of rfind(" [") string-scanning (improvise-zgb)
- parse errors now report line, column, and the offending line's text
  (improvise-6kj)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:43:43 -07:00
Edward Langley 9634c95687 docs: update git merge command in AGENTS.md
Update git merge command in AGENTS.md to use origin/main.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:14 -07:00
Edward Langley df9a02b2a9 fix(ui): fix multi-level header suppression during scrolling
Implemented `show_sublabel` to ensure the first rendered entry in a
scrolled viewport always shows its full group labels.

Add regression tests for scrolling behavior in multi-level headers.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:14 -07:00
Edward Langley 9e02245f37 refactor(ui): improve panel focus cycling logic
Refactored `CyclePanelFocus` to follow a consistent
`Formula -> Category -> View` order.

Focus now correctly wraps around and handles non-panel modes.

Add regression tests for improved cycling logic.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley 47ad9e6032 fix(io): improve persistence robustness and CSV compliance
Improved numeric parsing in Markdown files to surface errors instead of
defaulting to 0.0.

`export_csv` now uses `csv::Writer` to ensure RFC 4180 compliance (e.g.,
quoting fields containing commas).

`export_csv` now correctly respects the view's number format.

Add regression tests for numeric error handling and RFC 4180 compliance.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley 77a5124d16 fix(io): enforce category limit in import wizard
The import wizard now proactively checks the `MAX_CATEGORIES` limit during
the proposal and configuration steps.

Advancing is blocked with a descriptive message if the limit would be
exceeded.

Fixed UI rendering order in `ImportWizardWidget` so messages are correctly
displayed.

Add regression tests for category limit enforcement.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley f0b9227d8f feat(io): enhance CSV import with warnings and improved headless parsing
`parse_csv` now supports `parse_csv_with_warnings` to detect and report
short rows.

Short rows are now padded with `Value::Null` instead of being silently
dropped.

`ImportJsonHeadless` now uses `parse_csv_with_warnings` and surfaces
warnings in the status message.

`ImportJsonHeadless` now reuses parsed JSON/CSV data via
`json_import_records` instead of re-parsing.

Add regression tests for short row handling and headless import.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley f04fe517ae fix(core): improve view robustness and axis management
`records_display` now returns `None` for out-of-bounds columns.

Added `try_axis_of` to `View` for non-panicking axis retrieval.

`cycle_axis` now uses `try_axis_of` to avoid panicking on unknown
categories.

Add regression tests for out-of-bounds access and unknown category cycling.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley 45bfe2c4c7 refactor(core): use epsilon for float comparison and IndexSet for stem collection
Use `FLOAT_EQ_EPSILON` for equality/inequality operators and
division-by-zero guards in formula evaluation to ensure consistent
semantics.

Replace `Vec` with `IndexSet` for stem collection in `recompute_formulas`
to improve performance from O(n²) to O(n).

Add regression tests for epsilon-based comparison and stem collection
performance.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 21:43:13 -07:00
Edward Langley 0fe04de53e docs(context): refresh repo-map after ModelState/ViewState refactor
Line counts, 16 AppMode variants (RecordsNormal/RecordsEditing), 16 mode
keymaps, ModelState/ViewState split, crossterm 0.29. Findings from the
2026-06-09 systematic review (issues improvise-1cz .. improvise-2ud).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:00:31 -07:00
Edward Langley ecb524c947 chore(deps): update dependencies
Update Cargo.lock with new versions and additional dependencies.

This includes updates to bitflags, chrono, and hashbrown, along with the
addition of new packages such as approx, critical-section, and the futures
suite.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 20:48:44 -07:00
Edward Langley 62d18ffe7c chore(dev): add universal-ctags and global to devShell
Adds pkgs.global and pkgs.universal-ctags to the development shell.

This update also includes the corresponding changes in flake.lock.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-06-09 20:46:00 -07:00
Edward Langley 7249facf94 docs(ui): classify App-resident runtime/derived fields (improvise-99k)
Step 4 of vb4. After steps 1–3, App owns model_state, view_state, and a
residue of five fields that don't belong to either slice. Add a top-level
doc on App explaining the slice structure and tag each residue field as
derived cache / runtime metadata / transient / config with a one-line
rationale. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:33:27 -07:00
Edward Langley 9ad8abd8a5 refactor(ui): move UI session fields into ViewState (improvise-ew0)
Step 3 of vb4. Populates ViewState with the 20 UI session fields (mode,
status_msg, wizard, search_query, search_mode, three panel-open flags,
three panel cursors, formula_cursor, yanked, tile_cat_idx, two view nav
stacks, drill_state, help_page, expanded_cats, buffers, transient_keymap)
and routes every read/write site through app.view_state.X. App now
contains only model_state, view_state, and the runtime/derived residue
(term dims, layout, last_autosave, abort_effects, keymap_set).

ViewState gets a manual Default impl mirroring the previous App::new
field initialisers; AppMode has no Default of its own so AppMode::Normal
is the explicit baseline. Effect::apply still takes &mut App; narrowing
remains step 5 (improvise-drg).

A structural test (app_view_state_owns_ui_session_fields) locks in the
20-field layout. 624 tests pass workspace-wide (+1 new). cargo clippy
--workspace --tests clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:19:31 -07:00
Edward Langley 917b928759 refactor(ui): move workbook/file_path/dirty into ModelState (improvise-x2c)
Step 2 of vb4. Populates ModelState with the document slice and routes
every read/write site (effects, draw, main, app methods, tests) through
app.model_state.X. App no longer owns workbook, file_path, or dirty
directly. Effect::apply signatures still take &mut App; narrowing happens
in step 5 (improvise-drg).

A structural test (app_model_state_owns_workbook_file_path_and_dirty)
locks in the field layout. ModelState now has a manual Default impl
that creates an "Untitled" Workbook so the existing constructibility
test keeps working.

623 tests pass workspace-wide (+1 new). cargo clippy --workspace --tests
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:06:33 -07:00
Edward Langley f11d79f700 refactor(ui): scaffold ModelState and ViewState types (improvise-3vr)
Step 1 of vb4 (Split AppState into ModelState + ViewState): introduce the
named slice types in src/ui/app.rs as empty structs, with doc comments
pointing at the follow-up issues that fill them in. App still owns every
field directly; subsequent steps migrate the fields.

A structural test locks in that both types are constructible.

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