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:
parent
b102d449d2
commit
9ae381f740
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})?,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue