pmacs/docs/ux-arc-framing.md

13 KiB
Raw Blame History

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 #6469), 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:499586) 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_LEFTx - TEXT_LEFT - G;
  • byte→pixel — four sites, each TEXT_LEFTTEXT_LEFT + G: glyph render origin (TextArea.left, :3894), text clip bounds.left (:3898, 0G), 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 polishi/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.