From 9ae381f74088a284a992037757f8247f80e1d102 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 21:34:14 -0400 Subject: [PATCH 1/3] feat(tui): relative + hybrid line-number modes (sub-arc 3, TUI half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the gutter with the last two of the framed modes (Q#UX4): - Relative: each line shows its distance from the cursor line (cursor = 0). - Hybrid: cursor line shows its absolute number, others relative (Vim number + relativenumber). `LineNumberMode` gains `Relative`/`Hybrid` + `number_for(line, cursor_line)` (the per-line displayed value) and `is_on()`. `paint_line_number_gutter` now derives each number from the mode and the cursor's buffer line (`text_view.line_at_offset(cursor)`); the TUI re-renders the whole frame on cursor motion, so relative numbers track the cursor for free. Gutter width is sized by `digits(line_count)` for every on-mode, so the text never jitters as the cursor moves. Mode selection (chosen over a 4-way cycle): `window.toggle-line-numbers` stays a binary off/absolute toggle; a new `window.set-line-numbers` opens the minibuffer with an arrow-navigable completion dropdown (off|absolute|relative|hybrid) to pick a mode directly. `set_line_numbers` accepts all four; the getter returns them. No protocol change here — the GPU half (which needs the mode over the wire, protocol v14) follows. Test: number_for across all modes. fmt + clippy clean both flavors; 1446 lib tests pass. Needs a TUI eyeball. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- builtin/commands/default.lua | 23 +++++++++++-- src/editor.rs | 16 ++++++--- src/lua_bindings/mod.rs | 7 +++- src/window.rs | 65 ++++++++++++++++++++++++++++++++---- 4 files changed, 98 insertions(+), 13 deletions(-) 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/src/editor.rs b/src/editor.rs index 8f85a33..95d4388 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1812,6 +1812,10 @@ fn paint_line_number_gutter( gutter_w: u32, ) { let line_count = window.text_view.line_count(); + // Relative/Hybrid measure distance from the cursor's buffer line; + // Absolute ignores it. Computed once per frame (the gutter repaints on + // cursor motion, so this stays current). + let cursor_line = window.text_view.line_at_offset(window.cursor); let style = crate::cell::Style { fg: crate::cell::Color::Indexed(8), ..crate::cell::Style::default() @@ -1833,10 +1837,14 @@ fn paint_line_number_gutter( if buffer_line >= line_count { continue; // past end-of-buffer: blank gutter } - // Write the 1-based number right-aligned, rightmost digit first, - // alloc-free. `field >= digits(line_count)` by construction, so - // the leftmost digit always leaves at least a leading pad cell. - let mut val = buffer_line + 1; + // The mode picks the number: absolute (`line+1`), relative + // distance, or hybrid (absolute on the cursor line, else relative). + // Written right-aligned, rightmost digit first, alloc-free. + // `field >= digits(line_count)` by construction, so the leftmost + // digit always leaves at least a leading pad cell. + let Some(mut val) = window.line_numbers.number_for(buffer_line, cursor_line) else { + continue; + }; let mut col = field; loop { col -= 1; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 434f632..523acc6 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -10104,9 +10104,12 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result { let m = match mode.as_str() { "off" | "none" => crate::window::LineNumberMode::Off, "absolute" | "abs" | "on" => crate::window::LineNumberMode::Absolute, + "relative" | "rel" => crate::window::LineNumberMode::Relative, + "hybrid" => crate::window::LineNumberMode::Hybrid, other => { return Err(mlua::Error::external(format!( - "unknown line-number mode {other:?} (expected off|absolute)" + "unknown line-number mode {other:?} \ + (expected off|absolute|relative|hybrid)" ))); } }; @@ -10125,6 +10128,8 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ let mode = match cc.borrow().active_window().line_numbers { crate::window::LineNumberMode::Off => "off", crate::window::LineNumberMode::Absolute => "absolute", + crate::window::LineNumberMode::Relative => "relative", + crate::window::LineNumberMode::Hybrid => "hybrid", }; Ok(mode) })?, diff --git a/src/window.rs b/src/window.rs index 9988622..9649166 100644 --- a/src/window.rs +++ b/src/window.rs @@ -132,7 +132,6 @@ pub struct Selection { /// Line-number display mode for a window's left gutter (UX gutter arc). /// `Off` reserves no gutter at all — text starts at column 0, and every /// coordinate is unchanged (the default, matching the Emacs tradition). -/// Additional modes (relative, hybrid) arrive in a later sub-arc. #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum LineNumberMode { /// No gutter; zero layout change. @@ -140,6 +139,36 @@ pub enum LineNumberMode { Off, /// Absolute 1-based line numbers, right-aligned in the gutter. Absolute, + /// Distance from the cursor line (the cursor line shows `0`). + Relative, + /// Like `Relative`, but the cursor line shows its absolute 1-based + /// number instead of `0` (Vim `number` + `relativenumber`). + Hybrid, +} + +impl LineNumberMode { + /// Whether this mode reserves a gutter at all (everything but `Off`). + #[must_use] + pub fn is_on(self) -> bool { + !matches!(self, Self::Off) + } + + /// The displayed 0-or-1-based number for a buffer `line` given the + /// cursor's buffer line, or `None` in `Off`. `Relative`/`Hybrid` depend + /// on `cursor_line`; `Absolute` ignores it. + #[must_use] + pub fn number_for(self, line: usize, cursor_line: usize) -> Option { + match self { + Self::Off => None, + Self::Absolute => Some(line + 1), + Self::Relative => Some(line.abs_diff(cursor_line)), + Self::Hybrid => Some(if line == cursor_line { + line + 1 + } else { + line.abs_diff(cursor_line) + }), + } + } } /// Cells of horizontal padding the line-number gutter adds around the @@ -219,11 +248,15 @@ impl Window { /// reads this one function so the width stays consistent. #[must_use] pub fn gutter_width(&self) -> u32 { - match self.line_numbers { - LineNumberMode::Off => 0, - LineNumberMode::Absolute => { - decimal_digits(self.text_view.line_count().max(1)) + LINE_NUMBER_GUTTER_PAD - } + // Every on-mode reserves the same width — sized for the largest + // number any mode could show (the absolute line count, which + // bounds relative distances and hybrid's cursor-line number). A + // fixed width keeps the text from jittering as the cursor moves in + // relative/hybrid modes. + if self.line_numbers.is_on() { + decimal_digits(self.text_view.line_count().max(1)) + LINE_NUMBER_GUTTER_PAD + } else { + 0 } } @@ -514,6 +547,26 @@ fn collapse_single_child_splits(node: &mut LayoutNode) { mod tests { use super::*; + #[test] + fn line_number_mode_number_for_covers_all_modes() { + use LineNumberMode::{Absolute, Hybrid, Off, Relative}; + // Cursor on buffer line 5 (0-based). Lines 3 and 7 are 2 away. + assert_eq!(Off.number_for(3, 5), None); + // Absolute ignores the cursor line: 1-based. + assert_eq!(Absolute.number_for(3, 5), Some(4)); + assert_eq!(Absolute.number_for(5, 5), Some(6)); + // Relative: distance from the cursor line; cursor line is 0. + assert_eq!(Relative.number_for(3, 5), Some(2)); + assert_eq!(Relative.number_for(7, 5), Some(2)); + assert_eq!(Relative.number_for(5, 5), Some(0)); + // Hybrid: absolute on the cursor line, relative elsewhere. + assert_eq!(Hybrid.number_for(5, 5), Some(6)); + assert_eq!(Hybrid.number_for(3, 5), Some(2)); + // Every on-mode reserves a gutter; Off does not. + assert!(!Off.is_on()); + assert!(Absolute.is_on() && Relative.is_on() && Hybrid.is_on()); + } + #[test] fn decimal_digits_counts_correctly() { assert_eq!(decimal_digits(1), 1); From 40ebdd8d7ef74cc3834a2b8239fd8a4423c0d60d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 09:59:27 -0400 Subject: [PATCH 2/3] feat(gpu): relative + hybrid line numbers over protocol v14 (sub-arc 3, GPU half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the line-number mode to the GPU so it renders relative/hybrid, not just on/off. The v13 wire carried `LineNumbers { enabled: bool }` (off/absolute only); v14 carries the full mode. - Protocol: `LineNumberMode {Off, Absolute, Relative, Hybrid}` moves into pmacs-protocol (with `number_for`/`is_on`) so the wire, daemon, and both frontends share ONE enum and ONE number rule (Q#UX7); `pmacs` re-exports it as `crate::window::LineNumberMode`. `LineNumbers.enabled: bool` → `mode: LineNumberMode`. PROTOCOL_VERSION 13 → 14, SUPPORTED → [6..14], daemon-gated `< 14` (a v13 peer gets no LineNumbers, like the v10 SearchPrompt bump). - Producer (`line_numbers_msg`): ships the window's mode (cached-suppress on the mode now, seeded to Off). - GPU: `line_numbers` field becomes the mode; `refresh_gutter_buffer` computes each number via `mode.number_for(line, cursor_line)` against the GPU's own cursor line (`cursor_line()` off `current_line_starts`). The buffer rebuilds every render, so relative numbers track the cursor for free. Gutter width unchanged (sized by line count → stable). Tests: GPU headless render proves relative ≠ absolute with the cursor on line 2; producer test asserts the mode ships; protocol version pins → 14. fmt + clippy --all-targets clean both flavors + gpu; 1446 lib + 12 protocol + 55 pmacs-gpu tests pass. Needs a GPU eyeball. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- pmacs-gpu/src/main.rs | 100 ++++++++++++++++++++++++++-------- pmacs-protocol/src/lib.rs | 7 ++- pmacs-protocol/src/message.rs | 73 +++++++++++++++++++++---- src/daemon.rs | 7 ++- src/protocol.rs | 17 +++--- src/semantic_render.rs | 45 ++++++++------- src/window.rs | 46 ++-------------- 7 files changed, 190 insertions(+), 105 deletions(-) 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