# UX arc — framing (the gutter, and what it unlocks) A UX overhaul arc for pmacs. The diagnostic-surface work (colors, status counts, wavy squiggles, minimap marks) already shipped (PRs #64–69), so the next frontier isn't polishing diagnostics — it's the **one layout primitive both frontends still lack: a gutter column.** Line numbers, diagnostic *gutter signs* (the last deferred Task #23 item), and later fold/git-diff markers all live in the gutter. This doc frames the umbrella UX arc and designs its keystone, the gutter, grounded in a coordinate- system recon of both frontends. ## Why the gutter is the keystone Two independently-requested features share one missing foundation: - **Line numbers** — requested; every editor has them; pmacs has none. - **Diagnostic gutter signs** — explicitly parked in the diagnostics arc: *"no gutter column exists; adding one is a layout-level project, deliberately not done."* Today the TUI fakes it with a col-0 background marker (`diag.rs`) and the GPU proxies it with minimap marks. Both need a reserved left column and the coordinate-math to go with it. Build the column once (per frontend) and both features — plus future fold markers, git-diff bars, breakpoint dots — become content that rides it. ## The load-bearing bet: no protocol change The daemon is **pixel-pure** — it never learns screen geometry; the frontend owns viewport and visual layout (the semantic-frontend contract). The gutter is therefore **entirely frontend-local**, and the recon confirms each frontend already holds the data it needs: - **Line numbers** — the frontend owns the text, so it has line indices. - **Diagnostic severity per line** — already frontend-side (TUI: `DiagnosticView` over the diag store; GPU: `current_decorations`, the same source the squiggles use). Map each diagnostic's `range.start` to a line, take the max severity per line — no new wire data. So **zero protocol change, zero daemon change.** The blast radius is two frontend renderers. (This also means it's implemented *twice* — the TUI grid and the GPU are separate codebases with no shared render code — so the design fixes a shared *convention*, §Q#UX7, even though the code is duplicated.) ## The reserved-width model A gutter is a fixed-width strip carved from the **left**, mirroring how the GPU already carves the **right** for the minimap. Text shrinks by the gutter width; the gutter stays fixed while text scrolls (neither frontend has horizontal scroll, so "fixed while scrolling" is free — only the *numbers* printed change with the vertical scroll position). **TUI** (recon: clean seam). The renderer paints everything relative to a per-window `Viewport { cell_origin, cell_size }`. The whole gutter is fundamentally *one shift* at the viewport-construction site (`editor.rs:1610`): `cell_origin.col += gutter_w`, `cell_size.cols -= gutter_w`, then paint the gutter into the reclaimed `[rect.origin.col, +gutter_w)` strip. Every painter that consumes `viewport.cell_origin` (base text, syntax, diagnostic underline, search) becomes gutter-agnostic **for free**. The handful that read `rect.origin.col` *directly* each need a manual `+gutter_w`: - cursor placement (`editor.rs:1683`), local selection (`:1794`), remote-presence cursor/selection (`overlay_paint.rs:160,253`); - mouse hit-test (`dispatch_mouse`, `editor.rs:866`) subtracts `gutter_w` from `local_col`, and clicks with `local_col < gutter_w` are gutter clicks (§Q#UX6); - the diagnostic col-0 sign (`diag.rs:499–586`) relocates into the strip — `gutter_glyph()` (`diag.rs:75`) is already defined and unused, waiting for exactly this. `pos_to_display`/`display_to_pos` (the byte↔display-column core) operate in line-local space and need **no change** — the gutter is applied by callers crossing into grid space, not in the mapping itself. **GPU** (recon: one knob). All horizontal geometry hangs off `TEXT_LEFT = 16.0`. A gutter of width `G` is: - **pixel→byte** — one site: `hit_test_source_byte` (`main.rs:2758`), `x - TEXT_LEFT` → `x - TEXT_LEFT - G`; - **byte→pixel** — four sites, each `TEXT_LEFT` → `TEXT_LEFT + G`: glyph render origin (`TextArea.left`, `:3894`), text clip `bounds.left` (`:3898`, `0` → `G`), caret (`:4350/4355`), washes+squiggles (`:4413`); - **placement loop** — walk `layout_runs()` (`run.line_top`, `run.line_i`) and draw gutter glyphs/quads at `x ∈ [0, G)`, exactly the minimap's mirror on the left. Reserve the band for clicks like `in_minimap_band`. Neither frontend has horizontal scroll or soft-wrap today, so there is no scroll-offset interaction to reconcile — the single biggest simplifier. ## Forced decisions **Q#UX1 — frontend-local, no protocol change. ✗ DISPROVEN (see below).** Framed as: the data is local in both frontends, so no wire change. That was half-right — *rendering* is local, but the **control** (`M-x window.toggle-line-numbers`) lives daemon-side, so the mode must reach the GUI over the wire. Corrected to: the toggle is daemon-owned per-window state, shipped to the frontend via a new additive `InstanceMessage:: LineNumbers` variant (**protocol v13**, daemon-gated `< 13`). The GUI still *renders* locally; it just receives the on/off flag. See the as-built control-plane note. **Q#UX2 — reserve on the left, mirror the minimap.** Text area shrinks; the gutter is a fixed strip. TUI: shift the viewport at one site. GPU: add `G` to the four byte→pixel sites, subtract at the one pixel→byte site, set a `text_bounds_left`. **Q#UX3 — dynamic width, digit-count driven.** `gutter_w = digits(line_ count) + padding` (padding = 1 leading + 1 trailing cell/space typical). Recomputed as the line count crosses a power of ten. Rejected: fixed width (wastes space on small files, truncates on huge ones). Line count is already in hand at the width-computation site in both frontends. **Q#UX4 — modes: `off | absolute | relative | hybrid`.** `absolute` = line index + 1. `relative` = distance from the cursor line (Vim-style, for fast `N j`/`N k`). `hybrid` = current line absolute, others relative (the popular default). Ships incrementally: **absolute first** (proves the column + width + coordinate math with zero cursor-coupling), relative/ hybrid as a follow-up (they add a cursor-line dependency + repaint-on- cursor-move, which is why they come second). **Q#UX5 — the setting hook + where state lives.** Model on the existing `frame_target_ms` tunable (`async_runtime.rs:630` + Lua `_frame_target_ms` /`_set_frame_target_ms`). Line-number mode is naturally **per-window** (relative numbers frequently are), so the field lives on `struct Window` (TUI) and is read at paint time; a `pmacs.window`/`pmacs.frontend` binding sets it, wrapped by a friendly `builtin/` Lua chunk. The GPU reads an equivalent local setting (it has no window tree, so a single frontend-wide mode is fine there for v1). Consistency of *value* across frontends is a convention, not shared code. **Q#UX6 — gutter click behavior.** MVP: a click in the gutter band selects the whole line (common editor affordance) — or, if that's too much for the first cut, is consumed as a no-op (never mis-mapped to a text byte). The recon shows both frontends can classify a gutter-band click cheaply (`local_col < gutter_w` / `in_minimap_band`-style). Pick line-select if it's a few lines; else no-op and defer. **Q#UX7 — shared convention across the two renderers.** The code is duplicated, so the design pins the *contract* both must honor: same width formula (`digits + padding`), same number formatting (right-aligned, 1-based), same mode set, same diagnostic-sign glyphs/severity precedence, gutter never overlaps the mode line / status band / minibuffer. A short "gutter contract" section in each frontend's code comments points back here so they don't drift. ## Sub-arc sequence (each its own PR, each green under both flavors) 1. **Gutter + absolute line numbers.** Introduce the reserved column and the coordinate shift in *both* frontends; render absolute numbers. The riskiest, most valuable step — it lands the foundation and the coordinate math. Validation is a human eyeball per frontend (cursor lands on the right glyph after a click; caret draws in the right place; selection/overlays don't bleed into the gutter). 2. **Diagnostic gutter signs.** Relocate the TUI col-0 marker into the gutter (`gutter_glyph()`) and add GPU gutter glyphs; max-severity per line from data already present. Closes the last Task #23 item. 3. **Relative / hybrid line-number modes.** Add the cursor-line dependency + repaint-on-cursor-move; a mode toggle on the same machinery. 4. **The rest of the UX backlog** (below), sequenced later. ## The umbrella UX backlog (beyond the gutter) Named now so the arc has a horizon; not committed, sequenced after the gutter sub-arcs: - **Minibuffer polish** — `i/total` hint, Telescope-style preview pane, candidate annotations (kind/docstring), multibyte-exact band caret. - **Editing affordances** — current-line highlight refinements, whitespace rendering, indent guides. - **Folding** — needs a fold engine *and* gutter fold markers (rides the gutter built here); big, greenfield. - **Git-diff gutter markers** — needs a diff source; rides the gutter. - **Context-menu polish** — submenus, kill-ring/clipboard history, first-letter mnemonic jump. ## Categorical bets (score at the arc's close) - **No protocol change holds. → SCORED FALSE.** *Rendering* is local, but the toggle *command* lives daemon-side, so the mode has to cross the wire. It cost one additive variant + a version bump (v13) — cheap and routine here, but the bet was wrong: "renders locally" does not imply "no protocol change" when control is daemon-owned. Lesson for the rest of the arc: a frontend-rendered feature still needs a wire channel whenever its *control* is a daemon command. - **The coordinate shift is localized, not pervasive.** Recon says TUI = 1 viewport shift + ~5 manual sites; GPU = 1 pixel→byte + 4 byte→pixel sites. If a gutter bug shows up somewhere *not* on those lists, the bet was wrong and the mapping was less centralized than the recon found. - **`pos_to_display` / cosmic-hit stay gutter-agnostic.** The mapping core is untouched; only the grid-crossing callers change. If a mapping-core edit becomes necessary, the seam was leakier than modeled. - **Duplication is cheaper than abstraction here.** Two ~50-line gutter implementations beat inventing a shared cross-frontend layout layer for two consumers. Revisit only if a third frontend appears. ## Validation implications Per sub-arc: `fmt` + `clippy --all-targets` + full tests under **both** Lua flavors, as always. But the load-bearing validation is a **human eyeball in each frontend** — the coordinate shift is precisely the class of change where a unit test passes and the caret still lands one column off. Minimum manual checklist for sub-arc 1: click-to-place lands correctly with the gutter present; caret draws in the right cell/pixel; selection drag stays out of the gutter; resize/scroll keep the gutter fixed; a >999-line file grows the width without misaligning anything. GPU and TUI checked separately. ## As-built **Sub-arc 1 — gutter + absolute line numbers (TUI + GPU).** - **TUI** (`window.rs`/`editor.rs`/`overlay_paint.rs`): `LineNumberMode {Off, Absolute}` per-window field + `Window::gutter_width()`. One viewport shift (`editor.rs`) makes text/syntax/diag/search painters gutter-agnostic; ~5 direct-coordinate sites (cursor, selection, mouse hit-test → line-start, remote presence) each add `gutter_w`. `paint_line_number_gutter` writes right-aligned dim digits alloc-free. - **GPU** (`pmacs-gpu/main.rs`): everything hangs off `TEXT_LEFT`; a `text_left()` (= `TEXT_LEFT + gutter_width_px()`) is applied at the 4 byte→pixel sites (main text, caret, washes/squiggles) and subtracted at the 1 pixel→byte hit-test. A dedicated `gutter_text_renderer`/buffer draws right-aligned dim numbers reshaped per scroll, mirroring the minimap's reserved column. - **Control plane (the Q#UX1 correction).** M-x `window.toggle-line-numbers` sets the active window's mode (daemon-side). The TUI reads its window directly; the GUI receives the mode via the new **`InstanceMessage::LineNumbers { buffer_id, enabled }`** (protocol **v13**, additive, daemon-gated `< 13`). Producer: `SemanticRenderState::line_numbers_msg` (cached-compare suppression, seeded to the frontend's `off` default so a plain window adds no traffic). Consumer: the GUI drives its local `line_numbers` from it. The earlier `--line-numbers` GPU flag was retired. Now one `M-x` toggle works in **both** frontends, each affecting its own window — a single source of truth. Validated: `fmt` + `clippy --all-targets` clean under both Lua flavors; 1440 lib tests (incl. the gutter render + `line_numbers_msg` emit/suppress tests), 12 protocol, 53 pmacs-gpu (incl. the headless gutter render test on the adapter). Both frontends eyeballed for coordinate correctness. Deferred to later sub-arcs: relative/hybrid modes; diagnostic gutter signs (sub-arc 2); the exact gutter padding is a tunable to eyeball. **Sub-arc 2 — diagnostic gutter signs (TUI + GPU).** Per-line severity signs riding the sub-arc-1 gutter — closing the last deferred Task #23 item. No protocol/daemon change: the per-line severity is already frontend-side (the TUI's diag store, the GPU's `current_decorations`). - **Coupling decision.** Signs ride the *line-number* gutter — they show when line numbers are on, and vanish when off (the legacy col-0 background marker returns in the TUI's no-gutter mode). A signs-without-numbers mode is deferred; when it lands, the gutter's presence test widens from "line numbers on" to "line numbers OR signs on." - **TUI** (`diag.rs`/`editor.rs`/`view.rs`): `Viewport` gains `gutter_w` so overlays can address the gutter's leading column at `cell_origin.col - gutter_w`. The gutter number pass moved *before* the overlay loop so `DiagnosticView` can draw its sign into the gutter's blanked leading column without the number pass erasing it. The sign is the severity glyph (`E`/`W`/`I`/`H`) colored by `underline_color()`; most-severe wins (the existing `line_markers` map). Extracted `paint_line_markers`. - **GPU** (`pmacs-gpu/main.rs`): `collect_gutter_sign_rects` walks `layout_runs()`, finds the most-severe diagnostic decoration overlapping each line (`diagnostic_severity_rank`, min wins) and pushes a thin severity-colored bar at the gutter's left edge, riding the existing background quad batch. Rendered as a **bar**, not a glyph — the GPU gutter number layer is single-color, so a per-line-colored bar was the clean path; same convention as the TUI, per-frontend rendering (Q#UX7). Validated: `fmt` + `clippy --all-targets` clean both flavors; lib tests (incl. the TUI gutter-sign placement test) + 54 pmacs-gpu (incl. a headless sign render test on the adapter). Both frontends eyeballed. **Cross-cutting bug fixed en route (PR #87, off the arc):** a bare `--daemon` + a GPU is a *two-frontend* session; `EditorCore::close_active` / `close_others` operated on the global `windows` set and so closed *other* frontends' windows, dangling their `view.active` and crashing the daemon in `active_window()`. Scoped both to the active frontend's layout. (Surfaced while eyeballing this sub-arc against the two-frontend setup.) **Known follow-up (not this arc):** the GPU minibuffer completion-navigation highlight sticks / doesn't wrap on arrow-up; reproduces on the "normal" nav path but not the alternate one. Its own thread. **Sub-arc 3 — relative + hybrid line-number modes (TUI + GPU, protocol v14).** The last two of the framed modes (Q#UX4): **Relative** (each line shows its distance from the cursor line; cursor = 0) and **Hybrid** (cursor line shows its absolute number, others relative — Vim `number`+`relativenumber`). - **One enum, one number rule.** `LineNumberMode {Off, Absolute, Relative, Hybrid}` + `number_for(line, cursor_line)` live in **pmacs-protocol** so the wire, daemon, TUI, and GPU all compute the same value (Q#UX7); `pmacs` re-exports it as `crate::window::LineNumberMode`. - **Protocol v14 (the scored-false Q#UX1 debt, part two).** Sub-arc 1 shipped `LineNumbers { enabled: bool }` (off/absolute) as a deliberate placeholder; relative/hybrid need the mode, so the variant now carries `mode: LineNumberMode`. Encoding change → 13 → 14, daemon-gated `< 14` (a v13 peer gets no `LineNumbers`), same shape as the v10 `SearchPrompt` bump. - **Cursor-line dependency + repaint-on-move.** Relative numbers change as the cursor moves, not just on scroll. TUI: the full-frame render already repaints on cursor motion, and `paint_line_number_gutter` reads the cursor's buffer line (`text_view.line_at_offset(cursor)`) — free. GPU: `refresh_gutter_buffer` rebuilds every render (which cursor moves already trigger) using `cursor_line()` off `current_line_starts` — also free. - **Stable width.** `gutter_width` is sized by `digits(line_count)` for every on-mode, so the text never jitters as the cursor moves. - **Selection UX (chosen over a 4-way cycle):** `window.toggle-line-numbers` stays a binary off/absolute toggle; `window.set-line-numbers` opens the minibuffer with an arrow-navigable completion dropdown (off|absolute|relative|hybrid) — dogfooding the #89 arrow-nav fix. Validated: `fmt` + `clippy --all-targets` clean both flavors + gpu; 1446 lib (incl. `number_for` across all modes), 12 protocol, 55 pmacs-gpu (incl. a headless render proving relative ≠ absolute). Both frontends eyeballed, including live cursor-move renumbering. ## Arc close All four sub-arcs shipped: 1) gutter + absolute (v13), 2) diagnostic signs, 3) relative/hybrid (v14). Q#UX1 ("no protocol change") **scored false** — the gutter cost two protocol bumps (v13 daemon-owned toggle, v14 mode) — because the *control* is daemon-owned even though *rendering* is frontend-local. Deferred, riding the gutter when someone wants them: signs-without-numbers mode; whitespace/indent guides; folding placeholders; git change markers.