131 Commits

Author SHA1 Message Date
c1f4ebf5fc fix: :import command now opens the wizard instead of silently closing
execute_command set mode to ImportWizard, but the caller immediately
reset it to Normal for any non-Quit mode. Added ImportWizard to the
exclusion list so the wizard survives the reset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:40:55 -07:00
1345142fe0 fix: make .improv parser order-independent via two-pass approach
Root cause: set_axis silently ignores unregistered categories, so a
view section appearing before its categories would produce wrong axis
assignments when on_category_added later ran and assigned defaults.

Fix: collect all raw data in pass 1, then build the model in the
correct dependency order in pass 2 (categories → views → data/formulas).
The file can now list sections in any order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:37:40 -07:00
b13ab13738 chore: do not use JSON for serialization 2026-03-24 10:52:31 -07:00
41f138d5c3 feat: replace JSON with human-readable markdown .improv format
New format is diffable plain text with categories, items, formulas,
data cells, and views all readable without tooling. Legacy JSON files
(detected by leading '{') still load correctly for backwards compat.

Format overview:
  # Model Name
  ## Category: Type
  - Food
  - Gas [Essentials]
  ## Data
  Month=Jan, Type=Food = 100
  ## View: Default (active)
  Type: row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:46:57 -07:00
cb43a8130e feat: add 't' key to transpose row/column axes
Pressing 't' swaps all Row-axis categories to Column and all
Column-axis categories to Row, leaving Page categories unchanged.
Selection and scroll offsets are reset to (0,0).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 09:40:02 -07:00
c42553fa97 feat: 2D multi-level grid headers with repeat suppression
Column headers now render one row per column category instead of
joining with '/'. Row headers render one sub-column per row category.
Repeat suppression hides labels when the prefix is unchanged from
the previous row/column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 09:32:01 -07:00
c8b88c63b3 refactor: eliminate Box<dyn Iterator> and Option sentinels in export_csv
The CSV export used Box<dyn Iterator<Item = Option<usize>>> to unify
empty and non-empty row iteration, violating the CLAUDE.md rule that
Box/Rc container management should be split from logic. Replaced with a
direct for loop over row indices, removing both the Box and the Option
sentinels used to represent "placeholder empty row/col".

Also removes unused pub use cell::CellKey re-export and an unused
import in cell.rs tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 09:00:37 -07:00
6038cb2d81 refactor: make active_view and axis_of infallible
Both functions previously returned Option despite their invariants
guaranteeing a value: active_view always names an existing view
(maintained by new/switch_view/delete_view), and axis_of only returns
None for categories never registered with the view (a programming error).

Callers no longer need to handle the impossible None case, eliminating
~15 match/if-let Option guards across app.rs, dispatch.rs, grid.rs,
tile_bar.rs, and category_panel.rs.

Also adds Model::evaluate_f64 (returns 0.0 for empty cells) and collapses
the double match-on-axis pattern in tile_bar/category_panel into a single
axis_display(Axis) helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 09:00:25 -07:00
a2e519efcc refactor: replace CellValue::Empty with Option<CellValue>
Previously CellValue had three variants: Number, Text, and Empty.
The Empty variant acted as a null sentinel, but the compiler could not
distinguish between "this is a real value" and "this might be empty".
Code that received a CellValue could use it without checking for Empty,
because there was no type-level enforcement.

Now CellValue has only Number and Text. The absence of a value is
represented as None at every API boundary:

  DataStore::get()    → Option<&CellValue>  (was &CellValue / Empty)
  Model::get_cell()   → Option<&CellValue>  (was &CellValue / Empty)
  Model::evaluate()   → Option<CellValue>   (was CellValue::Empty)
  eval_formula()      → Option<CellValue>   (was CellValue::Empty)

Model gains clear_cell() for explicit key removal; ClearCell dispatch
calls it instead of set_cell(key, CellValue::Empty).

The compiler now forces every caller of evaluate/get_cell to handle
the None case explicitly — accidental use of an empty value as if it
were real is caught at compile time rather than silently computing
wrong results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:06:51 -07:00
e680b098ec refactor: remove Axis::Unassigned; axis_of returns Option<Axis>
Axis::Unassigned served two purposes that Option already covers:
  1. "this category has no assignment yet" → None
  2. "this category doesn't exist" → None

By removing the variant and changing axis_of to return Option<Axis>,
callers are forced by the compiler to handle the absent-category case
explicitly (via match or unwrap_or), rather than silently treating it
like a real axis value.

SetAxis { axis: String } also upgraded to SetAxis { axis: Axis }.
Previously, constructing SetAxis with an invalid string (e.g. "diagonal")
would compile and then silently fail at dispatch. Now the type only admits
valid axis values; the dispatch string-parser is gone.

Axis gains #[serde(rename_all = "lowercase")] so existing JSON command
files (smoke.jsonl, etc.) using "row"/"column"/"page" continue to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 07:32:48 -07:00
599f1adcbd refactor: replace BinOp string with typed enum in Expr AST
Previously Expr::BinOp(String, ...) accepted any string as an operator.
Invalid operators (e.g. "diagonal") would compile fine and silently
return CellValue::Empty at eval time.

Now BinOp is an enum with variants Add/Sub/Mul/Div/Pow/Eq/Ne/Lt/Gt/Le/Ge.
The parser produces enum variants directly; the evaluator pattern-matches
exhaustively with no fallback branch. An invalid operator is now a
compile error at the call site, and the compiler ensures every variant
is handled in both eval_expr and eval_bool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:20:08 -07:00
5434a60cc4 refactor: make Model::formulas private, expose read-only accessor
Previously `pub formulas: Vec<Formula>` allowed any code to call
`model.formulas.push(formula)` directly, bypassing the dedup logic in
`add_formula` that enforces the (target, target_category) uniqueness
invariant.

Making the field private means the only mutation paths are
`add_formula` and `remove_formula`, both of which maintain the invariant.
A `pub fn formulas(&self) -> &[Formula]` accessor preserves read access
for the UI and tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:18:43 -07:00
d99d22820e refactor: extract GridLayout as single source for view→grid mapping
Three copies of cross_product existed (grid.rs, app.rs, persistence/mod.rs)
with slightly different signatures. Extracted into GridLayout in
src/view/layout.rs, which is now the single canonical mapping from a View
to a 2-D grid: row/col counts, labels, and cell_key(row, col) → CellKey.

All consumers updated to use GridLayout::new(model, view):
- grid.rs: render_grid, total-row computation, page bar
- persistence/mod.rs: export_csv
- app.rs: move_selection, jump_to_last_row/col, scroll_rows,
  search_navigate, selected_cell_key

Also includes two app.rs UI bug fixes that were discovered while
refactoring:
- Ctrl+Arrow tile movement was unreachable (shadowed by plain arrow arms);
  moved before plain arrow handlers
- RemoveFormula dispatch now passes target_category (required by the
  formula management fix in the previous commit)

GridLayout has 6 unit tests covering counts, label formatting, cell_key
correctness, out-of-bounds, page coord inclusion, and evaluate round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:12:00 -07:00
9ef0a72894 fix: formula evaluation and management bugs
Three bugs fixed, each with a failing regression test added first:

1. WHERE filter fallthrough: when the filter's category was absent from the
   cell key, the formula was applied unconditionally. Now returns the raw
   stored value (no formula applied) when the category is missing.

2. Agg inner/filter ignored: SUM(Revenue) was summing ALL cells in the
   partial slice rather than constraining to the Revenue item. Now resolves
   the inner Ref to its category and pins that coordinate before scanning.

3. Formula dedup by target only: add_formula and remove_formula keyed on
   target name alone, so two formulas with the same item name in different
   categories would collide. Both now key on (target, target_category).

RemoveFormula command updated to carry target_category.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:11:33 -07:00
09caf815d3 test: add proptest property-based tests
Add proptest dependency and property tests for:
- CellKey: key normalization invariants (sort order, dedup, round-trip,
  prefix non-equality, merge commutativity)
- View: axis exclusivity, set_axis, idempotency, page_selection roundtrip,
  hide/show roundtrip, toggle_group_collapse involution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:10:44 -07:00
45b848dc67 fix: navigation bounds and stale state after model load
- app.rs: scroll_rows (Ctrl+D/U) now clamps to the cross-product row
  count and follows the viewport, matching move_selection's behaviour.
  Previously it could push selected past the last row, causing
  selected_cell_key to return None and silently ignoring edits.
- model.rs: add normalize_view_state() which resets row/col offsets to
  zero on all views.
- main.rs, dispatch.rs, app.rs: call normalize_view_state() after every
  model replacement (initial load, :Load command, wizard import) so
  stale offsets from a previous session can't hide the grid.
- app.rs: clamp formula_cursor to the current formula list length at the
  top of handle_formula_panel_key so a model reload with fewer formulas
  can't leave the cursor pointing past the end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:32:26 -07:00
0cb491901d fix: formula correctness and command validation bugs
- model.rs: division by zero now returns Empty instead of 0 so the cell
  visually signals the error rather than silently showing a wrong value.
  Updated the test to assert Empty.
- parser.rs: missing closing ')' in aggregate functions (SUM, AVG, etc.)
  and IF(...) now returns a parse error instead of silently succeeding,
  preventing malformed formulas from evaluating with unexpected results.
- dispatch.rs: SetCell validates that every category in the coords
  exists before mutating anything; previously a typo in a category name
  silently wrote an orphaned cell (stored but never visible in any grid)
  and returned ok. Now returns an error immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:32:14 -07:00
6ba7245338 fix: page navigation works with multiple page-axis categories
[/] previously broke after the first page category due to a hard-coded
`break`. Replaced with odometer-style navigation: ] advances the last
page category, carrying into the previous when it wraps (like digit
incrementing). [ decrements the same way. Single-category behaviour is
unchanged except it now wraps around instead of clamping at the end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:02:01 -07:00
8063a484e1 fix: multi-category axis navigation and cell key resolution
move_selection, jump_to_last_row/col, and selected_cell_key all used
items.get(sel_row) on the first axis category, which returned None for
any cursor position beyond that category's item count. They now compute
the full Cartesian product (via cross_product_strs) and index into it,
so navigation and cell edits work correctly with multiple categories on
the same axis.

Also adds viewport-following scroll in move_selection/jump helpers so
the cursor stays visible when navigating past the visible window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:51:30 -07:00
2b5e3b3576 feat: number formatting, real search navigation, CSV cross-product fix
- grid.rs: honour view.number_format (",.0" default, ",.2", ".4", etc.)
  via parse_number_format/format_f64(n,comma,decimals); format_f64 now
  pub so callers can reuse the same formatting logic.
- app.rs: n/N actually navigate to next/prev search match (cross-product
  aware); fix dead unreachable N arm; add :set-format / :fmt command to
  change the active view's number_format at runtime.
- persistence/mod.rs: CSV export now uses full cross-product of all
  row/col-axis categories, matching grid rendering behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:47:52 -07:00
cc072e192d Chore: remove unused imports and suppress unused variable warnings
Removes dead use statements across dispatch, formula, import, model, and
UI modules. Prefixes intentionally unused variables with _ in app.rs,
analyzer.rs, and grid.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:35:50 -07:00
77f83bac3a chore: start testing the grid 2026-03-21 23:34:32 -07:00
56d11aee74 Test: add unit tests co-located with the code they cover
- model/cell.rs: CellKey (sorting, get, with, without, matches_partial,
  display) and DataStore (set/get, overwrite, empty-removes-key,
  sum_matching, matching_cells, text exclusion)
- model/category.rs: item ids, deduplication, group assignment,
  top_level_groups, item_index insertion order
- formula/parser.rs: subtraction, WHERE clause, SUM/AVG, IF,
  numeric literal, chained arithmetic, error on missing =
- view/view.rs: auto-axis assignment, set_axis, categories_on,
  page_selection, group collapse toggle, hide/show, cycle_axis
  (all transitions + scroll/selection reset)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:28:48 -07:00
6a9d28ecd6 Fix formula evaluator infinite recursion on unresolved item references
When a formula Ref resolves to an item that doesn't exist in any
category, find_item_category returned None and the fallback
unwrap_or(name) used the item name as a category name. The resulting
key still matched the formula target, causing infinite recursion and a
stack overflow. Now returns None immediately via ? so the expression
evaluates to Empty.

Adds unit tests for Model (structure, cell I/O, views), formula
evaluation (arithmetic, WHERE, aggregation, IF), and a full
five-category Region×Product×Channel×Time×Measure integration test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:28:37 -07:00
197b66e4e1 Refactor: split ImportWizard into pure ImportPipeline and UI wrapper
ImportPipeline holds all data and logic (raw JSON, records, proposals,
model_name, build_model). ImportWizard wraps it with UI-only state
(step, cursor, message). Rename WizardState → WizardStep throughout.
Headless import in dispatch.rs now constructs ImportPipeline directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:28:27 -07:00
413601517d Fix cursor getting stuck when Enter pressed on last row
move_selection() only clamped at 0, letting the row/col index go past
the last valid item. Selected_cell_key() would then return None, leaving
the cursor in a phantom position with no selectable cell.

Now clamp both row and col against the actual item count so pressing
Enter on the last row keeps the cursor on that row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:05:57 -07:00
c9d1313072 Add quick-add mode for categories
N (from anywhere) or n (in Category panel) opens an inline prompt
to add categories one after another without typing :add-cat each time.

- Yellow border + prompt distinguishes it from item-add (green)
- Enter / Tab adds the category and clears the buffer, staying open
- Esc returns to the category list
- Cursor automatically moves to the newly added category

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:03:45 -07:00
4f322e53cd Add quick multi-item entry to categories
Two new ways to add multiple items without repeating yourself:

1. :add-items <category> item1 item2 item3 ...
   Adds all space-separated items in one command.

2. Category panel quick-add mode (press 'a' or 'o' on a category):
   - Opens an inline prompt at the bottom of the panel
   - Enter adds the item and clears the buffer — stays open for next entry
   - Tab does the same as Enter
   - Esc closes and returns to the category list
   - The panel border turns green and the title updates to signal add mode
   - Item count in the category list updates live as items are added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:51:36 -07:00
66dfdf705f Improve UX: welcome screen, vim keybindings, command mode
- Welcome overlay shown when model has no categories, listing common
  commands and navigation hints to orient new users
- Vim-style keybindings:
  - i / a → Insert mode (edit cell); Esc → Normal
  - x → clear cell; yy / p → yank / paste
  - G / gg → last / first row; 0 / $ → first / last col
  - Ctrl+D / Ctrl+U → half-page scroll
  - n / N → next / prev search match
  - T → tile-select mode (single key, no Ctrl needed)
  - ZZ → save + quit
  - F / C / V → toggle panels (no Ctrl needed)
  - ? → help (in addition to F1)
- Command mode (:) for vim-style commands:
  :q  :q!  :w [path]  :wq  ZZ
  :import <file.json>  :export [path]
  :add-cat <name>  :add-item <cat> <item>
  :formula <cat> <Name=expr>  :add-view [name]  :help
- Status bar now context-sensitive: shows mode-specific hint text
  instead of always showing the same generic shortcuts
- Mode label changed: "Editing" → "INSERT" to match vim convention
- Title bar shows filename in parentheses when model is backed by a file
- Help widget updated with full key reference in two-column layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:41:35 -07:00
eae00522e2 Initial implementation of Improvise TUI
Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:11:55 -07:00
0ba39672d3 (root) 2026-03-20 13:33:59 -07:00