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>
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>
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>
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>
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>
- 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>