diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index eb0fb3a..5f3678c 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -222,8 +222,27 @@ cmd { name = "window.split-vertical", 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") + pmacs.window.set_line_numbers( + pmacs.window.line_numbers() == "off" and "absolute" or "off") + end } + +-- Pick a line-number mode directly from the completion dropdown, rather +-- than cycling. Arrow-navigable candidates (off/absolute/relative/hybrid). +cmd { name = "window.set-line-numbers", + description = "Set the active window's line-number mode (off/absolute/relative/hybrid).", + fn = function() + pmacs.minibuffer.read { + prompt = "Line numbers: ", + source = function() return { "off", "absolute", "relative", "hybrid" } end, + history = "line-numbers", + on_accept = function(mode) + if mode == nil or mode == "" then return end + local ok, err = pcall(pmacs.window.set_line_numbers, mode) + if not ok then + pmacs.editor.set_status("line-numbers: " .. (tostring(err):match("^[^\n]*") or "")) + end + end, + } end } cmd { name = "window.focus-next", description = "Move focus to the next window in iteration order.", diff --git a/docs/ux-arc-framing.md b/docs/ux-arc-framing.md index 21c2e2e..68a0fcb 100644 --- a/docs/ux-arc-framing.md +++ b/docs/ux-arc-framing.md @@ -281,4 +281,48 @@ while eyeballing this sub-arc against the two-frontend setup.) 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. + diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index f1207ba..22b3acf 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -36,8 +36,8 @@ use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal, - Key as ProtocolKey, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment, - StyleSpan, + Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, + StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; @@ -650,10 +650,12 @@ 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, + /// Line-number gutter mode (UX gutter arc, GPU side). Shipped by the + /// daemon over `InstanceMessage::LineNumbers` (protocol v14). `Off` ⇒ + /// zero coordinate change: `gutter_width_px()` is 0 and every shift site + /// is a no-op. Relative/Hybrid are rendered locally against the GPU's + /// own cursor line. + line_numbers: LineNumberMode, /// 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, @@ -1882,7 +1884,7 @@ impl State { mb_text_renderer, mb_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, - line_numbers: false, + line_numbers: LineNumberMode::Off, gutter_buffer, gutter_text_renderer, } @@ -2532,12 +2534,12 @@ 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; + // UX gutter (protocol v14): the daemon owns the per-window + // line-number mode; apply it to our local gutter state and + // repaint on change. + InstanceMessage::LineNumbers { mode, .. } => { + if self.line_numbers != mode { + self.line_numbers = mode; self.request_redraw(); } None @@ -2812,7 +2814,7 @@ impl State { /// 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 { + if !self.line_numbers.is_on() { return 0.0; } let lines = self.current_line_starts.len().max(1); @@ -2826,24 +2828,43 @@ impl State { TEXT_LEFT + self.gutter_width_px() } + /// The GPU's own cursor's 0-based buffer line, or `0` when there's no + /// own cursor in the displayed buffer (relative/hybrid then count from + /// the top — a rare transient). Derived from the whole-buffer line + /// table, so it's independent of the shaped slice. + fn cursor_line(&self) -> usize { + let byte = match self.own_cursor.as_ref() { + Some(c) if Some(c.buffer_id) == self.current_buffer_id => c.byte, + _ => 0, + }; + self.current_line_starts + .partition_point(|&start| start <= byte) + .saturating_sub(1) + } + /// 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 { + if !self.line_numbers.is_on() { return; } let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize; let first = self.shaped_top; + // Relative/Hybrid measure distance from the cursor's buffer line; + // Absolute ignores it. Rebuilt every render, so it tracks the cursor. + let cursor_line = self.cursor_line(); + let mode = self.line_numbers; 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); + let num = mode.number_for(first + i, cursor_line).unwrap_or(0); + let _ = write!(text, "{num:>digits$}"); } self.gutter_buffer.set_text( &mut self.font_system, @@ -4020,7 +4041,7 @@ impl State { // 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 { + let gutter_clip_left = if self.line_numbers.is_on() { text_left.floor() as i32 } else { 0 @@ -4087,7 +4108,7 @@ impl State { // 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