13 KiB
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:
DiagnosticViewover the diag store; GPU:current_decorations, the same source the squiggles use). Map each diagnostic'srange.startto 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) subtractsgutter_wfromlocal_col, and clicks withlocal_col < gutter_ware 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 clipbounds.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 atx ∈ [0, G), exactly the minimap's mirror on the left. Reserve the band for clicks likein_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)
- 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).
- 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. - Relative / hybrid line-number modes. Add the cursor-line dependency
- repaint-on-cursor-move; a mode toggle on the same machinery.
- 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/totalhint, 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 addgutter_w.paint_line_number_gutterwrites right-aligned dim digits alloc-free. - GPU (
pmacs-gpu/main.rs): everything hangs offTEXT_LEFT; atext_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 dedicatedgutter_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-numberssets the active window's mode (daemon-side). The TUI reads its window directly; the GUI receives the mode via the newInstanceMessage::LineNumbers { buffer_id, enabled }(protocol v13, additive, daemon-gated< 13). Producer:SemanticRenderState::line_numbers_msg(cached-compare suppression, seeded to the frontend'soffdefault so a plain window adds no traffic). Consumer: the GUI drives its localline_numbersfrom it. The earlier--line-numbersGPU flag was retired. Now oneM-xtoggle 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.