The seven text-entry modes (Editing, RecordsEditing, FormulaEdit,
CategoryAdd, ItemAdd, ExportPrompt, CommandMode) hand-bound the same
five keys each. They now go through a TextEntry builder that binds the
shared spine — AnyChar appends to one named buffer, Backspace pops from
it, Esc clears it and leaves for a given mode — and welds each commit
key to its clear-buffer via .commit(key, cmd, args). A new text mode
can no longer type into one buffer while clearing another, or bind a
commit without its clear.
SearchMode stays hand-written: it has no commit key and its query is
owned by exit-search-mode rather than clear-buffer. CommandMode needs
.rebind for its Backspace, which exits on an empty ':' line instead of
popping.
CORRECTION TO THE TICKET: this collapses ~39 production lines, not
~150. The ticket said only buffer name, commit command and exit mode
vary; Tab and Backspace vary too (Tab is absent in three modes, doubles
as Enter in two, and advances the cursor in two others), and the
builder itself costs ~62 lines. The win is the welded commit/clear
pairing, not line count.
Verified behavior-preserving by dumping every (mode, key, modifier)
lookup for all 16 modes before and after: the two tables are identical.
Adds text_entry_modes_clear_the_buffer_they_type_into, which asserts
that invariant directly over default_keymaps(). It passes against the
pre-refactor code — there was no live bug of this class — so it is a
regression guard, not a fix. Confirmed it fails when a clear-buffer is
dropped from a mode's Esc sequence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CommitFormula, CommitCategoryAdd and CommitItemAdd collapse into a single
CommitBuffer { kind: CommitKind }, registered as
`commit-buffer <formula|category|item>`. The five keymap bindings pass
the kind as an argument, giving improvise-w3q a uniform primitive to
build its text-entry template on.
The three did not have identical shape — they differed in buffer name,
empty-buffer landing mode, post-commit landing mode, status text and add
effect, and two of those are semantic (formula returns to its panel;
category/item stay open for batch entry). CommitKind carries those as
methods rather than flattening them, so this costs ~24 lines rather than
saving 40. The win is one home for minibuffer commit semantics.
CommitKind::Item carries its target category, resolved from
AppMode::ItemAdd by parse_commit_kind at registry time. execute() is
therefore a pure function of kind + buffer, the shared helper's
Option<Box<dyn Effect>> is gone, and the old silent no-op outside
ItemAdd mode becomes a real error on the `:` line.
Fixes a bug found on the way: the commit pushed set_status("Formula
added") after AddFormula, clobbering the parse error that
AddFormula::apply writes to the status bar — an unparseable formula was
dropped while the UI reported success. The add effect now goes last, so
the only party that knows whether it worked gets the last word.
Also adds a keymap test that dispatches Enter in each minibuffer mode:
Keymap::dispatch swallows an unresolvable command name with .ok()?, so
the existing lookup-only smoke tests would have passed even had the
rename missed the registry.
Filed improvise-djn: AddCategory still swallows its Result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Line reflow and import ordering only — no semantic change. These are
leftovers from the improvise-tvt/2hi/6sq work, which landed unformatted
because cargo fmt is not installed on this toolchain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`:enter-edit-at-cursor editing` produced
[RebuildLayout, EnterEditAtCursor(Editing), ChangeMode(Normal)] — ExecuteCommand
appends a return-to-Normal unless some effect claims the mode, and
EnterEditAtCursor set view_state.mode without saying so. The snap-back undid
the edit mode, so the command never actually entered editing.
Found while investigating whether EditOrDrill and EnterEditAtCursorCmd can be
merged (they cannot — see the issue). Regression test asserts the absence of
the trailing ChangeMode(Normal); asserting on changes_mode() alone passes
vacuously because the appended effect reports true itself.
ViewBack, ViewForward and StartImportWizard have the same symptom but change
the mode conditionally, which a per-type constant cannot express — filed
improvise-toc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds AppMode::label(), hint_text() and panel(), each an exhaustive match, and
moves Panel next to AppMode (re-exported from effect.rs so callers are
unchanged) since the two are inverses.
Collapses the duplicated "is this mode focused in panel X" logic from four
places (registry.rs plus the three PanelContent impls) into AppMode::panel().
PanelContent::is_active(mode) becomes PanelContent::panel(), so each panel
declares its identity instead of restating the mode match.
Two wildcard arms that hid gaps are now exhaustive:
- ModeKey::from_app_mode had `_ => None`, so a new AppMode would have silently
swallowed every keypress. Now only Quit maps to None.
- hint_text had `_ => ""`, hiding that ExportPrompt shows no hint. Behavior is
unchanged and pinned by a test; filed improvise-1c9 for the missing hint.
mode_style stays in draw.rs: Style is a ratatui type and improvise-command
carries no rendering-framework dependency.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces 165 `wb.model.category_mut("X").unwrap().add_item("Y")` chains (and
their `if let Some(cat) { … }` block form) with `wb.add_item("X", "Y")` across
layout, persistence, app, effect, cmd and grid tests. Net -125 lines.
Leaves the `.views.get(..).unwrap()` half of tvt to improvise-ont, which
covers exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workbook gains add_item / add_item_in_group / remove_item so callers stop
reaching through model.category_mut(..) and inventing their own "unknown
category" handling. Migrates the effect layer and the import pipeline; the
wizard's impossible-to-fail `if let Some(cat)` guards become `?`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten sites tested whether a category was virtual by comparing its name to
"_Measure"/"_Index"/"_Dim". Adding a CategoryKind variant produced no
compile error at any of them, and the underscore-prefix convention was
restated in every one.
CategoryKind::category_name() is now the single place the name↔kind
mapping is written down, and its match is exhaustive, so a new variant
forces a decision there. of_name() reads the mapping backwards for the
call sites that only hold a name; it is sound in place of a Category
lookup because add_category returns the existing virtual category rather
than shadowing a reserved name — asserted by a test, since the whole
substitution rests on it.
Sites converted across model/types.rs, view/layout.rs, persistence,
ui/effect.rs and cmd/grid.rs, plus the reserved-name literals at
Model::new and the drill/records axis setup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
model/types.rs carried four copy-pasted blocks across its two evaluation
paths (cached and uncached). All four copies were byte-identical — no
drift yet, but any change to aggregation or item lookup had to be made
twice to stay correct.
- apply_agg(values, func) — cell aggregation, shared by
evaluate_aggregated and aggregate_raw. Absorbs the callers' emptiness
guards so "empty means absent, not zero" is stated once.
- eval_agg(...) — formula-level SUM/AVG/MIN/MAX/COUNT, shared by
eval_expr_cached and eval_expr. This pair was a second instance of the
same defect, not named in either ticket but the same 27 duplicated
lines. It keeps its own emptiness rule (0 where an identity exists,
error otherwise) and now expresses that by delegating to apply_agg.
- Model::find_item_category — was a nested fn defined identically in
both eval paths.
Filter joins the improvise-formula re-exports so eval_agg can name it.
Tests cover each helper directly, including the deliberate emptiness
asymmetry between the two aggregation paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
format_md wrote every view in the workbook, including the scratch views
records mode and drill-down create. Those put _Index/_Dim on axes, and
neither category is ever persisted, so the saved file carried axis lines
naming categories the loader could not resolve. Saving while in records
mode was worse: Initial View named _Records, so reopening the file landed
in a view that no longer existed.
- view::VIRTUAL_VIEW_NAMES + View::is_virtual() name the two scratch
views in one place; grid.rs now builds them from those constants
- format_md skips virtual views, and Initial View falls back to the first
real view when a scratch one is active
- parse_md drops virtual views from files written by older builds, so an
affected file self-heals on the next load
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WizardKey::apply was a 126-line step machine: it matched on wizard.step,
mutated wizard state, and swapped view_state.mode, all inside an effect.
The step/key decision is pure, so it moves to a new command/cmd/wizard.rs
as one small key table per step (with the shared j/k/arrow/Esc fallback
factored out). CmdContext gains a read-only `wizard` so the command can
see the step. What is left on the effect side is three discrete
mutations: WizardApply(WizardOp) runs one wizard verb, CloseWizard
dismisses, BuildWizardModel commits the import.
HandleWizardKey also leaves text_buffer.rs, which had no reason to own it.
Behavior is unchanged — the 10 handle_key characterization tests from the
previous commit pass untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 126-line WizardKey state machine had no tests. Pin the user-visible
contract through handle_key first — step advancement, cursor movement,
proposal toggling, formula editing, name entry, build success/failure,
and Esc semantics — so the upcoming move of that logic into the command
layer is verifiably behavior-preserving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EnterEditAtCursor called app.rebuild_layout() and then built a whole
CmdContext just to read one field, so effect semantics depended on apply
order and the read cost a column-width computation.
Split the two concerns the command layer should own:
- new RebuildLayout effect, emitted by EnterEditAtCursorCmd and
CommitAndAdvance ahead of the re-edit, so the ordering is visible at
the command level instead of hidden inside apply
- App::display_value_at_cursor() lifts the display-value block out of
cmd_context; both the context and the effect now call it
Behavior is unchanged. The new effect-level test pins it: removing the
row-0 item slides row 1 under the cursor, and the edit buffer must hold
that cell's value — it reads "" instead of "2" if the rebuild is dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build_model silently skipped any record lacking a value for an accepted
Category field, so a large import could lose rows with no user-visible
sign. Add build_model_with_warnings, mirroring parse_csv_with_warnings:
it tallies drops per category (plus non-object records) and returns
warnings naming the count, the total, and the offending fields.
Wired into all three consumers: the wizard's final confirm and the
import effect fold them into the status line, and the headless CLI
prints them to stderr alongside the CSV warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Command layer + semantic App state move below the TUI: command/ (cmd,
keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport
helpers now live in crates/improvise-command, which depends on core +
formula + io and provably not on ratatui/crossterm (cargo tree clean).
The root crate remains the TUI binary and re-exports improvise_command
under the old crate::command / crate::ui::{app,effect,cat_tree} paths,
so widgets, draw.rs, and main.rs are unchanged. App moves whole (per
the improvise-ltq design note) rather than splitting an AppState out —
its fields are all command-safe after the key/color decoupling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- export_csv writes through csv::Writer (RFC 4180) and honors the view's
number format (improvise-1cz)
- malformed numbers yield CellValue::Error naming the text instead of
silent 0.0 (improvise-4yc)
- formula [Category] suffix moved into the pest grammar; walker extracts
it structurally instead of rfind(" [") string-scanning (improvise-zgb)
- parse errors now report line, column, and the offending line's text
(improvise-6kj)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Improved numeric parsing in Markdown files to surface errors instead of
defaulting to 0.0.
`export_csv` now uses `csv::Writer` to ensure RFC 4180 compliance (e.g.,
quoting fields containing commas).
`export_csv` now correctly respects the view's number format.
Add regression tests for numeric error handling and RFC 4180 compliance.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
The import wizard now proactively checks the `MAX_CATEGORIES` limit during
the proposal and configuration steps.
Advancing is blocked with a descriptive message if the limit would be
exceeded.
Fixed UI rendering order in `ImportWizardWidget` so messages are correctly
displayed.
Add regression tests for category limit enforcement.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
`parse_csv` now supports `parse_csv_with_warnings` to detect and report
short rows.
Short rows are now padded with `Value::Null` instead of being silently
dropped.
`ImportJsonHeadless` now uses `parse_csv_with_warnings` and surfaces
warnings in the status message.
`ImportJsonHeadless` now reuses parsed JSON/CSV data via
`json_import_records` instead of re-parsing.
Add regression tests for short row handling and headless import.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
`records_display` now returns `None` for out-of-bounds columns.
Added `try_axis_of` to `View` for non-panicking axis retrieval.
`cycle_axis` now uses `try_axis_of` to avoid panicking on unknown
categories.
Add regression tests for out-of-bounds access and unknown category cycling.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
Use `FLOAT_EQ_EPSILON` for equality/inequality operators and
division-by-zero guards in formula evaluation to ensure consistent
semantics.
Replace `Vec` with `IndexSet` for stem collection in `recompute_formulas`
to improve performance from O(n²) to O(n).
Add regression tests for epsilon-based comparison and stem collection
performance.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
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>
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>
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>
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>
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)
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)
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)
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)