92f8ac2113
context/code/protocol.md gains section 10 and tui.md's colour section now describes face_style; the pointers to axis_fg, minibuf_fg and AxisColor elsewhere follow. Evidence for the new tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
606 lines
33 KiB
Markdown
606 lines
33 KiB
Markdown
# 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:
|
||
|
||
- **`layout` resolves a records-mode column** (`record_col`), and only that. It
|
||
keys `drill_state`'s pending edits, so it is session-local by design and
|
||
correct to keep off the wire (gotcha 1).
|
||
- **`drill_state` is 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::Key` regardless of
|
||
`KeyEventKind` (Press/Repeat/Release), matching the pre-conversion behaviour,
|
||
which never inspected the kind.
|
||
- Codes the command layer does not model (media keys, `Insert`, …) become
|
||
`KeyCode::Null`, which no keymap binds.
|
||
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
|
||
`META`) is dropped.
|
||
|
||
### `face_style` — the one place a face becomes a terminal style
|
||
|
||
ADR 004 replaced the four per-family maps this section used to describe
|
||
(`axis_fg`, `minibuf_fg`, `field_kind_fg`, `mode_style`) with one function:
|
||
`face_style(Face) -> Style` resolves the face against the shipped theme
|
||
(`Theme::resolve`, in `improvise-protocol`) and translates the result. That is
|
||
design-principles §5's shape with nothing left over: **the decision is shared,
|
||
and only the last translation is local** — `Colour` → `ratatui::Color` and the
|
||
six attributes → `Modifier`. The chain is never walked here (ADR 004 clause 3).
|
||
What the vocabulary is and why is `context/code/protocol.md` §10.
|
||
|
||
Three things to know.
|
||
|
||
- **`Colour::Default` sets nothing.** It maps to "no `fg`/`bg` on the `Style`",
|
||
so the terminal's own colour shows through. That is also why a face with no
|
||
stated colour produces `Style::default()` and not a `Reset`.
|
||
- **The slot names follow ANSI, not ratatui.** ANSI slot 7 is `White`, which
|
||
ratatui calls `Gray`; slot 8 is `BrightBlack`, ratatui's `DarkGray`; slot 15
|
||
is `BrightWhite`, ratatui's `White`. `terminal_colour` is the one place that
|
||
translation is written.
|
||
- **The shipped theme is resolved once**, into a `OnceLock`, because there is no
|
||
theme picker yet (ADR 004 "Deferred"). When one arrives the theme becomes the
|
||
TUI's own state rather than a static, and a theme is still never on `App`'s
|
||
`ViewState` (clause 7).
|
||
|
||
**The mode indicator is still exhaustive over `AppMode`**, now in
|
||
`Face::of_mode` rather than here: adding a variant fails the build there
|
||
(improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants
|
||
are no longer one arm: they are the five faces ADR 004 derives
|
||
(`mode.resting`, `mode.text-entry`, `mode.panel.*`, `mode.overlay`,
|
||
`mode.transient`), which all still inherit the same colours from `mode`.
|
||
|
||
**No-op evidence.** `face_tests::the_shipped_theme_reproduces_every_migrated_literal`
|
||
holds the old literals as a table and asserts the shipped theme draws each one,
|
||
which is clause 8's property. The one deliberate difference, the category-add
|
||
prompt turning green, is asserted by
|
||
`the_category_add_prompt_draws_as_the_item_add_prompt_does` rather than being
|
||
left out of the table silently.
|
||
|
||
**Still literal**: every other `Color::` in `src/` (the selection sites, the
|
||
panels' accents, the grid's headers, help, the welcome popup). They move as
|
||
their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch
|
||
`src/ui/grid.rs` for this while improvise-felo.6 is in flight.
|
||
|
||
### 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`/`l` and `Tab` were only ever on the
|
||
derived line. The tile bar's own hints are therefore **deleted**, both of
|
||
them, and `TileBar::render` now advertises no keys at all. The one it drew in
|
||
Normal was false: it named `Ctrl+↑↓←→` as "to move tiles" while those four
|
||
keys are bound to `enter-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 inside `HandleWizardKey`, and one static line off the mode's
|
||
keymap is wrong on four of its eight steps, including telling the user `Esc`
|
||
cancels on `Done` where `Esc` goes back. So `draw_status` suppresses 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 grid's width is one answer, and a scroll is measured where it is drawn
|
||
|
||
Two parts of the TUI scroll horizontally, and both lost their cursor for the
|
||
same reason: **the code that chose the scroll offset measured a different width
|
||
from the code that drew it** (improvise-gtn7.1).
|
||
|
||
- **The grid.** `App::cmd_context` counts `visible_cols` for the keypress path,
|
||
and `GridWidget` counts again from the `Rect` it is handed. The keypress path
|
||
counted from `term_width`, while `draw_content` gave the grid `term_width`
|
||
minus the 32-column side panel. So with any of the three panels open the
|
||
cursor was scrolled to a column the widget never drew: it went "behind the
|
||
panel". Now `viewport::grid_width(term_width, side_panel_open)` is the one
|
||
answer, reached through `App::grid_width`. `draw_content` splits the content
|
||
row on it, and `cmd_context` counts on it. `App::side_panel_open` is the one
|
||
answer to "is a panel open", so a panel that did not count toward the width
|
||
would not be drawn either, and the two sides still agree.
|
||
- **The tile bar.** `TileBar` chose its first tile against the width after
|
||
the prefix, but drew tiles one column later, and two columns later once
|
||
scrolled, to leave room for `◀`. When the selected tile fitted within those
|
||
one or two columns, the scroll stopped too early and the draw loop dropped the
|
||
tile. `tiles_left(start)` is now where both the scroll (`first_tile`) and the
|
||
draw begin.
|
||
|
||
**Why it looked like a regression.** The grid defect dates from `b5418f2e`
|
||
(2026-04-06), when `compute_visible_cols` replaced a rough estimate and took
|
||
`term_width`. It was always wrong with a panel open. But until `50e4a372`
|
||
(2026-09-20) no panel was open at launch. That commit opened the category panel
|
||
by default, in the shared `ViewState::default()`, and from then on every TUI
|
||
session started in the broken configuration. The tile bar's offset has been
|
||
wrong since `4686f470` (2026-04-09), when it first scrolled.
|
||
|
||
**What is still wrong: improvise-gtn7.2.** `visible_cols` is a *count*, taken at
|
||
the *old* offset, and `viewport_effects` applies it at the *new* one. Across
|
||
columns of mixed width no single count is right, so the cursor can still leave
|
||
the grid with no panel open. The fix is to measure the new offset against the
|
||
widths, as `first_tile` does. It is not in this change because
|
||
`resolve.rs::scrolled_to` mirrors `viewport_effects` over a window counted in
|
||
columns, and `lockstep.rs` holds the two in agreement.
|
||
|
||
### 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 through `project`, 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 `View` in 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 from
|
||
`ViewListCache` (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.
|
||
|
||
## `,` walks the view history forward (improvise-htl6)
|
||
|
||
`view-forward` was bound in **no** keymap, in either front end, from the day it
|
||
was written. The TUI reached it as `:view-forward` and a browser could not reach
|
||
it at all, because `declined()` refuses to enter `CommandMode` at the door. It
|
||
survived because pressing `<` twice is the only way to observe the forward stack
|
||
in the first place, and until PR #115 no client maintained one.
|
||
|
||
**The key is `,`, and `>` is why the obvious answer is wrong.** `<` is
|
||
`view-back` and `>` is `drill-into-cell`, so the pair that *looks* symmetric is
|
||
not one — `>` moves within the grid, `<` moves between views. Binding the
|
||
forward half to anything that reads as `>`'s partner would deepen exactly the
|
||
confusion that hid the gap. `,` is the unshifted twin of `<`, so both halves of
|
||
the history sit on one physical key, and it carries no direction-in-the-grid
|
||
meaning to collide with.
|
||
|
||
**It is deliberately not a `Ctrl-` key.** `Ctrl-R`, the universal "forward
|
||
through a history" spelling, is browser reload; `Ctrl-S` and `Ctrl-W` are
|
||
already bound here and `Ctrl-S` is already a filed bug for precisely that reason
|
||
(improvise-clso, "Ctrl-S saves and also opens the browser's own save dialog").
|
||
The keymap is **shared** between the two front ends, so a binding chosen for the
|
||
terminal is a binding the browser must also live with, and adding a second
|
||
instance of an open bug is not a thing to do by accident.
|
||
|
||
Nothing else moved. `help.rs` keys its lines off the command *name* and looks
|
||
the key up in the keymap, so the overlay and the hint line follow the binding;
|
||
`every_bound_command_name_is_registered` already covers the new entry. The
|
||
absence assertion PR #117 put in
|
||
`resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it` was
|
||
**inverted rather than deleted** — the test is renamed `…_and_one_key_reaches_it`
|
||
and now asserts the binding exists, with `view-back` kept as the control so it
|
||
cannot pass against a table that binds everything or nothing. Coming there and
|
||
saying so was the point of that assertion, and an inverted guard keeps the
|
||
property under watch where deleting it would have let the key quietly go away
|
||
again.
|
||
|
||
`,` is not advertised in `AppMode::Normal`'s hint line, and neither is `<`: the
|
||
hint is width-constrained and the history lives on help page 5 under "View
|
||
history" with both halves. That is the #149 lesson — a hint that does not fit is
|
||
never drawn, so the test pinning the widget cannot see it.
|
||
|
||
## A status message lasts until the next key (improvise-cq3d)
|
||
|
||
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
|
||
nothing in production ever cleared `status_msg`: it was only replaced by the next
|
||
message. So after the first "Saved", "Loaded" or error of a session, the derived
|
||
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
|
||
any mode.
|
||
|
||
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
|
||
applies `SetStatus("")` and then dispatches the key, so:
|
||
|
||
- a message a key produces is drawn after that key, whatever it is, errors
|
||
included. It stays until the user presses something, which is the moment they
|
||
have acted on it. An error is never wiped before it has been drawn.
|
||
- a message that arrives outside a key, such as one from startup or an import,
|
||
also stands until the next key.
|
||
- the next key clears it *before* its own effects run. So a key that writes a
|
||
new message shows that message, and a key that writes none shows the hint.
|
||
|
||
**Rejected alternatives.**
|
||
|
||
- *Expire on mode change.* This fails the ticket's own case: a message written in
|
||
Normal would stand in Normal indefinitely.
|
||
- *Expire on the next successful command.* Effects carry no notion of success,
|
||
and a failed command would leave the stale message in place. That is exactly
|
||
the case where the user most needs the hint back.
|
||
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
|
||
test's middle step exists to catch that.
|
||
- *Compose message and hint on one line.* A long message still eats the hint, and
|
||
the stale message still stands forever.
|
||
|
||
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
|
||
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
|
||
browser after every key. Expiring there without changing the browser's resolver
|
||
too would make the two ends disagree. The expiry is also a presentation decision
|
||
about the TUI's one-line status row, so it belongs to the TUI front end.
|
||
|
||
**The browser already behaves this way.** Its `status_msg` is state that is never
|
||
cleared either, but `web/app.js` shows it in a toast that hides after
|
||
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
|
||
is always drawn. So a browser user sees a message briefly and never loses the
|
||
hint. The TUI now does the same, on a keypress rather than a timer, because a
|
||
terminal status row has one line to share and a keypress is the event the TUI
|
||
already has.
|
||
|
||
### Except while a minibuffer stays open under it (improvise-su4u)
|
||
|
||
In a text-entry mode `minibuffer_line` draws the message to the right of the
|
||
prompt. Since improvise-v4hg, a formula that does not parse keeps the user in
|
||
`FormulaEdit` with the error. Under the plain rule, the error vanished on the
|
||
first correcting key: one Backspace and the user was fixing text they could no
|
||
longer see the complaint about. The same happened to any refusal that leaves
|
||
the user in a minibuffer.
|
||
|
||
**The rule: a key that leaves the user in the same editor, and says nothing of
|
||
its own, puts the stale message back.** `press` remembers the editor session and
|
||
the message, clears the message as before, dispatches the key, and then restores
|
||
the message only if all three of these hold:
|
||
- a text-entry mode was open before the key;
|
||
- the same editor session is open after it (below);
|
||
- the key wrote no message of its own.
|
||
|
||
So:
|
||
|
||
- editing keys keep the refusal on screen until the user acts on it;
|
||
- a key's own message always wins, so a second refused `Enter` shows the new
|
||
error, not the old one;
|
||
- leaving the minibuffer, by a successful commit or by `Esc`, drops it, exactly
|
||
as the plain rule does.
|
||
|
||
**Rejected alternatives.**
|
||
|
||
- *Expire only on the commit key.* That needs to know which keys commit, which
|
||
is the keymap's knowledge (`TextEntry::commit`), and a second copy of it in
|
||
the TUI loop. "The mode did not change" is observed rather than predicted,
|
||
like gotcha 31's `ReturnToNormalUnlessMoved`.
|
||
- *Never clear in a text-entry mode.* The message would then outlive `Esc` and
|
||
stand in the mode the user returns to, which is the cq3d bug again.
|
||
- *Compare the message after the key with the stale one.* A key that wrote the
|
||
same text would be indistinguishable from a key that wrote none. Clearing
|
||
first and restoring only an empty slot has no such ambiguity.
|
||
|
||
### What "the same editor" means (improvise-sx7h)
|
||
|
||
The first version of this rule compared modes: an equal `AppMode` before and
|
||
after the key meant the same editor. That is false for the two cell editors.
|
||
`Editing` and `RecordsEditing` commit with `CommitAndAdvance`, which drops to
|
||
Normal, writes the cell, moves the cursor and re-opens the editor through
|
||
`EnterEditAtCursor`, which sets an **equal** `AppMode`. So after an `Enter` or
|
||
`Tab` that committed and moved on, the mode looked unchanged, and the stale
|
||
message was restored beside a different cell's editor. It came back again on
|
||
every later commit until `Esc`. The reviewer's repro was `R`, `o`, `5`, `Tab`,
|
||
`7`: "Added new record row" survived the `Tab`, and so did `o`'s refusal
|
||
"That record row is already here…" after the `Tab` that filled the row in.
|
||
`FormulaEdit` could not show it, because its successful commit leaves the mode
|
||
and writes a message of its own.
|
||
|
||
An editor session is one *opening* of a minibuffer. The TUI loop cannot see
|
||
openings, so `EditorSession` compares what an opening sets:
|
||
- **the whole `AppMode`**, not only its variant: an `ItemAdd` for `Month` is
|
||
not the `ItemAdd` for `Region` it replaced;
|
||
- **the cursor cell**, which is a cell editor's target, so a commit that
|
||
advances is a new editor. The prompt modes do not move the cursor while the
|
||
user types, so for them this is inert;
|
||
- **the editor's seed** (`opened_editor.seed`). `EnterEditAtCursor` re-seeds
|
||
from the cell. So a commit that re-opens on the **same** cell, such as `Tab`
|
||
in the last column, is a new editor too, because the seed is now the
|
||
committed text.
|
||
|
||
What it cannot see: a commit that re-opens on the same cell with the same seed.
|
||
That means the user committed the text the cell already held, which writes
|
||
nothing. Restoring a message across a commit that changed nothing is harmless.
|
||
|
||
**Rejected: an editor generation counter.** It would be exact, but an editor is
|
||
opened by more than one effect (`EnterEditAtCursor`, and `ChangeMode` from
|
||
`enter-mode`). A counter bumped in each is a hand-kept list of the places a mode
|
||
is written, which is the shape gotcha 24 retired. The three fields are set by
|
||
the opening itself, so they need no list.
|
||
|
||
**Not included: the active view.** The cursor is per view, so in principle the
|
||
view belongs with the cell. But no key a minibuffer binds can switch the view
|
||
and stay in the minibuffer: a `:` line that switches view settles back to
|
||
Normal. So nothing could test the field, and an untestable field is a claim no
|
||
test can check.
|
||
|
||
**The browser asks the same question (improvise-1x9h).** `EditorSession` now
|
||
lives in `improvise-protocol` and is built from a `ViewState`, so the TUI's
|
||
`press` and the browser's `ClientState::status_stands` compare the same value.
|
||
The browser's mechanism differs: its message is a toast on a timer, and there
|
||
is no per-key clear. So it keeps the message's *owner* and suspends the timer
|
||
while that owner is open. See `context/code/clients.md`, "A message beside its
|
||
open editor stands until that editor closes".
|
||
|
||
## The import wizard wraps and scrolls; it never truncates a message (improvise-cgqt)
|
||
|
||
The user's ruling of 2026-09-25: "a message like this should wrap not truncate
|
||
so I can read the whole thing." Every warning and message the wizard draws goes
|
||
through `ui::text::wrap`. That covers the Confirm step's warnings, the bottom-line message,
|
||
the duplicates step's intro, options, disabled reasons and colliding records,
|
||
and the name step's explanation. `wrap` measures in cells, like `truncate`
|
||
(gotcha 50). It breaks at spaces, cuts a word only when the word alone is wider
|
||
than the line, and emits a lone double-width character rather than dropping it
|
||
when the line is narrower than that character. A leading indent is kept on every
|
||
line, so an indented row hangs under itself.
|
||
|
||
**The bottom-line message wraps upward** from the row the key hint uses. A
|
||
one-line message therefore draws exactly as before, which is why the pinned
|
||
frames (`every_step_draws_what_it_drew_before`,
|
||
`the_formula_step_draws_its_transient_state`) did not move.
|
||
|
||
**Lists scroll by item, and the header says what is shown.** `Panel::scrolling`
|
||
draws the items from `WizardCache::scroll` on, each one wrapped whole. The
|
||
list's own header row reads `Colliding records 3–9 of 14 (PgUp/PgDn):` whenever
|
||
some items are off screen. A first version put "more above" and "more below"
|
||
markers inside the list's rows. When the first item filled the list exactly,
|
||
the marker landed on the hint row and was drawn over (Codex, PR #304). The
|
||
header row belongs to no item, so no item can cover it, and no item loses a line
|
||
to make room for a marker. `ImportWizard::scroll` clamps to `len - 1`, so the last item can reach the top
|
||
and the list can never scroll away. It is by item, not by line, because the
|
||
wizard state cannot know the panel width that line-level clamping needs. The
|
||
residual is honest. A **single** item taller than the whole list area, roughly
|
||
1,500 characters at 80 columns, is still clipped at the panel's bottom. A
|
||
collision tuple has at most `MAX_CATEGORIES` coordinates (24 since
|
||
improvise-felo.1, up from 12) plus labels. With short names that is a few
|
||
hundred characters. Neither the names nor a label value is bounded, though, so
|
||
reaching the residual got easier when the cap doubled.
|
||
|
||
**Every write is clipped to the panel.** The widget draws through `Panel`,
|
||
whose `put` writes only inside the panel's inner rectangle, and its height
|
||
arithmetic saturates. Before this change, the duplicates step wrote at an
|
||
unbounded row and ratatui panicked at 80×10, and four older steps panicked on
|
||
`u16` underflow at heights of 3 to 5 (review B2 on PR #304). The bottom row's
|
||
`set_bottom_line` now truncates in cells through `ui::text::truncate`. It had
|
||
used the widget's byte-measured `truncate`, so a wrapped line holding a `←`
|
||
was cut one character early, with `…`, although it fit (review B1). The local
|
||
byte `truncate` still draws field names and labels. It is byte-identical on
|
||
ASCII, which keeps every pinned frame, and those rows are labels rather than
|
||
messages.
|
||
|
||
Other TUI surfaces still truncate text a user needs. They are out of this
|
||
ticket's scope and reported in its hand-back rather than fixed here.
|