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
18 KiB
The terminal front end (src/)
The root crate is the ratatui binary: src/draw.rs (event loop and frame
composition), src/ui/*.rs (the widgets), src/main.rs (clap). It is one of
two front ends, and design-principles §5 is the rule it is judged by — it
paints and wires, and every decision with a silently wrong answer is made in
improvise-protocol so the browser cannot answer it differently.
Test narratives live in context/test-evidence/tui.md. Facts about
GridCache, AxisFrame, Projection and the Command/Effect layer belong
to context/code/protocol.md and context/code/command.md; this file cites
them and does not restate them.
src/ui/grid.rs — GridWidget
The grid is drawn from a GridCache (improvise-n10).
No &Model. Every value the grid paints — each cell's text, whether it is
an aggregate, the column widths, the page bar and the totals strip — arrives
already decided, on the cache. A widget that cannot reach the model cannot
quietly answer a question differently from the way the browser client answers
it, and that divergence is the whole thing projecting exists to prevent.
The frame comes off the cache too (improvise-anr9). GridCache carries an
AxisFrame per axis — the header levels, the label tuples and the groups
with their collapse state — so the multi-level headers, the repeat suppression,
the group spans and the pieces below are all read rather than derived, and a
browser painting the same frame paints the same thing. It used to be derived
here from GridLayout + View::is_group_collapsed while the protocol carried
GridLayout::row_label's slash-joined form — the rendering
two_row_categories_produce_cross_product_labels asserts must not appear — so
the browser drew Food/Alice.
Two things are still not on the cache and are passed separately, each a different kind of absence:
layoutresolves a records-mode column (record_col), and only that. It keysdrill_state's pending edits, so it is session-local by design and correct to keep off the wire (gotcha 1).drill_stateis session-local residue (gotcha 30). Pending drill edits belong to whoever staged them and are deliberately not projected, so they shadow the cache rather than being on it.
The cache is expected to cover the whole grid — offsets zero, every row and every column — so a position in it is an absolute position and the widget's scrolling arithmetic is unchanged.
Inside render_grid
GridWidget::cursor is this client's cursor in the active view (improvise-8uz),
passed in rather than read off View, which no longer carries per-client state.
Everything about the frame is the cache's answer (improvise-anr9): how many
header levels, what each level's label is, which group a position sits in and
whether that group is collapsed. Nothing in render_grid reads GridLayout or
View, so a browser folding the same AxisFrame draws the same header.
The bulk forms are built once per frame, and that is not a micro-optimisation.
header_labels(offset), groups() and shares_ancestry(sel_col) each make one
pass. The per-position accessors (item, label, shows_level, group_of,
same_prefix) scan the frame from the start, so asking them inside the draw
loops is quadratic in the grid and, at a deep scroll offset, grows with the
offset. col_lines transposes header_labels to level-major because the column
header is drawn one line per level while header_labels answers per column.
The underline on the column header marks columns sharing sel_col's
ancestor group through each level: at the bottom level that is only sel_col,
at higher levels it spans all the sub-columns.
Pending drill edits shadow the projected value. They are this session's uncommitted typing and by construction are not on the cache (gotcha 30).
Aggregate cells are drawn in italic to signal "drill to edit", and the fact
is not re-derived here: the projection already named what each cell is, and
CellId::Aggregate is exactly that fact (ADR 002), decided once by the side
that holds the model. The widget used to walk none_cats × kind.is_regular()
— a second hand-rolled copy of project::cell_id.
Whether there is a total row at all is the cache's answer. An empty totals
strip means "no totals" (records mode, or an empty grid), which is why the
widget does not read is_records_mode a second time.
The collapse glyph (indicator) is the one thing that is legitimately
local: the fact — whether the group is collapsed — is on the frame; only its
spelling is the terminal's (design-principles §5).
src/draw.rs
The key-event boundary
convert_key_event / convert_key_code / convert_modifiers are the only
place crossterm's key types and the crate's own command::key types may both
appear. Three rules:
- The event loop converts every incoming
Event::Keyregardless ofKeyEventKind(Press/Repeat/Release), matching the pre-conversion behaviour, which never inspected the kind. - Codes the command layer does not model (media keys,
Insert, …) becomeKeyCode::Null, which no keymap binds. SHIFT/CONTROL/ALTsurvive; every other modifier bit (SUPER,HYPER,META) is dropped.
Semantic-to-terminal colour, and the one that is wrong
This file holds the mapping design-principles §5 names as the shape everything else should imitate: the decision lives in the shared layer as a semantic enum, and only the last translation is local.
axis_fg(AxisColor) -> Color—Axis::coloris the single place each axis's accent is decided; this is the single place axis colouring is rendered (improvise-rml, gotcha 26). Before it, the tile bar and the category panel each carried their own copy of the colour table with nothing keeping them in agreement.minibuf_fg(MinibufColor) -> Color— the same shape for the minibuffer.src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color— the same shape again.improvise-protocolmust not name a ratatuiColor.
mode_style(&AppMode) -> Style stays here for the same reason (Style and
Color are ratatui types and improvise-command, where AppMode lives,
carries no rendering-framework dependency), and since improvise-zx8t it is
exhaustive: the thirteen variants that take the default chrome are spelled
out rather than swept up by a _ => wildcard, so adding an AppMode fails the
build here until someone chooses its colour. The arithmetic is the check a
prose count does not get: 13 + Editing/RecordsEditing + CommandMode +
TileSelect = 17, which is AppMode's variant count. It read fourteen until
2026-09-22, and nothing could have caught that — a count written in prose is
checked by no one.
It is still not quite the same shape as axis_fg and its two siblings, and the
difference is deliberate rather than pending. Those three map a semantic enum
the shared layer owns; mode_style matches AppMode itself, because there is
no ModeColor. improvise-zx8t offered making one, and it was declined here
for two reasons: the colour is the only thing at stake, and the ticket says to
decide it together with improvise-k099, which asks the same question of
hint_text/label and is not in this unit. Exhaustiveness is what the
repo map's "the compiler names every one you must fill in" promise needed; a
semantic enum is a separate, larger answer and is still open.
The guard is the compiler, not a test. Nothing asserts the colours, and a
test enumerating the variants would not fail when a fifteenth is added — only
the missing match arm does. Verified by deleting the AppMode::Help arm:
error[E0004]: non-exhaustive patterns: &AppMode::Help not covered.
The minibuffer shares the bottom row with the status line
draw_bottom_bar used to render the minibuffer instead of draw_status, so a
status message written while any text-entry mode was open was never drawn
(improvise-mai9, and the stranding gotcha 21 describes). minibuffer_line
composes both: the prompt and buffer on the left, any pending status_msg
right-aligned. The alternatives and why they lost are in
context/test-evidence/tui.md.
fill_line — which segment yields is a per-call-site decision
Three rows in the TUI are one left segment and one right segment sharing a line:
the title bar, minibuffer_line and draw_status. fill_line composes them,
and it owes two things that it did not originally do — it must measure in
display cells (it padded with left.len(), bytes, and the title's own ·
is two of them, so every document's right segment sat one cell in from its edge)
and it must truncate, because concatenating and letting ratatui clip clips
the right.
What clipping the right costs depends on the row, which is why Keeps is a
parameter and not a rule:
- The title bar keeps the right. Its left segment is decoration whose length
is unbounded — a document name plus a filename — and its right segment is the
fixed
" ?:help :q quit ", the only thing on screen telling a stuck user how to get out. Unconditional left priority made the escape hatch the first thing sacrificed, at 100 columns with an ordinary document name (improvise-t14d). - Both bottom-bar rows keep the left, which is what they already did. There
the left is the user's own typing plus the cursor (
minibuffer_line) or the status message (draw_status), and the right is a view badge or the message's own margin. Right priority there would let a long message eat the text being edited — the same mistake in the other direction.
So the rule is not "the right wins" but "the affordance wins over the
decoration", and which is which is local. Truncation goes through
ui::text::truncate (improvise-8l0c), so an overflow is a … rather than a
mid-token clip of ?:.
One key table on screen, and it is the one derived from the keymap
The TUI stacks the tile bar (row 3) directly above the status bar (row 4), and the import wizard's overlay sits above the status bar too, so a mode can put two key tables on screen at once. When it does, they disagree — different notation, different content, and in one case a flat contradiction.
The rule is not "the overlay wins" or "the status bar wins". It is keep whichever table is derived from the thing it describes (improvise-rr8n):
- Tile select: the status bar wins.
hint_line(&mode)is read off the keymap, so it cannot go stale, and it is a strict superset of what the tile bar's literal carried —Space,h/landTabwere only ever on the derived line. The tile bar's own hints are therefore deleted, both of them, andTileBar::rendernow advertises no keys at all. The one it drew in Normal was false: it namedCtrl+↑↓←→as "to move tiles" while those four keys are bound toenter-tile-select, which moves nothing. - The import wizard: the overlay wins.
hint_line(&AppMode::ImportWizard)is derived too, but from the wrong input — the wizard's real keys are resolved per step insideHandleWizardKey, and one static line off the mode's keymap is wrong on four of its eight steps, including telling the userEsccancels onDonewhereEscgoes back. Sodraw_statussuppresses its hint fallback for that one mode and lets the overlay's per-step hints stand alone.
Two things worth keeping from how this survived. every_hint_key_resolves_in_its_mode
checks AppMode::hint_text() against the keymaps and cannot see a string
literal in a widget, which is the same blind spot improvise-wk18 names for
help.rs. And the widget's own characterization test renders at 80 columns,
where the hint does not fit and is never drawn — so the test that pins this
widget's output was structurally incapable of showing either hint. A fixture
width is part of what a test can see.
The longer-term shape the ticket names is still open: the wizard's per-step keys
belong in advertised() keyed by step, which would make them derived like
everything else and let the status bar carry them correctly instead of being
switched off.
The caches are built here, per frame
project::formulas_of, category_tree_of, view_list_of, tile_bar_of,
grid_of and wizard_of are all called from draw/draw_content against the
authoritative App. This is the temporary half of improvise-35e: when the
TUI becomes a client it will hold each cache and fold the matching
Projection into it, and none of the widgets change either way.
Two consequences today:
- The grid's viewport covers the whole grid, so a cache position is an absolute position (gotcha 44). Only the viewport narrows when the TUI becomes a client.
- The wizard is built here rather than sent: it is session-local and has no
Projection(improvise-764). It still goes throughproject, so the widget sees the same shape a browser wizard would.
draw renders overlays last so they appear on top. The which-key popup's armed
prefix is stored as the key that opened it and resolved back to a sub-keymap
by the keymap set (improvise-jc8, gotcha 13).
src/ui/import_wizard_ui.rs — ImportWizardWidget
Drawn from a WizardCache (improvise-764).
No ImportWizard, no ImportPipeline, no Workbook. The widget used to
hold the live wizard and read through it into improvise-io types and into the
model — it called regular_category_names() and measure_item_names() on the
built Workbook to write its one-line confirmation summary. Everything it draws
now arrives already decided, from project::wizard_of.
The cache is not a Projection, and that is the ticket's answer to where
wizard state lives: a half-open import dialogue belongs to the session that
opened it, not to every connected client. WizardCache's own documentation
carries the argument.
Smaller facts the drawing depends on: the root array path is already spelled
(root) by the projection, so both ends say it the same way; the date
components arrive in the order the wizard offers them, already paired with
whether they are on, so the cursor index counts exactly those rows; and on
Done the workbook is built but not yet installed, so the step shows what the
user is about to accept, warnings included (improvise-907).
set_bottom_line — one writer for the bottom row
That row has two writers — each step's key hint, and the wizard message that
overrides it while present — and they write in that order to the same y.
Buffer::set_string writes exactly as many cells as the string has and leaves
the rest alone, so a message shorter than the hint it replaced left the hint's
tail sitting beside it: something went wrongcel, the cel from Esc: cancel
(improvise-q0vb).
Padding here rather than at each site is the point. The bug is not that one of the five writers forgot; it is that the row had five writers and no one of them owned clearing it.
The padding is measured in terminal cells, not characters. Buffer lays a
string out by display width, so a char count over-pads a message carrying a wide
character — enough to run past the panel and overwrite the border — and
under-pads a combining mark, which is how a sliver of the hint would be left
behind. src/ui/grid.rs measures the same way.
truncate / prefix — bytes, deliberately
truncate cuts by byte length. prefix returns the longest prefix at most
max bytes that ends on a char boundary, because every string truncate cuts
can carry a field name, a formula or a message the user typed — and a multi-byte
character straddling the cut used to panic the whole TUI on a slice at a
non-boundary index. Walking down to the nearest boundary is the smallest change
that cannot: for ASCII, which is what every fixture in this file holds, max is
already a boundary and the result is byte-identical, so the characterization
tests keep saying what they said.
It is bytes and not columns deliberately. Byte length is the measure
truncate has always used, and changing it to display width would move where
every line wraps — a different question from not crashing, and one that belongs
with the hint-line padding it travels with. Bytes are never fewer than cells, so
truncate cutting by bytes and set_bottom_line padding by cells cannot
together overrun.
The blind spot, stated because the fixtures cannot state it: the eight
exact-frame characterization fixtures in this file are entirely ASCII. They are
therefore blind to every question about display width — a change to either
measure is invisible to them, and the evidence for those has to be built
directly (see set_bottom_line_clears_every_cell_it_covers_and_no_more).
src/ui/help.rs
~600 lines of mostly-static content: five pages of styled (key, description)
lines, a tab bar and a footer. improvise-ktv1 proposes moving that content
into the shared layer so a browser help overlay is not a second copy of it.
Until then it is TUI-only and help_page is already in ViewState, which is
why gotcha 32 says the overlay needs no Projection.
The panel widgets
src/ui/panel.rs holds the PanelContent trait and the generic Panel widget.
The one non-obvious member is panel(): each impl declares its own identity, and
the widget compares it against AppMode::panel() to decide whether to draw
itself as active, instead of restating the mode match per panel.
category_panel.rs, formula_panel.rs, view_panel.rs and tile_bar.rs each
draw one cache and nothing else. Two facts worth keeping:
- The category panel draws one flattened tree — the axis travels on the entry
(improvise-edp), so there is no
Viewin it. - The view panel's "which view is this client on" is read off
ViewState, not the workbook: the active view is per-client and is deliberately absent fromViewListCache(improvise-8uz, gotcha 29).
tile_bar.rs scrolls by whole tiles: it finds the smallest start index at which
the selected tile still fits in the available width, and draws ‹/› overflow
indicators. That is the file's only piece of real arithmetic and it is local
geometry, not a shared derivation.