diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index b220a74..eb0fb3a 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -219,6 +219,12 @@ cmd { name = "window.split-horizontal", cmd { name = "window.split-vertical", description = "Split the active window vertically (children sit side-by-side).", fn = function() pmacs.window.split_vertical() end } +cmd { name = "window.toggle-line-numbers", + description = "Toggle the active window's line-number gutter (off / absolute).", + fn = function() + local cur = pmacs.window.line_numbers() + pmacs.window.set_line_numbers(cur == "off" and "absolute" or "off") + end } cmd { name = "window.focus-next", description = "Move focus to the next window in iteration order.", fn = function() pmacs.window.focus_next() end } diff --git a/docs/ux-arc-framing.md b/docs/ux-arc-framing.md new file mode 100644 index 0000000..4897841 --- /dev/null +++ b/docs/ux-arc-framing.md @@ -0,0 +1,242 @@ +# 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. + + diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 076a9d8..981df12 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -87,6 +87,15 @@ const MINIMAP_CODE_COLS: f32 = 100.0; const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5; const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0; const CODE_LINE_HEIGHT: f32 = 22.0; +/// Font size of the code buffer (and the line-number gutter, so their +/// line heights match and rows align). +const CODE_FONT_SIZE: f32 = 16.0; +/// Gap in px between the line-number gutter digits and the code +/// (UX gutter arc, GPU side of sub-arc 1 — mirrors the TUI gutter). +const GUTTER_GAP_PX: f32 = 10.0; +/// Fallback monospace advance in px when no shaped glyph is available to +/// measure (0.6 em at the 16px code font). +const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6; const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92]; const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82]; const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18]; @@ -262,6 +271,19 @@ enum Mode { Attach { socket: PathBuf }, } +/// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes +/// the line-number gutter (UX gutter arc). Mirrors the TUI's +/// `pmacs::window::decimal_digits` — kept local since pmacs-gpu doesn't +/// depend on the `pmacs` crate. +fn decimal_digits(mut n: usize) -> u32 { + let mut d = 1u32; + while n >= 10 { + n /= 10; + d += 1; + } + d +} + fn main() { env_logger::init(); let mode = parse_args(std::env::args().skip(1).collect()); @@ -622,6 +644,15 @@ struct State { /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, + /// Line-number gutter toggle (UX gutter arc, GPU side). Frontend-local + /// (Q#UX5), set from the `--line-numbers` flag. Off ⇒ zero coordinate + /// change: `gutter_width_px()` is 0 and every shift site is a no-op. + line_numbers: bool, + /// Shaped right-aligned line numbers, one per visible code line — its + /// own text layer over the code, aligned row-for-row (same line height). + gutter_buffer: Buffer, + /// Dedicated renderer for the gutter number layer (like the menu / mb). + gutter_text_renderer: TextRenderer, } /// The wire-authoritative status facts (Q#S1, protocol v8), @@ -1700,6 +1731,9 @@ impl State { // Q#MB1 — a third renderer for the minibuffer dropdown layer. let mb_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // UX gutter — a renderer for the line-number layer. + let gutter_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, format); let squiggle_renderer = SquiggleRenderer::new(&device, format); @@ -1749,6 +1783,17 @@ impl State { Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); + // Line-number gutter buffer (UX gutter arc): same font size + line + // height as the code buffer so its rows align one-for-one. + let mut gutter_buffer = Buffer::new( + &mut font_system, + Metrics::new(CODE_FONT_SIZE, CODE_LINE_HEIGHT), + ); + gutter_buffer.set_size( + &mut font_system, + Some(config.width as f32), + Some(config.height as f32), + ); buffer.set_text( &mut font_system, initial_text, @@ -1831,6 +1876,9 @@ impl State { mb_text_renderer, mb_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, + line_numbers: false, + gutter_buffer, + gutter_text_renderer, } } @@ -2478,6 +2526,16 @@ impl State { self.request_redraw(); None } + // UX gutter (protocol v13): the daemon owns the per-window + // line-number toggle (`M-x window.toggle-line-numbers`); apply + // it to our local gutter state and repaint on change. + InstanceMessage::LineNumbers { enabled, .. } => { + if self.line_numbers != enabled { + self.line_numbers = enabled; + self.request_redraw(); + } + None + } // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). // `query: None` clears the band (search ended); `Some` shows // `[Regex] I-search: (n/m)` on the band's left side. @@ -2732,6 +2790,66 @@ impl State { self.scroll_top != old } + /// Monospace glyph advance in px, read from the currently-shaped code + /// buffer (every glyph shares it in a monospace font), with a fallback + /// when the buffer has no glyphs yet. Used to size the line-number + /// gutter (UX gutter arc). + fn mono_advance(&self) -> f32 { + self.buffer + .layout_runs() + .flat_map(|run| run.glyphs.iter()) + .next() + .map_or(GUTTER_MONO_ADVANCE_FALLBACK, |g| g.w) + } + + /// Width in px the line-number gutter reserves on the left, or 0 when + /// disabled (UX gutter arc, Q#UX3): `digits * advance + gap`. Mirrors + /// the TUI's `Window::gutter_width`; the unit here is pixels. + fn gutter_width_px(&self) -> f32 { + if !self.line_numbers { + return 0.0; + } + let lines = self.current_line_starts.len().max(1); + decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX + } + + /// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every + /// byte→pixel x site adds this instead of the bare `TEXT_LEFT` (Q#UX2), + /// and the pixel→byte hit-test subtracts it. + fn text_left(&self) -> f32 { + TEXT_LEFT + self.gutter_width_px() + } + + /// Reshape the gutter buffer to the right-aligned line numbers for the + /// currently-shaped code lines (UX gutter arc). One number per code + /// line starting at `shaped_top`, so the two buffers align row-for-row + /// at the same `top` and line height. No-op when the gutter is off. + fn refresh_gutter_buffer(&mut self) { + use std::fmt::Write as _; + if !self.line_numbers { + return; + } + let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize; + let first = self.shaped_top; + let n = self.buffer.lines.len(); + let mut text = String::new(); + for i in 0..n { + if i > 0 { + text.push('\n'); + } + let _ = write!(text, "{:>digits$}", first + i + 1); + } + self.gutter_buffer.set_text( + &mut self.font_system, + &text, + &Attrs::new().family(Family::Name("JetBrains Mono")), + Shaping::Advanced, + None, + ); + self.gutter_buffer + .shape_until_scroll(&mut self.font_system, false); + } + /// Resolve a window-pixel position to an **absolute source byte** /// (Q#M2): pixel → cosmic-text hit (shaped line + byte within /// line) → projected byte → run map → slice byte → + `vstart`. @@ -2755,7 +2873,7 @@ impl State { self.projected_line_starts = projected_line_starts; self.hit_map_dirty = false; } - let rel_x = x as f32 - TEXT_LEFT; + let rel_x = x as f32 - self.text_left(); let rel_y = y as f32 - TEXT_TOP; let cursor = self.buffer.hit(rel_x, rel_y)?; let line_start = *self.projected_line_starts.get(cursor.line)?; @@ -3640,6 +3758,14 @@ impl State { Some(width as f32), Some(STATUS_BAND_HEIGHT), ); + // UX gutter: resize the line-number buffer too, else it keeps its + // construction-time (800x200) height and `shape_until_scroll` only + // shapes the ~10 lines that fit — the "numbers stop at 10" bug. + self.gutter_buffer.set_size( + &mut self.font_system, + Some(width as f32), + Some(height as f32), + ); // A taller/shorter window changes the visible line count, so the // slice + scoped viewport change (session S1). self.reshape(); @@ -3771,6 +3897,9 @@ impl State { let frame_start = debug_frame().then(std::time::Instant::now); self.refresh_status_line(); self.refresh_menu_buffer(); + // UX gutter: reshape the line-number layer to the current scroll + // (no-op when the gutter is off). + self.refresh_gutter_buffer(); // Q#CM1 — the context-menu popup quads (bg / highlight / // separators), drawn as a top layer after everything else. let menu_vertices = self.menu_vertex_bytes(); @@ -3881,6 +4010,15 @@ impl State { (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); let status_top = text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; + // UX gutter: the code's left origin (past the gutter) and the + // main-text clip-left. Computed here as locals — calling `self.*` + // inside the `prepare` args would conflict with its `&mut` borrows. + let text_left = self.text_left(); + let gutter_clip_left = if self.line_numbers { + text_left.floor() as i32 + } else { + 0 + }; self.text_renderer .prepare( &self.device, @@ -3891,11 +4029,11 @@ impl State { [ TextArea { buffer: &self.buffer, - left: TEXT_LEFT, + left: text_left, top: TEXT_TOP, scale: 1.0, bounds: TextBounds { - left: 0, + left: gutter_clip_left, top: 0, right: text_bounds_right, // Clip at the status band (Q#S3): a final @@ -3940,6 +4078,39 @@ impl State { ) .expect("text_renderer prepare"); + // UX gutter: prepare the line-number layer in the reserved left + // strip (empty when off → renders nothing). Same `top` + line + // height as the code, so numbers align row-for-row. + let gutter_areas: Vec