axis_fg, minibuf_fg, field_kind_fg and mode_style are gone, and AxisColor
leaves improvise-core. The category-add prompt turns green, ADR 004
clause 8's one declared exception.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Grid: App::cmd_context counted visible_cols from term_width while
draw_content gave the grid term_width minus the 32-column side panel, so
with any panel open the cursor was scrolled past the last drawn column.
viewport::grid_width, reached through App::grid_width, is now the one
answer: draw_content splits on it and the keypress path counts on it.
Tile bar: the scroll measured from the end of the prefix while the draw
began one column later, two once scrolled (the left arrow), so the
selected tile could be dropped. tiles_left(start) is now where both the
scroll (first_tile) and the draw begin.
The grid defect dates from b5418f2e; it became the launch state when
50e4a372 opened the category panel by default, which is why it read as
a regression. Mixed-width columns still lose the cursor with no panel
open: improvise-gtn7.2. Reasoning: context/code/tui.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Two hand-written key tables were drawn BESIDE derived ones, in a different
notation, and one of them was false.
The tile bar's non-TileSelect hint read " Ctrl+^v<-> to move tiles" while
Ctrl-Left/Right/Up/Down are bound in Normal to enter-tile-select -- pressing one
ENTERS tile mode and moves nothing. It was the only persistent on-screen
documentation of those keys, and it spelt Ctrl+ where key_label, and therefore
every derived hint and help row, spells Ctrl-.
The fix DELETES both of the tile bar's hints rather than rewriting one, which
departs from the ticket's acceptance criteria and does so on the ticket's own
evidence. rr8n proposed rewriting the string and suppressing the STATUS BAR's
hint in TileSelect. But the derived status line is a strict superset of the tile
bar's literal -- it carries Space, h/l and Tab, which the tile bar's omits, as
the ticket's own finding 2 records -- and it cannot go stale, being read off the
keymap. Suppressing it would have deleted information and kept the table that had
just been shown to rot. In Normal the derived line already says T:tiles, so only
the Ctrl-arrow spelling is lost, and it was never documented correctly.
The wizard goes the other way and there the ticket is right. Its overlay draws
correct per-step hints; the status bar drew one static line under it, wrong on
four of eight steps, including telling the user Esc cancels on Done where Esc
goes BACK. That line is derived too, but from the wrong input -- the wizard's
real keys are resolved per step inside HandleWizardKey. So draw_status suppresses
its hint fallback for that one mode.
The rule, stated in context/code/tui.md: keep whichever table is derived from the
thing it describes.
Five tests. Four faults injected, each caught by a distinct test; the sharpest is
suppressing the hint fallback unconditionally, which passes both tests aimed at
the suppression and is caught only by the counterweight asserting TileSelect
still advertises its keys. Two things that let the false hint survive are
recorded: every_hint_key_resolves_in_its_mode cannot see a literal in a widget,
and the widget's own characterization test renders at 80 columns, where the hint
does not fit and is never drawn.
Evidence: context/test-evidence/tui.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The panel migration tests (improvise-35e) keep their claim and lose their
history; tile_bar's scroll loop keeps its algorithm note. gen-grammar's
usage block is trimmed to the one line a reader runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ticket asked for `RenderCache::from_app(app, subs)` populating a fat
aggregate cache. That aggregate does not exist and will not: improvise-rkeq
decided caches are per-widget and state syncs by events (gotcha 32). Checking
the premise first (doctrine(check_the_ticket_premise)) found the builders
already present and public in `ui/project.rs` — `grid_of`, `category_rows`,
`formula_texts`, `view_summaries`, `tile_entries` — with `Dirty` already
answering what a `SubscriptionSet` was for. Two things were actually missing.
**The root crate could not name a cache.** `improvise` has no
`improvise-protocol` dependency and `improvise-command` did not re-export it,
so no widget in `src/` could write `fn new(cache: &FormulaCache)`. Re-exported
as `improvise_command::protocol`, so a host names the caches through the crate
that builds them.
**The builders returned payloads, not caches.** `grid_of` handed back a
`GridCache` while its four siblings handed back the `Vec`s inside one.
`category_tree_of`/`formulas_of`/`view_list_of`/`tile_bar_of` complete the set,
and `document_projections` and the tests' `server_truth` now derive from them,
so the cache a widget holds and the projection a client folds are one
derivation. There is deliberately no function returning all five and no struct
holding them — that aggregate is the rejected shape.
Three widgets migrate off the model: the formula panel, the view panel and the
tile bar now render from `FormulaCache`, `ViewListCache` and `TileBarCache`.
The view panel takes its active view from `ViewState`, which is where per-client
state belongs. No widget names the seam: `draw.rs` builds the caches per frame,
and that per-frame rebuild is the temporary half, deleted when the TUI becomes
a client.
Not migrated, and why: the category panel's `build_cat_tree` walks
`Model::category_names()` and so shows `_Index`/`_Dim`/`_Measure`, while
`CategoryRow` carries only `regular_category_names()`. Migrating it would drop
three rows from the panel — a behaviour change, not a relocation, so it is left
for improvise-edp to decide. The grid is `edp`/`cr3` work: `GridCache` is
viewport-indexed and carries no group headers, cursor or edit overlay.
Latent bug noted, not fixed: `TileBar` called `View::axis_of`, which panics for
a category the view has never seen, on a list `tileable_cats` admits exactly
such categories to. `tile_entries` uses `try_axis_of`, so the migrated widget
cannot panic there.
Tests: an equivalence test per migrated widget asserting the cache carries what
the widget used to read off `&Model`/`&View`, plus pinned render output. All
five fire under injection (doctrine(check_the_checker)): reversing
`formula_texts`, filtering virtual categories out of `tile_entries`, and
dropping Column axes from `view_summaries` each fail their test and pass
without.
Gates: 930 passing (924 on base, +6 new), clippy clean, fmt clean,
wasm32 check clean for improvise-command and improvise-protocol.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
Drilling into a cell fixed its coordinates on Axis::Page, which put them
in the tile bar and under [ and ] as if the user could move them. They
are constraints of the drill, not dimensions to page through. They now
land on Axis::Filter.
The variant splits one question into two, and picking the wrong one is
the trap this introduces. "What data is visible" is View::filtering_cats
-- Page and Filter both constrain -- and GridLayout and add-record-row
ask it. "What can the user act on" is categories_on(Axis::Page) for [/]
and View::tileable_cats for the three tile-select sites. Both predicates
are named on Axis (filters_data, is_user_assignable) rather than left as
tests against the variant, so a third variant does not silently join the
wrong side.
Axis::next maps Filter to itself and no navigable axis rotates into it,
so neither tile select nor cycle-axis can hand out a Filter assignment.
The .improv grammar gains a filter keyword and round-trips a selection
with it. Filter only ever occurs in the _Drill view, which is never
written -- but the alternative to supporting it was silently writing a
Filter axis out as some other axis.
Worth recording: adding the variant produced exactly one compile error
outside axis.rs. improvise-rml had already collapsed the four scattered
display matches into one file, so the consolidation paid for itself on
the very next ticket.
Closes improvise-dz8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
Axis label and color tables were spread across four sites: the tile bar,
the category panel, the view panel, and the tile command. Adding an axis
variant meant finding all four. They now hang off Axis in view/axis.rs.
The ticket's signature was a layering violation. It asked for
display_short() -> (&'static str, Color), but Color is a ratatui type and
Axis lives in improvise-core, which may not name a UI type. Split into
color() -> AxisColor, a semantic palette, plus the label methods, with
draw.rs::axis_fg resolving the palette to a terminal color -- the same
arrangement minibuf_fg already has for MinibufColor.
The ticket also called the category panel an "exact copy" of the tile
bar. It was not. The colors were identical; the labels were not ("Row ↕"
vs "Row", "None ∅" vs "·"). So only the color table was true
duplication, and that is the part with nothing keeping the two copies in
agreement. The four label sets are four deliberate vocabularies and stay
four methods -- plain_label, glyph (Display appends it), short_label
(three columns; None is "·", not a truncation of "Non"), and initial
(one letter; None is empty). A characterization test pins all four
verbatim so the move could not quietly restyle anything.
next() replaces the rotation match in both cmd/tile.rs and
View::cycle_axis. is_data_axis() from the ticket is omitted: no caller
wanted it.
Not touched: persistence/mod.rs maps Axis to the on-disk keywords. That
is a format concern and belongs in the io layer.
Closes improvise-rml
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
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>
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)
When there are more tiles than fit in the available width, the tile
bar now auto-scrolls to ensure the selected tile is always visible.
Overflow indicators (◀ ▶) show when tiles exist beyond the visible
area. Scroll offset is computed fresh each frame from tile_cat_idx.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Improve UI rendering and feedback:
- `src/draw.rs` :
- Automatically enter Help mode if the model is empty.
- Render the `HelpWidget` with the current help page.
- Render the `WhichKeyWidget` when a transient keymap is active.
- `src/ui/tile_bar.rs` : Use more descriptive labels for axes (e.g., "Row",
"Col", "Pag").
- `src/ui/view_panel.rs` :
- Include an axis summary (e.g., "R:Region C:Product") next to view
names.
- Refactor `ViewContent` to hold a reference to the `Model` .
- `src/ui/effect.rs` : Add error feedback to `AddItem` , `AddItemInGroup` ,
and `AddFormula` when operations fail.
- `src/ui/help.rs` : Refactor `HelpWidget` into a multi-page system.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
Update TileBar to use UnicodeWidthStr for accurate text width calculation and
improve axis display.
- Use `unicode_width::UnicodeWidthStr` for calculating label and hint widths
- Move `axis_display` to a static method on `TileBar`
- Update axis symbols for better visual clarity (e.g., '|', '-', '=', '.')
- Clear the tile bar area before rendering to prevent stale characters
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
Add new fields to CmdContext for tracking search mode, panel cursors,
tile category index, named text buffers, and key information.
Add SetBuffer and SetTileCatIdx effects for managing application state.
Update TileBar to accept tile_cat_idx parameter for rendering.
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
Categories on the None axis are excluded from the grid and cell keys.
When evaluating cells, values across hidden dimensions are aggregated
using a per-measure function (default SUM). Adds evaluate_aggregated
to Model, none_cats to GridLayout, and 'n' shortcut in TileSelect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>