# 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 fourteen 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. 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`. ### 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.