9eb83538fe
The toast hid every status after six seconds, so a refusal the user was still fixing vanished with its prompt open: su4u's bug on a timer. The browser now asks the TUI's question. EditorSession moves into improvise-protocol, ClientState records which editor a status was written in, and RenderModel::status_stands tells the toast to hold it until that editor closes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
515 lines
28 KiB
Markdown
515 lines
28 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.
|
||
|
||
### 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::color` is 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-protocol` must not name a ratatui `Color`.
|
||
|
||
`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`/`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 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".
|