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