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>
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>
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>
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>
- 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>
- 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>
[/] 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
- 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>