73 Commits

Author SHA1 Message Date
1181ffd0ab chore: update roadmap 2026-04-26 11:10:32 -07:00
6756b00e4a chore: minor formatting changes 2026-04-26 11:08:41 -07:00
3324ceef69 chore: don't use IFD for Cargo.nix 2026-04-26 10:28:37 -07:00
452234f2d5 chore(merge): remote-tracking branch 'gh/dependabot/cargo/rand-0.8.6' 2026-04-26 10:09:29 -07:00
782ca9dfaa chore(merge): remote-tracking branch 'me/main' 2026-04-26 10:09:16 -07:00
36ee0e229f chore: update issues.jsonl 2026-04-26 10:08:57 -07:00
9e310b9e4b chore: update agent instructions 2026-04-26 09:45:23 -07:00
699d4d58dc chore(deps): bump rand from 0.8.5 to 0.8.6
Bumps [rand](https://github.com/rust-random/rand) from 0.8.5 to 0.8.6.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.8.6/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.8.5...0.8.6)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.8.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 22:17:23 +00:00
21fc03cf18 chore: save session memories via bd remember
Captures three insights from 2026-04-16 deep review session:
- review-methodology-scoped-explore-agents
- compiler-exhaustiveness-theme
- agent-issue-drift-pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 16:07:45 -07:00
47313c4e80 chore: update repo-map 2026-04-16 16:05:42 -07:00
6362078032 chore: bd dolt sync conf + issue jsonl 2026-04-16 11:11:30 -07:00
8242ef3dbe bd: update sync.remote 2026-04-16 11:04:54 -07:00
c158a8b99e bd init: initialize beads issue tracking 2026-04-16 10:58:57 -07:00
d99cb5ac8c Merge branch 'main' into worktree-improvise-ewi-formula-crate 2026-04-15 23:43:14 -07:00
4e37e12f9a style: reformat code and cleanup whitespace
Reformat code for improved readability and remove unnecessary whitespace.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 23:42:44 -07:00
a900f147b5 feat(cmd): use new effects to improve command behavior
Update various commands to utilize the new AbortChain and CleanEmptyRecords
effects.

- CommitAndAdvance now pushes a mode change effect when aborting.
- ToggleRecordsMode now cleans up empty records upon exiting.
- EnterAdvance now emits AbortChain when at the bottom-right corner.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 23:42:44 -07:00
489e2805e8 feat(ui): implement AbortChain and CleanEmptyRecords effects
Implement AbortChain and CleanEmptyRecords effects to allow
short-circuiting effect batches and purging cells with empty coordinates.
Update the App struct to support aborting effects during the application of
an effect batch.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 23:42:44 -07:00
f272a9d459 chore: update roadmap 2026-04-15 23:35:08 -07:00
ff74d619a3 docs(io): note improvise-io layout in repo-map
Reflect improvise-8zh: `persistence/` and `import/` now live in the
`improvise-io` sub-crate under `crates/`.

- Sub-crate list expanded to describe improvise-io's scope, its
  dependencies (improvise-core, improvise-formula), and the
  standalone-build guarantee.
- File Inventory reorganized: "Import layer" + persistence entries
  collapsed into a single "I/O crate layers" block with paths rooted at
  `crates/improvise-io/src/`.
- Updated line/test counts to current contents.
- Top-level block trimmed (persistence/format no longer live there) and
  lib.rs annotated as a re-export facade.

No consumer-facing path changes; `crate::persistence::*` and
`crate::import::*` still resolve via the main crate's re-exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:08:55 -07:00
5807464fc7 refactor(io): move persistence and import into improvise-io (improvise-8zh)
Relocate the two I/O module trees into the improvise-io sub-crate
scaffolded in the previous commit:

  git mv src/persistence -> crates/improvise-io/src/persistence
  git mv src/import      -> crates/improvise-io/src/import

The grammar file `improv.pest` moves alongside `persistence/mod.rs`;
the `#[grammar = "persistence/improv.pest"]` attribute resolves relative
to the new crate root and keeps working unchanged.

No path edits inside the moved code: the `crate::model::*`,
`crate::view::*`, `crate::workbook::*`, `crate::format::*`, and
`crate::formula::*` imports inside persistence and import all continue
to resolve because improvise-io's lib.rs re-exports those modules from
improvise-core and improvise-formula, mirroring the pattern improvise-core
uses for `formula`. Verified no `crate::ui::*`, `crate::command::*`,
`crate::draw::*` imports exist in the moved code (per improvise-8zh
acceptance criterion #3).

Main-crate `src/lib.rs` now re-exports `import` and `persistence` from
improvise-io, keeping every `crate::persistence::*` and `crate::import::*`
path in the 4 consumer files (ui/app.rs, ui/effect.rs,
ui/import_wizard_ui.rs, main.rs) resolving unchanged — no downstream
edits needed.

`examples/gen-grammar.rs` had `include_str!("../src/persistence/improv.pest")`;
updated the relative path to the new location under
`crates/improvise-io/src/persistence/`.

Verification:
- cargo check --workspace --examples: clean
- cargo test --workspace: 616 passing (219 main + 190 core + 65 formula + 142 io)
- cargo clippy --workspace --tests: clean
- cargo build -p improvise-io: standalone build succeeds, confirming no
  UI/command leakage into the IO crate (improvise-8zh acceptance #2, #3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:08:00 -07:00
bd17aed169 refactor(io): scaffold empty improvise-io sub-crate
Lay groundwork for Phase 3 of the workspace split (improvise-8zh): a new
`crates/improvise-io/` sub-crate that will house the persistence and
import layers once the files move in the next commit.

Only scaffolding here:
- New `crates/improvise-io/Cargo.toml` declares deps on improvise-core,
  improvise-formula, and the external crates persistence+import already
  use: anyhow, chrono, csv, flate2, indexmap, pest, pest_derive, serde,
  serde_json. Dev-deps: pest_meta, proptest, tempfile.
- New `crates/improvise-io/src/lib.rs` re-exports the core modules under
  their conventional names (`format`, `model`, `view`, `workbook`,
  `formula`) so in the next commit the moved code's `crate::model::*`,
  `crate::view::*`, `crate::workbook::*`, `crate::format::*`, and
  `crate::formula::*` paths resolve unchanged.
- Root `Cargo.toml` adds the new crate to workspace members and the main
  crate's `[dependencies]`, ready to receive the move.

No source files change yet; cargo check --workspace still compiles as
before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:05:17 -07:00
79dc54de21 Merge branch 'main' into worktree-improvise-ewi-formula-crate
# Conflicts:
#	TAGS
2026-04-15 22:47:51 -07:00
9efbed403a chore: update tags 2026-04-15 22:46:03 -07:00
03c7c00b25 chore: update tags 2026-04-15 22:45:35 -07:00
08f190a036 chore: update gitignore 2026-04-15 22:45:18 -07:00
d20eb75a0b feat: roadmap from beads 2026-04-15 22:44:47 -07:00
30383f203e refactor(keymap): pass mode arguments in keybindings
Update keybindings for normal, records-normal, editing, and records-editing
modes to pass the appropriate mode names as arguments to the parameterized
commands.

This ensures that the correct mode is entered when using commands like
`edit-or-drill` , `enter-edit-at-cursor` , `commit-cell-edit` , and
`commit-and-advance-right` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 22:44:13 -07:00
cece34a1d4 refactor(command): parameterize mode-related commands and effects
Make mode-related commands and effects mode-agnostic by passing the target
mode as an argument instead of inspecting the current application mode.

- `CommitAndAdvance` now accepts `edit_mode` .
- `EditOrDrill` now accepts `edit_mode` .
- `EnterEditAtCursorCmd` now accepts `target_mode` .
- `EnterEditAtCursor` effect now accepts `target_mode` .

Update the command registry to parse mode names from arguments and pass
them to the corresponding commands.

Add tests to verify the new mode-passing behavior.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 22:44:13 -07:00
79f78c496e docs(core): note improvise-core layout in repo-map
Reflect Phase B of improvise-36h: `model/`, `view/`, `workbook.rs`, and
`format.rs` now live in the `improvise-core` sub-crate under `crates/`.

- Sub-crate list expanded to describe improvise-core's scope, its
  dependency on improvise-formula, and the standalone-build guarantee.
- File Inventory reorganized into a single "Core crate layers" block
  covering model/view/workbook/format, with paths rooted at
  `crates/improvise-core/src/`.
- Updated line/test counts to match current contents (Phase A + merge
  with main brought in records mode, IndexMap-backed DataStore, etc).

No architectural change; the main crate's re-exports keep every
`crate::model`/`crate::view`/`crate::workbook`/`crate::format` path
resolving unchanged, so no "How to Find Things" table edits are needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:33:45 -07:00
fc9d9cb52a refactor(core): move model, view, workbook, format into improvise-core
Relocate the four pure-data module trees into the improvise-core
sub-crate scaffolded in the previous commit. Phase A already made
these modules UI/IO-free; this commit is purely mechanical:

  git mv src/format.rs   -> crates/improvise-core/src/format.rs
  git mv src/workbook.rs -> crates/improvise-core/src/workbook.rs
  git mv src/model       -> crates/improvise-core/src/model
  git mv src/view        -> crates/improvise-core/src/view

The moved code contains no path edits: the `crate::formula::*`,
`crate::model::*`, `crate::view::*`, `crate::workbook::*`,
`crate::format::*` imports inside the four trees all continue to
resolve because the new crate mirrors the same module layout and
re-exports improvise_formula under `formula` via its lib.rs.

Main-crate `src/lib.rs` flips from declaring these as owned modules
(`pub mod model;` etc.) to re-exporting them from improvise-core
(`pub use improvise_core::model;` etc.). This keeps every
`crate::model::*`, `crate::view::*`, `crate::workbook::*`,
`crate::format::*` path inside the 26 consumer files in src/ (ui,
command, persistence, import, draw, main) resolving unchanged — no
downstream edits needed.

Verification:
- cargo check --workspace: clean
- cargo test --workspace: 612 passing (357 main + 190 core + 65 formula)
- cargo clippy --workspace --tests: clean
- cargo build -p improvise-core: standalone build succeeds, confirming
  zero UI/IO leakage into the core crate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:42 -07:00
0d75c7bd0b refactor(core): scaffold empty improvise-core sub-crate
Lay the groundwork for Phase B of improvise-36h: a new
`crates/improvise-core/` sub-crate that will house the pure-data core
(model, view, workbook, format) once the files move in the next commit.

Only scaffolding here:
- New `crates/improvise-core/Cargo.toml` mirroring improvise-formula's
  structure; declares deps on improvise-formula, anyhow, indexmap, serde.
- New `crates/improvise-core/src/lib.rs` with the single re-export
  `pub use improvise_formula as formula;` so that in the next commit the
  moved code's `crate::formula::*` paths resolve unchanged.
- Root `Cargo.toml` adds the new crate to the workspace members and the
  main crate's `[dependencies]`, ready to receive the move.

No source files change yet; cargo check --workspace still compiles as
before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:21:58 -07:00
a22478eb87 Merge branch 'main' into worktree-improvise-ewi-formula-crate
# Conflicts:
#	src/ui/app.rs
#	src/ui/effect.rs
#	src/view/layout.rs
2026-04-15 21:39:00 -07:00
242ddebb49 chore: matches -> method 2026-04-15 21:33:18 -07:00
030865a0ff feat(records): implement records mode for data entry
Implement a new "Records" mode for data entry.
- Add `RecordsNormal` and `RecordsEditing` to `AppMode` and `ModeKey` .
- `DataStore` now uses `IndexMap` and supports `sort_by_key()` to ensure
  deterministic row order.
- `ToggleRecordsMode` command now sorts data and switches to
  `RecordsNormal` .
- `EnterEditMode` command now respects records editing variants.
- `RecordsNormal` mode includes a new `o` keybinding to add a record row.
- `RecordsEditing` mode inherits from `Editing` and adds an `Esc` binding
  to return to `RecordsNormal` .
- Added `SortData` effect to trigger data sorting.
- Updated UI to display "RECORDS" and "RECORDS INSERT" mode names and
  styles.
- Updated keymaps, command registry, and view navigation to support these
  new modes.
- Added comprehensive tests for records mode behavior, including sorting
  and boundary conditions for Tab/Enter.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 21:32:35 -07:00
ded35f705c feat(model): use IndexMap for deterministic insertion order in DataStore
Replace `HashMap` with `IndexMap` in `DataStore::cells` to preserve
insertion order. This allows records mode to display rows in the order they
were added. Update `remove` to use `shift_remove` to maintain order during
deletions.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 21:32:35 -07:00
7c00695398 refactor(navigation): include AppMode in view navigation stack
Introduce `ViewFrame` to store both the view name and the `AppMode` when
pushing to the navigation stack. Update `view_back_stack` and
`view_forward_stack` to use `ViewFrame` instead of `String` . Update
`CmdContext` and `Effect` implementations (SwitchView, ViewBack,
ViewForward) to handle the new `ViewFrame` structure. Add `is_editing()`
helper to `AppMode` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 21:32:34 -07:00
23c7c530e3 refactor(parser): simplify tests and generator logic
Refactor formula parser tests to use more concise assert!(matches!(...))
syntax. Simplify the formula generator implementation by removing unused
expression variants and using expect() for mandatory grammar rules. Add a
regression test for hyphenated identifiers in bare names.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf)
2026-04-15 21:32:34 -07:00
1c16dc17e8 fix(workbook): align default virtual-category axes with main (improvise-kos)
Workbook::new was setting _Index=Row and _Dim=Column on the default view,
which forced a fresh workbook into records mode on every startup — records
mode auto-activates when both _Index and _Dim sit on axes. The result felt
like broken keybindings: the grid rendered as records instead of an
aggregated pivot, and every navigation key landed in an unexpected spot.

Main fixed this on the Model layer in 709f2df + 6d4b19a (improvise-kos);
the Workbook refactor accidentally reintroduced the pre-fix behavior.
Restore the post-kos defaults: all virtual categories start on Axis::None
(via on_category_added's "_"-prefix rule), then _Measure is bumped to
Axis::Page so aggregated pivot views show one measure at a time. _Index
and _Dim only move onto axes when the user explicitly enters records mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:22:59 -07:00
f1229a60e4 Merge branch 'main' into worktree-improvise-ewi-formula-crate
# Conflicts:
#	src/model/types.rs
#	src/view/layout.rs
2026-04-15 21:11:55 -07:00
3fbf56ec8b refactor: break Model↔View cycle, introduce Workbook wrapper
Model is now pure data (categories, cells, formulas, measure_agg) with
no references to view/. The Workbook struct owns the Model together
with views and the active view name, and is responsible for cross-slice
operations (add/remove category → notify views, view management).

- New: src/workbook.rs with Workbook wrapper and cross-slice helpers
  (add_category, add_label_category, remove_category, create_view,
  switch_view, delete_view, normalize_view_state).
- Model: strip view state and view-touching methods. recompute_formulas
  remains on Model as a primitive; the view-derived none_cats list is
  gathered at each call site (App::rebuild_layout, persistence::load)
  so the view dependency is explicit, not hidden behind a wrapper.
- View: add View::none_cats() helper.
- CmdContext: add workbook and view fields so commands can reach both
  slices without threading Model + View through every call.
- App: rename `model` field to `workbook`.
- Persistence (save/load/format_md/parse_md/export_csv): take/return
  Workbook so the on-disk format carries model + views together.
- Widgets (GridWidget, TileBar, CategoryContent, ViewContent): take
  explicit &Model + &View instead of routing through Model.

Tests updated throughout to reflect the new shape. View-management
tests that previously lived on Model continue to cover the same
behaviour via a build_workbook() helper in model/types.rs. All 573
tests pass; clippy is clean.

This is Phase A of improvise-36h. Phase B will mechanically extract
crates/improvise-core/ containing model/, view/, format.rs, workbook.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:08:11 -07:00
6d4b19a940 fix(model): default _Measure to Page axis, skip empty page categories (improvise-kos)
Virtual categories _Index and _Dim now default to None on new models
instead of being forced onto Row/Column. _Measure defaults to Page
(the natural home for measure filtering). Fixed page_coords builder
to skip Page categories with no items/selection, preventing empty-string
contamination of cell keys.

Made-with: Cursor
2026-04-15 04:37:06 -07:00
709f2df11f fix(model): initialize virtual categories with Axis::None
Ensure that virtual categories (_Index, _Dim, _Measure) are registered in
the default view with Axis::None. This prevents potential panics when
calling axis_of on these categories and allows users to move them to a
specific axis manually.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 04:32:14 -07:00
5fbc73269f refactor(formula): trust grammar invariants in parser
Refactor the formula parser to assume grammar invariants, replacing
Result-based error handling in tree walkers with infallible functions and
.expect(GRAMMAR_INVARIANT) calls.

This simplification is based on the guarantee that the Pest grammar already
validates the input structure. To verify these invariants and ensure
correctness, extensive new unit tests and property-based tests using
proptest have been added.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 04:32:14 -07:00
dba8a5269e refactor(records): use CategoryKind for column filtering, stabilize _Measure position
- Filter records-mode columns by CategoryKind instead of string names
- Simplify cell fetching: matching_cells already handles empty filters
- Sort _Measure to always appear right before Value column

Made-with: Cursor
2026-04-15 04:16:09 -07:00
3f69f88709 refactor!(formula): migrate parser to use pest
Replace the manual tokenizer and recursive descent parser with a PEG
grammar using the pest library.

This migration involves introducing a formal grammar in formula.pest and
updating the parser implementation to utilize the generated Pest parser
with a tree-walking approach to construct the AST.

The change introduces a stricter requirement for identifiers: multi-word
identifiers must now be enclosed in pipe quotes (e.g., |Total Revenue|) and
are no longer accepted as bare words.

Tests have been updated to reflect the new parsing logic, remove
tokenizer-specific tests, and verify the new pipe-quoting and escape
semantics.

BREAKING CHANGE: Multi-word identifiers now require pipe-quoting (e.g. |Total Revenue|) and
are no longer accepted as bare words.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 04:04:57 -07:00
38f83b2417 fix(records): include _Measure as visible column in records mode (improvise-rbv)
The records mode column filter excluded all categories starting with '_',
which hid _Measure. Changed to explicitly exclude only _Index and _Dim,
making _Measure visible as a data column. Updated the blank-model editing
test to reflect the new column order (_Measure first, Value last).

Made-with: Cursor
2026-04-15 03:56:18 -07:00
ee5fc89e43 chore(merge): branch 'worktree-improvise-ewi-formula-crate'
Fixes: improvise-ewi
2026-04-15 03:02:14 -07:00
efab0cc32e test(ui): clean up formatting in app.rs tests
Clean up formatting of a test assertion in app.rs to improve readability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 03:01:30 -07:00
d8375ceaa7 refactor(command): simplify commit_cell_value by extracting helper functions
Simplify the commit_cell_value function by extracting its core logic into
specialized helper functions: commit_regular_cell_value, stage_drill_edit,
and commit_plain_records_edit. This improves readability and reduces
nesting.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 03:01:30 -07:00
6f291ccd04 refactor(ui): extract record coordinates error message to constant
Extract the hardcoded error message "Record coordinates cannot be empty"
into a public constant in effect.rs to avoid duplication and improve
maintainability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-15 03:01:30 -07:00
9710fb534e feat(cmd): allow direct commit of synthetic records in records mode
Allow synthetic record edits to be applied directly to the model when not
in drill mode, whereas they remain staged in drill state when a drill
snapshot is active. This enables editing of records in plain records view.

Additionally, add validation to prevent creating records with empty
coordinates in both direct commits and when applying staged drill edits.
Includes regression tests for persistence in blank models, drill state
staging, and empty coordinate prevention.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 02:32:03 -07:00
f02d905aac refactor(formula): extract formula parser into separate crate
Extract the formula AST and parser into a dedicated `improvise-formula`
crate and convert the project into a Cargo workspace.

The root crate now re-exports `improvise-formula` as `crate::formula` to
maintain backward compatibility for internal callers. The repository map is
updated to reflect the new crate structure.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:49:35 -07:00
c79498b04b chore: update gitignore 2026-04-14 01:43:32 -07:00
60a8559a7f docs(design): add principle about decomposing instead of early returning
Add a new design principle encouraging decomposition of functions instead
of relying on early returns, as early returns often signal mixed
responsibilities.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:26:30 -07:00
1cea06e14b fix(records): allow adding rows in empty records view
Add helper methods to CmdContext to clarify layout state and synthetic
record presence. Update AddRecordRow to check for records mode generally
rather than requiring a synthetic record at the current cursor, which
allows adding the first row to an empty records view.

Includes a regression test for the empty records view scenario.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:26:29 -07:00
8b7b45587b refactor(ui): use iterator in TileBar loop
Update the loop in TileBar to use an iterator-based approach with
enumerate, take, and skip instead of indexing.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:03:25 -07:00
d551d53eb4 refactor(test): simplify assertions and calls in various tests
Clean up various test cases by simplifying Option checks, removing
redundant clones, using contains instead of any for DateComponent checks,
and removing unnecessary references in string formatting.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:03:25 -07:00
f3996da2ec refactor(cmd): simplify commit and navigation logic
Simplify the return value of CommitFormula::execute to use a vec! macro and
move the page_cat_data helper function in navigation.rs for better
organization.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:03:25 -07:00
648e50860e refactor(grammar): use is_multiple_of and let-chains
Replace modulo operations with is_multiple_of calls and simplify nested if
statements using let-chains in the grammar generator.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 01:03:24 -07:00
bdbfe08476 chore: update {AGENTS,CLAUDE}.md 2026-04-14 00:52:51 -07:00
1ae6187285 chore: update gitignore 2026-04-14 00:50:29 -07:00
5a7ba5fb30 docs: update repository map and project metadata
Update the repository map with the latest project metadata, including
version bumps, updated dependency versions, and technical notes on formula
drilling, formula recomputation, and minibuffer clearing.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 00:49:32 -07:00
35e2626a7d style: add braces to if statements for consistency
Consolidate if-let chain formatting by adding missing braces throughout the
project. This improves readability and ensures consistency across the
codebase.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 00:49:32 -07:00
8baa4c4865 test(grid): ensure formulas are recomputed before rendering
The render helper used in Grid tests previously did not recompute formulas,
meaning tests for computed values were either ignored or required manual
setup that didn't reflect actual application behavior.

Update the render helper to identify 'None' axis categories and trigger
recompute_formulas, and update all call sites to pass a mutable model.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 00:49:31 -07:00
f019577810 feat(grid): allow drilling into formula cells
When drilling into a cell that is the target of a formula, the resulting
record set was empty because the formula target coordinate itself does not
exist in the raw data.

This change strips the '_Measure' coordinate from the drill key if the
value is a known formula target, allowing the underlying data records that
feed the formula to be discovered.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-14 00:49:31 -07:00
a94abd6e6c chore: add TAGS 2026-04-14 00:03:27 -07:00
cb35e38df9 refactor(all): use let chains to flatten nested if statements
Replace nested if and if let blocks with combined if statements using let
chains. This reduces indentation and improves readability across the
project.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:58:14 -07:00
6370f8b19f chore: format 2026-04-13 21:30:37 -07:00
af74dc3d3f chore: update project instructions
Add a quirk to project instructions in CLAUDE.md.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:30:19 -07:00
d4e948827b refactor(model): disable formula evaluation entry point
Disable the eval_formula method in Model. This may be a temporary change or
part of a larger refactor of formula evaluation.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:30:19 -07:00
ed1ee7e23a feat(examples): add grammar generation and pretty-printing utilities
Add new examples for generating sample .improv data based on the Pest
grammar and pretty-printing existing .improv files.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:30:19 -07:00
c48a5cd575 feat!(persistence): update .improv format to v2025-04-09
Update the .improv file grammar to require version v2025-04-09 and remove
unnecessary blank line requirements at the start of files. Update
bank-info.improv to comply with the new version and reformat date entries
and categories.

BREAKING CHANGE: The .improv grammar now strictly requires the version line 'v2025-04-09'.
Existing files without this line will fail to parse.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:30:18 -07:00
53b13d4942 refactor(project): transition to library structure
Move module declarations from src/main.rs to a new src/lib.rs to transition
the project to a library-first structure. This allows other tools and
examples to utilize the core logic.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-31B-it-UD-Q4_K_XL.gguf)
2026-04-13 21:30:18 -07:00
74 changed files with 19341 additions and 3097 deletions

View File

@ -52,3 +52,5 @@
# - linear.api-key
# - github.org
# - github.repo
sync.remote: "git+ssh://git@git.fiddlerwoaroof.com/u/edwlan/improvise.git"

View File

@ -1,5 +1,5 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
@ -21,4 +21,4 @@ if command -v bd >/dev/null 2>&1; then
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
# --- END BEADS INTEGRATION v1.0.2 ---

View File

@ -1,5 +1,5 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
@ -21,5 +21,5 @@ if command -v bd >/dev/null 2>&1; then
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
# --- END BEADS INTEGRATION v1.0.2 ---

View File

@ -1,5 +1,5 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
@ -21,4 +21,4 @@ if command -v bd >/dev/null 2>&1; then
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
# --- END BEADS INTEGRATION v1.0.2 ---

View File

@ -1,5 +1,5 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
@ -21,5 +21,5 @@ if command -v bd >/dev/null 2>&1; then
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
# --- END BEADS INTEGRATION v1.0.2 ---

View File

@ -1,5 +1,5 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
@ -21,4 +21,4 @@ if command -v bd >/dev/null 2>&1; then
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
# --- END BEADS INTEGRATION v1.0.2 ---

125
.beads/issues.jsonl Normal file
View File

@ -0,0 +1,125 @@
{"id":"improvise-4yc","title":"Malformed numbers in .improv file silently become 0.0","description":"crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:17Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-dqn","title":"CyclePanelFocus does not actually cycle between panels","description":"The CyclePanelFocus command at src/command/cmd/panel.rs:299-313 is documented as 'Tab through open panels' but the if/else if chain always resolves to the first open panel (formula \u003e category \u003e view). Pressing the bound key repeatedly never rotates. Test at panel.rs:84 only asserts a panel mode is entered, not rotation. Fix: either implement real rotation using ctx.mode to determine 'next' open panel, or rename to FocusFirstOpenPanel. Also replace the three bool fields with an enum of open panel states.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:07Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-fpo","title":"Dynamic _Measure: formula targets auto-included","description":"_Measure should dynamically include all formula targets without add_formula manually adding items. Formula refs should resolve against Model::formulas as fallback. CommitFormula should not need to pick a target category for _Measure.","status":"closed","priority":1,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T10:14:14Z","created_by":"spot","updated_at":"2026-04-09T20:52:01Z","closed_at":"2026-04-09T20:52:01Z","close_reason":"Dynamic _Measure, optional target_category, atomic formula eval on load, skip virtual category persistence","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-h4r","title":"Replace ad-hoc parser with pest grammar","description":"The pest grammar at src/persistence/improv.pest defines the .improv format spec. Replace the hand-written line-scanner in parse_md with a pest-derived parser that walks the grammar's parse tree. This eliminates drift between spec and implementation.","status":"closed","priority":1,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T09:03:53Z","created_by":"spot","updated_at":"2026-04-09T09:32:38Z","closed_at":"2026-04-09T09:32:38Z","close_reason":"Replaced ad-hoc line scanner with pest-derived parser. Grammar at improv.pest is now the single source of truth for both parsing and test generation. Grammar-walking generator uses pest_meta to read the grammar AST at test time and produce random valid files as proptest entropy.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-5xd","title":"Rename Measure to _Measure (virtual category)","description":"Measure should be a virtual category (_Measure) like _Index and _Dim. This exempts it from the 12-category limit and follows naming conventions. Requires updating: import wizard, evaluate_aggregated hardcoded reference, persistence parser, demo.improv, and CategoryKind.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T08:52:11Z","created_by":"Edward Langley","updated_at":"2026-04-09T09:40:23Z","closed_at":"2026-04-09T09:40:23Z","close_reason":"Measure renamed to _Measure (VirtualMeasure kind). Fixed-point formula evaluation implemented via recompute_formulas cache. Formulas now resolve correctly with hidden dimensions.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-ubu","title":"Circular formula references produce ERR instead of stack overflow","description":"Added depth limit (16) to formula evaluation. Circular/self-referencing formulas return CellValue::Error(\"circular\") instead of infinite recursion. Errors propagate through expression tree via Result. Already fixed.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:35:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:36:08Z","closed_at":"2026-04-09T08:36:08Z","close_reason":"Fixed in commit 56bf736","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-di3","title":"Bug: AddFormula doesn't add target item to category","description":"When adding a formula interactively (e.g. Margin = Profit / Revenue), the formula is registered but the target item (Margin) is never added to the target category (Measure). The grid layout never creates cells for it, so the formula is invisible. Found during demo recording.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:24:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:25:15Z","closed_at":"2026-04-09T08:25:15Z","close_reason":"Fixed: AddFormula::apply now calls category_mut().add_item() before registering the formula. Regression test added.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-n1h","title":"1.5 Create examples/demo.improv and examples/demo.csv","description":"Create two synthetic example files with obviously-fake data. demo.csv: ~30-50 rows with Date, Region, Product, Customer, Revenue, Cost columns. demo.improv: result of importing demo.csv, optionally with interesting default view and sample Profit formula. Data must be obviously synthetic.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:53Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:16:33Z","closed_at":"2026-04-09T07:16:33Z","close_reason":"Created examples/demo.csv (40 rows, obviously-fake data with fictional companies) and examples/demo.improv (generated via headless import with Profit formula and a useful default view). Added import command to README.","dependency_count":0,"dependent_count":3,"comment_count":0}
{"id":"improvise-bv1","title":"1.4 Audit CSV quote handling","description":"Verify csv_parser correctly handles RFC 4180 quoted fields: embedded commas, escaped quotes. Ensure it uses the csv crate, not manual split. Add unit test round-tripping a row with embedded comma and escaped quotes. If fundamentally broken, add Known Limitations note to README instead.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:51Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:58:41Z","closed_at":"2026-04-09T06:58:41Z","close_reason":"Audit complete. The csv crate handles all RFC 4180 cases correctly: embedded commas, escaped quotes, embedded newlines. Added 4 regression tests. No bugs found.","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-2fr","title":"1.3 Verify publish-readiness (cargo publish --dry-run)","description":"Run cargo publish --dry-run from inside nix develop. Fix any errors or warnings (missing license file, files too large, dependency issues). Do not actually publish.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:49Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:21:22Z","closed_at":"2026-04-09T07:21:22Z","close_reason":"cargo publish --dry-run passes cleanly: 72 files, 952KB, no errors or warnings.","dependencies":[{"issue_id":"improvise-2fr","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:20:17Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0}
{"id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:10:05Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
{"id":"improvise-tvt","title":"Route mutations through Workbook facade instead of app.workbook.model chains","description":"40+ sites follow the pattern 'if let Some(cat) = app.workbook.model.category_mut(\u0026name) { cat.add_item(\u0026item); }' (src/ui/effect.rs:38, 54, 99 and many test setups in src/command/cmd/mod.rs, src/ui/grid.rs).\\n\\nSimilarly .views.get().unwrap().axis_of() appears in 11 sites (workbook.rs:167-177, model/types.rs:1868-1890, persistence:943-946,1165) even though Workbook::active_view() / active_view_mut() already exist and are used correctly in some places.\\n\\nFix: add facade methods on Workbook: add_item(cat, item), remove_item(cat, item), view_axis(view_name, cat), sort_cells(), cell_count(). Migrate the existing chains to use them.\\n\\nCorrectness win: (a) Workbook becomes a real facade with the option to enforce invariants (e.g., notify views when items change); (b) Tests and effects stop reaching through three layers; (c) future refactors to Model/DataStore internals don't cascade into 40+ call sites.\\n\\n~60 LOC in production + simplification of many tests.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:18Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:18Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-2hi","title":"Extract AppMode dispatch methods (mode_label, mode_style, mode_key)","description":"AppMode is matched in ~52 sites across 8 files to derive UI labels, styles, and mode keys. Each match arm is a small piece of per-variant logic that should live on the variant itself.\\n\\nHot spots:\\n- src/draw.rs:119-149 — mode_name() and mode_style() match blocks (16+ arms)\\n- src/command/keymap.rs:69-89 — ModeKey::from_app_mode match (16 arms)\\n- src/ui/{formula,category,view}_panel.rs — matches!(mode, AppMode::X) checks\\n- src/ui/tile_bar.rs, src/ui/effect.rs:898-900, src/command/cmd/registry.rs:376\\n- src/ui/app.rs: 13 scattered matches!() checks\\n\\nFix: add methods on AppMode — mode_label() -\u003e \u0026'static str, mode_style() -\u003e Style, mode_key() -\u003e Option\u003cModeKey\u003e, panel_mode() -\u003e Option\u003cPanel\u003e, is_text_entry_mode() -\u003e bool (partially present via minibuffer()). Also extend the existing minibuffer() with buffer_key() convenience.\\n\\nCorrectness win: adding a new AppMode variant requires updating the enum's methods (one file), not chasing 8 files with match blocks. Exhaustive match inside the method body still forces the compiler to flag every new variant.\\n\\n~80 LOC collapsed and 7 files depend on fewer concerns of AppMode.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:09Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:09Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-2lh","title":"Migrate name-string comparisons to CategoryKind enum checks","description":"8 sites compare category names as strings against virtual-category prefixes ('_Measure', '_Index', '_Dim') instead of checking the typed CategoryKind enum. Adding a new CategoryKind variant won't produce a compile error at any of these sites.\\n\\nSites:\\n- crates/improvise-core/src/model/types.rs:215, 226, 429, 636 (target_category == '_Measure', cat_name == '_Measure')\\n- crates/improvise-io/src/persistence/mod.rs:213, 226 (formula target comparison)\\n- crates/improvise-core/src/view/layout.rs:108 (c == '_Index' \u0026\u0026 c == '_Dim')\\n- src/command/cmd/grid.rs:306 (target_category == '_Measure')\\n\\nFix: add methods on CategoryKind: is_virtual(), is_measure(), is_persisted(), is_index_or_dim(). Look up the Category by name once, dispatch on its .kind.\\n\\nCorrectness win: the compiler flags every site when a new variant is added. Also removes the hardcoded-name dependency — if the underscore prefix convention ever changes, one place to edit.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:01:53Z","created_by":"Ed L","updated_at":"2026-04-16T19:01:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-6sq","title":"Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}","description":"command/cmd/mode.rs:254-307 has two commands (EditOrDrill, EnterEditAtCursorCmd) that each thread an edit_mode parameter. The drill-vs-edit decision is mode-independent; merging eliminates the fork.\\n\\nAbstraction: EnterEdit { mode: AppMode } — single command that checks aggregation state, pre-fills the buffer if editing, then enters the supplied mode.\\n\\nMatches the parameterization precedent from commit 30383f2 ('parameterize mode-related commands and effects'). ~50 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:39Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-6t3","title":"Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)","description":"command/cmd/commit.rs:349-439 has three commands with identical shape: read buffer named X, if empty abort, else emit AddY effect, mark dirty, optionally exit mode. Only the buffer name and the target effect type differ.\\n\\nAbstraction: CommitBuffer { kind: CommitKind } where CommitKind is Formula | Category | Item.\\n\\nCorrectness win: minibuffer commit semantics live in one place. New minibuffer modes (rename-view, rename-item) get correct semantics for free. Pairs with improvise-{text-entry keymap template} — once the commit is generic, the keymap template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly across all text-entry modes.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:33Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:33Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-k8h","title":"Consolidate minibuffer AppMode constructors","description":"ui/app.rs:54-185 has 7 AppMode minibuffer variants (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode) that each hand-build MinibufferConfig { buffer_key, prompt, color, .. }. The buffer_key string must match what AppendChar/PopChar/commit commands look up in ctx.buffers — nothing enforces this.\\n\\nAbstraction: impl AppMode { fn editing() -\u003e Self; fn command_mode() -\u003e Self; ... } with buffer_key drawn from a shared constant or enum.\\n\\nNote: full enum refactor to AppMode::Minibuffer { kind, config } is not worth the churn — most of the 35 match sites use { .. } and don't care about the payload; the constructor consolidation captures the correctness win (single buffer_key source) without the enum refactor.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:25Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:25Z","dependencies":[{"issue_id":"improvise-k8h","depends_on_id":"improvise-2hi","type":"blocks","created_at":"2026-04-16T12:02:40Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-4pg","title":"Extract panel mode keymap template","description":"command/keymap.rs:554-713 has three nearly-identical panel keymaps (FormulaPanel, CategoryPanel, ViewPanel) sharing Esc/Tab/j/k/F/C/V/: bindings. Only panel name varies.\\n\\nAbstraction: fn panel_keymap(panel: Panel) -\u003e Keymap\\n\\nCorrectness win: adding a 4th panel becomes a one-line call; prevents per-panel binding drift. Also helps keep CyclePanelFocus keybinding consistent once improvise-dqn is fixed.\\n\\n~80 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:24Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-9cn","title":"Introduce register_cmd! macro to prevent name drift","description":"src/command/cmd/registry.rs has 20+ register_pure(\u0026SomeCmd(vec![]), SomeCmd::parse) sites where the registered name string must match SomeCmd::name(). Nothing enforces this match — a typo produces a silent dispatch failure.\\n\\nAbstraction: register_cmd!(r, SomeCmd) macro that derives the name from the Cmd impl at compile time.\\n\\nCorrectness win: compile-time guarantee that the registered name matches Cmd::name(). Subsumes improvise-61f (the runtime invariant test); keeping 61f as a belt-and-braces check is fine but may be unnecessary once this lands.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:22Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:22Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-w3q","title":"Extract text-entry keymap template for minibuffer modes","description":"command/keymap.rs:770-966 hand-binds Enter/Esc/Tab/Backspace/AnyChar for 8 modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, SearchMode). Only buffer name, commit command, and exit mode vary between them.\\n\\nAbstraction: fn text_entry_keymap(buffer: \u0026str, commit: \u0026str, exit: AppMode, parent: Option\u003cArc\u003cKeymap\u003e\u003e) -\u003e Keymap\\n\\nCorrectness win: impossible to omit clear-buffer in one mode's Esc sequence (the exact class of bug fixed in a recent refactor). New text modes inherit semantics for free. Pairs naturally with generic CommitBuffer(kind) — once the commit primitive is unified, the template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly.\\n\\n~150 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:14Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:14Z","dependencies":[{"issue_id":"improvise-w3q","depends_on_id":"improvise-6t3","type":"blocks","created_at":"2026-04-16T11:54:32Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-0bf","title":"Inconsistent float-equality tolerance in formula eval","description":"crates/improvise-core/src/model/types.rs uses different float-equality strategies: division checks 'rv == 0.0' exactly (:674), while = and != comparisons use '1e-10' epsilon (:555, :749). A formula like IF(x = 0, ..., y/x) with x = 1e-11 treats x as zero in the condition but divides without error. Either unify on a single FLOAT_EQ_EPSILON constant or document the design choice explicitly in comments.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:35Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:35Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-kdp","title":"Duplicated aggregation dispatch block","description":"crates/improvise-core/src/model/types.rs:305-312 and :598-604 contain identical Sum/Avg/Min/Max/Count match blocks against AggFunc. If business logic changes (NaN handling, default agg), updates must be made in both sites. Fix: extract fn apply_agg(values: \u0026[f64], func: \u0026AggFunc) -\u003e f64 as a pure helper.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:29Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:29Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-nrs","title":"Duplicated find_item_category helper in eval paths","description":"crates/improvise-core/src/model/types.rs has identical nested find_item_category helpers at lines 420 and 627 (inside eval_formula_with_cache and eval_formula_depth). Both search all categories for an item, then fall back to formula targets. Fix: extract as Model::find_item_category(\u0026self, item: \u0026str) -\u003e Option\u003c\u0026str\u003e so bug fixes apply in one place.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:26Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:26Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-6os","title":"O(n^2) stem deduplication in recompute_formulas","description":"crates/improvise-core/src/model/types.rs:346-359 accumulates unique formula stems using 'if !stems.contains(\u0026stripped) { stems.push(stripped); }'. This is O(n^2) over every cell for every formula category. recompute_formulas runs at startup and after every data mutation. Large workbooks will show visible slowdown. Fix: collect into HashSet\u003cCellKey\u003e, convert to Vec once for iteration order.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:24Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-dwe","title":"Split App into AppState + App wrapper (in-place, no crate change)","description":"Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from \u0026mut App to \u0026mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes.","design":"AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(\u0026mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert(\"edit\", self.initial_value.clone()); state.mode = self.target_mode.clone();","acceptance_criteria":"(1) AppState struct exists; App wraps it. (2) Effect::apply takes \u0026mut AppState, not \u0026mut App. (3) CmdContext.workbook still resolves to \u0026AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change.","notes":"Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T06:28:47Z","created_by":"spot","updated_at":"2026-04-16T23:02:32Z","closed_at":"2026-04-16T23:02:32Z","dependencies":[{"issue_id":"improvise-dwe","depends_on_id":"improvise-vb4","type":"supersedes","created_at":"2026-04-16T16:02:31Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-3zq","title":"Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing","description":"Two related bugs at the bottom-right cell of records view:\n\n1. Enter on bottom-right cell re-enters editing at the same cell (because EnterAdvance stays put at bottom-right and EnterEditAtCursor then re-edits). Expected: exit edit mode.\n\n2. Tab on bottom-right cell in records mode calls AddRecordRow, which SetCells with an empty CellKey when no page filters are active (the default records view has _Measure/foo on None axis, not Page). This writes a nonsense ' = 0' cell to the model that gets persisted (see sample.improv line 26: ' = 0'). Re-toggling records mode (RR) shows the empty record at the top of the table.\n\nRoot causes:\n- CommitAndAdvance (Down) always appends EnterEditAtCursor, even when no forward motion is possible.\n- AddRecordRow::execute unconditionally produces SetCell(empty_key, 0) when coords vec is empty, rather than bailing out.\n\nTests should be added in crates/improvise-persistence (or wherever) that demonstrate ' = 0' is not persisted, plus command-level tests that:\n- CommitAndAdvance(Down) at bottom-right produces a non-editing mode change.\n- AddRecordRow with no page filters produces no SetCell effect.","acceptance_criteria":"1. AddRecordRow with empty coords emits only a status message, no SetCell.\n2. commit-cell-edit (Enter) at bottom-right exits editing (lands in Normal/RecordsNormal).\n3. Tab at bottom-right of records view does not create an empty cell in the model.\n4. All new tests fail on main before the fix; pass after.","notes":"Bug #1 (Enter at bottom-right re-enters editing) — fixed via effect-chain abort mechanism:\n- Added App::abort_effects flag; apply_effects short-circuits and resets per batch\n- New AbortChain effect sets the flag\n- EnterAdvance at bottom-right now emits only AbortChain\n- CommitAndAdvance pushes change_mode(exit_mode_for(edit_mode)) BEFORE advance; trailing EnterEditAtCursor lifts back to editing only if advance succeeded\n\nBug #2 (Tab persists empty record across RR) — fixed by cleanup-on-leave:\n- New CleanEmptyRecords effect removes cells with empty CellKey\n- ToggleRecordsMode on leave emits [CleanEmptyRecords, ViewBack, status] — inverse of the SortData on entry\n\n5 new tests (2 integration demonstrating the bugs, 3 unit for the new primitives). 366 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"bug","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T06:01:20Z","created_by":"fido","updated_at":"2026-04-16T06:26:44Z","closed_at":"2026-04-16T06:26:44Z","close_reason":"Both bugs fixed; see notes for implementation summary. All 5 new tests pass; 366 workspace tests green; clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-4ju","title":"Make EditOrDrill mode-agnostic; delete EnterEditMode","description":"EnterEditMode::execute currently checks ctx.mode.is_records() to decide between editing() and records_editing(). Per the keymap design principle (commands/effects must be mode-agnostic; modes switch keymaps), this is wrong. EditOrDrill (the only caller) should take an edit_mode parameter, and the records-mode keymap should pass records-editing while the normal keymap passes editing. Delete EnterEditMode; replace its registration with a parameterized binding for edit-or-drill.","notes":"Refactored both:\n- EditOrDrill (i/a) — now takes edit_mode: AppMode; keymap supplies it\n- EnterEditAtCursor effect + Cmd — now take target_mode: AppMode\n- CommitAndAdvance (Enter/Tab in editing modes) — now takes edit_mode: AppMode\n- Records-normal keymap overrides i/a to pass records-editing\n- Records-editing keymap overrides Enter/Tab to pass records-editing\n- Records-normal o sequence now passes records-editing\n- Shared parse_mode_name helper in registry.rs\n\nNo is_records() checks remain in any of these commands/effects.\nTests added for each parameterization (5 new tests).\n551 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"task","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T05:04:39Z","created_by":"fido","updated_at":"2026-04-16T05:35:11Z","closed_at":"2026-04-16T05:35:11Z","close_reason":"EditOrDrill, EnterEditAtCursor (effect + cmd), and CommitAndAdvance all parameterized with mode args from keymap; no runtime is_records() checks remain in those paths; 5 new tests cover the contract.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-kos","title":"_Dim and _Index should work as pivot axes outside Records/Drill view; must default to None axis on non-records views including empty models","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:15:40Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:37:10Z","closed_at":"2026-04-15T11:37:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:13:29Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-04-15T10:34:46Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-04-14T07:53:04Z","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-04-14T07:50:54Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0}
{"id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-04-14T07:53:18Z","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-04-14T07:53:17Z","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-04-14T07:48:38Z","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-04-14T07:48:36Z","dependency_count":0,"dependent_count":5,"comment_count":0}
{"id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-04-14T07:48:34Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
{"id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-04-14T07:39:29Z","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":3,"comment_count":0}
{"id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"Terminal node of standalone epic (improvise-tm6). Depends on improvise-djm (main-thread), the worker-server issue, improvise-d31 (static shell + deploy), and all upstream prerequisites transitively.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-04-14T07:39:37Z","dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
{"id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-04-14T07:38:45Z","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
{"id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-04-14T07:34:08Z","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-04-14T07:33:49Z","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-04-14T07:33:47Z","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
{"id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-04-14T07:33:45Z","dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
{"id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-04-14T07:30:04Z","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at bank-info.improv or any existing test file.","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-04-14T07:24:44Z","dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"id":"improvise-gsw","title":"improvise-wasm-client crate (keymap + reduce_view in wasm)","description":"Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream.","design":"crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -\u003e Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -\u003e Option\u003cString\u003e (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -\u003e JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5).","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket.","notes":"Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:37Z","created_by":"spot","updated_at":"2026-04-14T07:39:34Z","dependencies":[{"issue_id":"improvise-gsw","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-gsw","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
{"id":"improvise-q08","title":"improvise-ws-server binary (tokio + tungstenite session wrapper)","description":"New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet).","design":"crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters.","acceptance_criteria":"(1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back.","notes":"Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:14Z","created_by":"spot","updated_at":"2026-04-14T07:23:14Z","dependencies":[{"issue_id":"improvise-q08","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-q08","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
{"id":"improvise-cqq","title":"Server-side projection emission layer + per-client viewport tracking","description":"After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work.","design":"Server holds HashMap\u003cSessionId, ClientSession\u003e where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range.","acceptance_criteria":"(1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec\u003c(SessionId, Command)\u003e to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region.","notes":"Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:12Z","created_by":"spot","updated_at":"2026-04-14T07:23:12Z","dependencies":[{"issue_id":"improvise-cqq","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:45Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
{"id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-04-14T07:23:10Z","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0}
{"id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-04-14T07:22:02Z","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-04-16T23:02:18Z","dependency_count":0,"dependent_count":3,"comment_count":0}
{"id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-04-14T07:21:21Z","dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
{"id":"improvise-3mm","title":"Step 5: Extract improvise-command crate below improvise-tui","description":"Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.","design":"AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).","acceptance_criteria":"(1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.","notes":"IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(\u0026mut App, \u0026Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders \u0026App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option\u003cImportWizard\u003e. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:53:59Z","created_by":"spot","updated_at":"2026-04-14T07:54:16Z","dependencies":[{"issue_id":"improvise-3mm","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"improvise-45v","title":"Step 4: Convert Effect trait to an enum (in-place, no crate change)","description":"Refactor Effect from a trait (Box\u003cdyn Effect\u003e with apply(\u0026self, \u0026mut App)) into a data enum, with a single apply(app: \u0026mut App, effect: \u0026Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.","design":"Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: \u0026mut App, e: \u0026Effect) function that matches exhaustively. Commands still return Vec\u003cEffect\u003e (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.","acceptance_criteria":"(1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec\u003cBox\u003cdyn Effect\u003e\u003e is replaced with Vec\u003cEffect\u003e in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.","notes":"Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:36:11Z","created_by":"spot","updated_at":"2026-04-16T06:26:50Z","closed_at":"2026-04-16T06:26:50Z","close_reason":"Superseded. Effect stays as a trait — the original blocker (command transitively depending on App via Effect::apply(\u0026mut App)) is resolved by splitting App into AppState (semantic state, in improvise-command) + App (rendering state wrapper, in improvise-tui). Effect::apply takes \u0026mut AppState; the few effects that currently read App-level state (layout, display_value) pre-compute those values in their Cmd::execute and pass them via the effect struct's own fields (self). No enum conversion, no dual trait, no noise. See improvise-3mm for the revised design.","dependencies":[{"issue_id":"improvise-45v","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-8zh","title":"Step 3: Extract improvise-io crate (persistence + import)","description":"Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.","acceptance_criteria":"(1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.","notes":"Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:23Z","created_by":"spot","updated_at":"2026-04-16T06:09:19Z","closed_at":"2026-04-16T06:09:19Z","close_reason":"Done on worktree-improvise-ewi-formula-crate. Created crates/improvise-io/ containing persistence/ + import/; depends only on improvise-core and improvise-formula (plus external: anyhow, chrono, csv, flate2, indexmap, pest, serde, serde_json). No crate::ui::* or crate::command::* imports in moved code. All 616 tests pass (219 main + 190 core + 65 formula + 142 io); clippy clean; 'cargo build -p improvise-io' succeeds standalone. Kept as one crate per acceptance criterion #5.","dependencies":[{"issue_id":"improvise-8zh","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:54:39Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"improvise-36h","title":"Step 2: Break Model↔View cycle and extract improvise-core crate","description":"Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap\u003cString,View\u003e, and view/layout.rs reads \u0026Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.","design":"Introduce pub struct Workbook { pub model: Model, pub views: IndexMap\u003cString, View\u003e, pub active_view: String, pub measure_agg: HashMap\u003cString, AggFunc\u003e }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a \u0026View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.","acceptance_criteria":"(1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).","notes":"This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:05Z","created_by":"spot","updated_at":"2026-04-16T05:50:39Z","closed_at":"2026-04-16T05:50:39Z","close_reason":"Phase A + Phase B complete on worktree-improvise-ewi-formula-crate. Model↔View cycle broken via Workbook wrapper (pure-data Model with view state lifted to Workbook); improvise-core sub-crate extracted containing model/, view/, workbook.rs, format.rs; depends only on improvise-formula; builds standalone via 'cargo build -p improvise-core'. All 616 tests pass, clippy clean, persistence round-trips intact.","dependencies":[{"issue_id":"improvise-36h","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:54:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"improvise-ewi","title":"Step 1: Set up Cargo workspace and extract improvise-formula crate","description":"Convert the repo to a Cargo workspace and pull formula/ (parser, AST, Expr/BinOp/AggFunc/Formula) out into a standalone improvise-formula crate. This is the warm-up step — formula/ has zero local deps so extraction is mechanical. Proves the workspace plumbing before tackling the structural work.","acceptance_criteria":"(1) Root Cargo.toml becomes a workspace. (2) crates/improvise-formula/ contains everything from src/formula/ and compiles standalone. (3) improvise (the main crate) depends on improvise-formula and re-exports or uses it at crate::formula paths. (4) All existing tests pass. (5) cargo build and cargo test work from the workspace root.","notes":"Touchpoints: src/formula/{ast,parser,mod}.rs move; src/model/types.rs (uses AggFunc, Formula) and src/persistence/mod.rs and src/import/wizard.rs update imports. No logic changes.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:34:50Z","created_by":"spot","updated_at":"2026-04-15T10:09:14Z","closed_at":"2026-04-15T10:09:14Z","close_reason":"Workspace set up, improvise-formula extracted as standalone sub-crate under crates/. Root crate re-exports as crate::formula via 'pub use improvise_formula as formula;' so existing paths unchanged. 35 formula tests run standalone in the sub-crate, 537 tests run in the root crate (572 total, matching pre-refactor). cargo build, cargo test --workspace, and cargo clippy --workspace all clean.","dependency_count":0,"dependent_count":2,"comment_count":0}
{"id":"improvise-xgl","title":"Epic: Split improvise into enforced-boundary workspace crates","description":"Convert the single improvise crate into a Cargo workspace of 5 crates so that module boundaries become compile-enforced rather than convention. Today nothing stops model/types.rs from reaching into ui::app; the goal is to make that a compile error. Target shape: improvise-formula (leaf) → improvise-core (model+view+format) → improvise-io (persistence+import) → improvise-command → improvise-tui (bin). See child issues for sequenced steps. The expensive work is breaking the Model↔View cycle and decoupling Effect from \u0026mut App — those are features, not costs.","design":"Target crate graph: improvise-formula (no deps) ← improvise-core (model+view+format) ← improvise-io (persistence+import) ← improvise-command ← improvise-tui. Two structural obstacles: (1) Model owns views: IndexMap\u003cString,View\u003e, creating a model↔view cycle — fix by moving views out into a Workbook wrapper. (2) Effect::apply takes \u0026mut App, so command transitively depends on App — fix by converting Effect to an enum with apply(app, effect) in the tui crate. Prefer the enum over a trait-based EffectTarget: effects become loggable/replayable (already gestured at in design-principles §1) and it matches the existing enum-heavy style (Binding, Axis, CategoryKind, BinOp).","notes":"Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:33:09Z","created_by":"spot","updated_at":"2026-04-14T06:33:09Z","dependencies":[{"issue_id":"improvise-xgl","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-13T23:55:06Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:55:05Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:55:03Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
{"id":"improvise-s0h","title":"'o' (add-record-row) broken in fresh data models","description":"Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"cursor-f4c497bb","owner":"el-github@elangley.org","created_at":"2026-04-09T22:18:58Z","created_by":"spot","updated_at":"2026-04-14T08:07:41Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-32r","title":"Drill into formula cell shows empty view","description":"Drilling into a cell whose value comes from a formula (e.g. Profit = Revenue - Cost) enters _Drill view with 0 rows. Formula cells have no raw data backing them, so the drill finds nothing. Should either show the constituent data rows that feed the formula, or display a meaningful message like 'Formula cell — no raw data'.","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T22:03:59Z","created_by":"spot","updated_at":"2026-04-14T06:35:38Z","closed_at":"2026-04-14T06:35:38Z","close_reason":"Strip formula target from drill key so matching_cells finds raw data. Test: drill_into_formula_cell_returns_data_records","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-gmx","title":"Bug: tile bar doesn't scroll when cursor moves offscreen","description":"When there are more category tiles than fit in the tile bar width, moving the cursor past the visible area doesn't scroll. The selected tile is simply not rendered. Found during demo recording with 8 categories.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T08:37:04Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:38:35Z","closed_at":"2026-04-09T08:38:35Z","close_reason":"Tile bar now auto-scrolls to keep selected tile visible, with ◀▶ overflow indicators.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-4ig","title":"Stress-test .improv parser for double quotes and commas","description":"Write targeted tests exposing bugs in the .improv parser around: embedded quotes in text values, commas in category/item names, commas in text values, escaped quotes, and round-trip fidelity for edge-case data.","notes":"Found 4 bugs:\n1. Newlines in text values break line-based parser (format_md writes newlines literally)\n2. Category names containing ', ' break coordinate split(\", \") parsing \n3. Item names containing '[...]' are misinterpreted as group syntax by parse_bracketed\n4. Extreme floats (subnormal numbers) don't round-trip through 4-decimal display format\n\nEmbedded double quotes actually work by coincidence (strip_prefix/strip_suffix only remove one char).\n\nTests added to src/persistence/mod.rs: 15 targeted unit tests + 5 proptest property tests (500 cases each).","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T07:29:50Z","created_by":"spot","updated_at":"2026-04-09T09:01:59Z","closed_at":"2026-04-09T09:01:59Z","close_reason":"Fixed 6 parser bugs (newlines, commas in names, brackets in names, float precision, view name ambiguity, group brackets). Rewrote format: v2025-04-09 version line, Initial View, pipe quoting, Views→Formulas→Categories→Data order, comma-separated items. Added pest grammar, grammar-driven generator, 83 persistence tests including property tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-79u","title":"Bug: CommitFormula targets virtual category on empty model","description":"CommitFormula uses category_names().first() to pick the formula target category. category_names() includes virtual categories (_Index, _Dim). On a model with no regular categories, formulas would be assigned to _Index — which is meaningless since virtual categories exist only for drill-down rendering. Should filter to regular categories only, or show an error like 'Add at least one category first.' Found during test audit of command/cmd.rs.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:26:09Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:06Z","closed_at":"2026-04-09T06:38:06Z","close_reason":"Added Model::regular_category_names() that filters out virtual categories. Updated CommitFormula and ImportPipeline::build_model to use it. Regression test confirms empty model shows 'Add at least one category first.'","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-aa9","title":"Test audit: import/wizard.rs at 60% coverage","description":"import/wizard.rs has 60% line coverage with significant untested paths in the ImportPipeline and ImportWizard. Pipeline creation, schema inference, and complex nested JSON structures need more test coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:43Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:53:16Z","closed_at":"2026-04-09T05:53:16Z","close_reason":"Added 23 wizard tests covering step transitions, cursor movement, proposal toggle/cycle, formula lifecycle, date config, preview summary, sample formulas, and edge cases. Coverage from 60% to 94%.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-2kg","title":"Test audit: ui/effect.rs at 18% coverage","description":"ui/effect.rs has 50+ effect types but only 18% line coverage. Most effects are thin apply() methods tested indirectly through app.rs integration tests. Complex effects like drill reconciliation and import deserve targeted unit tests per the testing guidelines.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:36:48Z","closed_at":"2026-04-09T05:36:48Z","close_reason":"Added 41 direct effect tests covering model mutations, view navigation stacks, drill state (value edit, coord rename, clear), panels, toggles, search buffer special case, and more. Coverage from 18% to 75%.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-c02","title":"Test audit: command/cmd.rs at 52% coverage","description":"command/cmd.rs has 40+ commands but only 52% line coverage. Commands are pure functions (receive \u0026CmdContext, return Vec\u003cEffect\u003e) so they're easy to test. Many commands like add-category, add-item, set-cell, transpose, drill, export, and various panel commands lack tests. This is the highest bang-for-buck coverage improvement.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:01Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:28:07Z","closed_at":"2026-04-09T05:28:07Z","close_reason":"Added 53 tests. Coverage from 52% to 75%. Also found bug: CommitFormula targets virtual categories on empty models (filed as improvise-79u).","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-abz","title":"2.5 Verify all Phase 2 artifacts exist and are committed","description":"Confirm: README.md with all 10 sections, docs/demo.gif under 5MB referenced from README, docs/demo.tape regenerates GIF, all 4 .cast files exist, example files exist with synthetic data only, flake.nix includes asciinema/vhs/cargo-dist, nix develop succeeds.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:48Z","created_by":"Edward Langley","updated_at":"2026-04-09T22:24:08Z","closed_at":"2026-04-09T22:24:08Z","close_reason":"All Phase 2 artifacts verified: README, demo.gif (791KB), demo.tape, 4 casts at 120x37, example files, flake tooling, nix develop works","dependencies":[{"issue_id":"improvise-abz","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-abz","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"id":"improvise-d4w","title":"2.4 Record asciinema casts","description":"Record four .cast files under docs/casts/: import.cast (CSV import wizard), pivot.cast (axis reassignment), drill.cast (drill into aggregated cell), formulas.cast (add Profit formula). Terminal: 100x30. Use -i 2 flag. Each under 60 seconds. Add recording helper script.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:47Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:20:50Z","closed_at":"2026-04-09T08:20:50Z","close_reason":"All four casts recorded: pivot, drill, formulas, import.","dependencies":[{"issue_id":"improvise-d4w","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-d4w","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
{"id":"improvise-odx","title":"2.3 Create docs/demo.tape and generate docs/demo.gif","description":"Write VHS .tape file scripting a ~20-second pivot reassignment demo. Generate GIF via nix develop --command vhs docs/demo.tape. Must be under 5MB, show start in pivot view -\u003e T -\u003e axis reassigns -\u003e T -\u003e axis reassigns again. Iterate until readable.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:03:49Z","closed_at":"2026-04-09T08:03:49Z","close_reason":"docs/demo.tape and docs/demo.gif created, LFS-tracked, wired into README.","dependencies":[{"issue_id":"improvise-odx","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-odx","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:02:52Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0}
{"id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:30:19Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0}
{"id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
{"id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:37Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-rml","title":"Consolidate Axis display into a method on Axis","description":"Axis display logic is duplicated:\\n- src/ui/tile_bar.rs:30-36 — axis_display() match (4 arms)\\n- src/ui/category_panel.rs:14-19 — exact copy of the same match\\n- src/ui/view_panel.rs:40-44 — inline match\\n- src/command/cmd/tile.rs:112, 131, 143 — axis cycling and conversion matches\\n\\nAdd methods on Axis (crates/improvise-core/src/view/axis.rs):\\n- display_short() -\u003e (\u0026'static str, Color)\\n- display_long() -\u003e \u0026'static str\\n- next() -\u003e Self (cycle Row -\u003e Col -\u003e Page -\u003e None -\u003e Row)\\n- is_data_axis() -\u003e bool\\n\\nCorrectness win: eliminates the tile_bar/category_panel copy-paste — currently a change to axis colors or short labels must be made in both places.\\n\\n~25 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:21Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:21Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:14Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-669","title":"Extract axis pruning helper in view/layout.rs","description":"crates/improvise-core/src/view/layout.rs:245-262 (rows) and :265-284 (cols) contain near-identical pruning loops that track data_idx separately from iteration order against a keep_row/keep_col bool vector. The data_idx tracking was flagged as brittle in the deep review.\\n\\nAbstraction: fn prune_axis_items(items: \u0026mut Vec\u003cAxisEntry\u003e, keep: \u0026[bool]) -\u003e Vec\u003cAxisEntry\u003e\\n\\nCorrectness win: centralizes the invariant (one place to assert data_idx == keep.len() at loop exit). A bug in the row prune can't hide while the col prune stays correct. ~20 LOC collapsed.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:12Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:12Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-aaw","title":"Replace CreateAndSwitchView with Binding::Sequence","description":"command/cmd/panel.rs:546-560 bundles create-view + switch-view + change-mode. Both primitives already exist via effect_cmds.\\n\\nFix: bind Sequence [(create-view nix-shell-env), (switch-view nix-shell-env), (enter-mode normal)] in the keymap. If the auto-generated view name is load-bearing, either thread through a minibuffer (matches other add flows) or keep a single create-view primitive that both creates and switches by default.\\n\\n~12 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:54Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:54Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-git","title":"Replace SaveAndQuit with Binding::Sequence in keymap","description":"command/cmd/mode.rs:232-241 is an 8-line command chaining Save + ChangeMode(Quit). Both primitives already exist.\\n\\nFix: delete the struct; bind via Sequence [(w, []), (force-quit, [])] in the keymap.\\n\\nTrivial application of the 'if the keybinding is A then B, the binding should be a Sequence, not a command named AAndB' principle.\\n\\n~8 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:48Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-s13","title":"Delete mode-entry shim commands; use enter-mode with arg","description":"command/cmd/mode.rs:311-319 has EnterExportPrompt and similar shims (EnterSearchMode, EnterTileSelect, EnterFormulaEdit) that are each ~9-line wrappers around change_mode(AppMode::foo()). The generic EnterMode already exists with parse_mode_name() in the registry.\\n\\nFix: delete the shims; bind 'enter-mode' with the mode name as an arg in the keymap.\\n\\nCorrectness win: one code path for mode transitions. A new invariant on mode enter (e.g., 'always rebuild layout') needs to be added in one place instead of N shims.\\n\\n~40 LOC across all shims.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:44Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-7yx","title":"Close test gaps flagged by deep review","description":"Deep review identified several test-coverage gaps worth closing:\\n\\n1. Keymap fallback chain: command/keymap.rs has 22 tests but no case for 'transient keymap exists but key does not match' (app.rs:396-406).\\n2. Ambiguous date detection: import/analyzer.rs:49-77 first-match heuristic not tested against ['01/02/2025', '02/03/2025'] style ambiguity.\\n3. CSV adversarial inputs: no tests for BOM, CRLF, empty file, single-column file.\\n4. Records missing a category field mid-import: wizard.rs:160-229 has the valid=false/break path but it is not covered by build_model_cells_match_source_data or similar.\\n5. Gzip corruption error path: save_and_load_roundtrip_gzip tests happy path only; add a test that load() on a truncated .gz file surfaces an error.\\n\\nLow-effort, high-value additions. Batch into a single test-coverage PR.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:19:07Z","created_by":"Ed L","updated_at":"2026-04-16T18:19:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-61f","title":"Add invariant test for command registry name mapping","description":"src/command/cmd/registry.rs is 586 lines with 0 tests. Each registration manually duplicates the command name as a string alongside the Cmd::name() implementation on the struct. Nothing verifies they match. Fix: add a test that iterates all registered entries and asserts registry_name == command.name() after construction. Low effort, locks down a class of typo bugs.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:55Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:55Z","dependencies":[{"issue_id":"improvise-61f","depends_on_id":"improvise-9cn","type":"blocks","created_at":"2026-04-16T11:54:34Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-nr5","title":"Split persistence/mod.rs into focused sub-modules","description":"crates/improvise-io/src/persistence/mod.rs is 2402 lines mixing four cohesive units: pipe quoting (lines ~20-82), format_md (~155-282), parse_md (~285-580), export_csv (~581-630). Split into persistence/{quoting,format,parse,export}.rs behind the existing pub facade; no public-API change. Improves navigability and makes adding new format features easier to review.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:52Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:52Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-04-16T05:45:49Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-04-14T07:53:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-04-14T07:52:57Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
{"id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-04-14T07:53:21Z","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-04-14T07:53:20Z","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-04-14T07:39:41Z","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"Depends on improvise-djm (main-thread entry point) and the worker-server issue. Mostly configuration and glue — heavy lifting lives in the upstream wasm crates.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-04-14T07:39:31Z","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-cr3","title":"DOM renderer for browser grid (reads ViewState + render cache)","description":"JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a \u003ctable\u003e element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event.","design":"Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single \u003ctable\u003e for the grid body, \u003cthead\u003e for column labels, \u003ctbody\u003e with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected \u003ctd\u003e. Mode indicator is a \u003cdiv\u003e above the table. Minibuffer is a \u003cdiv\u003e shown conditionally when mode is Editing/FormulaEdit/etc.","acceptance_criteria":"(1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP.","notes":"Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:41Z","created_by":"spot","updated_at":"2026-04-14T07:53:24Z","dependencies":[{"issue_id":"improvise-cr3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cr3","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:42Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
{"id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:01Z","dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:01Z","dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-04-11T07:49:57Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:00Z","dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
{"id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-04-14T07:39:46Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:03Z","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:02Z","dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:02Z","dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"app-new-calls-recompute-formulas-before-building-initial","value":"App::new calls recompute_formulas before building initial layout so formula values appear on first frame. render() test helper also calls recompute_formulas."}
{"_type":"memory","key":"lib-rs-created-to-enable-examples-to-import","value":"lib.rs created to enable examples to import from improvise crate. main.rs uses 'use improvise::*' instead of mod declarations."}
{"_type":"memory","key":"review-methodology-scoped-explore-agents","value":"Deep-review batches work best with narrowly-scoped Explore agents (one per layer/principle lens: model+formula, command+ui, persistence+import, LoD violations, OCP violations). Prompt each with: (1) specific files with sizes, (2) the lens/principle to apply, (3) exactly how to report (count + file:line refs, prioritized, ~800 words). Parallel launches worked cleanly when file scopes did not overlap. Session 2026-04-16 produced a ~20-issue backlog this way."}
{"_type":"memory","key":"agent-issue-drift-pattern","value":"Beads issues created by agents (owner 'spot') can duplicate users actively in-progress work. Example 2026-04-16: improvise-dwe (Split App into AppState + App wrapper) was a coarser restatement of improvise-vb4 (Split AppState into ModelState + ViewState) which was already in_progress and assigned to the user; dwe was filed 2 days later by an agent unaware of vb4. Before filing a structural refactor, run bd search for keywords from its core concept AND check in_progress issues."}
{"_type":"memory","key":"drillintocell-strips-measure-coordinate-from-drill-key-when","value":"DrillIntoCell strips _Measure coordinate from drill key when it matches a formula target, so matching_cells finds raw data records instead of returning empty."}
{"_type":"memory","key":"gen-grammar-example-generates-random-valid-improv-content","value":"gen-grammar example: generates random valid .improv content from pest grammar rules. Uses word pools for realistic output. pretty-print example: parses stdin and prints formatted output. Both in examples/ directory."}
{"_type":"memory","key":"compiler-exhaustiveness-theme","value":"Running theme across the 2026-04-16 refactor backlog: the compilers exhaustive-match check is being bypassed. (1) string compares against virtual-category names (improvise-2lh); (2) registered command names as strings vs Cmd::name() (improvise-9cn, improvise-61f); (3) minibuffer buffer_key strings threaded through 7 AppMode constructors vs command lookups (improvise-k8h); (4) AppMode per-variant logic scattered across 52 match sites in 8 files (improvise-2hi); (5) duplicated Axis display matches in tile_bar + category_panel (improvise-rml). Common fix: push dispatch onto a method on the enum/type so the exhaustive match has one home."}
{"_type":"memory","key":"dynamic-measure-formula-targets-are-dynamically-included-via","value":"Dynamic _Measure: formula targets are dynamically included via Model::measure_item_names() and effective_item_names(). add_formula no longer adds items to _Measure category. find_item_category falls back to formula targets. CommitFormula defaults target_category to _Measure."}
{"_type":"memory","key":"persistence-index-and-dim-categories-are-never-written","value":"Persistence: _Index and _Dim categories are never written to .improv files. _Measure only persists non-formula items. Formulas targeting _Measure omit the [_Measure] suffix (it's the default). Parser defaults to _Measure when no [Category] suffix present."}
{"_type":"memory","key":"keymap-parent-inheritance-added-linked-list-of-keymaps","value":"Keymap parent inheritance added (linked list of keymaps). All minibuffer modes use Binding::Sequence for Enter and Esc to include clear-buffer command. ClearBufferCmd registered in registry."}

4
.gitignore vendored
View File

@ -22,3 +22,7 @@ bench/*.txt
# Added by git-smart-commit
*.swp
proptest-regressions/
.cursor
.claude/worktrees/
*~
/roadmap.html

View File

@ -1,3 +1,14 @@
- Always use tests to demonstrate the existence of a bug before fixing the bug.
- If you suspect that a bug exists, use a test to demonstrate it first:
- prefer unit tests testing a small amount of code to integration or e2e tests
- Option<...> or Result<...> are fine but should not be present in the majority of the code.
- Similarly, code managing Box<...> or RC<...>, etc. for containers pointing to heap data should be split
from logic
- @context/repo-map.md is your "road map" for the repository. use it to reduce exploration and keep it updated.
- @context/design-principles.md is also important for keeping the repository consistent.
- prefer merges to rebasing.
- always start responses with bananaS!
# Agent Instructions
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
@ -52,9 +63,9 @@ bd close <id> # Complete work
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
## Session Completion
@ -67,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 pull --rebase
git merge
bd dolt push
git push
git status # MUST show "up to date with origin"

View File

@ -1,12 +1,68 @@
- Always use tests to demonstrate the existence of a bug before fixing the bug.
- If you suspect that a bug exists, use a test to demonstrate it first:
- prefer unit tests testing a small amount of code to integration or e2e tests
- Option<...> or Result<...> are fine but should not be present in the majority of the code.
- Similarly, code managing Box<...> or RC<...>, etc. for containers pointing to heap data should be split
from logic
- @context/repo-map.md is your "road map" for the repository. use it to reduce exploration and keep it updated.
- @context/design-principles.md is also important for keeping the repository consistent.
- prefer merges to rebasing.
- see @AGENTS.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## Beads Issue Tracker
@ -24,9 +80,9 @@ bd close <id> # Complete work
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
## Session Completion

126
Cargo.lock generated
View File

@ -147,9 +147,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
@ -183,9 +183,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.60"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
@ -219,9 +219,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@ -241,9 +241,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@ -316,7 +316,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"crossterm_winapi",
"derive_more",
"document-features",
@ -737,6 +737,9 @@ dependencies = [
"dirs",
"enum_dispatch",
"flate2",
"improvise-core",
"improvise-formula",
"improvise-io",
"indexmap",
"pest",
"pest_derive",
@ -750,6 +753,49 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "improvise-core"
version = "0.1.0-rc2"
dependencies = [
"anyhow",
"improvise-formula",
"indexmap",
"proptest",
"serde",
]
[[package]]
name = "improvise-formula"
version = "0.1.0-rc2"
dependencies = [
"anyhow",
"pest",
"pest_derive",
"pest_meta",
"proptest",
"serde",
]
[[package]]
name = "improvise-io"
version = "0.1.0-rc2"
dependencies = [
"anyhow",
"chrono",
"csv",
"flate2",
"improvise-core",
"improvise-formula",
"indexmap",
"pest",
"pest_derive",
"pest_meta",
"proptest",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@ -846,9 +892,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
@ -865,7 +911,7 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
]
[[package]]
@ -897,9 +943,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.16.3"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown 0.16.1",
]
@ -969,7 +1015,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@ -1141,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
"rand 0.8.6",
]
[[package]]
@ -1214,9 +1260,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.11.0",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.2",
"rand 0.9.4",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
@ -1254,18 +1300,18 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
@ -1325,7 +1371,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"compact_str",
"hashbrown 0.16.1",
"indoc",
@ -1377,7 +1423,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"hashbrown 0.16.1",
"indoc",
"instability",
@ -1396,7 +1442,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
]
[[package]]
@ -1454,7 +1500,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
@ -1703,7 +1749,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
"base64",
"bitflags 2.11.0",
"bitflags 2.11.1",
"fancy-regex",
"filedescriptor",
"finl_unicode",
@ -1800,9 +1846,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucd-trie"
@ -1859,9 +1905,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"atomic",
"getrandom 0.4.2",
@ -1901,11 +1947,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
@ -1914,7 +1960,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.51.0",
]
[[package]]
@ -1990,7 +2036,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@ -2167,6 +2213,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
@ -2216,7 +2268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",

8015
Cargo.nix Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,6 @@
[workspace]
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io"]
[package]
name = "improvise"
version = "0.1.0-rc2"
@ -15,6 +18,9 @@ name = "improvise"
path = "src/main.rs"
[dependencies]
improvise-core = { path = "crates/improvise-core" }
improvise-formula = { path = "crates/improvise-formula" }
improvise-io = { path = "crates/improvise-io" }
ratatui = "0.30"
crossterm = "0.29"
serde = { version = "1", features = ["derive"] }

2121
TAGS Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,6 @@
v2025-04-09
# Imported Model
## Category: _Index
## Category: _Dim
## Category: Category
- transfers
- investments
@ -17,280 +14,280 @@
- null
## Category: Date
- 07/18/2025
- 11/21/2025
- 05/29/2025
- 03/03/2025
- 02/20/2025
- 03/13/2026
- 12/22/2025
- 10/29/2025
- 01/06/2026
- 06/02/2025
- 02/21/2025
- 02/10/2025
- 12/16/2024
- 03/10/2025
- 02/27/2025
- 06/03/2025
- 10/21/2025
- 03/31/2026
- 04/30/2025
- 12/15/2025
- 12/05/2025
- 03/06/2026
- 08/01/2025
- 11/25/2025
- 03/16/2026
- 11/12/2025
- 03/31/2025
- 12/29/2025
- 02/19/2025
- 08/18/2025
- 02/18/2026
- 05/20/2025
- 03/19/2026
- 04/25/2025
- 03/03/2026
- 12/16/2025
- 09/09/2025
- 08/05/2025
- 08/26/2025
- 07/08/2025
- 07/07/2025
- 03/30/2026
- 11/18/2025
- 12/17/2025
- 09/17/2025
- 04/16/2025
- 12/19/2025
- 01/16/2025
- 10/08/2025
- 01/30/2026
- 09/03/2025
- 10/01/2025
- 01/28/2026
- 02/18/2025
- 10/17/2025
- 09/02/2025
- 10/07/2025
- 02/28/2025
- 12/26/2025
- 11/13/2025
- 03/24/2026
- 06/20/2025
- 03/06/2025
- 03/12/2025
- 10/15/2025
- 12/27/2024
- 01/02/2025
- 08/28/2025
- 10/27/2025
- 10/20/2025
- 01/15/2026
- 05/01/2025
- 12/10/2024
- 02/24/2025
- 03/28/2025
- 12/05/2024
- 12/30/2024
- 06/09/2025
- 12/03/2024
- 04/15/2025
- 11/20/2025
- 02/11/2026
- 11/14/2025
- 03/04/2025
- 01/15/2025
- 03/25/2025
- 02/25/2025
- 10/16/2025
- 01/21/2026
- 12/31/2024
- 02/20/2026
- 01/12/2026
- 03/23/2026
- 03/18/2025
- 12/04/2025
- 07/22/2025
- 06/24/2025
- 05/05/2025
- 11/17/2025
- 01/29/2025
- 01/17/2025
- 04/28/2025
- 05/19/2025
- 02/26/2025
- 12/03/2025
- 03/05/2026
- 06/12/2025
- 08/14/2025
- 11/10/2025
- 02/26/2026
- 06/10/2025
- 03/24/2025
- 02/17/2026
- 02/13/2026
- 12/31/2025
- 12/10/2025
- 11/07/2025
- 04/02/2025
- 01/31/2025
- 02/25/2026
- 01/28/2025
- 12/11/2025
- 07/31/2025
- 10/24/2025
- 08/27/2025
- 02/12/2025
- 03/18/2026
- 09/15/2025
- 04/11/2025
- 02/05/2025
- 10/14/2025
- 03/12/2026
- 12/12/2025
- 07/29/2025
- 12/13/2024
- 09/11/2025
- 01/26/2026
- 09/08/2025
- 05/23/2025
- 04/14/2025
- 01/03/2025
- 02/13/2025
- 01/13/2026
- 01/02/2026
- 03/25/2026
- 11/04/2025
- 04/22/2025
- 07/14/2025
- 07/24/2025
- 06/30/2025
- 01/13/2025
- 01/08/2026
- 12/30/2025
- 01/07/2025
- 12/02/2025
- 04/01/2025
- 02/27/2026
- 02/02/2026
- 01/14/2025
- 09/30/2025
- 12/26/2024
- 06/18/2025
- 05/06/2025
- 03/27/2025
- 11/03/2025
- 12/09/2024
- 03/27/2026
- 08/19/2025
- 02/03/2026
- 12/23/2025
- 12/18/2025
- 08/25/2025
- 07/03/2025
- 06/06/2025
- 03/17/2025
- 10/10/2025
- 03/11/2025
- 03/04/2026
- 12/08/2025
- 03/10/2026
- 01/27/2026
- 01/05/2026
- 06/16/2025
- 05/13/2025
- 02/04/2026
- 03/02/2026
- 04/17/2025
- 07/15/2025
- 06/04/2025
- 04/21/2025
- 02/04/2025
- 10/30/2025
- 07/17/2025
- 06/23/2025
- 07/02/2025
- 09/10/2025
- 01/20/2026
- 09/12/2025
- 05/09/2025
- 12/20/2024
- 02/06/2026
- 08/04/2025
- 12/02/2024
- 07/16/2025
- 06/05/2025
- 07/01/2025
- 09/23/2025
- 12/17/2024
- 02/23/2026
- 02/09/2026
- 01/22/2026
- 05/02/2025
- 01/29/2026
- 08/29/2025
- 02/11/2025
- 02/03/2025
- 04/08/2025
- 12/24/2025
- 03/17/2026
- 06/17/2025
- 01/22/2025
- 03/09/2026
- 09/22/2025
- 05/16/2025
- 05/12/2025
- 02/19/2026
- 12/12/2024
- 09/16/2025
- 04/24/2025
- 07/28/2025
- 10/28/2025
- 02/05/2026
- 02/24/2026
- 01/09/2026
- 01/14/2026
- 05/27/2025
- 03/26/2026
- 12/06/2024
- 06/13/2025
- 03/11/2026
- 01/23/2025
- 04/07/2025
- 10/09/2025
- 08/12/2025
- 11/24/2025
- 02/12/2026
- 10/31/2025
- 05/30/2025
- 09/26/2025
- 08/11/2025
- 06/25/2025
- 11/19/2025
- 03/14/2025
- 10/06/2025
- 12/01/2025
- 06/26/2025
- 08/13/2025
- 05/28/2025
- 09/29/2025
- 06/27/2025
- 12/23/2024
- 02/10/2026
- 04/29/2025
- 01/27/2025
- 12/09/2025
- 01/23/2026
- 12/24/2024
- 01/07/2026
- 02/14/2025
- 08/15/2025
- 02/06/2025
- 11/28/2025
- 01/16/2026
- 03/20/2026
- |07/18/2025|
- |11/21/2025|
- |05/29/2025|
- |03/03/2025|
- |02/20/2025|
- |03/13/2026|
- |12/22/2025|
- |10/29/2025|
- |01/06/2026|
- |06/02/2025|
- |02/21/2025|
- |02/10/2025|
- |12/16/2024|
- |03/10/2025|
- |02/27/2025|
- |06/03/2025|
- |10/21/2025|
- |03/31/2026|
- |04/30/2025|
- |12/15/2025|
- |12/05/2025|
- |03/06/2026|
- |08/01/2025|
- |11/25/2025|
- |03/16/2026|
- |11/12/2025|
- |03/31/2025|
- |12/29/2025|
- |02/19/2025|
- |08/18/2025|
- |02/18/2026|
- |05/20/2025|
- |03/19/2026|
- |04/25/2025|
- |03/03/2026|
- |12/16/2025|
- |09/09/2025|
- |08/05/2025|
- |08/26/2025|
- |07/08/2025|
- |07/07/2025|
- |03/30/2026|
- |11/18/2025|
- |12/17/2025|
- |09/17/2025|
- |04/16/2025|
- |12/19/2025|
- |01/16/2025|
- |10/08/2025|
- |01/30/2026|
- |09/03/2025|
- |10/01/2025|
- |01/28/2026|
- |02/18/2025|
- |10/17/2025|
- |09/02/2025|
- |10/07/2025|
- |02/28/2025|
- |12/26/2025|
- |11/13/2025|
- |03/24/2026|
- |06/20/2025|
- |03/06/2025|
- |03/12/2025|
- |10/15/2025|
- |12/27/2024|
- |01/02/2025|
- |08/28/2025|
- |10/27/2025|
- |10/20/2025|
- |01/15/2026|
- |05/01/2025|
- |12/10/2024|
- |02/24/2025|
- |03/28/2025|
- |12/05/2024|
- |12/30/2024|
- |06/09/2025|
- |12/03/2024|
- |04/15/2025|
- |11/20/2025|
- |02/11/2026|
- |11/14/2025|
- |03/04/2025|
- |01/15/2025|
- |03/25/2025|
- |02/25/2025|
- |10/16/2025|
- |01/21/2026|
- |12/31/2024|
- |02/20/2026|
- |01/12/2026|
- |03/23/2026|
- |03/18/2025|
- |12/04/2025|
- |07/22/2025|
- |06/24/2025|
- |05/05/2025|
- |11/17/2025|
- |01/29/2025|
- |01/17/2025|
- |04/28/2025|
- |05/19/2025|
- |02/26/2025|
- |12/03/2025|
- |03/05/2026|
- |06/12/2025|
- |08/14/2025|
- |11/10/2025|
- |02/26/2026|
- |06/10/2025|
- |03/24/2025|
- |02/17/2026|
- |02/13/2026|
- |12/31/2025|
- |12/10/2025|
- |11/07/2025|
- |04/02/2025|
- |01/31/2025|
- |02/25/2026|
- |01/28/2025|
- |12/11/2025|
- |07/31/2025|
- |10/24/2025|
- |08/27/2025|
- |02/12/2025|
- |03/18/2026|
- |09/15/2025|
- |04/11/2025|
- |02/05/2025|
- |10/14/2025|
- |03/12/2026|
- |12/12/2025|
- |07/29/2025|
- |12/13/2024|
- |09/11/2025|
- |01/26/2026|
- |09/08/2025|
- |05/23/2025|
- |04/14/2025|
- |01/03/2025|
- |02/13/2025|
- |01/13/2026|
- |01/02/2026|
- |03/25/2026|
- |11/04/2025|
- |04/22/2025|
- |07/14/2025|
- |07/24/2025|
- |06/30/2025|
- |01/13/2025|
- |01/08/2026|
- |12/30/2025|
- |01/07/2025|
- |12/02/2025|
- |04/01/2025|
- |02/27/2026|
- |02/02/2026|
- |01/14/2025|
- |09/30/2025|
- |12/26/2024|
- |06/18/2025|
- |05/06/2025|
- |03/27/2025|
- |11/03/2025|
- |12/09/2024|
- |03/27/2026|
- |08/19/2025|
- |02/03/2026|
- |12/23/2025|
- |12/18/2025|
- |08/25/2025|
- |07/03/2025|
- |06/06/2025|
- |03/17/2025|
- |10/10/2025|
- |03/11/2025|
- |03/04/2026|
- |12/08/2025|
- |03/10/2026|
- |01/27/2026|
- |01/05/2026|
- |06/16/2025|
- |05/13/2025|
- |02/04/2026|
- |03/02/2026|
- |04/17/2025|
- |07/15/2025|
- |06/04/2025|
- |04/21/2025|
- |02/04/2025|
- |10/30/2025|
- |07/17/2025|
- |06/23/2025|
- |07/02/2025|
- |09/10/2025|
- |01/20/2026|
- |09/12/2025|
- |05/09/2025|
- |12/20/2024|
- |02/06/2026|
- |08/04/2025|
- |12/02/2024|
- |07/16/2025|
- |06/05/2025|
- |07/01/2025|
- |09/23/2025|
- |12/17/2024|
- |02/23/2026|
- |02/09/2026|
- |01/22/2026|
- |05/02/2025|
- |01/29/2026|
- |08/29/2025|
- |02/11/2025|
- |02/03/2025|
- |04/08/2025|
- |12/24/2025|
- |03/17/2026|
- |06/17/2025|
- |01/22/2025|
- |03/09/2026|
- |09/22/2025|
- |05/16/2025|
- |05/12/2025|
- |02/19/2026|
- |12/12/2024|
- |09/16/2025|
- |04/24/2025|
- |07/28/2025|
- |10/28/2025|
- |02/05/2026|
- |02/24/2026|
- |01/09/2026|
- |01/14/2026|
- |05/27/2025|
- |03/26/2026|
- |12/06/2024|
- |06/13/2025|
- |03/11/2026|
- |01/23/2025|
- |04/07/2025|
- |10/09/2025|
- |08/12/2025|
- |11/24/2025|
- |02/12/2026|
- |10/31/2025|
- |05/30/2025|
- |09/26/2025|
- |08/11/2025|
- |06/25/2025|
- |11/19/2025|
- |03/14/2025|
- |10/06/2025|
- |12/01/2025|
- |06/26/2025|
- |08/13/2025|
- |05/28/2025|
- |09/29/2025|
- |06/27/2025|
- |12/23/2024|
- |02/10/2026|
- |04/29/2025|
- |01/27/2025|
- |12/09/2025|
- |01/23/2026|
- |12/24/2024|
- |01/07/2026|
- |02/14/2025|
- |08/15/2025|
- |02/06/2025|
- |11/28/2025|
- |01/16/2026|
- |03/20/2026|
## Category: Entity
- JORDAN MARTINEZ
@ -474,7 +471,7 @@
- 2025-Q1
- 2024-Q4
## Category: Measure
## Category: _Measure
- Amount
## Data

View File

@ -32,6 +32,12 @@ Commands compose via `Binding::Sequence` — a keymap entry can chain multiple
commands, each contributing effects independently. The `o` key (add row + begin
editing) is two commands composed at the binding level, not a monolithic handler.
### Decompose rather than early return
Early Returns usually are a signal of mixed responsibilities: if an early return
would clarify a function, consider how the function could be decomposed for the
same effect without the early return.
---
## 2. Polymorphism Over Conditionals

View File

@ -1,283 +1,97 @@
# Repository Map (LLM Reference)
Terminal pivot-table modeling app. Rust, Ratatui TUI, command/effect architecture.
Crate `improvise` v0.1.0, Apache-2.0, edition 2021.
Terminal pivot-table app. Rust 2024, Ratatui TUI, command/effect architecture.
Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
- `improvise-core``Model`, `View`, `Workbook`, number formatting. No UI/IO.
- `improvise-formula` — formula parser, AST, `parse_formula`.
- `improvise-io``.improv` save/load, CSV/JSON import. No UI/commands.
The main crate re-exports each as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle.
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
---
## How to Find Things
| I need to... | Look in |
|---------------------------------------|----------------------------------------------|
| Add a new keybinding | `command/keymap.rs``default_keymaps()` |
| Add a new user-facing command | `command/cmd/` → implement `Cmd` in the relevant submodule, register in `registry.rs` |
| Add a new state mutation | `ui/effect.rs` → implement `Effect` |
| Change formula evaluation | `model/types.rs``eval_formula()`, `eval_expr()` |
| Change how cells are stored/queried | `model/cell.rs``DataStore` |
| Change category/item behavior | `model/category.rs``Category` |
| Change view axis logic | `view/types.rs``View` |
| Change grid layout computation | `view/layout.rs``GridLayout` |
| Change .improv file format | `persistence/improv.pest` (grammar), `persistence/mod.rs``format_md()`, `parse_md()` |
| Change number display formatting | `format.rs``format_f64()` |
| Change CLI arguments | `main.rs` clap structs |
| Change import wizard logic | `import/wizard.rs``ImportPipeline` |
| Change grid rendering | `ui/grid.rs``GridWidget` |
| Change TUI frame layout | `draw.rs``draw()` |
| Change app state / mode transitions | `ui/app.rs``App`, `AppMode` |
| Write a test for model logic | `model/types.rs``mod tests` / `mod formula_tests` |
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests` |
| I need to... | Look in |
|-------------------------------------|-------------------------------------------------|
| Add a keybinding | `command/keymap.rs``default_keymaps()` |
| Add a user command | `command/cmd/<submodule>.rs`, register in `registry.rs` |
| Add a state mutation | `ui/effect.rs` → implement `Effect` |
| Change formula eval | `model/types.rs``eval_formula` / `eval_expr` |
| Change cell storage / lookup | `model/cell.rs``DataStore` |
| Change category/item behavior | `model/category.rs``Category` |
| Change view axis logic | `view/types.rs``View` |
| Change grid layout | `view/layout.rs``GridLayout` |
| Change `.improv` format | `persistence/improv.pest` + `persistence/mod.rs` |
| Change number display | `format.rs``format_f64` |
| Change CLI args | `main.rs` (clap) |
| Change import logic | `import/wizard.rs``ImportPipeline` |
| Change frame layout | `draw.rs``draw()` |
| Change app state / modes | `ui/app.rs``App`, `AppMode` |
| Write a test for model logic | `model/types.rs``mod tests` / `formula_tests` |
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests`; helpers in `cmd/mod.rs::test_helpers` |
---
## Core Types and Traits
### Command/Effect Pipeline (the central architecture pattern)
## Central Pattern: Cmd → Effect
```
User keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
(immutable) (pure, read-only) (state mutations)
keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
(pure, read-only ctx) (list of mutations) (only mutation site)
```
```rust
// src/command/cmd/core.rs
pub trait Cmd: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
}
pub struct CmdContext<'a> {
pub model: &'a Model, // immutable
pub layout: &'a GridLayout, // immutable
pub registry: &'a CmdRegistry,
pub mode: &'a AppMode,
pub selected: (usize, usize), // (row, col) cursor
pub row_offset: usize,
pub col_offset: usize,
pub search_query: &'a str,
pub search_mode: bool,
pub yanked: &'a Option<CellValue>,
pub key_code: KeyCode, // the key that triggered this command
pub buffers: &'a HashMap<String, String>,
pub expanded_cats: &'a HashSet<String>,
// panel cursors, tile cursor, visible dimensions...
}
// src/ui/effect.rs
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
fn changes_mode(&self) -> bool { false } // override if effect changes AppMode
fn changes_mode(&self) -> bool { false } // override when Effect swaps AppMode
}
```
**To add a command**: implement `Cmd` in the appropriate `command/cmd/` submodule, then register in `command/cmd/registry.rs`. Use the `effect_cmd!` macro (in `effect_cmds.rs`) for simple effect-wrapping commands. Bind it in `default_keymaps()`.
`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `GridLayout`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors.
**To add an effect**: implement `Effect` in `effect.rs`, add a constructor function.
**Add a command**: implement `Cmd` in a `command/cmd/` submodule, register in `registry.rs`, bind in `default_keymaps()`. Simple wrappers go through the `effect_cmd!` macro in `effect_cmds.rs`.
### Data Model
```rust
// src/model/types.rs
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>, // ordered
pub data: DataStore,
pub formulas: Vec<Formula>,
pub views: IndexMap<String, View>,
pub active_view: String,
pub measure_agg: HashMap<String, AggFunc>, // per-measure aggregation override
}
// Key methods:
// add_category(&mut self, name) -> Result<CategoryId> [max 12 regular]
// category(&self, name) -> Option<&Category>
// category_mut(&mut self, name) -> Option<&mut Category>
// set_cell(&mut self, key: CellKey, value: CellValue)
// evaluate(&self, key: &CellKey) -> Option<CellValue> [formulas + raw data]
// evaluate_aggregated(&self, key, none_cats) -> Option<CellValue> [sums over hidden dims]
// recompute_formulas(&mut self, none_cats) [fixed-point formula cache]
// add_formula(&mut self, formula: Formula) [replaces same target+category, adds item]
// remove_formula(&mut self, target, category)
// category_names(&self) -> Vec<&str> [includes virtual]
// regular_category_names(&self) -> Vec<&str> [excludes _Index, _Dim, _Measure]
const MAX_CATEGORIES: usize = 12; // virtual categories don't count
```
```rust
// src/model/cell.rs
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct CellKey(pub Vec<(String, String)>); // always sorted by category name
// CellKey::new(coords) — sorts on construction, enforcing canonical form
// CellKey::with(cat, item) -> Self — returns new key with coord added/replaced
// CellKey::without(cat) -> Self — returns new key with coord removed
// CellKey::get(cat) -> Option<&str>
#[derive(Clone, PartialEq)]
pub enum CellValue {
Number(f64),
Text(String),
Error(String), // formula evaluation error (circular ref, div/0, etc.)
}
// CellValue::as_f64() -> Option<f64>
// CellValue::is_error() -> bool
pub struct DataStore {
cells: HashMap<InternedKey, CellValue>,
pub symbols: SymbolTable,
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>, // secondary index
}
// DataStore::set(&mut self, key: &CellKey, value: CellValue)
// DataStore::get(&self, key: &CellKey) -> Option<&CellValue>
// DataStore::matching_values(&self, partial: &[(String,String)]) -> Vec<CellValue>
// DataStore::matching_cells(&self, partial) -> Vec<(CellKey, CellValue)>
```
```rust
// src/model/category.rs
pub struct Category {
pub id: CategoryId, // usize
pub name: String,
pub kind: CategoryKind,
pub items: IndexMap<String, Item>, // ordered
pub groups: IndexMap<String, Group>,
next_item_id: ItemId, // private, auto-increment
}
// Category::add_item(&mut self, name) -> ItemId [deduplicates by name]
// Category::ordered_item_names(&self) -> Vec<&str> [respects group order]
pub enum CategoryKind { Regular, VirtualIndex, VirtualDim, VirtualMeasure, Label }
```
### Formula System
```rust
// src/formula/ast.rs
pub enum Expr {
Number(f64),
Ref(String), // reference to an item name
BinOp(BinOp, Box<Expr>, Box<Expr>),
UnaryMinus(Box<Expr>),
Agg(AggFunc, Box<Expr>, Option<Filter>),
If(Box<Expr>, Box<Expr>, Box<Expr>),
}
pub enum BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }
pub enum AggFunc { Sum, Avg, Min, Max, Count }
pub struct Formula {
pub raw: String, // "Profit = Revenue - Cost"
pub target: String, // "Profit"
pub target_category: String, // "Measure"
pub expr: Expr,
pub filter: Option<Filter>, // WHERE clause
}
// src/formula/parser.rs
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula>
```
Formula evaluation is in `model/types.rs``eval_formula()` / `eval_expr()`. Operates at full f64 precision. Display rounding in `format.rs` is view-only.
### View and Layout
```rust
// src/view/axis.rs
pub enum Axis { Row, Column, Page, None }
// src/view/types.rs
pub struct View {
pub name: String,
pub category_axes: IndexMap<String, Axis>,
pub page_selections: HashMap<String, String>,
pub hidden_items: HashMap<String, HashSet<String>>,
pub collapsed_groups: HashMap<String, HashSet<String>>,
pub number_format: String, // e.g. ",.0" or ",.2f"
pub prune_empty: bool,
// scroll/selection state...
}
// View::set_axis(&mut self, cat, axis)
// View::axis_of(&self, cat) -> Axis
// View::cycle_axis(&mut self, cat) [Row→Column→Page→None→Row]
// View::transpose(&mut self) [swap Row↔Column]
// View::categories_on(&self, axis) -> Vec<&str>
// View::on_category_added(&mut self, cat) [auto-assigns axis]
// src/view/layout.rs
pub struct GridLayout { /* computed from Model + View */ }
// GridLayout::new(model, view) -> Self
// GridLayout::cell_key(row, col) -> Option<CellKey>
// GridLayout::cell_value(row, col) -> Option<CellValue>
// GridLayout::row_label(row) -> &str
// GridLayout::col_label(col) -> &str
// GridLayout::drill_records(row, col) -> Vec<(CellKey, CellValue)>
// Records mode: auto-detected when _Index on Row + _Dim on Column
```
### App State
```rust
// src/ui/app.rs
pub enum AppMode {
Normal,
Editing { minibuf: MinibufferConfig },
FormulaEdit { minibuf: MinibufferConfig },
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
CategoryAdd { minibuf: MinibufferConfig },
ItemAdd { minibuf: MinibufferConfig },
ExportPrompt { minibuf: MinibufferConfig },
CommandMode { minibuf: MinibufferConfig },
ImportWizard,
Help,
Quit,
}
// Note: SearchMode is Normal + search_mode:bool flag, not a separate variant.
pub struct App {
pub model: Model,
pub mode: AppMode,
pub file_path: Option<PathBuf>,
pub dirty: bool,
pub help_page: usize,
pub transient_keymap: Option<Arc<Keymap>>, // for prefix keys
// layout cache, drill_state, wizard, buffers, panel cursors, etc.
}
// App::handle_key(&mut self, KeyEvent) -> Result<()> [main input dispatch]
// App::rebuild_layout(&mut self)
// App::is_empty_model(&self) -> bool [true when only virtual categories exist]
```
### Keymap System
```rust
// src/command/keymap.rs
pub enum KeyPattern { Key(KeyCode, KeyModifiers), AnyChar, Any }
pub enum Binding {
Cmd { name: &'static str, args: Vec<String> },
Prefix(Arc<Keymap>), // Emacs-style sub-keymap
Sequence(Vec<(&'static str, Vec<String>)>), // multi-command chain
}
pub enum ModeKey {
Normal, Help, FormulaPanel, CategoryPanel, ViewPanel, TileSelect,
Editing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode,
SearchMode, ImportWizard,
}
// Keymap::lookup(&self, key, mods) -> Option<&Binding>
// Fallback chain: exact(key,mods) → Char with NONE mods → AnyChar → Any
// KeymapSet::default_keymaps() -> Self [builds all 14 mode keymaps]
// KeymapSet::dispatch(&self, ctx, key, mods) -> Option<Vec<Box<dyn Effect>>>
```
**Add an effect**: implement `Effect` in `ui/effect.rs`, add a constructor fn if it helps composition.
---
## File Format (.improv)
## Key Types (skim; read the source for fields/methods)
Plain-text markdown-like, defined by a PEG grammar (`persistence/improv.pest`).
Parsed by pest; the grammar is the single source of truth for both the parser
and the grammar-walking test generator.
**Model** (`model/types.rs`): `categories: IndexMap<String, Category>`, `data: DataStore`, `formulas: Vec<Formula>`, `views: IndexMap<String, View>`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 12` (regular only). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers.
**Not JSON** (JSON is legacy, auto-detected by `{` prefix).
**CellKey** (`model/cell.rs`): `Vec<(cat, item)>`**always sorted by category**. Build with `CellKey::new(coords)` / `with(cat, item)` / `without(cat)`. Never construct the inner `Vec` directly.
**CellValue**: `Number(f64) | Text(String) | Error(String)`. Errors surface from formula eval (circular, div/0, etc.).
**DataStore** (`model/cell.rs`): interned symbols + secondary index `(Symbol, Symbol) → {InternedKey}`. Hot-path lookups go through `matching_values` / `matching_cells`.
**Formula AST** (`improvise-formula/src/ast.rs`):
- `Expr { Number, Ref, BinOp, UnaryMinus, Agg, If }`
- `BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }`
- `AggFunc { Sum, Avg, Min, Max, Count }`
- `Formula { raw, target, target_category, expr, filter }`
**View** (`view/types.rs`): `category_axes: IndexMap<cat, Axis>`, page selections, hidden items, collapsed groups, `number_format`, `prune_empty`. `Axis { Row, Column, Page, None }`. `cycle_axis` rotates Row → Column → Page → None.
**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.
**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()`.
---
## `.improv` File Format
Plain-text, markdown-like, defined by `persistence/improv.pest`. Parsed by pest — the grammar is the single source of truth (the grammar-walking test generator reads it via `pest_meta`).
```
v2025-04-09
@ -287,48 +101,126 @@ Initial View: Default
## View: Default
Region: row
Measure: column
|Time Period|: page, Q1 ← pipe-quoted name, page with selection
|Time Period|: page, Q1
hidden: Region/Internal
collapsed: |Time Period|/|2024|
format: ,.2f
## Formulas
- Profit = Revenue - Cost [Measure] ← [TargetCategory]
- Profit = Revenue - Cost # defaults to [_Measure]
- Tax = Revenue * 0.1 [CustomCat]
## Category: Region
- North, South, East, West bare items, comma-separated
- Coastal_East[Coastal] grouped item (one per line)
- Coastal_West[Coastal]
> Coastal ← group definition
## Category: Measure
- Revenue, Cost, Profit
- North, South, East, West # bare items, comma-separated
- Coastal_East[Coastal] # grouped item, one per line
> Coastal # group definition
## Data
Region=East, Measure=Revenue = 1200
Region=East, Measure=Cost = 800
Region=West, Measure=Revenue = |pending| ← pipe-quoted text value
Region=West, Measure=Revenue = |pending| # pipe-quoted text
```
### Name quoting
- **Name quoting**: bare `[A-Za-z_][A-Za-z0-9_-]*`, else CL-style `|…|` with escapes `\|`, `\\`, `\n`. Same convention in the formula tokenizer.
- **Write order**: Views → Formulas → Categories → Data. Parser tolerates any order.
- **Gzip**: `.improv.gz` (same text, gzipped).
- **Legacy JSON**: auto-detected by leading `{`; never written.
- **Virtual categories**: `_Index`/`_Dim` never persist; `_Measure` persists only non-formula items (formula targets are rebuilt from the `## Formulas` section).
Bare names match `[A-Za-z_][A-Za-z0-9_-]*`. Everything else uses CL-style
pipe quoting: `|Income, Gross|`, `|2025|`, `|Name with spaces|`.
Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline).
---
### Section order
## Gotchas (read before editing)
`format_md` writes Views → Formulas → Categories → Data (smallest to largest).
The parser accepts sections in any order.
1. **Commands never mutate.** `&CmdContext` is read-only; return `Vec<Box<dyn Effect>>`. If you want to touch `&mut App`, add or extend an Effect.
2. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form.
3. **No `Model::add_item`.** Go through the category: `m.category_mut("Region").unwrap().add_item("East")`.
4. **Virtual categories.** `_Measure` items = explicit items formula targets. Use `measure_item_names()` / `effective_item_names("_Measure")`. `add_formula` does *not* add items to `_Measure`. Formula-target lookup also falls back into `_Measure` via `find_item_category`.
5. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full `f64`. Never feed a rounded value back into eval.
6. **Formula eval is fixed-point.** `recompute_formulas(none_cats)` iterates until stable, bounded by `MAX_EVAL_DEPTH`; circular refs converge to `CellValue::Error("circular")`. `App::new` runs it before the first frame so formula cells render on startup.
7. **Drill into formula cells** strips the `_Measure` coordinate when it names a formula target, so `matching_cells` returns the raw records that feed the formula instead of returning empty.
8. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel.
9. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic.
10. **Float equality inside formula eval is currently mixed**`=`/`!=` use a `1e-10` epsilon but the div-by-zero guard checks `rv == 0.0` exactly. Don't assume one or the other; tracked for cleanup.
11. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this.
12. **Commit paths for cell edits** split across `commit_cell_value``commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior.
### Key design choices
---
- Version line (`v2025-04-09`) enables future format changes.
- `Initial View:` is a top-level header, not embedded in view sections.
- Text cell values are always pipe-quoted to distinguish from numbers.
- Bare items are comma-separated on one line; grouped items get one line each.
## File Inventory
Gzip variant: `.improv.gz` (same content, gzipped). Persistence code: `persistence/mod.rs`.
Line counts are static; test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
### `improvise-core` (`crates/improvise-core/src/`)
```
model/types.rs 2062 / 70t Model, formula eval (fixed-point), CRUD
model/cell.rs 650 / 28t CellKey (sorted), CellValue, DataStore (interned + index)
model/category.rs 222 / 6t Category, Item, Group, CategoryKind
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
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
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
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/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/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
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/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
# other ui/*.rs are small panel renderers — skip unless changing layout/style
```
### Examples
```
examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta)
examples/pretty-print.rs Parse stdin, print formatted .improv
```
### Context docs
```
context/design-principles.md Architectural principles & testing doctrine
context/plan.md Show HN launch plan
context/repo-map.md This file
docs/design-notes.md Product vision & non-goals
```
---
@ -345,185 +237,18 @@ Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axi
---
## Key Dependencies
## Testing — the short version
| Crate | Purpose |
|-------|---------|
| ratatui 0.29 | TUI framework |
| crossterm 0.28 | Terminal backend |
| clap 4.6 (derive) | CLI parsing |
| serde + serde_json | Serialization |
| indexmap 2 | Ordered maps (categories, views) |
| anyhow | Error handling |
| chrono 0.4 | Date parsing in import |
| pest + pest_derive | PEG parser for .improv format |
| flate2 | Gzip for .improv.gz |
| csv | CSV parsing |
| enum_dispatch | CLI subcommand dispatch |
| **dev:** proptest, tempfile, pest_meta | Property testing, temp dirs, grammar AST for test generator |
Full guidance lives in `context/design-principles.md` §6. Quick reminders:
- Suite runs in <2s. Don't let that drift.
- Commands: build a `CmdContext`, call `execute`, assert on returned effects. No terminal needed.
- Property tests (`proptest`, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip, `parse(format(parse(generate())))` stability.
- **Bug-fix workflow**: write a failing test *before* the fix (regression guard). Document the bug in the test's doc-comment (see `model/types.rs::formula_tests`).
- Coverage target ~80% line/branch on logic code; skip ratatui render paths.
---
## File Inventory
## Key dependencies
Lines / tests / path — grouped by layer.
### Model layer
```
1692 / 66t model/types.rs Model struct, formula eval, CRUD, MAX_CATEGORIES=12
621 / 28t model/cell.rs CellKey (canonical sort), CellValue, DataStore (interned)
216 / 6t model/category.rs Category, Item, Group, CategoryKind
79 / 3t model/symbol.rs Symbol interning (SymbolTable)
6 / 0t model/mod.rs
```
### Formula layer
```
461 / 29t formula/parser.rs Recursive descent parser → Formula AST
77 / 0t formula/ast.rs Expr, BinOp, AggFunc, Formula, Filter (data only)
5 / 0t formula/mod.rs
```
### View layer
```
1013 / 23t view/layout.rs GridLayout (pure fn of Model+View), records mode, drill
521 / 28t view/types.rs View config (axes, pages, hidden, collapsed, format)
21 / 0t view/axis.rs Axis enum {Row, Column, Page, None}
7 / 0t view/mod.rs
```
### Command layer
```
command/cmd/ Cmd trait, CmdContext, CmdRegistry, 40+ commands
297 / 2t core.rs Cmd trait, CmdContext, CmdRegistry, parse helpers
586 / 0t registry.rs default_registry() — all command registrations
475 / 10t navigation.rs Move, EnterAdvance, PageNext/Prev
198 / 6t cell.rs ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
330 / 7t commit.rs CommitFormula, CommitCategoryAdd/ItemAdd, CommitExport
437 / 5t effect_cmds.rs effect_cmd! macro, 25+ parseable effect-wrapper commands
409 / 7t grid.rs ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
308 / 8t mode.rs EnterMode, Quit, EditOrDrill, EnterTileSelect, etc.
587 / 13t panel.rs Panel toggle/cycle/cursor, formula/category/view panel cmds
202 / 4t search.rs SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
256 / 7t text_buffer.rs AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
160 / 5t tile.rs MoveTileCursor, TileAxisOp
121 / 0t mod.rs Module declarations, re-exports, test helpers
1066 / 22t command/keymap.rs KeyPattern, Binding, Keymap, ModeKey, 14 mode keymaps
236 / 19t command/parse.rs Script/command-line parser (prefix syntax)
12 / 0t command/mod.rs
```
### UI layer
```
942 / 41t ui/effect.rs Effect trait, 50+ effect types (all state mutations)
914 / 30t ui/app.rs App state, AppMode (15 variants), handle_key, autosave
1036 / 13t ui/grid.rs GridWidget (ratatui), col widths, rendering
617 / 0t ui/help.rs 5-page help overlay, HELP_PAGE_COUNT=5
347 / 0t ui/import_wizard_ui.rs Import wizard overlay rendering
165 / 6t ui/cat_tree.rs Category tree flattener for panel
113 / 0t ui/view_panel.rs View list panel
107 / 0t ui/category_panel.rs Category tree panel
95 / 0t ui/tile_bar.rs Tile bar (axis assignment tiles)
87 / 0t ui/panel.rs Generic panel frame widget
81 / 0t ui/formula_panel.rs Formula list panel
67 / 0t ui/which_key.rs Prefix-key hint popup
12 / 0t ui/mod.rs
```
### Import layer
```
773 / 38t import/wizard.rs ImportPipeline + ImportWizard
292 / 9t import/analyzer.rs Field kind detection (Category/Measure/Time/Skip)
244 / 8t import/csv_parser.rs CSV parsing, multi-file merge
3 / 0t import/mod.rs
```
### Top-level
```
400 / 0t draw.rs TUI event loop (run_tui), frame composition
391 / 0t main.rs CLI entry (clap): open, import, cmd, script
228 / 29t format.rs Number display formatting (view-only rounding)
124 / 0t persistence/improv.pest PEG grammar — single source of truth for .improv format
2291 / 83t persistence/mod.rs .improv save/load (pest parser + format + gzip + legacy JSON)
```
### Context docs
```
context/design-principles.md Architectural principles
context/plan.md Show HN launch plan
context/repo-map.md This file
docs/design-notes.md Product vision & non-goals (salvaged from former SPEC.md)
```
**Total: ~21,400 lines, 568 tests.**
---
## Testing Guidelines
### Coverage target
Aim for **~80% line and branch coverage** on logic code. This is a quality floor, not a
ceiling — go higher where the code warrants it, but don't chase 100% on rendering
widgets or write tests that just exercise trivial getters. Coverage should be run with
`cargo llvm-cov` (available via `nix develop`).
### What to test and how
| Layer | Approach | Notes |
|-------|----------|-------|
| **Model** (types, cell, category, symbol) | Unit tests + **proptest** | The data model is the foundation. Property tests catch invariant violations that hand-picked cases miss (see CellKey sort invariant, axis consistency). |
| **Formula** (parser, eval) | Unit tests per operator/construct | Cover each BinOp, AggFunc, IF, WHERE, unary minus, chained formulas, error cases (div-by-zero, missing ref). Ensure eval uses full f64 precision — never display-rounded values. |
| **View** (types, layout) | Unit tests + **proptest** | Property tests for axis assignment invariants (each category on exactly one axis, transpose is involutive, etc.). Unit tests for layout computation, records mode detection, drill. |
| **Command** (cmd, keymap, parse) | Unit tests | Test command execution by building a `CmdContext` and asserting on returned effects. Test keymap lookup fallback chain. Test script parser with edge cases (quoting, comments, dots). |
| **Persistence** | Round-trip + grammar-generated | `save → load → save` must be identical. Grammar-walking generator produces random valid files from the pest AST; proptests verify `parse(generate())` and `parse(format(parse(generate())))`. Cover groups, formulas, views, hidden items, pipe quoting edge cases. |
| **Format** | Unit tests | Boundary cases: comma placement at 3/4/7 digits, negative numbers, rounding half-away-from-zero (not banker's), zero, small fractions. |
| **Import** (analyzer, csv, wizard) | Unit tests | Field classification heuristics, CSV quoting (RFC 4180), multi-file merge, date extraction. |
| **UI rendering** (grid, panels, draw, help) | Generally skip | Ratatui widgets are hard to unit-test and change frequently. Test the *logic* they consume (layout, cat_tree, format) rather than the rendering itself. |
| **Effects** | Test indirectly | Effects are thin `apply` methods. Test via integration: send a key through `App::handle_key` and assert on resulting app state. The complex ones (drill reconciliation, import) deserve targeted unit tests. |
### Property tests (proptest)
Use property tests for **invariants that must hold across all inputs**, not as a
substitute for example-based tests. Good candidates:
- Structural invariants: CellKey always sorted, each category on exactly one axis,
toggle-collapse is involutive, hide/show roundtrips.
- Serialization roundtrips: save/load identity.
- Determinism: `evaluate` returns the same value for the same inputs.
Keep proptest case counts reasonable. The defaults (256 cases) are fine for most
properties. Don't crank them up to thousands — the test suite should complete in
under 2 seconds. If a property needs more cases to feel confident, that's a sign
the input space should be constrained with better strategies, not brute-forced.
### Bug-fix workflow
Per CLAUDE.md: **write a test that demonstrates the bug before fixing it.** Prefer
a small unit test targeting the specific function over an integration test. The test
should fail on the current code, then pass after the fix. Mark regression tests
with a doc-comment explaining the bug (see `model/types.rs` `formula_tests` for
examples).
### What not to test
- Trivial struct constructors and enum definitions (`ast.rs`, `axis.rs`).
- Ratatui `Widget::render` implementations — these are pure drawing code.
- Module re-export files (`mod.rs`).
- One-line delegation methods.
---
## Patterns to Know
1. **Commands never mutate.** They receive `&CmdContext` (read-only) and return `Vec<Box<dyn Effect>>`.
2. **CellKey is always sorted.** Use `CellKey::new()` — never construct the inner Vec directly.
3. **`category_mut()` for adding items.** `Model` has no `add_item` method; get the category first: `m.category_mut("Region").unwrap().add_item("East")`.
4. **Virtual categories** `_Index`, `_Dim`, and `_Measure` always exist. `is_empty_model()` checks whether any *non-virtual* categories exist. `_Measure` holds numeric data fields and formula targets; `add_formula` auto-adds the target item.
5. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full f64.
5b. **Formula evaluation is fixed-point.** `recompute_formulas(none_cats)` iterates formula evaluation until values stabilize, using a cache. `evaluate_aggregated` checks the cache for formula results. Circular refs produce `CellValue::Error("circular")`.
6. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the current mode, then the corresponding `Keymap` is looked up. Normal + `search_mode=true` maps to `SearchMode`.
7. **`effect_cmd!` macro** generates a command struct that just produces effects. Use for simple commands without complex logic.
8. **`.improv` format is defined by a PEG grammar** (`persistence/improv.pest`). Parsed by pest. Names use CL-style `|...|` pipe quoting when they aren't valid bare identifiers. JSON is legacy only.
9. **`IndexMap`** is used for categories and views to preserve insertion order.
10. **`MAX_CATEGORIES = 12`** applies only to `CategoryKind::Regular`. Virtual/Label categories are exempt.
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.

View File

@ -0,0 +1,16 @@
[package]
name = "improvise-core"
version = "0.1.0-rc2"
edition = "2024"
description = "Pure-data model, views, and workbook for improvise"
license = "Apache-2.0"
repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
improvise-formula = { path = "../improvise-formula" }
anyhow = "1"
indexmap = { version = "2", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
proptest = "1"

View File

@ -0,0 +1,12 @@
//! Pure-data core of the `improvise` project: `Model`, views, `Workbook`,
//! and number formatting. Depends on `improvise-formula` for AST types;
//! has no awareness of UI, I/O, or commands.
//!
//! Re-exports `improvise_formula` under `formula` so internal code can use
//! `crate::formula::*` paths, mirroring the main crate's convention.
pub use improvise_formula as formula;
pub mod format;
pub mod model;
pub mod view;
pub mod workbook;

View File

@ -1,3 +1,4 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@ -104,8 +105,9 @@ pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
/// to implement the `Serialize`-as-string requirement for JSON object keys.
#[derive(Debug, Clone, Default)]
pub struct DataStore {
/// Primary storage — interned keys for O(1) hash/compare.
cells: HashMap<InternedKey, CellValue>,
/// Primary storage — interned keys, insertion-ordered so records mode
/// can display rows in the order they were entered.
cells: IndexMap<InternedKey, CellValue>,
/// String interner — all category/item names are interned here.
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
@ -160,6 +162,26 @@ impl DataStore {
)
}
/// Sort cells by their CellKey for deterministic display order.
/// Call once on entry into records mode so existing data is ordered;
/// subsequent inserts append at the end.
pub fn sort_by_key(&mut self) {
let symbols = &self.symbols;
self.cells.sort_by(|a, _, b, _| {
let resolve = |k: &InternedKey| -> Vec<(String, String)> {
k.0.iter()
.map(|(c, i)| {
(
symbols.resolve(*c).to_string(),
symbols.resolve(*i).to_string(),
)
})
.collect()
};
resolve(a).cmp(&resolve(b))
});
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
let ikey = self.intern_key(&key);
// Update index for each coordinate pair
@ -193,7 +215,7 @@ impl DataStore {
let Some(ikey) = self.lookup_key(key) else {
return;
};
if self.cells.remove(&ikey).is_some() {
if self.cells.shift_remove(&ikey).is_some() {
for pair in &ikey.0 {
if let Some(set) = self.index.get_mut(pair) {
set.remove(&ikey);

View File

@ -1,24 +1,30 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::category::{Category, CategoryId};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::{AggFunc, Formula};
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
/// Pure-data document model: categories, cells, and formulas.
///
/// `Model` intentionally does **not** know about views. The view axes and
/// per-view state live in [`crate::workbook::Workbook`], which wraps a
/// `Model` with the view ensemble. Cross-slice operations — adding a
/// category and registering it on every view, for example — are therefore
/// methods on `Workbook`, not `Model`. This breaks the former `Model ↔ View`
/// cycle so the `model/` and `view/` modules can be lifted into a shared
/// `improvise-core` crate without pulling view code into pure data types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>,
pub data: DataStore,
formulas: Vec<Formula>,
pub views: IndexMap<String, View>,
pub active_view: String,
next_category_id: CategoryId,
/// Per-measure aggregation function (measure item name → agg func).
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
@ -33,11 +39,8 @@ impl Model {
pub fn new(name: impl Into<String>) -> Self {
use crate::model::category::CategoryKind;
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
let mut categories = IndexMap::new();
// Virtual categories — always present, default to Axis::None
// Virtual categories — always present.
categories.insert(
"_Index".to_string(),
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
@ -50,30 +53,20 @@ impl Model {
"_Measure".to_string(),
Category::new(2, "_Measure").with_kind(CategoryKind::VirtualMeasure),
);
let mut m = Self {
Self {
name,
categories,
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 3,
measure_agg: HashMap::new(),
formula_cache: HashMap::new(),
};
// Add virtuals to existing views (default view).
// Start in records mode; on_category_added will reclaim Row/Column
// for the first two regular categories.
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
view.on_category_added("_Measure");
view.set_axis("_Index", crate::view::Axis::Row);
view.set_axis("_Dim", crate::view::Axis::Column);
}
m
}
/// Add a pivot category. Enforces the `MAX_CATEGORIES` limit for regular
/// categories. The caller (typically [`crate::workbook::Workbook`]) is
/// responsible for registering the new category on every view.
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
// Only regular pivot categories count against the limit.
@ -92,19 +85,15 @@ impl Model {
self.next_category_id += 1;
self.categories
.insert(name.clone(), Category::new(id, name.clone()));
// Add to all views
for view in self.views.values_mut() {
view.on_category_added(&name);
}
Ok(id)
}
/// Add a Label-kind category: stored alongside regular categories so
/// records views can display it, but default to `Axis::None` and
/// excluded from the pivot-category count limit.
/// records views can display it, but excluded from the pivot-category
/// count limit. The caller is responsible for setting the view axis
/// (typically to `Axis::None`).
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
use crate::model::category::CategoryKind;
use crate::view::Axis;
let name = name.into();
if self.categories.contains_key(&name) {
return Ok(self.categories[&name].id);
@ -113,23 +102,17 @@ impl Model {
self.next_category_id += 1;
let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label);
self.categories.insert(name.clone(), cat);
for view in self.views.values_mut() {
view.on_category_added(&name);
view.set_axis(&name, Axis::None);
}
Ok(id)
}
/// Remove a category and all cells that reference it.
/// Remove a category and all cells that reference it. The caller is
/// responsible for removing the category from any views that referenced
/// it.
pub fn remove_category(&mut self, name: &str) {
if !self.categories.contains_key(name) {
return;
}
self.categories.shift_remove(name);
// Remove from all views
for view in self.views.values_mut() {
view.on_category_removed(name);
}
// Remove cells that have a coord in this category
let to_remove: Vec<CellKey> = self
.data
@ -184,10 +167,10 @@ impl Model {
// For non-_Measure target categories, add the target as a category item
// so it appears in the grid. _Measure targets are dynamically included
// via measure_item_names().
if formula.target_category != "_Measure" {
if let Some(cat) = self.categories.get_mut(&formula.target_category) {
cat.add_item(&formula.target);
}
if formula.target_category != "_Measure"
&& let Some(cat) = self.categories.get_mut(&formula.target_category)
{
cat.add_item(&formula.target);
}
// Replace if same target within the same category
if let Some(pos) = self.formulas.iter().position(|f| {
@ -208,59 +191,6 @@ impl Model {
&self.formulas
}
pub fn active_view(&self) -> &View {
self.views
.get(&self.active_view)
.expect("active_view always names an existing view")
}
pub fn active_view_mut(&mut self) -> &mut View {
self.views
.get_mut(&self.active_view)
.expect("active_view always names an existing view")
}
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
let name = name.into();
let mut view = View::new(name.clone());
// Copy category assignments from default if any
for cat_name in self.categories.keys() {
view.on_category_added(cat_name);
}
self.views.insert(name.clone(), view);
self.views.get_mut(&name).unwrap()
}
pub fn switch_view(&mut self, name: &str) -> Result<()> {
if self.views.contains_key(name) {
self.active_view = name.to_string();
Ok(())
} else {
Err(anyhow!("View '{name}' not found"))
}
}
pub fn delete_view(&mut self, name: &str) -> Result<()> {
if self.views.len() <= 1 {
return Err(anyhow!("Cannot delete the last view"));
}
self.views.shift_remove(name);
if self.active_view == name {
self.active_view = self.views.keys().next().unwrap().clone();
}
Ok(())
}
/// Reset all view scroll offsets to zero.
/// Call this after loading or replacing a model so stale offsets don't
/// cause the grid to render an empty area.
pub fn normalize_view_state(&mut self) {
for view in self.views.values_mut() {
view.row_offset = 0;
view.col_offset = 0;
}
}
/// Return all category names
/// Names of all categories (including virtual ones).
pub fn category_names(&self) -> Vec<&str> {
@ -274,7 +204,12 @@ impl Model {
pub fn measure_item_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.category("_Measure")
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
.map(|c| {
c.ordered_item_names()
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
for f in &self.formulas {
if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) {
@ -292,7 +227,12 @@ impl Model {
self.measure_item_names()
} else {
self.category(cat_name)
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
.map(|c| {
c.ordered_item_names()
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
}
}
@ -321,10 +261,10 @@ impl Model {
return Some(CellValue::Error("circular".into()));
}
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target {
return self.eval_formula_depth(formula, key, depth - 1);
}
if let Some(item_val) = key.get(&formula.target_category)
&& item_val == formula.target
{
return self.eval_formula_depth(formula, key, depth - 1);
}
}
self.data.get(key).cloned()
@ -379,9 +319,9 @@ impl Model {
.unwrap_or(0.0)
}
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH)
}
//pub fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
// self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH)
//}
/// Recompute all formula cells until values stabilize (fixed point).
/// Call this before rendering or exporting — it populates `formula_cache`
@ -447,10 +387,10 @@ impl Model {
/// Uses raw data aggregation for non-formula refs and the cache for formula refs.
fn evaluate_formula_cell(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target {
return self.eval_formula_with_cache(formula, key, none_cats);
}
if let Some(item_val) = key.get(&formula.target_category)
&& item_val == formula.target
{
return self.eval_formula_with_cache(formula, key, none_cats);
}
}
None
@ -504,8 +444,8 @@ impl Model {
match expr {
Expr::Number(n) => Ok(*n),
Expr::Ref(name) => {
let cat = find_item_category(model, name)
.ok_or_else(|| format!("ref:{name}"))?;
let cat =
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
let ref_key = context.clone().with(cat, name);
// Check formula cache first, then aggregate raw data
if let Some(cached) = model.formula_cache.get(&ref_key) {
@ -529,19 +469,33 @@ impl Model {
BinOp::Add => Ok(lv + rv),
BinOp::Sub => Ok(lv - rv),
BinOp::Mul => Ok(lv * rv),
BinOp::Div => if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) },
BinOp::Div => {
if rv == 0.0 {
Err("div/0".into())
} else {
Ok(lv / rv)
}
}
BinOp::Pow => Ok(lv.powf(rv)),
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Err("type".into()),
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
Err("type".into())
}
}
}
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(e, context, model, target_category, none_cats)?),
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(
e,
context,
model,
target_category,
none_cats,
)?),
Expr::Agg(func, inner, agg_filter) => {
use crate::formula::AggFunc;
let mut partial = context.without(target_category);
if let Expr::Ref(item_name) = inner.as_ref() {
if let Some(cat) = find_item_category(model, item_name) {
partial = partial.with(cat, item_name.as_str());
}
if let Expr::Ref(item_name) = inner.as_ref()
&& let Some(cat) = find_item_category(model, item_name)
{
partial = partial.with(cat, item_name.as_str());
}
if let Some(f) = agg_filter {
partial = partial.with(&f.category, &f.item);
@ -554,9 +508,23 @@ impl Model {
.collect();
match func {
AggFunc::Sum => Ok(values.iter().sum()),
AggFunc::Avg => if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::<f64>() / values.len() as f64) },
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
AggFunc::Avg => {
if values.is_empty() {
Err("empty".into())
} else {
Ok(values.iter().sum::<f64>() / values.len() as f64)
}
}
AggFunc::Min => values
.iter()
.cloned()
.reduce(f64::min)
.ok_or_else(|| "empty".into()),
AggFunc::Max => values
.iter()
.cloned()
.reduce(f64::max)
.ok_or_else(|| "empty".into()),
AggFunc::Count => Ok(values.len() as f64),
}
}
@ -597,7 +565,13 @@ impl Model {
}
}
match eval_expr_cached(&formula.expr, context, self, &formula.target_category, none_cats) {
match eval_expr_cached(
&formula.expr,
context,
self,
&formula.target_category,
none_cats,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
@ -679,8 +653,8 @@ impl Model {
match expr {
Expr::Number(n) => Ok(*n),
Expr::Ref(name) => {
let cat = find_item_category(model, name)
.ok_or_else(|| format!("ref:{name}"))?;
let cat =
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
let new_key = context.clone().with(cat, name);
match model.evaluate_depth(&new_key, depth) {
Some(CellValue::Number(n)) => Ok(n),
@ -712,10 +686,10 @@ impl Model {
Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?),
Expr::Agg(func, inner, agg_filter) => {
let mut partial = context.without(target_category);
if let Expr::Ref(item_name) = inner.as_ref() {
if let Some(cat) = find_item_category(model, item_name) {
partial = partial.with(cat, item_name.as_str());
}
if let Expr::Ref(item_name) = inner.as_ref()
&& let Some(cat) = find_item_category(model, item_name)
{
partial = partial.with(cat, item_name.as_str());
}
if let Some(f) = agg_filter {
partial = partial.with(&f.category, &f.item);
@ -735,8 +709,16 @@ impl Model {
Ok(values.iter().sum::<f64>() / values.len() as f64)
}
}
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
AggFunc::Min => values
.iter()
.cloned()
.reduce(f64::min)
.ok_or_else(|| "empty".into()),
AggFunc::Max => values
.iter()
.cloned()
.reduce(f64::max)
.ok_or_else(|| "empty".into()),
AggFunc::Count => Ok(values.len() as f64),
}
}
@ -779,7 +761,13 @@ impl Model {
}
}
match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
match eval_expr(
&formula.expr,
context,
self,
&formula.target_category,
depth,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
@ -790,7 +778,6 @@ impl Model {
mod model_tests {
use super::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
@ -801,13 +788,6 @@ mod model_tests {
)
}
#[test]
fn new_model_has_default_view() {
let m = Model::new("Test");
// active_view() panics if missing; this test just ensures it doesn't panic
let _ = m.active_view();
}
#[test]
fn add_category_creates_entry() {
let mut m = Model::new("Test");
@ -834,14 +814,6 @@ mod model_tests {
assert!(m.add_category("TooMany").is_err());
}
#[test]
fn add_category_notifies_existing_views() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
// axis_of panics for unknown categories; not panicking here confirms it was registered
let _ = m.active_view().axis_of("Region");
}
#[test]
fn set_and_get_cell_roundtrip() {
let mut m = Model::new("Test");
@ -923,80 +895,6 @@ mod model_tests {
0,
"all cells with Region coord should be removed"
);
// Views should no longer know about Region
// (axis_of would panic for unknown category, so check categories_on)
let v = m.active_view();
assert!(v.categories_on(crate::view::Axis::Row).is_empty());
}
#[test]
fn create_view_copies_category_structure() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
m.add_category("Product").unwrap();
m.create_view("Secondary");
let v = m.views.get("Secondary").unwrap();
// axis_of panics for unknown categories; not panicking confirms categories were registered
let _ = v.axis_of("Region");
let _ = v.axis_of("Product");
}
#[test]
fn switch_view_changes_active_view() {
let mut m = Model::new("Test");
m.create_view("Other");
m.switch_view("Other").unwrap();
assert_eq!(m.active_view, "Other");
}
#[test]
fn switch_view_unknown_returns_error() {
let mut m = Model::new("Test");
assert!(m.switch_view("NoSuchView").is_err());
}
#[test]
fn delete_view_removes_it() {
let mut m = Model::new("Test");
m.create_view("Extra");
m.delete_view("Extra").unwrap();
assert!(!m.views.contains_key("Extra"));
}
#[test]
fn delete_last_view_returns_error() {
let mut m = Model::new("Test");
assert!(m.delete_view("Default").is_err());
}
#[test]
fn delete_active_view_switches_to_another() {
let mut m = Model::new("Test");
m.create_view("Other");
m.switch_view("Other").unwrap();
m.delete_view("Other").unwrap();
assert_ne!(m.active_view, "Other");
}
#[test]
fn first_category_goes_to_row_second_to_column_rest_to_page() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
m.add_category("Product").unwrap();
m.add_category("Time").unwrap();
let v = m.active_view();
assert_eq!(v.axis_of("Region"), Axis::Row);
assert_eq!(v.axis_of("Product"), Axis::Column);
assert_eq!(v.axis_of("Time"), Axis::Page);
}
#[test]
fn data_is_shared_across_views() {
let mut m = Model::new("Test");
m.create_view("Second");
let k = coord(&[("Region", "East")]);
m.set_cell(k.clone(), CellValue::Number(77.0));
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
}
#[test]
@ -1011,11 +909,19 @@ mod model_tests {
m.category_mut("_Measure").unwrap().add_item("Amount");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("_Measure", "Amount")]),
coord(&[
("Payee", "Acme"),
("Date", "Jan-01"),
("_Measure", "Amount"),
]),
CellValue::Number(100.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("_Measure", "Amount")]),
coord(&[
("Payee", "Acme"),
("Date", "Jan-02"),
("_Measure", "Amount"),
]),
CellValue::Number(50.0),
);
@ -1636,6 +1542,7 @@ mod five_category {
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::workbook::Workbook;
const DATA: &[(&str, &str, &str, &str, f64, f64)] = &[
("East", "Shirts", "Online", "Q1", 1_000.0, 600.0),
@ -1706,6 +1613,52 @@ mod five_category {
m
}
/// Build a Workbook whose model matches `build_model()`. Used by the
/// view-management tests in this module: view state lives on Workbook,
/// not Model, so those tests need the wrapper.
fn build_workbook() -> Workbook {
let mut wb = Workbook::new("Sales");
for cat in ["Region", "Product", "Channel", "Time", "_Measure"] {
wb.add_category(cat).unwrap();
}
for cat in ["Region", "Product", "Channel", "Time"] {
let items: &[&str] = match cat {
"Region" => &["East", "West"],
"Product" => &["Shirts", "Pants"],
"Channel" => &["Online", "Retail"],
"Time" => &["Q1", "Q2"],
_ => &[],
};
if let Some(c) = wb.model.category_mut(cat) {
for &item in items {
c.add_item(item);
}
}
}
if let Some(c) = wb.model.category_mut("_Measure") {
for &item in &["Revenue", "Cost", "Profit", "Margin", "Total"] {
c.add_item(item);
}
}
for &(region, product, channel, time, rev, cost) in DATA {
wb.model.set_cell(
coord(region, product, channel, time, "Revenue"),
CellValue::Number(rev),
);
wb.model.set_cell(
coord(region, product, channel, time, "Cost"),
CellValue::Number(cost),
);
}
wb.model
.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
wb.model
.add_formula(parse_formula("Margin = Profit / Revenue", "_Measure").unwrap());
wb.model
.add_formula(parse_formula("Total = SUM(Revenue)", "_Measure").unwrap());
wb
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
@ -1715,7 +1668,7 @@ mod five_category {
let m = build_model();
let count = DATA
.iter()
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Revenue")).is_none())
.filter(|&&(r, p, c, t, _, _)| m.get_cell(&coord(r, p, c, t, "Revenue")).is_some())
.count();
assert_eq!(count, 16);
}
@ -1725,7 +1678,7 @@ mod five_category {
let m = build_model();
let count = DATA
.iter()
.filter(|&&(r, p, c, t, _, _)| !m.get_cell(&coord(r, p, c, t, "Cost")).is_none())
.filter(|&&(r, p, c, t, _, _)| m.get_cell(&coord(r, p, c, t, "Cost")).is_some())
.count();
assert_eq!(count, 16);
}
@ -1746,12 +1699,8 @@ mod five_category {
#[test]
fn distinct_cells_do_not_alias() {
let m = build_model();
let a = m
.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue"))
.clone();
let b = m
.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue"))
.clone();
let a = m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue"));
let b = m.get_cell(&coord("West", "Pants", "Retail", "Q2", "Revenue"));
assert_ne!(a, b);
}
@ -1780,8 +1729,10 @@ mod five_category {
.evaluate(&coord(region, product, channel, time, "Margin"))
.and_then(|v| v.as_f64())
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
assert!(approx(actual, expected),
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
assert!(
approx(actual, expected),
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}"
);
}
}
@ -1874,20 +1825,20 @@ mod five_category {
#[test]
fn default_view_first_two_on_axes_rest_on_page() {
let m = build_model();
let v = m.active_view();
let wb = build_workbook();
let v = wb.active_view();
assert_eq!(v.axis_of("Region"), Axis::Row);
assert_eq!(v.axis_of("Product"), Axis::Column);
assert_eq!(v.axis_of("Channel"), Axis::Page);
assert_eq!(v.axis_of("Time"), Axis::Page);
assert_eq!(v.axis_of("_Measure"), Axis::None);
assert_eq!(v.axis_of("_Measure"), Axis::Page);
}
#[test]
fn rearranging_axes_does_not_affect_data() {
let mut m = build_model();
let mut wb = build_workbook();
{
let v = m.active_view_mut();
let v = wb.active_view_mut();
v.set_axis("Region", Axis::Page);
v.set_axis("Product", Axis::Page);
v.set_axis("Channel", Axis::Row);
@ -1895,44 +1846,48 @@ mod five_category {
v.set_axis("_Measure", Axis::Page);
}
assert_eq!(
m.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")),
wb.model
.get_cell(&coord("East", "Shirts", "Online", "Q1", "Revenue")),
Some(&CellValue::Number(1_000.0))
);
}
#[test]
fn two_views_have_independent_axis_assignments() {
let mut m = build_model();
m.create_view("Pivot");
let mut wb = build_workbook();
wb.create_view("Pivot");
{
let v = m.views.get_mut("Pivot").unwrap();
let v = wb.views.get_mut("Pivot").unwrap();
v.set_axis("Time", Axis::Row);
v.set_axis("Channel", Axis::Column);
v.set_axis("Region", Axis::Page);
v.set_axis("Product", Axis::Page);
v.set_axis("_Measure", Axis::Page);
}
assert_eq!(m.views.get("Default").unwrap().axis_of("Region"), Axis::Row);
assert_eq!(m.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row);
assert_eq!(
m.views.get("Pivot").unwrap().axis_of("Channel"),
wb.views.get("Default").unwrap().axis_of("Region"),
Axis::Row
);
assert_eq!(wb.views.get("Pivot").unwrap().axis_of("Time"), Axis::Row);
assert_eq!(
wb.views.get("Pivot").unwrap().axis_of("Channel"),
Axis::Column
);
}
#[test]
fn page_selections_are_per_view() {
let mut m = build_model();
m.create_view("West only");
if let Some(v) = m.views.get_mut("West only") {
let mut wb = build_workbook();
wb.create_view("West only");
if let Some(v) = wb.views.get_mut("West only") {
v.set_page_selection("Region", "West");
}
assert_eq!(
m.views.get("Default").unwrap().page_selection("Region"),
wb.views.get("Default").unwrap().page_selection("Region"),
None
);
assert_eq!(
m.views.get("West only").unwrap().page_selection("Region"),
wb.views.get("West only").unwrap().page_selection("Region"),
Some("West")
);
}

View File

@ -1,7 +1,8 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::category::CategoryKind;
use crate::model::cell::{CellKey, CellValue};
use crate::view::{Axis, View};
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
@ -53,14 +54,14 @@ impl GridLayout {
frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
) -> Self {
let mut layout = Self::new(model, view);
if layout.is_records_mode() {
if let Some(records) = frozen_records {
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
layout.row_items = row_items;
layout.records = Some(records);
}
if layout.is_records_mode()
&& let Some(records) = frozen_records
{
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
layout.row_items = row_items;
layout.records = Some(records);
}
if view.prune_empty {
layout.prune_empty(model);
@ -92,14 +93,13 @@ impl GridLayout {
let page_coords = page_cats
.iter()
.map(|cat| {
.filter_map(|cat| {
let items: Vec<String> = model.effective_item_names(cat);
let sel = view
.page_selection(cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat.clone(), sel)
.or_else(|| items.first().cloned())?;
Some((cat.clone(), sel))
})
.collect();
@ -132,24 +132,12 @@ impl GridLayout {
page_coords: Vec<(String, String)>,
none_cats: Vec<String>,
) -> Self {
// Filter cells by page_coords
let partial: Vec<(String, String)> = page_coords.clone();
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
model
.data
.matching_cells(&partial)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
// Sort for deterministic ordering
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
let records: Vec<(CellKey, CellValue)> = model
.data
.matching_cells(&page_coords)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect();
// Synthesize row items: one per record, labeled with its index
let row_items: Vec<AxisEntry> = (0..records.len())
@ -157,12 +145,25 @@ impl GridLayout {
.collect();
// Synthesize col items: one per non-virtual category + "Value"
let cat_names: Vec<String> = model
let mut cat_names: Vec<String> = model
.category_names()
.into_iter()
.filter(|c| !c.starts_with('_'))
.filter(|c| {
let kind = model.category(c).map(|cat| cat.kind);
!matches!(
kind,
Some(CategoryKind::VirtualIndex | CategoryKind::VirtualDim)
)
})
.map(String::from)
.collect();
// _Measure goes last among category columns (right before Value)
cat_names.sort_by_key(|c| {
matches!(
model.category(c).map(|cat| cat.kind),
Some(CategoryKind::VirtualMeasure)
)
});
let mut col_items: Vec<AxisEntry> = cat_names
.iter()
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
@ -192,7 +193,7 @@ impl GridLayout {
// col_item is a category name
let found = record
.0
.0
.0
.iter()
.find(|(c, _)| c == &col_item)
.map(|(_, v)| v.clone());
@ -516,7 +517,10 @@ fn expand_category(
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat.items.get(item_name.as_str()).and_then(|i| i.group.as_deref());
let item_group = cat
.items
.get(item_name.as_str())
.and_then(|i| i.group.as_deref());
// Emit a group header at each group boundary.
if item_group != last_group {
@ -564,74 +568,79 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)]
mod tests {
use super::{synthetic_record_info, AxisEntry, GridLayout};
use super::{AxisEntry, GridLayout, synthetic_record_info};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
use crate::workbook::Workbook;
fn records_model() -> Model {
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.add_category("_Measure").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("_Measure").unwrap().add_item("Revenue");
m.category_mut("_Measure").unwrap().add_item("Cost");
m.set_cell(
fn records_workbook() -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("_Measure").unwrap();
wb.model.category_mut("Region").unwrap().add_item("North");
wb.model
.category_mut("_Measure")
.unwrap()
.add_item("Revenue");
wb.model.category_mut("_Measure").unwrap().add_item("Cost");
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(100.0),
);
m.set_cell(
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Cost".into()),
]),
CellValue::Number(50.0),
);
m
// Records mode setup: _Measure should not filter
wb.active_view_mut().set_axis("_Measure", Axis::None);
wb
}
#[test]
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
let mut m = Model::new("T");
m.add_category("Row").unwrap();
m.add_category("Col").unwrap();
m.category_mut("Row").unwrap().add_item("A");
m.category_mut("Col").unwrap().add_item("X");
m.category_mut("Col").unwrap().add_item("Y");
let mut wb = Workbook::new("T");
wb.add_category("Row").unwrap();
wb.add_category("Col").unwrap();
wb.model.category_mut("Row").unwrap().add_item("A");
wb.model.category_mut("Col").unwrap().add_item("X");
wb.model.category_mut("Col").unwrap().add_item("Y");
// Only X has data; Y is entirely empty
m.set_cell(
wb.model.set_cell(
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
CellValue::Number(1.0),
);
let mut layout = GridLayout::new(&m, m.active_view());
let mut layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.col_count(), 2); // X and Y before pruning
layout.prune_empty(&m);
layout.prune_empty(&wb.model);
assert_eq!(layout.col_count(), 1); // only X after pruning
assert_eq!(layout.col_label(0), "X");
}
#[test]
fn records_mode_activated_when_index_and_dim_on_axes() {
let mut m = records_model();
let v = m.active_view_mut();
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(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
assert!(layout.is_records_mode());
assert_eq!(layout.row_count(), 2); // 2 cells
}
#[test]
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
let mut m = records_model();
let v = m.active_view_mut();
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(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
assert!(layout.is_records_mode());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
@ -650,11 +659,11 @@ mod tests {
#[test]
fn records_mode_resolve_display_returns_values() {
let mut m = records_model();
let v = m.active_view_mut();
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(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
.collect();
@ -695,6 +704,108 @@ mod tests {
assert_eq!(dim, "Region");
}
/// Regression test for improvise-rbv: records mode should include _Measure
/// as a _Dim column so the measure name is visible per-record, and "Value"
/// must remain the last column.
#[test]
fn records_mode_includes_measure_in_dim_columns() {
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());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
.collect();
assert!(
cols.contains(&"_Measure".to_string()),
"records mode should include _Measure column; got {:?}",
cols
);
assert!(cols.contains(&"Region".to_string()));
assert!(cols.contains(&"Value".to_string()));
assert_eq!(
cols.last().unwrap(),
"Value",
"Value must be the last column so cursor defaults land on it; got {:?}",
cols
);
}
/// On initial entry into records mode, rows should be in a deterministic
/// (CellKey-sorted) order regardless of insertion order.
#[test]
fn records_mode_initial_layout_is_sorted() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("North");
wb.model.category_mut("Region").unwrap().add_item("East");
// Insert East before North — opposite of alphabetical
wb.model.set_cell(
CellKey::new(vec![("Region".into(), "East".into())]),
CellValue::Number(2.0),
);
wb.model.set_cell(
CellKey::new(vec![("Region".into(), "North".into())]),
CellValue::Number(1.0),
);
// Sort the store before entering records mode, as ToggleRecordsMode would
wb.model.data.sort_by_key();
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_eq!(layout.row_count(), 2);
let region_col = (0..layout.col_count())
.find(|&c| layout.col_label(c) == "Region")
.unwrap();
let row0_region = layout.records_display(0, region_col).unwrap();
let row1_region = layout.records_display(1, region_col).unwrap();
assert_eq!(
row0_region, "East",
"first row should be East (alphabetical)"
);
assert_eq!(
row1_region, "North",
"second row should be North (alphabetical)"
);
}
/// New records added after initial layout should appear at the bottom,
/// not re-sorted into the middle by CellKey order.
#[test]
fn records_mode_new_record_appends_at_bottom() {
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_eq!(layout.row_count(), 2);
let first_value = layout.records_display(0, layout.col_count() - 1).unwrap();
// Add a record whose CellKey sorts BEFORE the existing ones
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "AAA".into()),
("_Measure".into(), "Cost".into()),
]),
CellValue::Number(999.0),
);
let layout2 = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout2.row_count(), 3);
// First row should still be the same record — new one appends at bottom
let first_value2 = layout2.records_display(0, layout2.col_count() - 1).unwrap();
assert_eq!(
first_value, first_value2,
"first row should be unchanged after adding a new record; \
new record should append at bottom, not re-sort"
);
// New record should be the last row
let last_value = layout2.records_display(2, layout2.col_count() - 1).unwrap();
assert_eq!(last_value, "999", "new record should be the last row");
}
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
pairs
@ -704,31 +815,31 @@ mod tests {
)
}
fn two_cat_model() -> Model {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
fn two_cat_workbook() -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
for item in ["Food", "Clothing"] {
m.category_mut("Type").unwrap().add_item(item);
wb.model.category_mut("Type").unwrap().add_item(item);
}
for item in ["Jan", "Feb"] {
m.category_mut("Month").unwrap().add_item(item);
wb.model.category_mut("Month").unwrap().add_item(item);
}
m
wb
}
#[test]
fn row_and_col_counts_match_item_counts() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
let wb = two_cat_workbook();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_count(), 2); // Food, Clothing
assert_eq!(layout.col_count(), 2); // Jan, Feb
}
#[test]
fn cell_key_encodes_correct_coordinates() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
let wb = two_cat_workbook();
let layout = GridLayout::new(&wb.model, wb.active_view());
// row 0 = Food, col 1 = Feb
let key = layout.cell_key(0, 1).unwrap();
assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
@ -736,88 +847,93 @@ mod tests {
#[test]
fn cell_key_out_of_bounds_returns_none() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
let wb = two_cat_workbook();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert!(layout.cell_key(99, 0).is_none());
assert!(layout.cell_key(0, 99).is_none());
}
#[test]
fn cell_key_includes_page_coords() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Region").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Region").unwrap().add_item("East");
m.category_mut("Region").unwrap().add_item("West");
m.active_view_mut().set_page_selection("Region", "West");
let layout = GridLayout::new(&m, m.active_view());
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Region").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Month").unwrap().add_item("Jan");
wb.model.category_mut("Region").unwrap().add_item("East");
wb.model.category_mut("Region").unwrap().add_item("West");
wb.active_view_mut().set_page_selection("Region", "West");
let layout = GridLayout::new(&wb.model, wb.active_view());
let key = layout.cell_key(0, 0).unwrap();
assert_eq!(key.get("Region"), Some("West"));
}
#[test]
fn cell_key_round_trips_through_model_evaluate() {
let mut m = two_cat_model();
m.set_cell(
let mut wb = two_cat_workbook();
wb.model.set_cell(
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
CellValue::Number(42.0),
);
let layout = GridLayout::new(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
// Clothing = row 1, Feb = col 1
let key = layout.cell_key(1, 1).unwrap();
assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0)));
assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(42.0)));
}
#[test]
fn labels_join_with_slash_for_multi_cat_axis() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Year").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Year").unwrap().add_item("2025");
m.active_view_mut()
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Year").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Month").unwrap().add_item("Jan");
wb.model.category_mut("Year").unwrap().add_item("2025");
wb.active_view_mut()
.set_axis("Year", crate::view::Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.col_label(0), "Jan/2025");
}
#[test]
fn row_count_excludes_group_headers() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Feb", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
wb.model.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
}
#[test]
fn group_header_emitted_at_group_boundary() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
wb.model.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&wb.model, wb.active_view());
let headers: Vec<_> = layout
.row_items
.iter()
@ -834,21 +950,24 @@ mod tests {
#[test]
fn collapsed_group_has_header_but_no_data_items() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Feb", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
m.active_view_mut().toggle_group_collapse("Month", "Q1");
let layout = GridLayout::new(&m, m.active_view());
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
let layout = GridLayout::new(&wb.model, wb.active_view());
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
assert_eq!(layout.row_count(), 1); // only Apr
let q1_header = layout
@ -865,53 +984,61 @@ mod tests {
#[test]
fn ungrouped_items_produce_no_headers() {
let m = two_cat_model();
let layout = GridLayout::new(&m, m.active_view());
assert!(!layout
.row_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
assert!(!layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
let wb = two_cat_workbook();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert!(
!layout
.row_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
);
assert!(
!layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
);
}
#[test]
fn cell_key_correct_with_grouped_items() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
m.set_cell(
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.set_cell(
coord(&[("Month", "Apr"), ("Type", "Food")]),
CellValue::Number(99.0),
);
let layout = GridLayout::new(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
// data row 0 = Jan, data row 1 = Apr
let key = layout.cell_key(1, 0).unwrap();
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(99.0)));
}
#[test]
fn data_row_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
wb.model.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&wb.model, wb.active_view());
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
@ -920,17 +1047,19 @@ mod tests {
#[test]
fn data_col_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap(); // Row
wb.add_category("Month").unwrap(); // Column
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
assert_eq!(layout.data_col_to_visual(1), Some(3));
@ -939,17 +1068,19 @@ mod tests {
#[test]
fn row_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
wb.model.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.row_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
@ -962,28 +1093,30 @@ mod tests {
#[test]
fn row_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_group_for(0), None);
}
#[test]
fn col_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap(); // Row
wb.add_category("Month").unwrap(); // Column
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
wb.model
.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.col_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
@ -996,12 +1129,12 @@ mod tests {
#[test]
fn col_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
let mut wb = Workbook::new("T");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.col_group_for(0), None);
}
}

View File

@ -3,5 +3,5 @@ pub mod layout;
pub mod types;
pub use axis::Axis;
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
pub use layout::{AxisEntry, GridLayout, synthetic_record_info};
pub use types::View;

View File

@ -126,6 +126,16 @@ impl View {
.collect()
}
/// Owned-string variant of `categories_on(Axis::None)`. Used by callers
/// that need to pass the None-axis set to formula recomputation, which
/// takes `&[String]` so it can be stored without tying lifetimes to `View`.
pub fn none_cats(&self) -> Vec<String> {
self.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect()
}
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
self.page_selections
.insert(cat_name.to_string(), item.to_string());

View File

@ -0,0 +1,266 @@
//! A [`Workbook`] wraps a pure-data [`Model`] with the set of named [`View`]s
//! that are rendered over it. Splitting the two breaks the former
//! `Model ↔ View` cycle: `Model` knows nothing about views, while `View`
//! depends on `Model` (one direction).
//!
//! Cross-slice operations — adding or removing a category, for example, must
//! update both the model's categories and every view's axis assignments
//! — live here rather than on `Model`, so `Model` stays pure data and
//! `improvise-core` can be extracted without pulling view code along.
use anyhow::{Result, anyhow};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::model::Model;
use crate::model::category::CategoryId;
use crate::view::{Axis, View};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workbook {
pub model: Model,
pub views: IndexMap<String, View>,
pub active_view: String,
}
impl Workbook {
/// Create a new workbook with a fresh `Model` and a single `Default` view.
/// Virtual categories (`_Index`, `_Dim`, `_Measure`) are registered on the
/// default view. All virtuals default to `Axis::None` via
/// `on_category_added` (see improvise-709f2df), then `_Measure` is bumped
/// to `Axis::Page` so aggregated pivot views show a single measure at a
/// time (see improvise-kos). Leaving `_Index`/`_Dim` on None keeps pivot
/// mode the default — records mode activates only when the user moves
/// both onto axes.
pub fn new(name: impl Into<String>) -> Self {
let model = Model::new(name);
let mut views = IndexMap::new();
views.insert("Default".to_string(), View::new("Default"));
let mut wb = Self {
model,
views,
active_view: "Default".to_string(),
};
for view in wb.views.values_mut() {
for cat_name in wb.model.categories.keys() {
view.on_category_added(cat_name);
}
view.set_axis("_Measure", Axis::Page);
}
wb
}
// ── Cross-slice category management ─────────────────────────────────────
/// Add a regular pivot category and register it with every view.
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
let id = self.model.add_category(&name)?;
for view in self.views.values_mut() {
view.on_category_added(&name);
}
Ok(id)
}
/// Add a label category (excluded from pivot-count limit) and register it
/// with every view on `Axis::None`.
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
let id = self.model.add_label_category(&name)?;
for view in self.views.values_mut() {
view.on_category_added(&name);
view.set_axis(&name, Axis::None);
}
Ok(id)
}
/// Remove a category from the model and from every view.
pub fn remove_category(&mut self, name: &str) {
self.model.remove_category(name);
for view in self.views.values_mut() {
view.on_category_removed(name);
}
}
// ── Active view access ──────────────────────────────────────────────────
pub fn active_view(&self) -> &View {
self.views
.get(&self.active_view)
.expect("active_view always names an existing view")
}
pub fn active_view_mut(&mut self) -> &mut View {
self.views
.get_mut(&self.active_view)
.expect("active_view always names an existing view")
}
// ── View management ─────────────────────────────────────────────────────
/// Create a new view pre-populated with every existing category, and
/// return a mutable reference to it. Does not change the active view.
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
let name = name.into();
let mut view = View::new(name.clone());
for cat_name in self.model.categories.keys() {
view.on_category_added(cat_name);
}
self.views.insert(name.clone(), view);
self.views.get_mut(&name).unwrap()
}
pub fn switch_view(&mut self, name: &str) -> Result<()> {
if self.views.contains_key(name) {
self.active_view = name.to_string();
Ok(())
} else {
Err(anyhow!("View '{name}' not found"))
}
}
pub fn delete_view(&mut self, name: &str) -> Result<()> {
if self.views.len() <= 1 {
return Err(anyhow!("Cannot delete the last view"));
}
self.views.shift_remove(name);
if self.active_view == name {
self.active_view = self.views.keys().next().unwrap().clone();
}
Ok(())
}
/// Reset all view scroll offsets to zero. Call after loading or replacing
/// a workbook so stale offsets don't render an empty grid.
pub fn normalize_view_state(&mut self) {
for view in self.views.values_mut() {
view.row_offset = 0;
view.col_offset = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::Workbook;
use crate::view::Axis;
#[test]
fn new_workbook_has_default_view_with_virtuals_seeded() {
let wb = Workbook::new("Test");
assert_eq!(wb.active_view, "Default");
let v = wb.active_view();
// Virtual categories default to Axis::None; _Measure is bumped to Page
// so aggregated pivot views show a single measure by default
// (improvise-kos, improvise-709f2df).
assert_eq!(v.axis_of("_Index"), Axis::None);
assert_eq!(v.axis_of("_Dim"), Axis::None);
assert_eq!(v.axis_of("_Measure"), Axis::Page);
}
#[test]
fn add_category_notifies_all_views() {
let mut wb = Workbook::new("Test");
wb.create_view("Secondary");
wb.add_category("Region").unwrap();
// Both views should know about Region (axis_of panics on unknown).
let _ = wb.views.get("Default").unwrap().axis_of("Region");
let _ = wb.views.get("Secondary").unwrap().axis_of("Region");
}
#[test]
fn add_label_category_sets_none_axis_on_all_views() {
let mut wb = Workbook::new("Test");
wb.create_view("Other");
wb.add_label_category("Note").unwrap();
assert_eq!(wb.views.get("Default").unwrap().axis_of("Note"), Axis::None);
assert_eq!(wb.views.get("Other").unwrap().axis_of("Note"), Axis::None);
}
#[test]
fn remove_category_removes_from_all_views() {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.create_view("Second");
wb.remove_category("Region");
// Region should no longer appear in either view's Row axis.
assert!(
wb.views
.get("Default")
.unwrap()
.categories_on(Axis::Row)
.iter()
.all(|c| *c != "Region")
);
assert!(
wb.views
.get("Second")
.unwrap()
.categories_on(Axis::Row)
.iter()
.all(|c| *c != "Region")
);
}
#[test]
fn switch_view_changes_active_view() {
let mut wb = Workbook::new("Test");
wb.create_view("Other");
wb.switch_view("Other").unwrap();
assert_eq!(wb.active_view, "Other");
}
#[test]
fn switch_view_unknown_returns_error() {
let mut wb = Workbook::new("Test");
assert!(wb.switch_view("NoSuchView").is_err());
}
#[test]
fn delete_view_removes_it() {
let mut wb = Workbook::new("Test");
wb.create_view("Extra");
wb.delete_view("Extra").unwrap();
assert!(!wb.views.contains_key("Extra"));
}
#[test]
fn delete_last_view_returns_error() {
let wb = Workbook::new("Test");
// Use wb without binding mut — delete_view would need &mut, so:
let mut wb = wb;
assert!(wb.delete_view("Default").is_err());
}
#[test]
fn delete_active_view_switches_to_another() {
let mut wb = Workbook::new("Test");
wb.create_view("Other");
wb.switch_view("Other").unwrap();
wb.delete_view("Other").unwrap();
assert_ne!(wb.active_view, "Other");
}
#[test]
fn first_category_goes_to_row_second_to_column_rest_to_page() {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
wb.add_category("Time").unwrap();
let v = wb.active_view();
assert_eq!(v.axis_of("Region"), Axis::Row);
assert_eq!(v.axis_of("Product"), Axis::Column);
assert_eq!(v.axis_of("Time"), Axis::Page);
}
#[test]
fn create_view_copies_category_structure() {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
wb.create_view("Secondary");
let v = wb.views.get("Secondary").unwrap();
let _ = v.axis_of("Region");
let _ = v.axis_of("Product");
}
}

View File

@ -0,0 +1,17 @@
[package]
name = "improvise-formula"
version = "0.1.0-rc2"
edition = "2024"
description = "Formula parser and AST for improvise"
license = "Apache-2.0"
repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
anyhow = "1"
pest = "2.8.6"
pest_derive = "2.8.6"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
pest_meta = "2.8.6"
proptest = "1"

View File

@ -0,0 +1,91 @@
// Formula grammar for improvise.
//
// A formula has the form: TARGET = EXPR [WHERE filter]
// See parser.rs for the tree walker that produces a Formula AST.
//
// Identifier rules (bare_ident / pipe_quoted) mirror `bare_name` and
// `pipe_quoted` in src/persistence/improv.pest: bare identifiers are
// alphanumeric plus `_` and `-`, with no internal spaces; multi-word
// names must be pipe-quoted.
// Auto-skip horizontal whitespace between tokens in non-atomic rules.
WHITESPACE = _{ " " | "\t" }
// ---- top-level ----------------------------------------------------------
formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI }
// The target keeps its raw text (including pipes, if any) — we capture
// the span directly rather than walking into its children.
target = { identifier }
where_clause = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
// ---- expressions --------------------------------------------------------
// Used by parse_expr() — forces a standalone expression to consume the
// whole input, so `1 + 2 3` fails instead of silently dropping " 3".
expr_eoi = { SOI ~ expr ~ EOI }
expr = { add_expr }
add_expr = { mul_expr ~ (add_op ~ mul_expr)* }
add_op = { "+" | "-" }
mul_expr = { pow_expr ~ (mul_op ~ pow_expr)* }
mul_op = { "*" | "/" }
pow_expr = { unary ~ (pow_op ~ unary)? }
pow_op = { "^" }
unary = { unary_minus | primary }
unary_minus = { "-" ~ primary }
primary = {
number
| agg_call
| if_expr
| paren_expr
| ref_expr
}
paren_expr = { "(" ~ expr ~ ")" }
// Aggregates with optional inline WHERE filter inside the parens.
agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" }
agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" }
inline_where = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
// IF(cond, then, else). Comparison is a standalone rule because comparison
// operators are not valid in general expressions — only inside an IF condition.
if_expr = { ^"IF" ~ "(" ~ comparison ~ "," ~ expr ~ "," ~ expr ~ ")" }
comparison = { expr ~ cmp_op ~ expr }
cmp_op = { "!=" | "<=" | ">=" | "<" | ">" | "=" }
// A reference to an item. `SUM` and `IF` without parens fall through to
// this rule because agg_call / if_expr require a "(" and otherwise fail.
ref_expr = { identifier }
// ---- identifiers --------------------------------------------------------
//
// Mirror of improv.pest's bare_name / pipe_quoted.
identifier = ${ pipe_quoted | bare_ident }
// Backslash escapes inside pipes: \| literal pipe, \\ backslash, \n newline.
pipe_quoted = @{ "|" ~ ("\\" ~ ANY | !"|" ~ ANY)* ~ "|" }
bare_ident = @{
(ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")*
}
// ---- literal values -----------------------------------------------------
filter_value = { string | pipe_quoted | bare_ident }
string = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
number = @{
ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT*)?
| "." ~ ASCII_DIGIT+
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
[package]
name = "improvise-io"
version = "0.1.0-rc2"
edition = "2024"
description = "Persistence and import for improvise (.improv format, CSV, JSON wizard)"
license = "Apache-2.0"
repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
improvise-core = { path = "../improvise-core" }
improvise-formula = { path = "../improvise-formula" }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
csv = "1"
flate2 = "1"
indexmap = { version = "2", features = ["serde"] }
pest = "2.8.6"
pest_derive = "2.8.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
pest_meta = "2.8.6"
proptest = "1"
tempfile = "3"

View File

@ -1,13 +1,13 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use serde_json::Value;
use super::analyzer::{
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
DateComponent, FieldKind, FieldProposal,
DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path,
extract_date_component, find_array_paths,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::workbook::Workbook;
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
@ -80,8 +80,8 @@ impl ImportPipeline {
}
}
/// Build a Model from the current proposals. Pure — no side effects.
pub fn build_model(&self) -> Result<Model> {
/// Build a Workbook from the current proposals. Pure — no side effects.
pub fn build_model(&self) -> Result<Workbook> {
let categories: Vec<&FieldProposal> = self
.proposals
.iter()
@ -128,11 +128,11 @@ impl ImportPipeline {
})
.collect();
let mut model = Model::new(&self.model_name);
let mut wb = Workbook::new(&self.model_name);
for cat_proposal in &categories {
model.add_category(&cat_proposal.field)?;
if let Some(cat) = model.category_mut(&cat_proposal.field) {
wb.add_category(&cat_proposal.field)?;
if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
for val in &cat_proposal.distinct_values {
cat.add_item(val);
}
@ -141,19 +141,19 @@ impl ImportPipeline {
// Create derived date-component categories
for (_, _, _, derived_name) in &date_extractions {
model.add_category(derived_name)?;
wb.add_category(derived_name)?;
}
// Create label categories (stored but not pivoted by default)
for lab in &labels {
model.add_label_category(&lab.field)?;
wb.add_label_category(&lab.field)?;
}
if !measures.is_empty() {
if let Some(cat) = model.category_mut("_Measure") {
for m in &measures {
cat.add_item(&m.field);
}
if !measures.is_empty()
&& let Some(cat) = wb.model.category_mut("_Measure")
{
for m in &measures {
cat.add_item(&m.field);
}
}
@ -170,20 +170,20 @@ impl ImportPipeline {
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
if let Some(v) = val {
if let Some(cat) = model.category_mut(&cat_proposal.field) {
if let Some(cat) = wb.model.category_mut(&cat_proposal.field) {
cat.add_item(&v);
}
coords.push((cat_proposal.field.clone(), v.clone()));
// Extract date components from this field's value
for (field, fmt, comp, derived_name) in &date_extractions {
if *field == cat_proposal.field {
if let Some(derived_val) = extract_date_component(&v, fmt, *comp) {
if let Some(cat) = model.category_mut(derived_name) {
cat.add_item(&derived_val);
}
coords.push((derived_name.clone(), derived_val));
if *field == cat_proposal.field
&& let Some(derived_val) = extract_date_component(&v, fmt, *comp)
{
if let Some(cat) = wb.model.category_mut(derived_name) {
cat.add_item(&derived_val);
}
coords.push((derived_name.clone(), derived_val));
}
}
} else {
@ -212,7 +212,7 @@ impl ImportPipeline {
})
})
.unwrap_or_default();
if let Some(cat) = model.category_mut(&lab.field) {
if let Some(cat) = wb.model.category_mut(&lab.field) {
cat.add_item(&val);
}
coords.push((lab.field.clone(), val));
@ -222,7 +222,8 @@ impl ImportPipeline {
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
let mut cell_coords = coords.clone();
cell_coords.push(("_Measure".to_string(), measure.field.clone()));
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
wb.model
.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
}
}
}
@ -233,11 +234,11 @@ impl ImportPipeline {
let formula_cat: String = "_Measure".to_string();
for raw in &self.formulas {
if let Ok(formula) = parse_formula(raw, &formula_cat) {
model.add_formula(formula);
wb.model.add_formula(formula);
}
}
Ok(model)
Ok(wb)
}
}
@ -521,7 +522,7 @@ impl ImportWizard {
// ── Delegate build to pipeline ────────────────────────────────────────────
pub fn build_model(&self) -> Result<Model> {
pub fn build_model(&self) -> Result<Workbook> {
self.pipeline.build_model()
}
}
@ -616,9 +617,9 @@ mod tests {
{"region": "West", "revenue": 200.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
assert!(model.category("region").is_some());
assert!(model.category("_Measure").is_some());
let wb = p.build_model().unwrap();
assert!(wb.model.category("region").is_some());
assert!(wb.model.category("_Measure").is_some());
}
#[test]
@ -634,9 +635,9 @@ mod tests {
assert_eq!(desc.kind, FieldKind::Label);
assert!(desc.accepted, "labels should default to accepted");
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
// Label field exists as a category with Label kind
let cat = model.category("desc").expect("desc category exists");
let cat = wb.model.category("desc").expect("desc category exists");
assert_eq!(cat.kind, CategoryKind::Label);
// Each record's cell key carries the desc label coord
use crate::model::cell::CellKey;
@ -645,7 +646,7 @@ mod tests {
("desc".to_string(), "row-7".to_string()),
("region".to_string(), "East".to_string()),
]);
assert_eq!(model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
assert_eq!(wb.model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
}
#[test]
@ -656,8 +657,8 @@ mod tests {
.collect();
let raw = serde_json::Value::Array(records);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let v = model.active_view();
let wb = p.build_model().unwrap();
let v = wb.active_view();
assert_eq!(v.axis_of("desc"), Axis::None);
}
@ -668,7 +669,7 @@ mod tests {
{"region": "West", "revenue": 200.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
use crate::model::cell::CellKey;
let k_east = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
@ -679,11 +680,11 @@ mod tests {
("region".to_string(), "West".to_string()),
]);
assert_eq!(
model.get_cell(&k_east).and_then(|v| v.as_f64()),
wb.model.get_cell(&k_east).and_then(|v| v.as_f64()),
Some(100.0)
);
assert_eq!(
model.get_cell(&k_west).and_then(|v| v.as_f64()),
wb.model.get_cell(&k_west).and_then(|v| v.as_f64()),
Some(200.0)
);
}
@ -703,14 +704,14 @@ mod tests {
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Profit = revenue - cost".to_string());
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
// The formula should produce Profit = 60 for East (100-40)
use crate::model::cell::CellKey;
let key = CellKey::new(vec![
("_Measure".to_string(), "Profit".to_string()),
("region".to_string(), "East".to_string()),
]);
let val = model.evaluate(&key).and_then(|v| v.as_f64());
let val = wb.model.evaluate(&key).and_then(|v| v.as_f64());
assert_eq!(val, Some(60.0));
}
@ -730,9 +731,9 @@ mod tests {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
assert!(model.category("Date_Month").is_some());
let cat = model.category("Date_Month").unwrap();
let wb = p.build_model().unwrap();
assert!(wb.model.category("Date_Month").is_some());
let cat = wb.model.category("Date_Month").unwrap();
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
assert!(items.contains(&"2025-01"));
assert!(items.contains(&"2025-02"));
@ -850,7 +851,7 @@ mod tests {
let mut w = ImportWizard::new(raw);
assert_eq!(w.step, WizardStep::SelectArrayPath);
w.confirm_path(); // selects first path
// Should advance past SelectArrayPath
// Should advance past SelectArrayPath
assert_ne!(w.step, WizardStep::SelectArrayPath);
assert!(!w.pipeline.records.is_empty());
}
@ -1003,20 +1004,12 @@ mod tests {
// Toggle Year component (cursor 0 = Year of first time field)
let had_year_before = {
let tc = w.time_category_proposals();
!tc.is_empty()
&& tc[0]
.date_components
.iter()
.any(|c| *c == DateComponent::Year)
!tc.is_empty() && tc[0].date_components.contains(&DateComponent::Year)
};
w.toggle_date_component();
let has_year_after = {
let tc = w.time_category_proposals();
!tc.is_empty()
&& tc[0]
.date_components
.iter()
.any(|c| *c == DateComponent::Year)
!tc.is_empty() && tc[0].date_components.contains(&DateComponent::Year)
};
assert_ne!(had_year_before, has_year_after);
}
@ -1054,14 +1047,14 @@ mod tests {
{"revenue": 200.0}, // missing "region"
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
// Only one cell should exist (the East record)
use crate::model::cell::CellKey;
let k = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
("region".to_string(), "East".to_string()),
]);
assert!(model.get_cell(&k).is_some());
assert!(wb.model.get_cell(&k).is_some());
}
#[test]
@ -1075,8 +1068,8 @@ mod tests {
{"id": "A", "type": "y", "value": 150.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let cat = model.category("id").expect("id should be a category");
let wb = p.build_model().unwrap();
let cat = wb.model.category("id").expect("id should be a category");
let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
assert!(items.contains(&"A"));
assert!(items.contains(&"B"));
@ -1093,11 +1086,11 @@ mod tests {
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Test = A + B".to_string());
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
// Formula should still be added (even if target category is suboptimal)
// The formula may fail to parse against a non-measure category, which is OK
// Just ensure build_model doesn't panic
assert!(model.category("region").is_some());
assert!(wb.model.category("region").is_some());
}
#[test]
@ -1114,12 +1107,15 @@ mod tests {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
let wb = p.build_model().unwrap();
let key = CellKey::new(vec![
("Date".to_string(), "03/31/2026".to_string()),
("Date_Month".to_string(), "2026-03".to_string()),
("_Measure".to_string(), "Amount".to_string()),
]);
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
assert_eq!(
wb.model.get_cell(&key).and_then(|v| v.as_f64()),
Some(100.0)
);
}
}

View File

@ -0,0 +1,16 @@
//! I/O layer for `improvise`: `.improv` persistence (parse/format, save/load,
//! CSV export) and import (CSV/JSON wizard, field analyzer).
//!
//! Depends on `improvise-core` for the data model (`Model`, `View`,
//! `Workbook`, `CellKey`, `CellValue`, `GridLayout`, `Axis`, `Group`) and on
//! `improvise-formula` for formula parsing. Has no awareness of UI or
//! commands — builds standalone via `cargo build -p improvise-io`.
//!
//! Re-exports the core modules under their conventional names so code in
//! this crate can keep using `crate::model::*`, `crate::view::*`,
//! `crate::workbook::*`, `crate::format::*`, and `crate::formula::*` paths.
pub use improvise_core::{format, model, view, workbook};
pub use improvise_formula as formula;
pub mod import;
pub mod persistence;

View File

@ -10,7 +10,6 @@
file = {
SOI ~
blank_lines ~
version_line ~
model_name ~
initial_view? ~
@ -18,7 +17,7 @@ file = {
EOI
}
version_line = { "v" ~ rest_of_line ~ NEWLINE ~ blank_lines }
version_line = { "v2025-04-09" ~ NEWLINE ~ blank_lines }
model_name = { "# " ~ rest_of_line ~ NEWLINE ~ blank_lines }
initial_view = { "Initial View: " ~ rest_of_line ~ NEWLINE ~ blank_lines }

343
examples/gen-grammar.rs Normal file
View File

@ -0,0 +1,343 @@
//! Generate a random valid example matching a rule from `improv.pest`.
//!
//! Usage:
//! cargo run --example gen-grammar -- <rule_name>
//!
//! Examples:
//! cargo run --example gen-grammar -- file
//! cargo run --example gen-grammar -- category_section
//! cargo run --example gen-grammar -- bare_name
//!
//! Each invocation generates one example seeded from the current time + PID.
//!
//! The generator adds constraints beyond what the grammar requires to produce
//! realistic, round-trippable output:
//! - bare names are drawn from a word pool instead of random letters
//! - pipe_inner is never empty
//! - rest_of_line always produces at least one character
//! - repetitions (`*`) produce 14 items, not 0
use pest_meta::ast::{Expr, RuleType};
use pest_meta::parser;
use std::collections::HashMap;
const GRAMMAR: &str = include_str!("../crates/improvise-io/src/persistence/improv.pest");
fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
let pairs = parser::parse(parser::Rule::grammar_rules, GRAMMAR)
.unwrap_or_else(|e| panic!("Bad grammar: {e}"));
let rules = parser::consume_rules(pairs).unwrap_or_else(|e| panic!("{e:?}"));
rules
.into_iter()
.map(|r| (r.name.clone(), (r.ty, r.expr)))
.collect()
}
// ── Word pools for realistic output ─────────────────────────────────────────
const BARE_WORDS: &[&str] = &[
"Region",
"Product",
"Customer",
"Channel",
"Date",
"North",
"South",
"East",
"West",
"Revenue",
"Cost",
"Profit",
"Margin",
"Widgets",
"Gadgets",
"Sprockets",
"Q1",
"Q2",
"Q3",
"Q4",
"Jan",
"Feb",
"Mar",
"Apr",
"Acme",
"Globex",
"Initech",
"Umbrella",
];
const QUOTED_WORDS: &[&str] = &[
"Total Revenue",
"Net Income",
"Gross Margin",
"2025-01",
"2025-02",
"2025-03",
"East Coast",
"West Coast",
"Acme Corp",
"Globex Inc",
"Cost of Goods",
"Operating Expense",
];
const MODEL_NAMES: &[&str] = &[
"Sales Report",
"Budget 2025",
"Quarterly Review",
"Inventory Model",
"Revenue Analysis",
"Demo Model",
];
const VIEW_NAMES: &[&str] = &["Default", "Summary", "Detail", "By Region", "Monthly"];
const FORMULA_EXPRS: &[&str] = &[
"Profit = Revenue - Cost",
"Margin = Profit / Revenue",
"Tax = Revenue * 0.1",
"Total = SUM(Revenue)",
"Net = Revenue - Cost - Tax",
];
const FORMAT_STRINGS: &[&str] = &[",.0", ",.2f", ",.1f", ".0%"];
// ── PRNG ────────────────────────────────────────────────────────────────────
struct Xs64(u64);
impl Xs64 {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn byte(&mut self) -> u8 {
(self.next() & 0xff) as u8
}
fn pick_from<'a>(&mut self, pool: &[&'a str]) -> &'a str {
pool[self.next() as usize % pool.len()]
}
}
// ── Generator ───────────────────────────────────────────────────────────────
struct Gen<'g> {
rules: &'g HashMap<String, (RuleType, Expr)>,
rng: Xs64,
}
impl<'g> Gen<'g> {
fn new(rules: &'g HashMap<String, (RuleType, Expr)>, seed: u64) -> Self {
Self {
rules,
rng: Xs64::new(seed),
}
}
fn pick(&mut self) -> u8 {
self.rng.byte()
}
/// Try a rule-specific override. Returns true if handled.
fn try_override(&mut self, rule_name: &str, out: &mut String) -> bool {
match rule_name {
"bare_name" => {
out.push_str(self.rng.pick_from(BARE_WORDS));
true
}
"pipe_inner" => {
// Never empty
out.push_str(self.rng.pick_from(QUOTED_WORDS));
true
}
"rest_of_line" => {
// Context-sensitive: produce something non-empty
let word_count = 1 + self.pick() % 3;
for i in 0..word_count {
if i > 0 {
out.push(' ');
}
out.push_str(self.rng.pick_from(BARE_WORDS));
}
true
}
"model_name" => {
out.push_str("# ");
out.push_str(self.rng.pick_from(MODEL_NAMES));
out.push('\n');
true
}
"format_line" => {
out.push_str("format: ");
out.push_str(self.rng.pick_from(FORMAT_STRINGS));
out.push('\n');
true
}
"formula_line" => {
out.push_str("- ");
out.push_str(self.rng.pick_from(FORMULA_EXPRS));
out.push('\n');
true
}
"number" => {
let whole = 1 + self.rng.next() % 99999;
if self.pick().is_multiple_of(3) {
let frac = self.rng.next() % 100;
out.push_str(&format!("{whole}.{frac:02}"));
} else {
out.push_str(&format!("{whole}"));
}
true
}
"axis_kind" => {
let kinds = ["row", "column", "page", "none"];
out.push_str(kinds[self.pick() as usize % kinds.len()]);
true
}
"view_section" => {
out.push_str("## View: ");
out.push_str(self.rng.pick_from(VIEW_NAMES));
out.push('\n');
// Generate view_entry* from the grammar
let count = 1 + self.pick() % 4;
if let Some((_ty, expr)) = self.rules.get("view_entry") {
let expr = expr.clone();
for _ in 0..count {
self.emit(&expr, out);
}
}
true
}
_ => false,
}
}
fn emit(&mut self, expr: &Expr, out: &mut String) {
match expr {
Expr::Str(s) => out.push_str(s),
Expr::Range(lo, hi) => {
let lo = lo.chars().next().unwrap() as u32;
let hi = hi.chars().next().unwrap() as u32;
let range = hi - lo + 1;
let ch = char::from_u32(lo + (self.pick() as u32 % range)).unwrap();
out.push(ch);
}
Expr::Ident(name) => match name.as_str() {
"ANY" => {
let ch = (b'a' + self.pick() % 26) as char;
out.push(ch);
}
"NEWLINE" => out.push('\n'),
"SOI" | "EOI" => {}
"ASCII_DIGIT" => {
let d = (b'0' + self.pick() % 10) as char;
out.push(d);
}
_ => {
// Try override first, fall back to grammar walk
if !self.try_override(name, out)
&& let Some((_ty, expr)) = self.rules.get(name)
{
let expr = expr.clone();
self.emit(&expr, out);
}
}
},
Expr::Seq(a, b) => {
self.emit(a, out);
self.emit(b, out);
}
Expr::Choice(a, b) => {
let mut alts: Vec<&Expr> = vec![a.as_ref()];
let mut cur = b.as_ref();
while let Expr::Choice(l, r) = cur {
alts.push(l.as_ref());
cur = r.as_ref();
}
alts.push(cur);
let idx = self.pick() as usize % alts.len();
self.emit(alts[idx], out);
}
Expr::Opt(inner) => {
if !self.pick().is_multiple_of(3) {
// ~66% chance of emitting
self.emit(inner, out);
}
}
Expr::Rep(inner) => {
// 14 reps (never 0 — avoid degenerate empty output)
let count = 1 + self.pick() % 4;
for _ in 0..count {
self.emit(inner, out);
}
}
Expr::RepOnce(inner) => {
let count = 1 + self.pick() % 4;
for _ in 0..count {
self.emit(inner, out);
}
}
Expr::NegPred(_) | Expr::PosPred(_) => {}
_ => {}
}
}
fn generate(&mut self, rule_name: &str) -> Option<String> {
// Check override first (for top-level rule invocation)
let mut out = String::new();
if self.try_override(rule_name, &mut out) {
return Some(out);
}
let (_ty, expr) = self.rules.get(rule_name)?.clone();
self.emit(&expr, &mut out);
Some(out)
}
}
fn print_rules(rules: &HashMap<String, (RuleType, Expr)>) {
let mut names: Vec<_> = rules.keys().collect();
names.sort();
for name in names {
println!(" {name}");
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let rules = load_grammar();
if args.len() < 2 {
eprintln!("Usage: {} <rule_name>", args[0]);
eprintln!();
eprintln!("Available rules:");
print_rules(&rules);
std::process::exit(2);
}
let rule = &args[1];
if !rules.contains_key(rule) {
eprintln!("Unknown rule '{rule}'. Available rules:");
print_rules(&rules);
std::process::exit(1);
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
^ (std::process::id() as u64).wrapping_mul(0x9E3779B97F4A7C15);
let mut g = Gen::new(&rules, seed);
match g.generate(rule) {
Some(out) => print!("{out}"),
None => {
eprintln!("Failed to generate from rule '{rule}'");
std::process::exit(1);
}
}
}

25
examples/pretty-print.rs Normal file
View File

@ -0,0 +1,25 @@
//! Parse a `.improv` file from stdin and print the formatted result to stdout.
//!
//! Usage:
//! cargo run --example pretty-print < file.improv
//! cargo run --example gen-grammar -- file | cargo run --example pretty-print
use std::io::Read;
fn main() {
let mut input = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
eprintln!("Failed to read stdin: {e}");
std::process::exit(1);
}
match improvise::persistence::parse_md(&input) {
Ok(model) => {
print!("{}", improvise::persistence::format_md(&model));
}
Err(e) => {
eprintln!("Parse error: {e:#}");
std::process::exit(1);
}
}
}

View File

@ -27,15 +27,11 @@
extensions = ["rust-src" "clippy" "rustfmt" "llvm-tools-preview"];
};
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
name = "improvise";
src = ./.;
};
cargoNix = import generatedCargoNix {
pkgs = pkgs;
cargoNix = import ./Cargo.nix {
inherit nixpkgs pkgs;
};
in {
inherit cargoNix;
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
@ -55,6 +51,9 @@
RUST_BACKTRACE = "1";
};
packages.default = cargoNix.rootCrate.build;
packages = {
improvise = cargoNix.workspaceMembers.improvise.build;
default = self.packages.${system}.improvise;
};
});
}

3490
roadmap.org Normal file

File diff suppressed because it is too large Load Diff

179
scripts/gen_roadmap.py Executable file
View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate roadmap.org from bd.
Inverted-tree layout: each tree is rooted at an issue that nothing else
(among the open set) depends on — the "epic" or standalone goal. Its
children are the things blocking it, recursively. Diamonds in the DAG
are broken into a tree by duplicating shared deps under each parent so
emacs `[/]` cookies count whole subtrees.
Usage: python3 scripts/gen_roadmap.py [output-path]
(default output: roadmap.org at the repo root)
"""
import json
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
def bd_json(*args):
"""Run `bd <args>` and parse JSON from stdout."""
cmd = ['bd', *args, '--json']
res = subprocess.run(cmd, check=True, capture_output=True, text=True)
return json.loads(res.stdout)
def main():
repo = Path(__file__).resolve().parent.parent
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else repo / 'roadmap.org'
issues = bd_json('list')
by_id = {i['id']: i for i in issues}
# `bd list --all` includes closed issues — used only to resolve titles
# of already-done dep targets so we can label them in :DONE_DEPS:.
all_issues = bd_json('list', '--all')
all_by_id = {i['id']: i for i in all_issues}
# --- Build dep graph (open-issue subgraph only) ----------------------
# child depends_on parent; in our inverted tree, parent (the goal/epic)
# sits ABOVE its deps (children).
tree_children = defaultdict(set)
tree_parents = defaultdict(set)
for i in issues:
iid = i['id']
for d in (i.get('dependencies') or []):
dep = d['depends_on_id']
if dep in by_id and iid in by_id:
tree_children[iid].add(dep)
tree_parents[dep].add(iid)
def order_key(iid):
i = by_id[iid]
return (
0 if i['status'] == 'in_progress' else 1,
i['priority'],
iid,
)
roots = sorted(
[iid for iid in by_id if not tree_parents.get(iid)],
key=order_key,
)
# --- Helpers ---------------------------------------------------------
status_kw = {
'open': 'TODO',
'in_progress': 'DOING',
'closed': 'DONE',
'blocked': 'WAIT',
'deferred': 'WAIT',
}
def tag(s):
return re.sub(r'[^A-Za-z0-9_@#%]', '_', s)
def headline_tags(i, kind):
tags = [kind, f"P{i['priority']}", tag(i['issue_type'])]
if i.get('assignee'):
tags.append('@' + tag(i['assignee']))
return ':' + ':'.join(tags) + ':'
def fmt_date(s):
return s.replace('T', ' ').rstrip('Z') if s else ''
def kind_of(iid):
return 'epic' if tree_children.get(iid) else 'standalone'
out = []
out.append('#+TITLE: Improvise Roadmap')
out.append('#+AUTHOR: Edward Langley')
out.append('#+TODO: TODO DOING WAIT | DONE')
out.append('#+STARTUP: overview')
out.append('#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug')
out.append('#+PROPERTY: COOKIE_DATA todo recursive')
out.append('')
out.append(
f'Generated from ~bd list --json~. {len(issues)} open issues '
f'organised as {len(roots)} inverted dep-trees: each root is a goal '
'that nothing else depends on; its children are the deps blocking '
'it. Diamonds in the DAG are duplicated so each tree stands alone.'
)
out.append('')
def emit(iid, depth, path):
i = by_id[iid]
kind = kind_of(iid)
kw = status_kw.get(i['status'], 'TODO')
title = i['title'].replace('[', '(').replace(']', ')')
stars = '*' * depth
cookie = ' [/]' if tree_children.get(iid) else ''
head = f'{stars} {kw} {title} ({kind}){cookie}'
pad = max(1, 95 - len(head))
out.append(f'{head}{" " * pad}{headline_tags(i, kind)}')
indent = ' ' * (depth - 1) + ' '
out.append(f'{indent}:PROPERTIES:')
out.append(f'{indent}:ID: {iid}')
out.append(f'{indent}:TYPE: {i["issue_type"]}')
out.append(f'{indent}:PRIORITY: P{i["priority"]}')
out.append(f'{indent}:STATUS: {i["status"]}')
if i.get('assignee'):
out.append(f'{indent}:ASSIGNEE: {i["assignee"]}')
if i.get('owner'):
out.append(f'{indent}:OWNER: {i["owner"]}')
if i.get('created_by'):
out.append(f'{indent}:CREATED_BY: {i["created_by"]}')
out.append(f'{indent}:CREATED: {fmt_date(i["created_at"])}')
out.append(f'{indent}:UPDATED: {fmt_date(i["updated_at"])}')
if i.get('comment_count'):
out.append(f'{indent}:COMMENTS: {i["comment_count"]}')
out.append(f'{indent}:KIND: {kind}')
closed_deps = sorted(
d['depends_on_id']
for d in (i.get('dependencies') or [])
if (ref := all_by_id.get(d['depends_on_id'])) and ref['status'] == 'closed'
)
if closed_deps:
out.append(f'{indent}:DONE_DEPS: {", ".join(closed_deps)}')
if iid in path:
out.append(f'{indent}:CYCLE: yes — descendants pruned at this node')
out.append(f'{indent}:END:')
if iid in path:
return # cycle guard
details_stars = '*' * (depth + 1)
section_stars = '*' * (depth + 2)
section_indent = ' ' * (depth + 1) + ' '
if any(i.get(k) for k in ('description', 'design', 'acceptance_criteria', 'notes')):
out.append(f'{details_stars} Details')
for label, key in [
('Description', 'description'),
('Design', 'design'),
('Acceptance Criteria', 'acceptance_criteria'),
('Notes', 'notes'),
]:
v = i.get(key)
if not v:
continue
out.append(f'{section_stars} {label}')
for line in v.rstrip('\n').splitlines():
out.append(f'{section_indent}{line}' if line else '')
new_path = path | {iid}
for c in sorted(tree_children.get(iid, set()), key=order_key):
out.append('')
emit(c, depth + 1, new_path)
for r in roots:
emit(r, 1, frozenset())
out.append('')
out_path.write_text('\n'.join(out))
print(f'Wrote {out_path} ({len(out)} lines, {len(roots)} roots, {len(by_id)} issues)')
if __name__ == '__main__':
main()

View File

@ -15,7 +15,7 @@ mod tests {
("Type".to_string(), "Food".to_string()),
("Month".to_string(), "Jan".to_string()),
]);
m.set_cell(key, CellValue::Number(42.0));
m.model.set_cell(key, CellValue::Number(42.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
@ -33,7 +33,7 @@ mod tests {
("Type".to_string(), "Food".to_string()),
("Month".to_string(), "Jan".to_string()),
]);
m.set_cell(key, CellValue::Number(99.0));
m.model.set_cell(key, CellValue::Number(99.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
@ -47,7 +47,7 @@ mod tests {
#[test]
fn paste_with_yanked_value_produces_set_cell() {
let mut m = two_cat_model();
m.set_cell(
m.model.set_cell(
CellKey::new(vec![
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),

View File

@ -3,7 +3,8 @@ use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{Cmd, CmdContext};
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
use super::grid::AddRecordRow;
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
#[cfg(test)]
mod tests {
@ -11,7 +12,7 @@ mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::Model;
use crate::workbook::Workbook;
#[test]
fn commit_formula_with_categories_adds_formula() {
@ -38,7 +39,7 @@ mod tests {
/// categories exist. _Measure is a virtual category that always exists.
#[test]
fn commit_formula_without_regular_categories_targets_measure() {
let m = Model::new("Empty");
let m = Workbook::new("Empty");
let layout = make_layout(&m);
let reg = make_registry();
let mut bufs = HashMap::new();
@ -117,6 +118,35 @@ mod tests {
assert!(effects.is_empty());
}
/// `CommitAndAdvance` must thread its `edit_mode` through to the
/// trailing `EnterEditAtCursor` effect so the post-commit re-edit lands
/// in the mode the keymap requested. The command never reads ctx.mode.
#[test]
fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut bufs = HashMap::new();
bufs.insert("edit".to_string(), "42".to_string());
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.buffers = &bufs;
// ctx.mode stays Normal — the command must not look at it.
let key = ctx.cell_key().unwrap();
let cmd = CommitAndAdvance {
key,
value: "42".to_string(),
advance: super::AdvanceDir::Down,
cursor: super::CursorState::from_ctx(&ctx),
edit_mode: AppMode::records_editing(),
};
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("EnterEditAtCursor") && dbg.contains("RecordsEditing"),
"Expected trailing EnterEditAtCursor with RecordsEditing target, got: {dbg}"
);
}
#[test]
fn commit_export_produces_export_and_normal_mode() {
let m = two_cat_model();
@ -137,27 +167,89 @@ mod tests {
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
/// or apply directly; for real keys, write to the model.
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx,
col_name,
new_value: value.to_string(),
}));
} else if value.is_empty() {
/// in drill mode, or apply directly in plain records mode; for real keys, write
/// to the model.
fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
if value.is_empty() {
effects.push(Box::new(effect::ClearCell(key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(
key.clone(),
CellValue::Text(value.to_string()),
)));
effects.push(effect::mark_dirty());
}
effects.push(effect::mark_dirty());
}
/// Stage a synthetic edit in drill state so it can be applied atomically on exit.
fn stage_drill_edit(record_idx: usize, col_name: String, value: &str) -> Box<dyn Effect> {
Box::new(effect::SetDrillPendingEdit {
record_idx,
col_name,
new_value: value.to_string(),
})
}
/// Apply a synthetic records-mode edit directly to the underlying model cell.
fn commit_plain_records_edit(
ctx: &CmdContext,
record_idx: usize,
col_name: &str,
value: &str,
effects: &mut Vec<Box<dyn Effect>>,
) {
let Some((orig_key, _)) = ctx
.layout
.records
.as_ref()
.and_then(|records| records.get(record_idx))
else {
return;
};
if col_name == "Value" {
commit_regular_cell_value(orig_key, value, effects);
return;
}
if value.is_empty() {
effects.push(effect::set_status(effect::RECORD_COORDS_CANNOT_BE_EMPTY));
return;
}
let Some(existing_value) = ctx.model.get_cell(orig_key).cloned() else {
return;
};
effects.push(Box::new(effect::ClearCell(orig_key.clone())));
effects.push(Box::new(effect::AddItem {
category: col_name.to_string(),
item: value.to_string(),
}));
effects.push(Box::new(effect::SetCell(
orig_key.clone().with(col_name, value),
existing_value,
)));
effects.push(effect::mark_dirty());
}
fn commit_cell_value(
ctx: &CmdContext,
key: &CellKey,
value: &str,
effects: &mut Vec<Box<dyn Effect>>,
) {
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
if ctx.has_drill_state {
effects.push(stage_drill_edit(record_idx, col_name, value));
return;
}
commit_plain_records_edit(ctx, record_idx, &col_name, value, effects);
return;
}
commit_regular_cell_value(key, value, effects);
}
/// Direction to advance after committing a cell edit.
@ -169,14 +261,30 @@ pub enum AdvanceDir {
Right,
}
/// Return the normal-mode counterpart of an editing mode. Used by
/// `CommitAndAdvance` to compute the mode to land in if the advance
/// aborts (commit + exit editing at boundary).
fn exit_mode_for(edit_mode: &AppMode) -> AppMode {
match edit_mode {
AppMode::RecordsEditing { .. } => AppMode::RecordsNormal,
_ => AppMode::Normal,
}
}
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
///
/// `edit_mode` is the editing mode to re-enter after advancing. The keymap
/// binding supplies this — the editing-mode keymap passes `editing` and the
/// records-editing keymap passes `records-editing`. The command itself
/// never inspects `ctx.mode`.
#[derive(Debug)]
pub struct CommitAndAdvance {
pub key: CellKey,
pub value: String,
pub advance: AdvanceDir,
pub cursor: CursorState,
pub edit_mode: AppMode,
}
impl Cmd for CommitAndAdvance {
fn name(&self) -> &'static str {
@ -187,7 +295,14 @@ impl Cmd for CommitAndAdvance {
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
commit_cell_value(&self.key, &self.value, &mut effects);
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
// Pre-emptively drop to the normal counterpart of edit_mode. If the
// advance succeeds, the trailing `EnterEditAtCursor` below will lift
// us back into editing on the new cell. If the advance aborts
// (e.g. already at bottom-right on Enter), `EnterEditAtCursor` is
// skipped and we land in normal mode — which is the desired
// "Enter at bottom-right commits and exits" behavior.
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
match self.advance {
AdvanceDir::Down => {
let adv = EnterAdvance {
@ -197,18 +312,36 @@ impl Cmd for CommitAndAdvance {
}
AdvanceDir::Right => {
let col_max = self.cursor.col_count.saturating_sub(1);
let nc = (self.cursor.col + 1).min(col_max);
effects.extend(viewport_effects(
self.cursor.row,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
let row_max = self.cursor.row_count.saturating_sub(1);
let at_bottom_right = self.cursor.row >= row_max && self.cursor.col >= col_max;
if at_bottom_right && ctx.is_records_mode() {
let add = AddRecordRow;
effects.extend(add.execute(ctx));
effects.extend(viewport_effects(
self.cursor.row + 1,
0,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
} else {
let nc = (self.cursor.col + 1).min(col_max);
effects.extend(viewport_effects(
self.cursor.row,
nc,
self.cursor.row_offset,
self.cursor.col_offset,
self.cursor.visible_rows,
self.cursor.visible_cols,
));
}
}
}
effects.push(Box::new(effect::EnterEditAtCursor));
effects.push(Box::new(effect::EnterEditAtCursor {
target_mode: self.edit_mode.clone(),
}));
effects
}
}
@ -222,22 +355,23 @@ impl Cmd for CommitFormula {
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let buf = ctx.buffers.get("formula").cloned().unwrap_or_default();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Default formula target to _Measure (the virtual measure category).
// _Measure dynamically includes all formula targets.
effects.push(Box::new(effect::AddFormula {
raw: buf,
target_category: "_Measure".to_string(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status("Formula added"));
effects.push(effect::change_mode(AppMode::FormulaPanel));
effects
vec![
Box::new(effect::AddFormula {
raw: buf,
target_category: "_Measure".to_string(),
}),
effect::mark_dirty(),
effect::set_status("Formula added"),
effect::change_mode(AppMode::FormulaPanel),
]
}
}
/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty
/// + status effects. If empty, return to CategoryPanel.
///
/// Buffer clearing is handled by the keymap (Enter → [commit, clear-buffer]).
fn commit_add_from_buffer(
ctx: &CmdContext,

View File

@ -3,15 +3,22 @@ use std::fmt::Debug;
use crossterm::event::KeyCode;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
use crate::ui::effect::{Effect, Panel};
use crate::view::{Axis, GridLayout};
use crate::view::{Axis, GridLayout, View};
use crate::workbook::Workbook;
/// Read-only context available to commands for decision-making.
///
/// Commands receive a `&Model` (pure data) and a `&View` (the active view).
/// The full `&Workbook` is also available for the rare command that needs
/// to enumerate all views (e.g. the view panel).
pub struct CmdContext<'a> {
pub model: &'a Model,
pub workbook: &'a Workbook,
pub view: &'a View,
pub layout: &'a GridLayout,
pub registry: &'a CmdRegistry,
pub mode: &'a AppMode,
@ -34,8 +41,10 @@ pub struct CmdContext<'a> {
/// Named text buffers
pub buffers: &'a HashMap<String, String>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: &'a [String],
pub view_forward_stack: &'a [String],
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
/// Whether the app currently has an active drill snapshot.
pub has_drill_state: bool,
/// Display value at the cursor — works uniformly for pivot and records mode.
pub display_value: String,
/// How many data rows/cols fit on screen (for viewport scrolling).
@ -48,9 +57,22 @@ pub struct CmdContext<'a> {
}
impl<'a> CmdContext<'a> {
/// Return true when the current layout is a records-mode layout.
pub fn is_records_mode(&self) -> bool {
self.layout.is_records_mode()
}
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
/// Return synthetic record coordinates for the current cursor, if any.
pub fn synthetic_record_at_cursor(&self) -> Option<(usize, String)> {
self.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
}
pub fn row_count(&self) -> usize {
self.layout.row_count()
}

View File

@ -2,7 +2,7 @@ use crate::model::cell::CellValue;
use crate::ui::effect::{self, Effect};
use crate::view::Axis;
use super::core::{require_args, Cmd, CmdContext};
use super::core::{Cmd, CmdContext, require_args};
#[cfg(test)]
mod tests {
@ -110,7 +110,6 @@ macro_rules! effect_cmd {
};
}
effect_cmd!(
AddCategoryCmd,
"add-category",
@ -202,7 +201,10 @@ effect_cmd!(
"add-formula",
|args: &[String]| {
if args.is_empty() || args.len() > 2 {
return Err(format!("add-formula requires 1-2 argument(s), got {}", args.len()));
return Err(format!(
"add-formula requires 1-2 argument(s), got {}",
args.len()
));
}
Ok(())
},
@ -397,7 +399,10 @@ effect_cmd!(
"help",
|_args: &[String]| -> Result<(), String> { Ok(()) },
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)]
vec![
effect::help_page_set(0),
effect::change_mode(crate::ui::app::AppMode::Help),
]
}
);

View File

@ -1,4 +1,5 @@
use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use crate::view::AxisEntry;
@ -67,7 +68,10 @@ mod tests {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let fwd_stack = vec!["View 2".to_string()];
let fwd_stack = vec![crate::ui::app::ViewFrame {
view_name: "View 2".to_string(),
mode: crate::ui::app::AppMode::Normal,
}];
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_forward_stack = &fwd_stack;
let cmd = ViewNavigate { forward: true };
@ -84,7 +88,10 @@ mod tests {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let back_stack = vec!["Default".to_string()];
let back_stack = vec![crate::ui::app::ViewFrame {
view_name: "Default".to_string(),
mode: crate::ui::app::AppMode::Normal,
}];
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_back_stack = &back_stack;
let cmd = ViewNavigate { forward: false };
@ -111,6 +118,61 @@ mod tests {
"Expected TogglePruneEmpty, got: {dbg}"
);
}
/// Drilling into a formula cell (e.g. Profit = Revenue - Cost) should
/// return the underlying data records, not an empty result set. The
/// formula target coordinate is stripped from the drill key so that
/// matching_cells finds the raw data backing the formula.
#[test]
fn drill_into_formula_cell_returns_data_records() {
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::workbook::Workbook;
let mut m = Workbook::new("Test");
m.add_category("Region").unwrap();
m.model.category_mut("Region").unwrap().add_item("East");
m.model
.category_mut("_Measure")
.unwrap()
.add_item("Revenue");
m.model.category_mut("_Measure").unwrap().add_item("Cost");
m.model.set_cell(
CellKey::new(vec![
("_Measure".into(), "Revenue".into()),
("Region".into(), "East".into()),
]),
CellValue::Number(1000.0),
);
m.model.set_cell(
CellKey::new(vec![
("_Measure".into(), "Cost".into()),
("Region".into(), "East".into()),
]),
CellValue::Number(600.0),
);
m.model
.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
// Drill into the Profit/East cell — a formula-derived cell
let key = CellKey::new(vec![
("_Measure".into(), "Profit".into()),
("Region".into(), "East".into()),
]);
let cmd = DrillIntoCell { key };
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
// Should find underlying data records, not "0 rows"
assert!(
!dbg.contains("0 rows"),
"Drill into formula cell should find data records, got: {dbg}"
);
}
}
// ── Grid operations ─────────────────────────────────────────────────────
@ -233,9 +295,27 @@ impl Cmd for DrillIntoCell {
let drill_name = "_Drill".to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// If drilling into a formula cell, strip the formula target from the
// key so matching_cells finds the underlying raw data records instead
// of returning nothing.
let drill_key = if let Some(measure_val) = self.key.get("_Measure") {
let is_formula_target = ctx
.model
.formulas()
.iter()
.any(|f| f.target_category == "_Measure" && f.target == measure_val);
if is_formula_target {
self.key.without("_Measure")
} else {
self.key.clone()
}
} else {
self.key.clone()
};
// Capture the records snapshot NOW (before we switch views).
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
if self.key.0.is_empty() {
if drill_key.0.is_empty() {
ctx.model
.data
.iter_cells()
@ -244,7 +324,7 @@ impl Cmd for DrillIntoCell {
} else {
ctx.model
.data
.matching_cells(&self.key.0)
.matching_cells(&drill_key.0)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
@ -307,7 +387,7 @@ impl Cmd for TogglePruneEmpty {
"toggle-prune-empty"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let currently_on = ctx.model.active_view().prune_empty;
let currently_on = ctx.view.prune_empty;
vec![
Box::new(effect::TogglePruneEmpty),
effect::set_status(if currently_on {
@ -332,13 +412,22 @@ impl Cmd for ToggleRecordsMode {
let is_records = ctx.layout.is_records_mode();
if is_records {
// Navigate back to the previous view (restores original axes)
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
// Leaving records mode: clean up any records with empty CellKeys
// (produced by AddRecordRow when no page filters are set) before
// restoring the previous view. This is the inverse of `SortData`
// that runs on entry.
return vec![
Box::new(effect::CleanEmptyRecords),
Box::new(effect::ViewBack),
effect::set_status("Pivot mode"),
];
}
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
let records_name = "_Records".to_string();
effects.push(Box::new(effect::SortData));
// Create (or replace) a _Records view and switch to it
effects.push(Box::new(effect::CreateView(records_name.clone())));
effects.push(Box::new(effect::SwitchView(records_name)));
@ -360,6 +449,7 @@ impl Cmd for ToggleRecordsMode {
}));
}
}
effects.push(effect::change_mode(AppMode::RecordsNormal));
effects.push(effect::set_status("Records mode"));
effects
}
@ -374,18 +464,13 @@ impl Cmd for AddRecordRow {
"add-record-row"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.is_some();
if !is_records {
if !ctx.is_records_mode() {
return vec![effect::set_status(
"add-record-row only works in records mode",
)];
}
// Build a CellKey from the current page filters
let view = ctx.model.active_view();
let view = ctx.view;
let page_cats: Vec<String> = view
.categories_on(crate::view::Axis::Page)
.into_iter()

View File

@ -1,15 +1,15 @@
pub mod core;
pub mod navigation;
pub mod mode;
pub mod cell;
pub mod search;
pub mod panel;
pub mod grid;
pub mod tile;
pub mod text_buffer;
pub mod commit;
pub mod core;
pub mod effect_cmds;
pub mod grid;
pub mod mode;
pub mod navigation;
pub mod panel;
pub mod registry;
pub mod search;
pub mod text_buffer;
pub mod tile;
// Re-export items used by external code
pub use self::core::{Cmd, CmdContext, CmdRegistry};
@ -21,10 +21,10 @@ pub(super) mod test_helpers {
use crossterm::event::KeyCode;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use crate::view::GridLayout;
use crate::workbook::Workbook;
use super::core::CmdContext;
use super::registry::default_registry;
@ -36,19 +36,21 @@ pub(super) mod test_helpers {
pub static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
std::sync::LazyLock::new(std::collections::HashSet::new);
pub fn make_layout(model: &Model) -> GridLayout {
GridLayout::new(model, model.active_view())
pub fn make_layout(workbook: &Workbook) -> GridLayout {
GridLayout::new(&workbook.model, workbook.active_view())
}
pub fn make_ctx<'a>(
model: &'a Model,
workbook: &'a Workbook,
layout: &'a GridLayout,
registry: &'a CmdRegistry,
) -> CmdContext<'a> {
let view = model.active_view();
let view = workbook.active_view();
let (sr, sc) = view.selected;
CmdContext {
model,
model: &workbook.model,
workbook,
view,
layout,
registry,
mode: &AppMode::Normal,
@ -69,10 +71,11 @@ pub(super) mod test_helpers {
buffers: &EMPTY_BUFFERS,
view_back_stack: &[],
view_forward_stack: &[],
has_drill_state: false,
display_value: {
let key = layout.cell_key(sr, sc);
key.as_ref()
.and_then(|k| model.get_cell(k).cloned())
.and_then(|k| workbook.model.get_cell(k).cloned())
.map(|v| v.to_string())
.unwrap_or_default()
},
@ -83,32 +86,32 @@ pub(super) mod test_helpers {
}
}
pub fn two_cat_model() -> Model {
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Type").unwrap().add_item("Clothing");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Month").unwrap().add_item("Feb");
m
pub fn two_cat_model() -> Workbook {
let mut wb = Workbook::new("Test");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Type").unwrap().add_item("Clothing");
wb.model.category_mut("Month").unwrap().add_item("Jan");
wb.model.category_mut("Month").unwrap().add_item("Feb");
wb
}
pub fn three_cat_model_with_page() -> Model {
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Region").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Type").unwrap().add_item("Clothing");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Month").unwrap().add_item("Feb");
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("Region").unwrap().add_item("South");
m.category_mut("Region").unwrap().add_item("East");
let view = m.active_view_mut();
view.set_axis("Region", crate::view::Axis::Page);
m
pub fn three_cat_model_with_page() -> Workbook {
let mut wb = Workbook::new("Test");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Region").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Type").unwrap().add_item("Clothing");
wb.model.category_mut("Month").unwrap().add_item("Jan");
wb.model.category_mut("Month").unwrap().add_item("Feb");
wb.model.category_mut("Region").unwrap().add_item("North");
wb.model.category_mut("Region").unwrap().add_item("South");
wb.model.category_mut("Region").unwrap().add_item("East");
wb.active_view_mut()
.set_axis("Region", crate::view::Axis::Page);
wb
}
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {

View File

@ -8,22 +8,7 @@ use super::grid::DrillIntoCell;
mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::Model;
#[test]
fn enter_edit_mode_produces_editing_mode() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = EnterEditMode {
initial_value: String::new(),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
let dbg = format!("{:?}", effects[1]);
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
}
use crate::workbook::Workbook;
#[test]
fn enter_tile_select_with_categories() {
@ -43,7 +28,7 @@ mod tests {
#[test]
fn enter_tile_select_no_categories() {
let m = Model::new("Empty");
let m = Workbook::new("Empty");
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
@ -98,11 +83,80 @@ mod tests {
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = EditOrDrill.execute(&ctx);
let effects = EditOrDrill {
edit_mode: AppMode::editing(),
}
.execute(&ctx);
assert_eq!(effects.len(), 2);
let dbg = effects_debug(&effects);
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
}
/// EditOrDrill must trust its `edit_mode` parameter rather than checking
/// `ctx.mode` — the records-normal keymap supplies `records-editing`,
/// but the command itself never inspects the runtime mode. This is the
/// parallel of the (deleted) `enter_edit_mode_produces_editing_mode`
/// test for the records branch.
#[test]
fn edit_or_drill_passes_records_editing_mode_through() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
// Note: ctx.mode is still Normal here — the command must not look at it.
let ctx = make_ctx(&m, &layout, &reg);
let effects = EditOrDrill {
edit_mode: AppMode::records_editing(),
}
.execute(&ctx);
assert_eq!(effects.len(), 2);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("RecordsEditing"),
"Expected RecordsEditing mode, got: {dbg}"
);
}
/// `EnterEditAtCursorCmd` must hand its `target_mode` straight through
/// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence
/// or commit-and-advance) decides; the command never inspects ctx.
#[test]
fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = EnterEditAtCursorCmd {
target_mode: AppMode::records_editing(),
}
.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = format!("{:?}", effects[0]);
assert!(
dbg.contains("RecordsEditing"),
"Expected RecordsEditing target_mode, got: {dbg}"
);
}
/// The edit branch pre-fills the `edit` buffer with the cell's current
/// display value so the user can modify rather than retype.
#[test]
fn edit_or_drill_pre_fills_edit_buffer_with_display_value() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.display_value = "42".to_string();
let effects = EditOrDrill {
edit_mode: AppMode::editing(),
}
.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SetBuffer") && dbg.contains("\"edit\"") && dbg.contains("\"42\""),
"Expected SetBuffer(\"edit\", \"42\"), got: {dbg}"
);
}
#[test]
fn enter_search_mode_sets_flag_and_clears_query() {
let m = two_cat_model();
@ -188,31 +242,18 @@ impl Cmd for SaveAndQuit {
// ── Editing entry ───────────────────────────────────────────────────────
/// Enter editing mode with an initial buffer value.
#[derive(Debug)]
pub struct EnterEditMode {
pub initial_value: String,
}
impl Cmd for EnterEditMode {
fn name(&self) -> &'static str {
"enter-edit-mode"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetBuffer {
name: "edit".to_string(),
value: self.initial_value.clone(),
}),
effect::change_mode(AppMode::editing()),
]
}
}
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
/// (categories on `Axis::None`, no records mode), drill into it instead of
/// editing. Otherwise enter edit mode with the current displayed value.
/// (categories on `Axis::None` and the cell is not a synthetic records-mode
/// row), drill into it instead of editing. Otherwise pre-fill the edit
/// buffer with the displayed cell value and enter `edit_mode`.
///
/// `edit_mode` is supplied by the keymap binding — the command itself is
/// mode-agnostic, so the records-normal keymap passes `records-editing`
/// while the normal keymap passes `editing`.
#[derive(Debug)]
pub struct EditOrDrill;
pub struct EditOrDrill {
pub edit_mode: AppMode,
}
impl Cmd for EditOrDrill {
fn name(&self) -> &'static str {
"edit-or-drill"
@ -227,12 +268,9 @@ impl Cmd for EditOrDrill {
.map(|cat| cat.kind.is_regular())
.unwrap_or(false)
});
// In records mode (synthetic key), always edit directly — no drilling.
let is_synthetic = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.is_some();
// Synthetic records-mode cells are never aggregated — edit directly.
// (This is a layout property, not a mode flag.)
let is_synthetic = ctx.synthetic_record_at_cursor().is_some();
let is_aggregated = !is_synthetic && regular_none;
if is_aggregated {
let Some(key) = ctx.cell_key().clone() else {
@ -240,23 +278,31 @@ impl Cmd for EditOrDrill {
};
return DrillIntoCell { key }.execute(ctx);
}
EnterEditMode {
initial_value: ctx.display_value.clone(),
}
.execute(ctx)
vec![
Box::new(effect::SetBuffer {
name: "edit".to_string(),
value: ctx.display_value.clone(),
}),
effect::change_mode(self.edit_mode.clone()),
]
}
}
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
/// participate in `Binding::Sequence`.
/// participate in `Binding::Sequence`. `target_mode` is supplied as the
/// command argument by the keymap binding.
#[derive(Debug)]
pub struct EnterEditAtCursorCmd;
pub struct EnterEditAtCursorCmd {
pub target_mode: AppMode,
}
impl Cmd for EnterEditAtCursorCmd {
fn name(&self) -> &'static str {
"enter-edit-at-cursor"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::EnterEditAtCursor)]
vec![Box::new(effect::EnterEditAtCursor {
target_mode: self.target_mode.clone(),
})]
}
}

View File

@ -154,7 +154,10 @@ impl Cmd for EnterAdvance {
} else if c < col_max {
(0, c + 1)
} else {
(r, c) // already at bottom-right; stay
// Already at bottom-right — the advance premise no longer holds.
// Abort the rest of the batch so the caller's trailing effects
// (e.g. `CommitAndAdvance`'s `EnterEditAtCursor`) are skipped.
return vec![Box::new(effect::AbortChain)];
};
viewport_effects(
nr,
@ -243,6 +246,41 @@ impl Cmd for PagePrev {
}
}
/// Gather (cat_name, items, current_idx) for page-axis categories.
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
let view = ctx.view;
let page_cats: Vec<String> = view
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
page_cats
.into_iter()
.filter_map(|cat| {
let items: Vec<String> = ctx
.model
.category(&cat)
.map(|c| {
c.ordered_item_names()
.into_iter()
.map(String::from)
.collect()
})
.unwrap_or_default();
if items.is_empty() {
return None;
}
let current = view
.page_selection(&cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
let idx = items.iter().position(|i| *i == current).unwrap_or(0);
Some((cat, items, idx))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -296,6 +334,29 @@ mod tests {
);
}
/// At bottom-right `EnterAdvance` has no place to go, so it emits a
/// single `AbortChain` effect. Trailing effects in a `CommitAndAdvance`
/// batch (e.g. `EnterEditAtCursor`) are then skipped, which is how
/// "Enter at bottom-right commits and exits editing" is realised.
#[test]
fn enter_advance_at_bottom_right_emits_abort_chain() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mut cursor = CursorState::from_ctx(&ctx);
cursor.row = cursor.row_count.saturating_sub(1);
cursor.col = cursor.col_count.saturating_sub(1);
let cmd = EnterAdvance { cursor };
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1, "should emit exactly AbortChain");
let dbg = format!("{:?}", effects[0]);
assert!(
dbg.contains("AbortChain"),
"Expected AbortChain, got: {dbg}"
);
}
#[test]
fn law_move_to_start_idempotent() {
let m = two_cat_model();
@ -438,38 +499,3 @@ mod tests {
);
}
}
/// Gather (cat_name, items, current_idx) for page-axis categories.
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
let view = ctx.model.active_view();
let page_cats: Vec<String> = view
.categories_on(Axis::Page)
.into_iter()
.map(String::from)
.collect();
page_cats
.into_iter()
.filter_map(|cat| {
let items: Vec<String> = ctx
.model
.category(&cat)
.map(|c| {
c.ordered_item_names()
.into_iter()
.map(String::from)
.collect()
})
.unwrap_or_default();
if items.is_empty() {
return None;
}
let current = view
.page_selection(&cat)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
let idx = items.iter().position(|i| *i == current).unwrap_or(0);
Some((cat, items, idx))
})
.collect()
}

View File

@ -158,7 +158,7 @@ mod tests {
#[test]
fn delete_formula_at_cursor_with_formulas() {
let mut m = two_cat_model();
m.add_formula(crate::formula::ast::Formula {
m.model.add_formula(crate::formula::ast::Formula {
raw: "Profit = Revenue - Cost".to_string(),
target: "Profit".to_string(),
target_category: "Type".to_string(),
@ -529,7 +529,7 @@ impl Cmd for SwitchViewAtCursor {
"switch-view-at-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
vec![
Box::new(effect::SwitchView(name.clone())),
@ -549,7 +549,7 @@ impl Cmd for CreateAndSwitchView {
"create-and-switch-view"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let name = format!("View {}", ctx.model.views.len() + 1);
let name = format!("View {}", ctx.workbook.views.len() + 1);
vec![
Box::new(effect::CreateView(name.clone())),
Box::new(effect::SwitchView(name)),
@ -567,7 +567,7 @@ impl Cmd for DeleteViewAtCursor {
"delete-view-at-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let view_names: Vec<String> = ctx.model.views.keys().cloned().collect();
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
let mut effects: Vec<Box<dyn Effect>> = vec![
Box::new(effect::DeleteView(name.clone())),

View File

@ -2,6 +2,27 @@ use crate::model::cell::CellKey;
use crate::ui::app::AppMode;
use crate::ui::effect::Panel;
/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill`
/// keymap bindings) into an `AppMode`.
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
match s {
"normal" => Ok(AppMode::Normal),
"help" => Ok(AppMode::Help),
"formula-panel" => Ok(AppMode::FormulaPanel),
"category-panel" => Ok(AppMode::CategoryPanel),
"view-panel" => Ok(AppMode::ViewPanel),
"tile-select" => Ok(AppMode::TileSelect),
"command" => Ok(AppMode::command_mode()),
"category-add" => Ok(AppMode::category_add()),
"editing" => Ok(AppMode::editing()),
"records-normal" => Ok(AppMode::RecordsNormal),
"records-editing" => Ok(AppMode::records_editing()),
"formula-edit" => Ok(AppMode::formula_edit()),
"export-prompt" => Ok(AppMode::export_prompt()),
other => Err(format!("Unknown mode: {other}")),
}
}
use super::cell::*;
use super::commit::*;
use super::core::*;
@ -266,22 +287,16 @@ pub fn default_registry() -> CmdRegistry {
r.register_nullary(|| Box::new(SaveAndQuit));
r.register_nullary(|| Box::new(SaveCmd));
r.register_nullary(|| Box::new(EnterSearchMode));
r.register(
&EnterEditMode {
initial_value: String::new(),
},
|args| {
let val = args.first().cloned().unwrap_or_default();
Ok(Box::new(EnterEditMode { initial_value: val }))
},
|_args, ctx| {
Ok(Box::new(EnterEditMode {
initial_value: ctx.display_value.clone(),
}))
},
);
r.register_nullary(|| Box::new(EditOrDrill));
r.register_nullary(|| Box::new(EnterEditAtCursorCmd));
r.register_pure(&NamedCmd("edit-or-drill"), |args| {
require_args("edit-or-drill", args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
Ok(Box::new(EditOrDrill { edit_mode }))
});
r.register_pure(&NamedCmd("enter-edit-at-cursor"), |args| {
require_args("enter-edit-at-cursor", args, 1)?;
let target_mode = parse_mode_name(&args[0])?;
Ok(Box::new(EnterEditAtCursorCmd { target_mode }))
});
r.register_nullary(|| Box::new(EnterExportPrompt));
r.register_nullary(|| Box::new(EnterFormulaEdit));
r.register_nullary(|| Box::new(EnterTileSelect));
@ -310,21 +325,7 @@ pub fn default_registry() -> CmdRegistry {
);
r.register_pure(&NamedCmd("enter-mode"), |args| {
require_args("enter-mode", args, 1)?;
let mode = match args[0].as_str() {
"normal" => AppMode::Normal,
"help" => AppMode::Help,
"formula-panel" => AppMode::FormulaPanel,
"category-panel" => AppMode::CategoryPanel,
"view-panel" => AppMode::ViewPanel,
"tile-select" => AppMode::TileSelect,
"command" => AppMode::command_mode(),
"category-add" => AppMode::category_add(),
"editing" => AppMode::editing(),
"formula-edit" => AppMode::formula_edit(),
"export-prompt" => AppMode::export_prompt(),
other => return Err(format!("Unknown mode: {other}")),
};
Ok(Box::new(EnterMode(mode)))
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
});
// ── Search ───────────────────────────────────────────────────────────
@ -459,7 +460,7 @@ pub fn default_registry() -> CmdRegistry {
let (current, max) = match panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
Ok(Box::new(MovePanelCursor {
panel,
@ -520,25 +521,33 @@ pub fn default_registry() -> CmdRegistry {
r.register_nullary(|| Box::new(CommandModeBackspace));
// ── Commit ───────────────────────────────────────────────────────────
// commit-cell-edit / commit-and-advance-right take a mode-name arg
// (e.g. "editing" or "records-editing") as args[0]. The keymap supplies
// it; the command never inspects ctx.mode.
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
},
|args| {
if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into());
if args.len() < 3 {
return Err("commit-cell-edit requires a mode, value, and coords".into());
}
let edit_mode = parse_mode_name(&args[0])?;
Ok(Box::new(CommitAndAdvance {
key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(),
key: parse_cell_key_from_args(&args[2..]),
value: args[1].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode,
}))
},
|_args, ctx| {
|args, ctx| {
require_args("commit-cell-edit", args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
@ -546,6 +555,7 @@ pub fn default_registry() -> CmdRegistry {
value,
advance: AdvanceDir::Down,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
},
);
@ -555,9 +565,12 @@ pub fn default_registry() -> CmdRegistry {
value: String::new(),
advance: AdvanceDir::Right,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
},
|_| Err("commit-and-advance-right requires context".into()),
|_args, ctx| {
|args, ctx| {
require_args("commit-and-advance-right", args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
@ -565,6 +578,7 @@ pub fn default_registry() -> CmdRegistry {
value,
advance: AdvanceDir::Right,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
},
);

View File

@ -55,14 +55,14 @@ mod tests {
#[test]
fn search_navigate_forward_with_matching_value() {
let mut m = two_cat_model();
m.set_cell(
m.model.set_cell(
CellKey::new(vec![
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]),
CellValue::Number(42.0),
);
m.set_cell(
m.model.set_cell(
CellKey::new(vec![
("Type".into(), "Clothing".into()),
("Month".into(), "Feb".into()),

View File

@ -3,7 +3,7 @@ use crossterm::event::KeyCode;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{read_buffer, Cmd, CmdContext};
use super::core::{Cmd, CmdContext, read_buffer};
#[cfg(test)]
mod tests {

View File

@ -131,7 +131,7 @@ impl Cmd for TileAxisOp {
let new_axis = match self.axis {
Some(axis) => axis,
None => {
let current = ctx.model.active_view().axis_of(name);
let current = ctx.view.axis_of(name);
match current {
Axis::Row => Axis::Column,
Axis::Column => Axis::Page,

View File

@ -61,6 +61,8 @@ pub enum ModeKey {
CommandMode,
SearchMode,
ImportWizard,
RecordsNormal,
RecordsEditing,
}
impl ModeKey {
@ -80,6 +82,8 @@ impl ModeKey {
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
AppMode::RecordsNormal => Some(ModeKey::RecordsNormal),
AppMode::RecordsEditing { .. } => Some(ModeKey::RecordsEditing),
_ => None,
}
}
@ -235,9 +239,8 @@ impl Keymap {
/// Look up the binding for a key, falling through to parent keymaps.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.lookup_local(key, mods).or_else(|| {
self.parent.as_ref().and_then(|p| p.lookup(key, mods))
})
self.lookup_local(key, mods)
.or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods)))
}
/// Dispatch a key: look up binding, resolve through registry, return effects.
@ -428,9 +431,21 @@ impl KeymapSet {
);
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Editing entry — i/a drill into aggregated cells, else edit
normal.bind(KeyCode::Char('i'), none, "edit-or-drill");
normal.bind(KeyCode::Char('a'), none, "edit-or-drill");
// Editing entry — i/a drill into aggregated cells, else edit.
// The mode arg controls which editing mode is entered; records-normal
// overrides these to "records-editing" via its own bindings.
normal.bind_args(
KeyCode::Char('i'),
none,
"edit-or-drill",
vec!["editing".into()],
);
normal.bind_args(
KeyCode::Char('a'),
none,
"edit-or-drill",
vec!["editing".into()],
);
normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
@ -451,14 +466,9 @@ impl KeymapSet {
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history / add row
// Drill into aggregated cell / view history
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
normal.bind_seq(
KeyCode::Char('o'),
none,
vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])],
);
// Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
@ -485,7 +495,34 @@ impl KeymapSet {
z_map.bind(KeyCode::Char('Z'), none, "wq");
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
set.insert(ModeKey::Normal, Arc::new(normal));
let normal = Arc::new(normal);
set.insert(ModeKey::Normal, normal.clone());
// ── Records normal mode (inherits from normal) ────────────────────
let mut rn = Keymap::with_parent(normal);
// Override i/a so the edit branch produces records-editing mode
// instead of inheriting the normal-mode "editing" arg.
rn.bind_args(
KeyCode::Char('i'),
none,
"edit-or-drill",
vec!["records-editing".into()],
);
rn.bind_args(
KeyCode::Char('a'),
none,
"edit-or-drill",
vec!["records-editing".into()],
);
rn.bind_seq(
KeyCode::Char('o'),
none,
vec![
("add-record-row", vec![]),
("enter-edit-at-cursor", vec!["records-editing".into()]),
],
);
set.insert(ModeKey::RecordsNormal, Arc::new(rn));
// ── Help mode ────────────────────────────────────────────────────
let mut help = Keymap::new();
@ -728,51 +765,115 @@ impl KeymapSet {
set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Editing mode ─────────────────────────────────────────────────
// commit-* takes the target edit-mode arg so the command stays
// mode-agnostic; records-editing overrides Enter/Tab below.
let mut ed = Keymap::new();
ed.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["edit".into()]),
("enter-mode", vec!["normal".into()]),
]);
ed.bind_seq(KeyCode::Enter, none, vec![
("commit-cell-edit", vec![]),
("clear-buffer", vec!["edit".into()]),
]);
ed.bind_seq(KeyCode::Tab, none, vec![
("commit-and-advance-right", vec![]),
("clear-buffer", vec!["edit".into()]),
]);
ed.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["edit".into()]),
("enter-mode", vec!["normal".into()]),
],
);
ed.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-cell-edit", vec!["editing".into()]),
("clear-buffer", vec!["edit".into()]),
],
);
ed.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-and-advance-right", vec!["editing".into()]),
("clear-buffer", vec!["edit".into()]),
],
);
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed));
let ed = Arc::new(ed);
set.insert(ModeKey::Editing, ed.clone());
// ── Records editing mode (inherits from editing) ──────────────────
// Override Enter/Tab so the post-commit re-enter targets records-editing.
let mut re = Keymap::with_parent(ed);
re.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["edit".into()]),
("enter-mode", vec!["records-normal".into()]),
],
);
re.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-cell-edit", vec!["records-editing".into()]),
("clear-buffer", vec!["edit".into()]),
],
);
re.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-and-advance-right", vec!["records-editing".into()]),
("clear-buffer", vec!["edit".into()]),
],
);
set.insert(ModeKey::RecordsEditing, Arc::new(re));
// ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new();
fe.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["formula".into()]),
("enter-mode", vec!["formula-panel".into()]),
]);
fe.bind_seq(KeyCode::Enter, none, vec![
("commit-formula", vec![]),
("clear-buffer", vec!["formula".into()]),
]);
fe.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["formula".into()]),
("enter-mode", vec!["formula-panel".into()]),
],
);
fe.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-formula", vec![]),
("clear-buffer", vec!["formula".into()]),
],
);
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
fe.bind_any_char("append-char", vec!["formula".into()]);
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new();
ca.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["category".into()]),
("enter-mode", vec!["category-panel".into()]),
]);
ca.bind_seq(KeyCode::Enter, none, vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
]);
ca.bind_seq(KeyCode::Tab, none, vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
]);
ca.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["category".into()]),
("enter-mode", vec!["category-panel".into()]),
],
);
ca.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
],
);
ca.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-category-add", vec![]),
("clear-buffer", vec!["category".into()]),
],
);
ca.bind_args(
KeyCode::Backspace,
none,
@ -784,46 +885,74 @@ impl KeymapSet {
// ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new();
ia.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["item".into()]),
("enter-mode", vec!["category-panel".into()]),
]);
ia.bind_seq(KeyCode::Enter, none, vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
]);
ia.bind_seq(KeyCode::Tab, none, vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
]);
ia.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["item".into()]),
("enter-mode", vec!["category-panel".into()]),
],
);
ia.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
],
);
ia.bind_seq(
KeyCode::Tab,
none,
vec![
("commit-item-add", vec![]),
("clear-buffer", vec!["item".into()]),
],
);
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
ia.bind_any_char("append-char", vec!["item".into()]);
set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new();
ep.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["export".into()]),
("enter-mode", vec!["normal".into()]),
]);
ep.bind_seq(KeyCode::Enter, none, vec![
("commit-export", vec![]),
("clear-buffer", vec!["export".into()]),
]);
ep.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["export".into()]),
("enter-mode", vec!["normal".into()]),
],
);
ep.bind_seq(
KeyCode::Enter,
none,
vec![
("commit-export", vec![]),
("clear-buffer", vec!["export".into()]),
],
);
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
ep.bind_any_char("append-char", vec!["export".into()]);
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new();
cm.bind_seq(KeyCode::Esc, none, vec![
("clear-buffer", vec!["command".into()]),
("enter-mode", vec!["normal".into()]),
]);
cm.bind_seq(KeyCode::Enter, none, vec![
("execute-command", vec![]),
("clear-buffer", vec!["command".into()]),
]);
cm.bind_seq(
KeyCode::Esc,
none,
vec![
("clear-buffer", vec!["command".into()]),
("enter-mode", vec!["normal".into()]),
],
);
cm.bind_seq(
KeyCode::Enter,
none,
vec![
("execute-command", vec![]),
("clear-buffer", vec!["command".into()]),
],
);
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char("append-char", vec!["command".into()]);
set.insert(ModeKey::CommandMode, Arc::new(cm));
@ -1047,6 +1176,8 @@ mod tests {
ModeKey::CommandMode,
ModeKey::SearchMode,
ModeKey::ImportWizard,
ModeKey::RecordsNormal,
ModeKey::RecordsEditing,
];
for mode in &expected_modes {
assert!(
@ -1086,9 +1217,11 @@ mod tests {
let ks = KeymapSet::default_keymaps();
let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap();
// Should have AnyChar for text input
assert!(editing
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
.is_some());
assert!(
editing
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
.is_some()
);
// Should have Esc to exit
assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
}
@ -1097,9 +1230,11 @@ mod tests {
fn search_mode_has_any_char_and_esc() {
let ks = KeymapSet::default_keymaps();
let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap();
assert!(search
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
.is_some());
assert!(
search
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
.is_some()
);
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
}

View File

@ -5,7 +5,7 @@
//! Coordinate pairs use `/`: `Category/Item`
//! Quoted strings supported: `"Profit = Revenue - Cost"`
use super::cmd::{default_registry, Cmd, CmdRegistry};
use super::cmd::{Cmd, CmdRegistry, default_registry};
/// Parse a line into commands using the default registry.
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {

View File

@ -6,17 +6,16 @@ use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use crate::model::Model;
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent;
@ -51,13 +50,13 @@ impl<'a> Drop for TuiContext<'a> {
}
pub fn run_tui(
model: Model,
workbook: crate::workbook::Workbook,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
let mut app = App::new(workbook, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
@ -133,12 +132,16 @@ fn mode_name(mode: &AppMode) -> &'static str {
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
AppMode::RecordsNormal => "RECORDS",
AppMode::RecordsEditing { .. } => "RECORDS INSERT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
Style::default().fg(Color::Black).bg(Color::Green)
}
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
@ -167,10 +170,10 @@ fn draw(f: &mut Frame, app: &App) {
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.help_page), size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard {
f.render_widget(ImportWizardWidget::new(wizard), size);
}
if matches!(app.mode, AppMode::ImportWizard)
&& let Some(wizard) = &app.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 { .. }) {
@ -193,7 +196,10 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let title = format!(
" improvise · {}{}{} ",
app.workbook.model.name, file, dirty
);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
f.render_widget(
@ -234,19 +240,23 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = FormulaContent::new(&app.model, &app.mode);
let content = FormulaContent::new(&app.workbook.model, &app.mode);
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = CategoryContent::new(&app.model, &app.expanded_cats);
let content = CategoryContent::new(
&app.workbook.model,
app.workbook.active_view(),
&app.expanded_cats,
);
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let content = ViewContent::new(&app.model);
let content = ViewContent::new(&app.workbook);
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
}
} else {
@ -255,7 +265,9 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(
GridWidget::new(
&app.model,
&app.workbook.model,
app.workbook.active_view(),
&app.workbook.active_view,
&app.layout,
&app.mode,
&app.search_query,
@ -267,7 +279,15 @@ 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.model, &app.mode, app.tile_cat_idx), area);
f.render_widget(
TileBar::new(
&app.workbook.model,
app.workbook.active_view(),
&app.mode,
app.tile_cat_idx,
),
area,
);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
@ -306,7 +326,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let view_badge = format!(" {}{} ", app.workbook.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);

View File

@ -1,787 +0,0 @@
use anyhow::{anyhow, Result};
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
/// Parse a formula string like "Profit = Revenue - Cost"
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
let raw = raw.trim();
// Split on first `=` to get target = expression
let eq_pos = raw
.find('=')
.ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
let target = raw[..eq_pos].trim().to_string();
let rest = raw[eq_pos + 1..].trim();
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(parse_where).transpose()?;
let expr = parse_expr(expr_str.trim())?;
Ok(Formula::new(raw, target, target_category, expr, filter))
}
fn split_where(s: &str) -> (&str, Option<&str>) {
// Find WHERE not inside parens or quotes
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
b'"' => {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
}
b'|' => {
i += 1;
while i < bytes.len() && bytes[i] != b'|' {
i += 1;
}
}
_ if depth == 0 => {
if s[i..].to_ascii_uppercase().starts_with("WHERE") {
let before = &s[..i];
let after = &s[i + 5..];
if before.ends_with(char::is_whitespace) || i == 0 {
return (before.trim(), Some(after.trim()));
}
}
}
_ => {}
}
i += 1;
}
(s, None)
}
/// Strip pipe or double-quote delimiters from a value.
fn unquote(s: &str) -> String {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('|') && s.ends_with('|')) {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
fn parse_where(s: &str) -> Result<Filter> {
// Format: Category = "Item" or Category = |Item| or Category = Item
let eq_pos = s
.find('=')
.ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
let category = unquote(&s[..eq_pos]);
let item = unquote(&s[eq_pos + 1..]);
Ok(Filter { category, item })
}
/// Parse an expression using recursive descent
pub fn parse_expr(s: &str) -> Result<Expr> {
let tokens = tokenize(s)?;
let mut pos = 0;
let expr = parse_add_sub(&tokens, &mut pos)?;
if pos < tokens.len() {
return Err(anyhow!(
"Unexpected token at position {pos}: {:?}",
tokens[pos]
));
}
Ok(expr)
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(f64),
Ident(String),
Str(String),
Plus,
Minus,
Star,
Slash,
Caret,
LParen,
RParen,
Comma,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
}
fn tokenize(s: &str) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
' ' | '\t' | '\n' => i += 1,
'+' => {
tokens.push(Token::Plus);
i += 1;
}
'-' => {
tokens.push(Token::Minus);
i += 1;
}
'*' => {
tokens.push(Token::Star);
i += 1;
}
'/' => {
tokens.push(Token::Slash);
i += 1;
}
'^' => {
tokens.push(Token::Caret);
i += 1;
}
'(' => {
tokens.push(Token::LParen);
i += 1;
}
')' => {
tokens.push(Token::RParen);
i += 1;
}
',' => {
tokens.push(Token::Comma);
i += 1;
}
'!' if chars.get(i + 1) == Some(&'=') => {
tokens.push(Token::Ne);
i += 2;
}
'<' if chars.get(i + 1) == Some(&'=') => {
tokens.push(Token::Le);
i += 2;
}
'>' if chars.get(i + 1) == Some(&'=') => {
tokens.push(Token::Ge);
i += 2;
}
'<' => {
tokens.push(Token::Lt);
i += 1;
}
'>' => {
tokens.push(Token::Gt);
i += 1;
}
'=' => {
tokens.push(Token::Eq);
i += 1;
}
'"' => {
i += 1;
let mut s = String::new();
while i < chars.len() && chars[i] != '"' {
s.push(chars[i]);
i += 1;
}
if i < chars.len() {
i += 1;
}
tokens.push(Token::Str(s));
}
'|' => {
i += 1;
let mut s = String::new();
while i < chars.len() && chars[i] != '|' {
s.push(chars[i]);
i += 1;
}
if i < chars.len() {
i += 1;
}
tokens.push(Token::Ident(s));
}
c if c.is_ascii_digit() || c == '.' => {
let mut num = String::new();
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
num.push(chars[i]);
i += 1;
}
tokens.push(Token::Number(num.parse()?));
}
c if c.is_alphabetic() || c == '_' => {
let mut ident = String::new();
while i < chars.len()
&& (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ')
{
// Don't consume trailing spaces if next non-space is operator
if chars[i] == ' ' {
// Peek ahead past spaces to find the next word/token
let j = i + 1;
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
if matches!(
next_nonspace,
Some('+')
| Some('-')
| Some('*')
| Some('/')
| Some('^')
| Some(')')
| Some(',')
| Some('<')
| Some('>')
| Some('=')
| Some('!')
| Some('"')
| None
) {
break;
}
// Break if the identifier collected so far is a keyword
let trimmed = ident.trim_end().to_ascii_uppercase();
if matches!(
trimmed.as_str(),
"WHERE" | "SUM" | "AVG" | "MIN" | "MAX" | "COUNT" | "IF"
) {
break;
}
// Also break if the next word is a keyword
let rest: String = chars[j..].iter().collect();
let next_word: String = rest
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let upper = next_word.to_ascii_uppercase();
if matches!(
upper.as_str(),
"WHERE" | "SUM" | "AVG" | "MIN" | "MAX" | "COUNT" | "IF"
) {
break;
}
}
ident.push(chars[i]);
i += 1;
}
let ident = ident.trim_end().to_string();
tokens.push(Token::Ident(ident));
}
c => return Err(anyhow!("Unexpected character '{c}' in expression")),
}
}
Ok(tokens)
}
fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_mul_div(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Plus => BinOp::Add,
Token::Minus => BinOp::Sub,
_ => break,
};
*pos += 1;
let right = parse_mul_div(tokens, pos)?;
left = Expr::BinOp(op, Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_pow(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Star => BinOp::Mul,
Token::Slash => BinOp::Div,
_ => break,
};
*pos += 1;
let right = parse_pow(tokens, pos)?;
left = Expr::BinOp(op, Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let base = parse_unary(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Caret {
*pos += 1;
let exp = parse_unary(tokens, pos)?;
return Ok(Expr::BinOp(BinOp::Pow, Box::new(base), Box::new(exp)));
}
Ok(base)
}
fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos < tokens.len() && tokens[*pos] == Token::Minus {
*pos += 1;
let e = parse_primary(tokens, pos)?;
return Ok(Expr::UnaryMinus(Box::new(e)));
}
parse_primary(tokens, pos)
}
fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos >= tokens.len() {
return Err(anyhow!("Unexpected end of expression"));
}
match &tokens[*pos].clone() {
Token::Number(n) => {
*pos += 1;
Ok(Expr::Number(*n))
}
Token::Ident(name) => {
let name = name.clone();
*pos += 1;
// Check for function call
let upper = name.to_ascii_uppercase();
match upper.as_str() {
"SUM" | "AVG" | "MIN" | "MAX" | "COUNT" => {
let func = match upper.as_str() {
"SUM" => AggFunc::Sum,
"AVG" => AggFunc::Avg,
"MIN" => AggFunc::Min,
"MAX" => AggFunc::Max,
"COUNT" => AggFunc::Count,
_ => unreachable!(),
};
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let inner = parse_add_sub(tokens, pos)?;
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.eq_ignore_ascii_case("WHERE") {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => {
let s = s.clone();
*pos += 1;
s
}
t => {
return Err(anyhow!(
"Expected category name, got {t:?}"
))
}
};
// expect =
if *pos < tokens.len() && tokens[*pos] == Token::Eq {
*pos += 1;
}
let item = match &tokens[*pos] {
Token::Str(s) | Token::Ident(s) => {
let s = s.clone();
*pos += 1;
s
}
t => return Err(anyhow!("Expected item name, got {t:?}")),
};
Some(Filter {
category: cat,
item,
})
} else {
None
}
} else {
None
}
} else {
None
};
// expect )
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
} else {
return Err(anyhow!("Expected ')' to close aggregate function"));
}
return Ok(Expr::Agg(func, Box::new(inner), filter));
}
Ok(Expr::Ref(name))
}
"IF" => {
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let cond = parse_comparison(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
*pos += 1;
}
let then = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
*pos += 1;
}
let else_ = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
} else {
return Err(anyhow!("Expected ')' to close IF(...)"));
}
return Ok(Expr::If(Box::new(cond), Box::new(then), Box::new(else_)));
}
Ok(Expr::Ref(name))
}
_ => Ok(Expr::Ref(name)),
}
}
Token::LParen => {
*pos += 1;
let e = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
}
Ok(e)
}
t => Err(anyhow!("Unexpected token in expression: {t:?}")),
}
}
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let left = parse_add_sub(tokens, pos)?;
if *pos >= tokens.len() {
return Ok(left);
}
let op = match &tokens[*pos] {
Token::Eq => BinOp::Eq,
Token::Ne => BinOp::Ne,
Token::Lt => BinOp::Lt,
Token::Gt => BinOp::Gt,
Token::Le => BinOp::Le,
Token::Ge => BinOp::Ge,
_ => return Ok(left),
};
*pos += 1;
let right = parse_add_sub(tokens, pos)?;
Ok(Expr::BinOp(op, Box::new(left), Box::new(right)))
}
#[cfg(test)]
mod tests {
use super::parse_formula;
use crate::formula::{AggFunc, BinOp, Expr};
#[test]
fn parse_simple_subtraction() {
let f = parse_formula("Profit = Revenue - Cost", "Foo").unwrap();
assert_eq!(f.target, "Profit");
assert_eq!(f.target_category, "Foo");
assert!(matches!(f.expr, Expr::BinOp(BinOp::Sub, _, _)));
}
#[test]
fn parse_where_clause() {
let f = parse_formula("EastRev = Revenue WHERE Region = \"East\"", "Foo").unwrap();
assert_eq!(f.target, "EastRev");
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
#[test]
fn parse_sum_aggregation() {
let f = parse_formula("Total = SUM(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
}
#[test]
fn parse_avg_aggregation() {
let f = parse_formula("Avg = AVG(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Avg, _, _)));
}
#[test]
fn parse_if_expression() {
let f = parse_formula("Capped = IF(Revenue > 1000, 1000, Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
#[test]
fn parse_numeric_literal() {
let f = parse_formula("Fixed = 42", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
}
#[test]
fn parse_chained_arithmetic() {
parse_formula("X = (A + B) * (C - D)", "Cat").unwrap();
}
#[test]
fn parse_missing_equals_returns_error() {
assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err());
}
// ── Aggregate functions ─────────────────────────────────────────────
#[test]
fn parse_min_aggregation() {
let f = parse_formula("Lo = MIN(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Min, _, _)));
}
#[test]
fn parse_max_aggregation() {
let f = parse_formula("Hi = MAX(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Max, _, _)));
}
#[test]
fn parse_count_aggregation() {
let f = parse_formula("N = COUNT(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Count, _, _)));
}
// ── Aggregate with WHERE filter ─────────────────────────────────────
#[test]
fn parse_sum_with_top_level_where_works() {
let f = parse_formula(
"EastTotal = SUM(Revenue) WHERE Region = \"East\"",
"Foo",
)
.unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
/// Regression: WHERE inside aggregate parens must tokenize correctly.
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
#[test]
fn parse_sum_with_inline_where_filter() {
let f = parse_formula(
"EastTotal = SUM(Revenue WHERE Region = \"East\")",
"Foo",
)
.unwrap();
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
assert!(matches!(**inner, Expr::Ref(_)));
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
} else {
panic!("Expected SUM with inline WHERE filter, got: {:?}", f.expr);
}
}
// ── Comparison operators ────────────────────────────────────────────
#[test]
fn parse_if_with_comparison_operators() {
// Test each comparison operator in an IF expression
let f = parse_formula("X = IF(A != 0, A, 1)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A < 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A <= 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A >= 10, 10, A)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A = B, 1, 0)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
// ── Quoted strings in WHERE ─────────────────────────────────────────
#[test]
fn parse_where_with_quoted_string_inside_expression() {
// WHERE inside a formula string with quotes
let f = parse_formula("X = Revenue WHERE Region = \"West Coast\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "West Coast");
}
// ── Power operator ──────────────────────────────────────────────────
#[test]
fn parse_power_operator() {
let f = parse_formula("Sq = X ^ 2", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Pow, _, _)));
}
// ── Unary minus ─────────────────────────────────────────────────────
#[test]
fn parse_unary_minus() {
let f = parse_formula("Neg = -Revenue", "Foo").unwrap();
assert!(matches!(f.expr, Expr::UnaryMinus(_)));
}
// ── Division and multiplication ─────────────────────────────────────
#[test]
fn parse_multiplication() {
let f = parse_formula("Double = Revenue * 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Mul, _, _)));
}
#[test]
fn parse_division() {
let f = parse_formula("Half = Revenue / 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Div, _, _)));
}
// ── Parenthesized expression ────────────────────────────────────────
#[test]
fn parse_nested_parens() {
let f = parse_formula("X = ((A + B))", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
// ── Aggregate function name used as ref (no parens) ─────────────────
#[test]
fn parse_aggregate_name_without_parens_is_ref() {
// "SUM" without parens should be treated as a reference, not a function
let f = parse_formula("X = SUM + 1", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
if let Expr::BinOp(_, lhs, _) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(_)));
}
}
#[test]
fn parse_if_without_parens_is_ref() {
// "IF" without parens should be treated as a reference
let f = parse_formula("X = IF + 1", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, _) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(_)));
} else {
panic!("Expected BinOp(Add), got: {:?}", f.expr);
}
}
// ── Quoted string in tokenizer ──────────────────────────────────────
#[test]
fn parse_quoted_string_in_where() {
// Quoted strings work in top-level WHERE clauses
let f = parse_formula("X = Revenue WHERE Region = \"East\"", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East");
}
// ── Error paths ─────────────────────────────────────────────────────
#[test]
fn parse_unexpected_token_error() {
use super::parse_expr;
// Extra tokens after a valid expression
assert!(parse_expr("1 + 2 3").is_err());
}
#[test]
fn parse_unexpected_character_error() {
use super::parse_expr;
assert!(parse_expr("@invalid").is_err());
}
#[test]
fn parse_empty_expression_error() {
use super::parse_expr;
assert!(parse_expr("").is_err());
}
#[test]
fn tokenizer_breaks_at_where_keyword() {
use super::tokenize;
let tokens = tokenize("Revenue WHERE Region").unwrap();
// Should produce 3 tokens: Ident("Revenue"), Ident("WHERE"), Ident("Region")
assert_eq!(tokens.len(), 3, "Expected 3 tokens, got: {tokens:?}");
}
// ── Multi-word identifiers ──────────────────────────────────────────
#[test]
fn parse_multi_word_identifier() {
let f = parse_formula("Total Revenue = Base Revenue + Bonus", "Foo").unwrap();
assert_eq!(f.target, "Total Revenue");
}
// ── WHERE inside quotes in split_where ──────────────────────────────
#[test]
fn split_where_ignores_where_inside_quotes() {
// WHERE inside quotes should not be treated as a keyword
let f = parse_formula("X = Revenue WHERE Region = \"WHERE\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "WHERE");
}
// ── Pipe-quoted identifiers ─────────────────────────────────────────
#[test]
fn pipe_quoted_identifier_in_expression() {
let f = parse_formula("|Total Revenue| = |Base Revenue| + Bonus", "Foo").unwrap();
assert_eq!(f.target, "|Total Revenue|");
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "Base Revenue"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "Bonus"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_keyword_as_identifier() {
// A category named "WHERE" can be referenced with pipes
let f = parse_formula("X = |WHERE| + |SUM|", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "WHERE"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "SUM"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_identifier_with_special_chars() {
// Pipes allow characters that would normally break tokenization
let f = parse_formula("X = |Revenue (USD)| + |Cost + Tax|", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "Revenue (USD)"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "Cost + Tax"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_in_aggregate() {
let f = parse_formula("X = SUM(|Net Revenue|)", "Cat").unwrap();
if let Expr::Agg(AggFunc::Sum, inner, None) = &f.expr {
assert!(matches!(**inner, Expr::Ref(ref s) if s == "Net Revenue"));
} else {
panic!("Expected SUM aggregate, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_in_where_filter_value() {
let f = parse_formula("X = Revenue WHERE Region = |East Coast|", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East Coast");
}
#[test]
fn pipe_quoted_in_inline_where() {
let f = parse_formula(
"X = SUM(Revenue WHERE |Region Name| = |East Coast|)",
"Foo",
)
.unwrap();
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
assert_eq!(filter.category, "Region Name");
assert_eq!(filter.item, "East Coast");
} else {
panic!("Expected SUM with WHERE filter, got: {:?}", f.expr);
}
}
}

10
src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod command;
pub mod draw;
pub use improvise_core::format;
pub use improvise_core::model;
pub use improvise_formula as formula;
pub use improvise_io::import;
pub use improvise_io::persistence;
pub mod ui;
pub use improvise_core::view;
pub use improvise_core::workbook;

View File

@ -1,14 +1,12 @@
mod command;
mod draw;
mod format;
mod formula;
mod import;
mod model;
mod persistence;
mod ui;
mod view;
use improvise::command;
use improvise::draw;
use improvise::import;
use improvise::persistence;
use improvise::ui;
use improvise::view;
use improvise::workbook::Workbook;
use crate::import::csv_parser::csv_path_p;
use improvise::import::csv_parser::csv_path_p;
use std::path::PathBuf;
@ -17,7 +15,6 @@ use clap::{Parser, Subcommand};
use enum_dispatch::enum_dispatch;
use draw::run_tui;
use model::Model;
use serde_json::Value;
fn main() -> Result<()> {
@ -124,8 +121,8 @@ struct ScriptArgs {
struct OpenTui;
impl Runnable for OpenTui {
fn run(self, model_file: Option<PathBuf>) -> Result<()> {
let model = get_initial_model(&model_file)?;
run_tui(model, model_file, None)
let workbook = get_initial_workbook(&model_file)?;
run_tui(workbook, model_file, None)
}
}
@ -233,9 +230,9 @@ fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, confi
}
}
fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) {
fn apply_axis_overrides(wb: &mut Workbook, axes: &[(String, String)]) {
use view::Axis;
let view = model.active_view_mut();
let view = wb.active_view_mut();
for (cat, axis_str) in axes {
let axis = match axis_str.to_lowercase().as_str() {
"row" => Axis::Row,
@ -256,12 +253,12 @@ fn run_headless_import(
) -> Result<()> {
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
apply_config_to_pipeline(&mut pipeline, config);
let mut model = pipeline.build_model()?;
model.normalize_view_state();
apply_axis_overrides(&mut model, &config.axes);
let mut wb = pipeline.build_model()?;
wb.normalize_view_state();
apply_axis_overrides(&mut wb, &config.axes);
if let Some(path) = output.or(model_file) {
persistence::save(&model, &path)?;
persistence::save(&wb, &path)?;
eprintln!("Saved to {}", path.display());
} else {
eprintln!("No output path specified; use -o <path> or provide a model file");
@ -274,11 +271,11 @@ fn run_wizard_import(
_config: &ImportConfig,
model_file: Option<PathBuf>,
) -> Result<()> {
let model = get_initial_model(&model_file)?;
let workbook = get_initial_workbook(&model_file)?;
// Pre-configure will happen inside the TUI via the wizard
// For now, pass import_value and let the wizard handle it
// TODO: pass config to wizard for pre-population
run_tui(model, model_file, Some(import_value))
run_tui(workbook, model_file, Some(import_value))
}
// ── Import data loading ──────────────────────────────────────────────────────
@ -333,8 +330,8 @@ fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
use crossterm::event::{KeyCode, KeyModifiers};
let model = get_initial_model(file)?;
let mut app = ui::app::App::new(model, file.clone());
let workbook = get_initial_workbook(file)?;
let mut app = ui::app::App::new(workbook, file.clone());
let mut exit_code = 0;
for line in cmds {
@ -356,7 +353,7 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
}
if let Some(path) = file {
persistence::save(&app.model, path)?;
persistence::save(&app.workbook, path)?;
}
std::process::exit(exit_code);
@ -370,22 +367,22 @@ fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<
// ── Helpers ──────────────────────────────────────────────────────────────────
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
fn get_initial_workbook(file_path: &Option<PathBuf>) -> Result<Workbook> {
if let Some(path) = file_path {
if path.exists() {
let mut m = persistence::load(path)
let mut wb = persistence::load(path)
.with_context(|| format!("Failed to load {}", path.display()))?;
m.normalize_view_state();
Ok(m)
wb.normalize_view_state();
Ok(wb)
} else {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("New Model")
.to_string();
Ok(Model::new(name))
Ok(Workbook::new(name))
}
} else {
Ok(Model::new("New Model"))
Ok(Workbook::new("New Model"))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -36,14 +36,12 @@ pub fn build_cat_tree(model: &Model, expanded: &HashSet<String>) -> Vec<CatTreeE
item_count,
expanded: is_expanded,
});
if is_expanded {
if let Some(cat) = cat {
for item_name in cat.ordered_item_names() {
entries.push(CatTreeEntry::Item {
cat_name: cat_name.to_string(),
item_name: item_name.to_string(),
});
}
if is_expanded && let Some(cat) = cat {
for item_name in cat.ordered_item_names() {
entries.push(CatTreeEntry::Item {
cat_name: cat_name.to_string(),
item_name: item_name.to_string(),
});
}
}
}

View File

@ -6,9 +6,9 @@ use ratatui::{
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
use crate::ui::panel::PanelContent;
use crate::view::Axis;
use crate::view::{Axis, View};
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
@ -20,14 +20,18 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
}
pub struct CategoryContent<'a> {
model: &'a Model,
view: &'a View,
tree: Vec<CatTreeEntry>,
}
impl<'a> CategoryContent<'a> {
pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet<String>) -> Self {
pub fn new(
model: &'a Model,
view: &'a View,
expanded: &'a std::collections::HashSet<String>,
) -> Self {
let tree = build_cat_tree(model, expanded);
Self { model, tree }
Self { view, tree }
}
}
@ -57,7 +61,7 @@ impl PanelContent for CategoryContent<'_> {
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
let y = inner.y + index as u16;
let view = self.model.active_view();
let view = self.view;
let base_style = if is_selected {
Style::default()

View File

@ -4,7 +4,9 @@ use std::path::PathBuf;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use super::app::{App, AppMode};
use super::app::{App, AppMode, ViewFrame};
pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
@ -22,7 +24,7 @@ pub trait Effect: Debug {
pub struct AddCategory(pub String);
impl Effect for AddCategory {
fn apply(&self, app: &mut App) {
let _ = app.model.add_category(&self.0);
let _ = app.workbook.add_category(&self.0);
}
}
@ -33,7 +35,7 @@ pub struct AddItem {
}
impl Effect for AddItem {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
if let Some(cat) = app.workbook.model.category_mut(&self.category) {
cat.add_item(&self.item);
} else {
app.status_msg = format!("Unknown category '{}'", self.category);
@ -49,7 +51,7 @@ pub struct AddItemInGroup {
}
impl Effect for AddItemInGroup {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
if let Some(cat) = app.workbook.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group);
} else {
app.status_msg = format!("Unknown category '{}'", self.category);
@ -57,11 +59,19 @@ impl Effect for AddItemInGroup {
}
}
#[derive(Debug)]
pub struct SortData;
impl Effect for SortData {
fn apply(&self, app: &mut App) {
app.workbook.model.data.sort_by_key();
}
}
#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
fn apply(&self, app: &mut App) {
app.model.set_cell(self.0.clone(), self.1.clone());
app.workbook.model.set_cell(self.0.clone(), self.1.clone());
}
}
@ -69,7 +79,7 @@ impl Effect for SetCell {
pub struct ClearCell(pub CellKey);
impl Effect for ClearCell {
fn apply(&self, app: &mut App) {
app.model.clear_cell(&self.0);
app.workbook.model.clear_cell(&self.0);
}
}
@ -85,12 +95,12 @@ impl Effect for AddFormula {
// For non-_Measure targets, add the item to the category so it
// appears in the grid. _Measure targets are dynamically included
// via Model::measure_item_names().
if formula.target_category != "_Measure" {
if let Some(cat) = app.model.category_mut(&formula.target_category) {
cat.add_item(&formula.target);
}
if formula.target_category != "_Measure"
&& let Some(cat) = app.workbook.model.category_mut(&formula.target_category)
{
cat.add_item(&formula.target);
}
app.model.add_formula(formula);
app.workbook.model.add_formula(formula);
}
Err(e) => {
app.status_msg = format!("Formula error: {e}");
@ -106,17 +116,27 @@ pub struct RemoveFormula {
}
impl Effect for RemoveFormula {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.model
.remove_formula(&self.target, &self.target_category);
}
}
/// Re-enter edit mode by reading the cell value at the current cursor.
/// Used after commit+advance to continue data entry.
///
/// `target_mode` is supplied by the caller (keymap binding via
/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode`
/// field). The effect itself never inspects `app.mode` — the mode is decided
/// statically by whoever invoked us.
#[derive(Debug)]
pub struct EnterEditAtCursor;
pub struct EnterEditAtCursor {
pub target_mode: AppMode,
}
impl Effect for EnterEditAtCursor {
fn apply(&self, app: &mut App) {
// Layout may be stale relative to prior effects in this batch (e.g.
// AddRecordRow added a row); rebuild before reading display_value.
app.rebuild_layout();
let ctx = app.cmd_context(
crossterm::event::KeyCode::Null,
@ -125,7 +145,7 @@ impl Effect for EnterEditAtCursor {
let value = ctx.display_value.clone();
drop(ctx);
app.buffers.insert("edit".to_string(), value);
app.mode = AppMode::editing();
app.mode = self.target_mode.clone();
}
}
@ -133,7 +153,7 @@ impl Effect for EnterEditAtCursor {
pub struct TogglePruneEmpty;
impl Effect for TogglePruneEmpty {
fn apply(&self, app: &mut App) {
let v = app.model.active_view_mut();
let v = app.workbook.active_view_mut();
v.prune_empty = !v.prune_empty;
}
}
@ -155,7 +175,7 @@ pub struct RemoveItem {
}
impl Effect for RemoveItem {
fn apply(&self, app: &mut App) {
app.model.remove_item(&self.category, &self.item);
app.workbook.model.remove_item(&self.category, &self.item);
}
}
@ -163,7 +183,7 @@ impl Effect for RemoveItem {
pub struct RemoveCategory(pub String);
impl Effect for RemoveCategory {
fn apply(&self, app: &mut App) {
app.model.remove_category(&self.0);
app.workbook.remove_category(&self.0);
}
}
@ -173,7 +193,7 @@ impl Effect for RemoveCategory {
pub struct CreateView(pub String);
impl Effect for CreateView {
fn apply(&self, app: &mut App) {
app.model.create_view(&self.0);
app.workbook.create_view(&self.0);
}
}
@ -181,7 +201,7 @@ impl Effect for CreateView {
pub struct DeleteView(pub String);
impl Effect for DeleteView {
fn apply(&self, app: &mut App) {
let _ = app.model.delete_view(&self.0);
let _ = app.workbook.delete_view(&self.0);
}
}
@ -189,12 +209,15 @@ impl Effect for DeleteView {
pub struct SwitchView(pub String);
impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model.active_view.clone();
let current = app.workbook.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_back_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
});
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
let _ = app.workbook.switch_view(&self.0);
}
}
@ -203,10 +226,14 @@ impl Effect for SwitchView {
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
if let Some(frame) = app.view_back_stack.pop() {
let current = app.workbook.active_view.clone();
app.view_forward_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
});
let _ = app.workbook.switch_view(&frame.view_name);
app.mode = frame.mode;
}
}
}
@ -216,10 +243,14 @@ impl Effect for ViewBack {
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
if let Some(frame) = app.view_forward_stack.pop() {
let current = app.workbook.active_view.clone();
app.view_back_stack.push(ViewFrame {
view_name: current,
mode: app.mode.clone(),
});
let _ = app.workbook.switch_view(&frame.view_name);
app.mode = frame.mode;
}
}
}
@ -231,7 +262,7 @@ pub struct SetAxis {
}
impl Effect for SetAxis {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.active_view_mut()
.set_axis(&self.category, self.axis);
}
@ -244,7 +275,7 @@ pub struct SetPageSelection {
}
impl Effect for SetPageSelection {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.active_view_mut()
.set_page_selection(&self.category, &self.item);
}
@ -257,7 +288,7 @@ pub struct ToggleGroup {
}
impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.active_view_mut()
.toggle_group_collapse(&self.category, &self.group);
}
@ -270,7 +301,7 @@ pub struct HideItem {
}
impl Effect for HideItem {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.active_view_mut()
.hide_item(&self.category, &self.item);
}
@ -283,7 +314,7 @@ pub struct ShowItem {
}
impl Effect for ShowItem {
fn apply(&self, app: &mut App) {
app.model
app.workbook
.active_view_mut()
.show_item(&self.category, &self.item);
}
@ -293,7 +324,7 @@ impl Effect for ShowItem {
pub struct TransposeAxes;
impl Effect for TransposeAxes {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().transpose_axes();
app.workbook.active_view_mut().transpose_axes();
}
}
@ -301,7 +332,7 @@ impl Effect for TransposeAxes {
pub struct CycleAxis(pub String);
impl Effect for CycleAxis {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().cycle_axis(&self.0);
app.workbook.active_view_mut().cycle_axis(&self.0);
}
}
@ -309,7 +340,7 @@ impl Effect for CycleAxis {
pub struct SetNumberFormat(pub String);
impl Effect for SetNumberFormat {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().number_format = self.0.clone();
app.workbook.active_view_mut().number_format = self.0.clone();
}
}
@ -319,7 +350,7 @@ impl Effect for SetNumberFormat {
pub struct SetSelected(pub usize, pub usize);
impl Effect for SetSelected {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().selected = (self.0, self.1);
app.workbook.active_view_mut().selected = (self.0, self.1);
}
}
@ -327,7 +358,7 @@ impl Effect for SetSelected {
pub struct SetRowOffset(pub usize);
impl Effect for SetRowOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().row_offset = self.0;
app.workbook.active_view_mut().row_offset = self.0;
}
}
@ -335,7 +366,7 @@ impl Effect for SetRowOffset {
pub struct SetColOffset(pub usize);
impl Effect for SetColOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().col_offset = self.0;
app.workbook.active_view_mut().col_offset = self.0;
}
}
@ -449,21 +480,25 @@ impl Effect for ApplyAndClearDrill {
if col_name == "Value" {
// Update the cell's value
let value = if new_value.is_empty() {
app.model.clear_cell(orig_key);
app.workbook.model.clear_cell(orig_key);
continue;
} else if let Ok(n) = new_value.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(new_value.clone())
};
app.model.set_cell(orig_key.clone(), value);
app.workbook.model.set_cell(orig_key.clone(), value);
} else {
if new_value.is_empty() {
app.status_msg = RECORD_COORDS_CANNOT_BE_EMPTY.to_string();
continue;
}
// Rename a coordinate: remove old cell, insert new with updated coord
let value = match app.model.get_cell(orig_key) {
let value = match app.workbook.model.get_cell(orig_key) {
Some(v) => v.clone(),
None => continue,
};
app.model.clear_cell(orig_key);
app.workbook.model.clear_cell(orig_key);
// Build new key by replacing the coord
let new_coords: Vec<(String, String)> = orig_key
.0
@ -478,10 +513,10 @@ impl Effect for ApplyAndClearDrill {
.collect();
let new_key = CellKey::new(new_coords);
// Ensure the new item exists in that category
if let Some(cat) = app.model.category_mut(col_name) {
if let Some(cat) = app.workbook.model.category_mut(col_name) {
cat.add_item(new_value.clone());
}
app.model.set_cell(new_key, value);
app.workbook.model.set_cell(new_key, value);
}
}
app.dirty = true;
@ -513,7 +548,7 @@ pub struct Save;
impl Effect for Save {
fn apply(&self, app: &mut App) {
if let Some(ref path) = app.file_path {
match crate::persistence::save(&app.model, path) {
match crate::persistence::save(&app.workbook, path) {
Ok(()) => {
app.dirty = false;
app.status_msg = format!("Saved to {}", path.display());
@ -532,7 +567,7 @@ impl Effect for Save {
pub struct SaveAs(pub PathBuf);
impl Effect for SaveAs {
fn apply(&self, app: &mut App) {
match crate::persistence::save(&app.model, &self.0) {
match crate::persistence::save(&app.workbook, &self.0) {
Ok(()) => {
app.file_path = Some(self.0.clone());
app.dirty = false;
@ -648,9 +683,9 @@ impl Effect for WizardKey {
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
crossterm::event::KeyCode::Enter => match wizard.build_model() {
Ok(mut model) => {
model.normalize_view_state();
app.model = model;
Ok(mut workbook) => {
workbook.normalize_view_state();
app.workbook = workbook;
app.formula_cursor = 0;
app.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
@ -703,8 +738,8 @@ impl Effect for StartImportWizard {
pub struct ExportCsv(pub PathBuf);
impl Effect for ExportCsv {
fn apply(&self, app: &mut App) {
let view_name = app.model.active_view.clone();
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
let view_name = app.workbook.active_view.clone();
match crate::persistence::export_csv(&app.workbook, &view_name, &self.0) {
Ok(()) => {
app.status_msg = format!("Exported to {}", self.0.display());
}
@ -723,7 +758,7 @@ impl Effect for LoadModel {
match crate::persistence::load(&self.0) {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model = loaded;
app.workbook = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
@ -743,7 +778,7 @@ pub struct ImportJsonHeadless {
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
FieldKind, analyze_records, extract_array_at_path, find_array_paths,
};
use crate::import::wizard::ImportPipeline;
@ -833,8 +868,8 @@ impl Effect for ImportJsonHeadless {
};
match pipeline.build_model() {
Ok(new_model) => {
app.model = new_model;
Ok(new_workbook) => {
app.workbook = new_workbook;
app.status_msg = "Imported successfully".to_string();
}
Err(e) => {
@ -892,6 +927,44 @@ impl Effect for SetPanelCursor {
}
}
// ── Chain control ────────────────────────────────────────────────────────────
/// Signals `App::apply_effects` to skip the remaining effects in the batch.
/// The flag is reset at the start of every `apply_effects` call, so each
/// dispatch starts clean. Use this when a sequence's premise no longer
/// holds (e.g. "advance to next cell" at bottom-right) and later effects
/// (e.g. "re-enter editing there") should be short-circuited.
#[derive(Debug)]
pub struct AbortChain;
impl Effect for AbortChain {
fn apply(&self, app: &mut App) {
app.abort_effects = true;
}
}
// ── Records hygiene ──────────────────────────────────────────────────────────
/// Remove cells whose `CellKey` has no coordinates — these are meaningless
/// records that can only be produced by `AddRecordRow` when no page
/// filters are set. Pushed by `ToggleRecordsMode` when leaving records
/// mode, as the inverse of the `SortData` that runs on entry.
#[derive(Debug)]
pub struct CleanEmptyRecords;
impl Effect for CleanEmptyRecords {
fn apply(&self, app: &mut App) {
let empties: Vec<CellKey> = app
.workbook
.model
.data
.iter_cells()
.filter_map(|(k, _)| if k.0.is_empty() { Some(k) } else { None })
.collect();
for key in empties {
app.workbook.model.clear_cell(&key);
}
}
}
// ── Convenience constructors ─────────────────────────────────────────────────
pub fn mark_dirty() -> Box<dyn Effect> {
@ -953,17 +1026,17 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
mod tests {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::workbook::Workbook;
fn test_app() -> App {
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Type").unwrap().add_item("Clothing");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Month").unwrap().add_item("Feb");
App::new(m, None)
let mut wb = Workbook::new("Test");
wb.add_category("Type").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Type").unwrap().add_item("Food");
wb.model.category_mut("Type").unwrap().add_item("Clothing");
wb.model.category_mut("Month").unwrap().add_item("Jan");
wb.model.category_mut("Month").unwrap().add_item("Feb");
App::new(wb, None)
}
// ── Model mutation effects ──────────────────────────────────────────
@ -972,7 +1045,7 @@ mod tests {
fn add_category_effect() {
let mut app = test_app();
AddCategory("Region".to_string()).apply(&mut app);
assert!(app.model.category("Region").is_some());
assert!(app.workbook.model.category("Region").is_some());
}
#[test]
@ -984,6 +1057,7 @@ mod tests {
}
.apply(&mut app);
let items: Vec<&str> = app
.workbook
.model
.category("Type")
.unwrap()
@ -1012,10 +1086,13 @@ mod tests {
("Month".into(), "Jan".into()),
]);
SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app);
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(42.0)));
assert_eq!(
app.workbook.model.get_cell(&key),
Some(&CellValue::Number(42.0))
);
ClearCell(key.clone()).apply(&mut app);
assert_eq!(app.model.get_cell(&key), None);
assert_eq!(app.workbook.model.get_cell(&key), None);
}
#[test]
@ -1026,7 +1103,7 @@ mod tests {
target_category: "Type".to_string(),
}
.apply(&mut app);
assert!(!app.model.formulas().is_empty());
assert!(!app.workbook.model.formulas().is_empty());
}
/// Regression: AddFormula must add the target item to the target category
@ -1036,18 +1113,21 @@ mod tests {
fn add_formula_adds_target_item_to_category() {
let mut app = test_app();
// "Margin" does not exist as an item in "Type" before adding the formula
assert!(!app
.model
.category("Type")
.unwrap()
.ordered_item_names()
.contains(&"Margin"));
assert!(
!app.workbook
.model
.category("Type")
.unwrap()
.ordered_item_names()
.contains(&"Margin")
);
AddFormula {
raw: "Margin = Food * 2".to_string(),
target_category: "Type".to_string(),
}
.apply(&mut app);
let items: Vec<&str> = app
.workbook
.model
.category("Type")
.unwrap()
@ -1072,7 +1152,7 @@ mod tests {
}
.apply(&mut app);
// Should appear in effective_item_names (used by layout)
let effective = app.model.effective_item_names("_Measure");
let effective = app.workbook.model.effective_item_names("_Measure");
assert!(
effective.contains(&"Margin".to_string()),
"formula target 'Margin' should appear in effective _Measure items, got: {:?}",
@ -1080,7 +1160,8 @@ mod tests {
);
// Should NOT be in the category's own items
assert!(
!app.model
!app.workbook
.model
.category("_Measure")
.unwrap()
.ordered_item_names()
@ -1108,13 +1189,13 @@ mod tests {
target_category: "Type".to_string(),
}
.apply(&mut app);
assert!(!app.model.formulas().is_empty());
assert!(!app.workbook.model.formulas().is_empty());
RemoveFormula {
target: "Clothing".to_string(),
target_category: "Type".to_string(),
}
.apply(&mut app);
assert!(app.model.formulas().is_empty());
assert!(app.workbook.model.formulas().is_empty());
}
// ── View effects ────────────────────────────────────────────────────
@ -1122,12 +1203,13 @@ mod tests {
#[test]
fn switch_view_pushes_to_back_stack() {
let mut app = test_app();
app.model.create_view("View 2");
app.workbook.create_view("View 2");
assert!(app.view_back_stack.is_empty());
SwitchView("View 2".to_string()).apply(&mut app);
assert_eq!(app.model.active_view.as_str(), "View 2");
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
assert_eq!(app.workbook.active_view.as_str(), "View 2");
assert_eq!(app.view_back_stack.len(), 1);
assert_eq!(app.view_back_stack[0].view_name, "Default");
// Forward stack should be cleared
assert!(app.view_forward_stack.is_empty());
}
@ -1142,39 +1224,41 @@ mod tests {
#[test]
fn view_back_and_forward() {
let mut app = test_app();
app.model.create_view("View 2");
app.workbook.create_view("View 2");
SwitchView("View 2".to_string()).apply(&mut app);
assert_eq!(app.model.active_view.as_str(), "View 2");
assert_eq!(app.workbook.active_view.as_str(), "View 2");
// Go back
ViewBack.apply(&mut app);
assert_eq!(app.model.active_view.as_str(), "Default");
assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]);
assert_eq!(app.workbook.active_view.as_str(), "Default");
assert_eq!(app.view_forward_stack.len(), 1);
assert_eq!(app.view_forward_stack[0].view_name, "View 2");
assert!(app.view_back_stack.is_empty());
// Go forward
ViewForward.apply(&mut app);
assert_eq!(app.model.active_view.as_str(), "View 2");
assert_eq!(app.view_back_stack, vec!["Default".to_string()]);
assert_eq!(app.workbook.active_view.as_str(), "View 2");
assert_eq!(app.view_back_stack.len(), 1);
assert_eq!(app.view_back_stack[0].view_name, "Default");
assert!(app.view_forward_stack.is_empty());
}
#[test]
fn view_back_with_empty_stack_is_noop() {
let mut app = test_app();
let before = app.model.active_view.clone();
let before = app.workbook.active_view.clone();
ViewBack.apply(&mut app);
assert_eq!(app.model.active_view, before);
assert_eq!(app.workbook.active_view, before);
}
#[test]
fn create_and_delete_view() {
let mut app = test_app();
CreateView("View 2".to_string()).apply(&mut app);
assert!(app.model.views.contains_key("View 2"));
assert!(app.workbook.views.contains_key("View 2"));
DeleteView("View 2".to_string()).apply(&mut app);
assert!(!app.model.views.contains_key("View 2"));
assert!(!app.workbook.views.contains_key("View 2"));
}
#[test]
@ -1185,21 +1269,21 @@ mod tests {
axis: Axis::Page,
}
.apply(&mut app);
assert_eq!(app.model.active_view().axis_of("Type"), Axis::Page);
assert_eq!(app.workbook.active_view().axis_of("Type"), Axis::Page);
}
#[test]
fn transpose_axes_effect() {
let mut app = test_app();
let row_before: Vec<String> = app
.model
.workbook
.active_view()
.categories_on(Axis::Row)
.into_iter()
.map(String::from)
.collect();
let col_before: Vec<String> = app
.model
.workbook
.active_view()
.categories_on(Axis::Column)
.into_iter()
@ -1207,14 +1291,14 @@ mod tests {
.collect();
TransposeAxes.apply(&mut app);
let row_after: Vec<String> = app
.model
.workbook
.active_view()
.categories_on(Axis::Row)
.into_iter()
.map(String::from)
.collect();
let col_after: Vec<String> = app
.model
.workbook
.active_view()
.categories_on(Axis::Column)
.into_iter()
@ -1230,7 +1314,7 @@ mod tests {
fn set_selected_effect() {
let mut app = test_app();
SetSelected(3, 5).apply(&mut app);
assert_eq!(app.model.active_view().selected, (3, 5));
assert_eq!(app.workbook.active_view().selected, (3, 5));
}
#[test]
@ -1238,8 +1322,8 @@ mod tests {
let mut app = test_app();
SetRowOffset(10).apply(&mut app);
SetColOffset(5).apply(&mut app);
assert_eq!(app.model.active_view().row_offset, 10);
assert_eq!(app.model.active_view().col_offset, 5);
assert_eq!(app.workbook.active_view().row_offset, 10);
assert_eq!(app.workbook.active_view().col_offset, 5);
}
// ── App state effects ───────────────────────────────────────────────
@ -1252,21 +1336,117 @@ mod tests {
assert_eq!(app.mode, AppMode::Help);
}
/// `AbortChain` must cause subsequent effects in the same
/// `apply_effects` batch to be skipped, and the flag must reset so the
/// next dispatch starts clean.
#[test]
fn abort_chain_short_circuits_apply_effects() {
let mut app = test_app();
app.status_msg = String::new();
let effects: Vec<Box<dyn Effect>> = vec![
Box::new(SetStatus("before".into())),
Box::new(AbortChain),
Box::new(SetStatus("after".into())),
];
app.apply_effects(effects);
assert_eq!(
app.status_msg, "before",
"effects after AbortChain must not apply"
);
assert!(
!app.abort_effects,
"abort flag should reset at end of apply_effects"
);
// A subsequent batch must not be affected by the prior abort.
app.apply_effects(vec![Box::new(SetStatus("next-batch".into()))]);
assert_eq!(app.status_msg, "next-batch");
}
/// `CleanEmptyRecords` removes cells whose `CellKey` has no
/// coordinates, and leaves all other cells untouched.
#[test]
fn clean_empty_records_removes_only_empty_key_cells() {
let mut app = test_app();
// An empty-key cell (the bug: produced by AddRecordRow when no page
// filters are set).
app.workbook
.model
.set_cell(CellKey::new(vec![]), CellValue::Number(0.0));
// Plus a well-formed cell that must survive.
let valid = CellKey::new(vec![
("Type".to_string(), "Food".to_string()),
("Month".to_string(), "Jan".to_string()),
]);
app.workbook
.model
.set_cell(valid.clone(), CellValue::Number(42.0));
assert_eq!(app.workbook.model.data.iter_cells().count(), 2);
CleanEmptyRecords.apply(&mut app);
assert!(
!app.workbook
.model
.data
.iter_cells()
.any(|(k, _)| k.0.is_empty()),
"empty-key cell should be gone"
);
assert_eq!(
app.workbook.model.get_cell(&valid),
Some(&CellValue::Number(42.0)),
"valid cell must survive"
);
}
/// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever
/// `app.mode` happens to be when applied. Previous implementation
/// branched on `app.mode.is_records()` — the parameterized version
/// trusts the caller (keymap or composing command).
#[test]
fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
let mut app = test_app();
// App starts in Normal mode — but caller has decided we want
// RecordsEditing (e.g. records-mode `o` sequence).
assert_eq!(app.mode, AppMode::Normal);
EnterEditAtCursor {
target_mode: AppMode::records_editing(),
}
.apply(&mut app);
assert!(
matches!(app.mode, AppMode::RecordsEditing { .. }),
"Expected RecordsEditing, got {:?}",
app.mode
);
// Same effect with editing target — should land in plain Editing
// even if app.mode was something else.
let mut app2 = test_app();
app2.mode = AppMode::RecordsNormal;
EnterEditAtCursor {
target_mode: AppMode::editing(),
}
.apply(&mut app2);
assert!(
matches!(app2.mode, AppMode::Editing { .. }),
"Expected Editing, got {:?}",
app2.mode
);
}
/// SetBuffer with empty value clears the buffer (used by clear-buffer command
/// in keymap sequences after commit).
#[test]
fn set_buffer_empty_clears() {
let mut app = test_app();
app.buffers.insert("formula".to_string(), "old text".to_string());
app.buffers
.insert("formula".to_string(), "old text".to_string());
SetBuffer {
name: "formula".to_string(),
value: String::new(),
}
.apply(&mut app);
assert_eq!(
app.buffers.get("formula").map(|s| s.as_str()),
Some(""),
);
assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),);
}
#[test]
@ -1418,7 +1598,9 @@ mod tests {
("Month".into(), "Jan".into()),
]);
// Set original cell
app.model.set_cell(key.clone(), CellValue::Number(42.0));
app.workbook
.model
.set_cell(key.clone(), CellValue::Number(42.0));
let records = vec![(key.clone(), CellValue::Number(42.0))];
StartDrill(records).apply(&mut app);
@ -1434,7 +1616,10 @@ mod tests {
ApplyAndClearDrill.apply(&mut app);
assert!(app.drill_state.is_none());
assert!(app.dirty);
assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(99.0)));
assert_eq!(
app.workbook.model.get_cell(&key),
Some(&CellValue::Number(99.0))
);
}
#[test]
@ -1444,7 +1629,9 @@ mod tests {
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]);
app.model.set_cell(key.clone(), CellValue::Number(42.0));
app.workbook
.model
.set_cell(key.clone(), CellValue::Number(42.0));
let records = vec![(key.clone(), CellValue::Number(42.0))];
StartDrill(records).apply(&mut app);
@ -1460,15 +1647,19 @@ mod tests {
ApplyAndClearDrill.apply(&mut app);
assert!(app.dirty);
// Old cell should be gone
assert_eq!(app.model.get_cell(&key), None);
assert_eq!(app.workbook.model.get_cell(&key), None);
// New cell should exist
let new_key = CellKey::new(vec![
("Type".into(), "Drink".into()),
("Month".into(), "Jan".into()),
]);
assert_eq!(app.model.get_cell(&new_key), Some(&CellValue::Number(42.0)));
assert_eq!(
app.workbook.model.get_cell(&new_key),
Some(&CellValue::Number(42.0))
);
// "Drink" should have been added as an item
let items: Vec<&str> = app
.workbook
.model
.category("Type")
.unwrap()
@ -1485,7 +1676,9 @@ mod tests {
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]);
app.model.set_cell(key.clone(), CellValue::Number(42.0));
app.workbook
.model
.set_cell(key.clone(), CellValue::Number(42.0));
let records = vec![(key.clone(), CellValue::Number(42.0))];
StartDrill(records).apply(&mut app);
@ -1499,7 +1692,7 @@ mod tests {
.apply(&mut app);
ApplyAndClearDrill.apply(&mut app);
assert_eq!(app.model.get_cell(&key), None);
assert_eq!(app.workbook.model.get_cell(&key), None);
}
// ── Toggle effects ──────────────────────────────────────────────────
@ -1507,11 +1700,11 @@ mod tests {
#[test]
fn toggle_prune_empty_effect() {
let mut app = test_app();
let before = app.model.active_view().prune_empty;
let before = app.workbook.active_view().prune_empty;
TogglePruneEmpty.apply(&mut app);
assert_ne!(app.model.active_view().prune_empty, before);
assert_ne!(app.workbook.active_view().prune_empty, before);
TogglePruneEmpty.apply(&mut app);
assert_eq!(app.model.active_view().prune_empty, before);
assert_eq!(app.workbook.active_view().prune_empty, before);
}
#[test]
@ -1533,6 +1726,7 @@ mod tests {
}
.apply(&mut app);
let items: Vec<&str> = app
.workbook
.model
.category("Type")
.unwrap()
@ -1542,7 +1736,7 @@ mod tests {
assert!(!items.contains(&"Food"));
RemoveCategory("Month".to_string()).apply(&mut app);
assert!(app.model.category("Month").is_none());
assert!(app.workbook.model.category("Month").is_none());
}
// ── Number format ───────────────────────────────────────────────────
@ -1551,7 +1745,7 @@ mod tests {
fn set_number_format_effect() {
let mut app = test_app();
SetNumberFormat(",.2f".to_string()).apply(&mut app);
assert_eq!(app.model.active_view().number_format, ",.2f");
assert_eq!(app.workbook.active_view().number_format, ",.2f");
}
// ── Page selection ──────────────────────────────────────────────────
@ -1564,7 +1758,10 @@ mod tests {
item: "Food".to_string(),
}
.apply(&mut app);
assert_eq!(app.model.active_view().page_selection("Type"), Some("Food"));
assert_eq!(
app.workbook.active_view().page_selection("Type"),
Some("Food")
);
}
// ── Hide/show items ─────────────────────────────────────────────────
@ -1577,14 +1774,14 @@ mod tests {
item: "Food".to_string(),
}
.apply(&mut app);
assert!(app.model.active_view().is_hidden("Type", "Food"));
assert!(app.workbook.active_view().is_hidden("Type", "Food"));
ShowItem {
category: "Type".to_string(),
item: "Food".to_string(),
}
.apply(&mut app);
assert!(!app.model.active_view().is_hidden("Type", "Food"));
assert!(!app.workbook.active_view().is_hidden("Type", "Food"));
}
// ── Toggle group ────────────────────────────────────────────────────
@ -1597,19 +1794,21 @@ mod tests {
group: "MyGroup".to_string(),
}
.apply(&mut app);
assert!(app
.model
.active_view()
.is_group_collapsed("Type", "MyGroup"));
assert!(
app.workbook
.active_view()
.is_group_collapsed("Type", "MyGroup")
);
ToggleGroup {
category: "Type".to_string(),
group: "MyGroup".to_string(),
}
.apply(&mut app);
assert!(!app
.model
.active_view()
.is_group_collapsed("Type", "MyGroup"));
assert!(
!app.workbook
.active_view()
.is_group_collapsed("Type", "MyGroup")
);
}
// ── Cycle axis ──────────────────────────────────────────────────────
@ -1617,9 +1816,9 @@ mod tests {
#[test]
fn cycle_axis_effect() {
let mut app = test_app();
let before = app.model.active_view().axis_of("Type");
let before = app.workbook.active_view().axis_of("Type");
CycleAxis("Type".to_string()).apply(&mut app);
let after = app.model.active_view().axis_of("Type");
let after = app.workbook.active_view().axis_of("Type");
assert_ne!(before, after);
}

View File

@ -8,7 +8,7 @@ use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout};
use crate::view::{AxisEntry, GridLayout, View};
/// Minimum column width — enough for short numbers/labels + 1 char gap.
const MIN_COL_WIDTH: u16 = 5;
@ -22,6 +22,8 @@ const GROUP_COLLAPSED: &str = "▶";
pub struct GridWidget<'a> {
pub model: &'a Model,
pub view: &'a View,
pub view_name: &'a str,
pub layout: &'a GridLayout,
pub mode: &'a AppMode,
pub search_query: &'a str,
@ -30,8 +32,11 @@ pub struct GridWidget<'a> {
}
impl<'a> GridWidget<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
model: &'a Model,
view: &'a View,
view_name: &'a str,
layout: &'a GridLayout,
mode: &'a AppMode,
search_query: &'a str,
@ -40,6 +45,8 @@ impl<'a> GridWidget<'a> {
) -> Self {
Self {
model,
view,
view_name,
layout,
mode,
search_query,
@ -49,7 +56,7 @@ impl<'a> GridWidget<'a> {
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view();
let view = self.view;
let layout = self.layout;
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
@ -422,7 +429,7 @@ impl<'a> GridWidget<'a> {
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
if self.mode.is_editing() && ri == sel_row {
{
let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
let edit_x = col_x_at(sel_col);
@ -494,10 +501,9 @@ impl<'a> GridWidget<'a> {
impl<'a> Widget for GridWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view_name = self.model.active_view.clone();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" View: {} ", view_name));
.title(format!(" View: {} ", self.view_name));
let inner = block.inner(area);
block.render(area, buf);
@ -675,19 +681,32 @@ mod tests {
use super::GridWidget;
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::GridLayout;
use crate::workbook::Workbook;
// ── Helpers ───────────────────────────────────────────────────────────────
/// Render a GridWidget into a fresh buffer of the given size.
fn render(model: &Model, width: u16, height: u16) -> Buffer {
fn render(wb: &mut Workbook, width: u16, height: u16) -> Buffer {
let none_cats = wb.active_view().none_cats();
wb.model.recompute_formulas(&none_cats);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let bufs = std::collections::HashMap::new();
let layout = GridLayout::new(model, model.active_view());
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
let layout = GridLayout::new(&wb.model, wb.active_view());
let view_name = wb.active_view.clone();
GridWidget::new(
&wb.model,
wb.active_view(),
&view_name,
&layout,
&AppMode::Normal,
"",
&bufs,
None,
)
.render(area, &mut buf);
buf
}
@ -716,24 +735,25 @@ mod tests {
)
}
/// Minimal model: Type on Row, Month on Column.
/// Minimal workbook: Type on Row, Month on Column.
/// Every cell has a value so rows/cols survive pruning.
fn two_cat_model() -> Model {
let mut m = Model::new("Test");
fn two_cat_model() -> Workbook {
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap(); // → Row
m.add_category("Month").unwrap(); // → Column
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
c.add_item("Clothing");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
c.add_item("Feb");
}
// Fill every cell so nothing is pruned as empty.
for t in ["Food", "Clothing"] {
for mo in ["Jan", "Feb"] {
m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
m.model
.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
}
}
m
@ -743,8 +763,8 @@ mod tests {
#[test]
fn column_headers_appear() {
let m = two_cat_model();
let text = buf_text(&render(&m, 80, 24));
let mut m = two_cat_model();
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
}
@ -753,8 +773,8 @@ mod tests {
#[test]
fn row_headers_appear() {
let m = two_cat_model();
let text = buf_text(&render(&m, 80, 24));
let mut m = two_cat_model();
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
}
@ -764,30 +784,30 @@ mod tests {
#[test]
fn cell_value_appears_in_correct_position() {
let mut m = two_cat_model();
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan")]),
CellValue::Number(123.0),
);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("123"), "expected '123' in:\n{text}");
}
#[test]
fn multiple_cell_values_all_appear() {
let mut m = two_cat_model();
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan")]),
CellValue::Number(100.0),
);
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Feb")]),
CellValue::Number(200.0),
);
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
CellValue::Number(50.0),
);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("100"), "expected '100' in:\n{text}");
assert!(text.contains("200"), "expected '200' in:\n{text}");
assert!(text.contains("50"), "expected '50' in:\n{text}");
@ -796,17 +816,17 @@ mod tests {
#[test]
fn unset_cells_show_no_value() {
// Build a model without the two_cat_model helper (which fills every cell).
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
m.model.category_mut("Type").unwrap().add_item("Food");
m.model.category_mut("Month").unwrap().add_item("Jan");
// Set one cell so the row/col isn't pruned
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan")]),
CellValue::Number(1.0),
);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
// Should not contain large numbers that weren't set
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
}
@ -815,23 +835,23 @@ mod tests {
#[test]
fn total_row_label_appears() {
let m = two_cat_model();
let text = buf_text(&render(&m, 80, 24));
let mut m = two_cat_model();
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("Total"), "expected 'Total' in:\n{text}");
}
#[test]
fn total_row_sums_column_correctly() {
let mut m = two_cat_model();
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan")]),
CellValue::Number(100.0),
);
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
CellValue::Number(50.0),
);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
// Food(100) + Clothing(50) = 150 for Jan
assert!(
text.contains("150"),
@ -843,22 +863,22 @@ mod tests {
#[test]
fn page_filter_bar_shows_category_and_selection() {
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap(); // → Row
m.add_category("Month").unwrap(); // → Column
m.add_category("Payer").unwrap(); // → Page
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
}
if let Some(c) = m.category_mut("Payer") {
if let Some(c) = m.model.category_mut("Payer") {
c.add_item("Alice");
c.add_item("Bob");
}
m.active_view_mut().set_page_selection("Payer", "Bob");
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(
text.contains("Payer = Bob"),
"expected 'Payer = Bob' in:\n{text}"
@ -867,22 +887,22 @@ mod tests {
#[test]
fn page_filter_defaults_to_first_item() {
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Payer").unwrap();
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
}
if let Some(c) = m.category_mut("Payer") {
if let Some(c) = m.model.category_mut("Payer") {
c.add_item("Alice");
c.add_item("Bob");
}
// No explicit selection — should default to first item
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(
text.contains("Payer = Alice"),
"expected 'Payer = Alice' in:\n{text}"
@ -892,32 +912,36 @@ mod tests {
// ── Formula evaluation ────────────────────────────────────────────────────
#[test]
#[ignore = "needs render harness update for _Measure virtual category"]
fn formula_cell_renders_computed_value() {
let mut m = Model::new("Test");
m.add_category("_Measure").unwrap(); // → Row
let mut m = Workbook::new("Test");
m.add_category("Region").unwrap(); // → Column
if let Some(c) = m.category_mut("_Measure") {
c.add_item("Revenue");
c.add_item("Cost");
c.add_item("Profit");
}
if let Some(c) = m.category_mut("Region") {
c.add_item("East");
}
m.set_cell(
m.model
.category_mut("_Measure")
.unwrap()
.add_item("Revenue");
m.model.category_mut("_Measure").unwrap().add_item("Cost");
// Profit is a formula target — dynamically included in _Measure
m.model.category_mut("Region").unwrap().add_item("East");
m.model.set_cell(
coord(&[("_Measure", "Revenue"), ("Region", "East")]),
CellValue::Number(1000.0),
);
m.set_cell(
m.model.set_cell(
coord(&[("_Measure", "Cost"), ("Region", "East")]),
CellValue::Number(600.0),
);
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m.active_view_mut().set_axis("_Measure", crate::view::Axis::Row);
m.active_view_mut().set_axis("Region", crate::view::Axis::Column);
m.model
.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m.active_view_mut()
.set_axis("_Index", crate::view::Axis::None);
m.active_view_mut()
.set_axis("_Dim", crate::view::Axis::None);
m.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Row);
m.active_view_mut()
.set_axis("Region", crate::view::Axis::Column);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
}
@ -925,18 +949,18 @@ mod tests {
#[test]
fn two_row_categories_produce_cross_product_labels() {
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap(); // → Row
m.add_category("Month").unwrap(); // → Column
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
c.add_item("Clothing");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
}
if let Some(c) = m.category_mut("Recipient") {
if let Some(c) = m.model.category_mut("Recipient") {
c.add_item("Alice");
c.add_item("Bob");
}
@ -945,14 +969,14 @@ mod tests {
// Populate cells so rows/cols survive pruning
for t in ["Food", "Clothing"] {
for r in ["Alice", "Bob"] {
m.set_cell(
m.model.set_cell(
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
CellValue::Number(1.0),
);
}
}
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
// Multi-level row headers: category values shown separately, not joined with /
assert!(
!text.contains("Food/Alice"),
@ -971,44 +995,44 @@ mod tests {
#[test]
fn two_row_categories_include_all_coords_in_cell_lookup() {
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Recipient").unwrap();
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
}
if let Some(c) = m.category_mut("Recipient") {
if let Some(c) = m.model.category_mut("Recipient") {
c.add_item("Alice");
c.add_item("Bob");
}
m.active_view_mut()
.set_axis("Recipient", crate::view::Axis::Row);
// Set data at the full 3-coordinate key
m.set_cell(
m.model.set_cell(
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
CellValue::Number(77.0),
);
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
assert!(text.contains("77"), "expected '77' in:\n{text}");
}
#[test]
fn two_column_categories_produce_cross_product_headers() {
let mut m = Model::new("Test");
let mut m = Workbook::new("Test");
m.add_category("Type").unwrap(); // → Row
m.add_category("Month").unwrap(); // → Column
m.add_category("Year").unwrap(); // → Page by default; move to Column
if let Some(c) = m.category_mut("Type") {
if let Some(c) = m.model.category_mut("Type") {
c.add_item("Food");
}
if let Some(c) = m.category_mut("Month") {
if let Some(c) = m.model.category_mut("Month") {
c.add_item("Jan");
}
if let Some(c) = m.category_mut("Year") {
if let Some(c) = m.model.category_mut("Year") {
c.add_item("2024");
c.add_item("2025");
}
@ -1016,13 +1040,13 @@ mod tests {
.set_axis("Year", crate::view::Axis::Column);
// Populate cells so cols survive pruning
for y in ["2024", "2025"] {
m.set_cell(
m.model.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
CellValue::Number(1.0),
);
}
let text = buf_text(&render(&m, 80, 24));
let text = buf_text(&render(&mut m, 80, 24));
// Multi-level column headers: category values shown separately, not joined with /
assert!(
!text.contains("Jan/2024"),

View File

@ -8,18 +8,20 @@ use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::Axis;
use crate::view::{Axis, View};
pub struct TileBar<'a> {
pub model: &'a Model,
pub view: &'a View,
pub mode: &'a AppMode,
pub tile_cat_idx: usize,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
pub fn new(model: &'a Model, view: &'a View, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
Self {
model,
view,
mode,
tile_cat_idx,
}
@ -44,7 +46,7 @@ impl<'a> Widget for TileBar<'a> {
Style::default(),
);
let view = self.model.active_view();
let view = self.view;
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
Some(self.tile_cat_idx)
@ -80,11 +82,11 @@ impl<'a> Widget for TileBar<'a> {
// Check if selected tile is visible when starting from `start`
let mut used: u16 = 0;
let mut sel_visible = false;
for i in start..labels.len() {
if used + widths[i] > avail {
for (i, w) in widths.iter().enumerate().take(labels.len()).skip(start) {
if used + *w > avail {
break;
}
used += widths[i];
used += *w;
if i == sel {
sel_visible = true;
}

View File

@ -4,31 +4,31 @@ use ratatui::{
style::{Color, Modifier, Style},
};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::panel::PanelContent;
use crate::view::Axis;
use crate::workbook::Workbook;
pub struct ViewContent<'a> {
view_names: Vec<String>,
active_view: String,
model: &'a Model,
workbook: &'a Workbook,
}
impl<'a> ViewContent<'a> {
pub fn new(model: &'a Model) -> Self {
let view_names: Vec<String> = model.views.keys().cloned().collect();
let active_view = model.active_view.clone();
pub fn new(workbook: &'a Workbook) -> Self {
let view_names: Vec<String> = workbook.views.keys().cloned().collect();
let active_view = workbook.active_view.clone();
Self {
view_names,
active_view,
model,
workbook,
}
}
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
fn axis_summary(&self, view_name: &str) -> String {
let Some(view) = self.model.views.get(view_name) else {
let Some(view) = self.workbook.views.get(view_name) else {
return String::new();
};
let mut parts = Vec::new();