feat(tui): relative + hybrid line-number modes (sub-arc 3, TUI half)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-07-06 21:34:14 -04:00
parent b102d449d2
commit 9ae381f740
4 changed files with 98 additions and 13 deletions

View File

@ -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.",

View File

@ -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;

View File

@ -10104,9 +10104,12 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
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<Table> {
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)
})?,

View File

@ -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<usize> {
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);