pmacs/docs/ux-arc-framing.md

18 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.

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.