feat(tui): line-number gutter (UX arc sub-arc 1, TUI half)
Introduce a reserved left gutter column with absolute line numbers in the
TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc-
framing.md). Default OFF (Emacs tradition), so zero layout/coordinate
change until a window opts in.
- window.rs: LineNumberMode { Off, Absolute } + per-window `line_numbers`
field + `gutter_width()` (digits(line_count) + PAD) + `decimal_digits`.
- editor.rs: the gutter is one viewport shift at the paint site
(cell_origin.col += gutter_w, cell_size.cols -= gutter_w) — every
viewport-relative painter (text, syntax, diag underline, search) stays
gutter-agnostic. The sites that read rect.origin.col directly get a
manual +gutter_w: cursor placement, local selection, mouse hit-test
(a gutter click maps to line start, Q#UX6). paint_line_number_gutter
writes right-aligned dim digits alloc-free.
- overlay_paint.rs: remote-presence cursor/selection shift by gutter_w.
- Lua: pmacs.window.set_line_numbers/line_numbers +
window.toggle-line-numbers command.
No protocol/daemon change (frontend-local, Q#UX1). Tests: gutter render
(right-aligned digits + past-EOF blanks) + decimal_digits.
Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass
both flavors. Needs a human eyeball (coordinate-math change) before the PR.
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
6458e01882
commit
fae7ed3fd0
|
|
@ -219,6 +219,12 @@ cmd { name = "window.split-horizontal",
|
|||
cmd { name = "window.split-vertical",
|
||||
description = "Split the active window vertically (children sit side-by-side).",
|
||||
fn = function() pmacs.window.split_vertical() end }
|
||||
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")
|
||||
end }
|
||||
cmd { name = "window.focus-next",
|
||||
description = "Move focus to the next window in iteration order.",
|
||||
fn = function() pmacs.window.focus_next() end }
|
||||
|
|
|
|||
148
src/editor.rs
148
src/editor.rs
|
|
@ -882,7 +882,20 @@ impl EditorState {
|
|||
};
|
||||
let inner_rows = rect.size.rows.saturating_sub(1);
|
||||
let local_row = cell_row.saturating_sub(rect.origin.row);
|
||||
let local_col = cell_col.saturating_sub(rect.origin.col);
|
||||
// UX gutter (Q#UX6): subtract the reserved gutter width so the
|
||||
// hit-test lands on the right text byte. A click inside the gutter
|
||||
// strip (raw < gutter_w) saturates to column 0 → the start of that
|
||||
// line, a mild, useful affordance for the MVP.
|
||||
let gutter_w = {
|
||||
let core = self.core.borrow();
|
||||
core.windows.get(&win_id).map_or(0, |w| {
|
||||
let g = w.gutter_width();
|
||||
if g >= rect.size.cols { 0 } else { g }
|
||||
})
|
||||
};
|
||||
let local_col = cell_col
|
||||
.saturating_sub(rect.origin.col)
|
||||
.saturating_sub(gutter_w);
|
||||
|
||||
match ev.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
|
|
@ -1608,11 +1621,20 @@ pub fn paint_frame(
|
|||
continue;
|
||||
};
|
||||
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
|
||||
// UX gutter (Q#UX2): reserve a left strip for line numbers and
|
||||
// shrink+shift the text area into the remainder, so every
|
||||
// viewport-relative painter (text, syntax, diagnostics, search)
|
||||
// stays gutter-agnostic. A window too narrow for the gutter falls
|
||||
// back to no gutter this frame rather than starving the text.
|
||||
let gutter_w = {
|
||||
let w = window.gutter_width();
|
||||
if w >= rect.size.cols { 0 } else { w }
|
||||
};
|
||||
let viewport = Viewport {
|
||||
buffer_start: viewport_buffer_start,
|
||||
buffer_end: buf.len(),
|
||||
cell_origin: rect.origin,
|
||||
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols),
|
||||
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
|
||||
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
|
||||
};
|
||||
// Composition (T M2.9): base text_view paints first, then
|
||||
// each overlay in attach order. See [`crate::view::View`].
|
||||
|
|
@ -1620,7 +1642,10 @@ pub fn paint_frame(
|
|||
for overlay in &mut window.overlays {
|
||||
overlay.render(buf, viewport, grid);
|
||||
}
|
||||
paint_local_selection(grid, buf, window, &rect, inner_rows);
|
||||
if gutter_w > 0 {
|
||||
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w);
|
||||
}
|
||||
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w);
|
||||
// Mode line for this window. Painted last so the line
|
||||
// itself is always visible regardless of overlay activity.
|
||||
let coord = window
|
||||
|
|
@ -1685,9 +1710,15 @@ pub fn paint_frame(
|
|||
{
|
||||
return None;
|
||||
}
|
||||
// UX gutter: the terminal caret sits in the text area, past the
|
||||
// reserved gutter strip (mirrors the viewport shift above).
|
||||
let gutter_w = {
|
||||
let w = aw.gutter_width();
|
||||
if w >= active_rect.size.cols { 0 } else { w }
|
||||
};
|
||||
let grid_row = active_rect.origin.row + (disp.row - aw.view_top as u32);
|
||||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (active_rect.origin.col + disp.col).min(max_col);
|
||||
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
Some(CellCoord::new(grid_row, grid_col))
|
||||
}
|
||||
|
||||
|
|
@ -1745,12 +1776,69 @@ fn inner_rows(rect: &crate::window::Rect) -> u32 {
|
|||
rect.size.rows.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Paint the left line-number gutter for `window` into the reserved strip
|
||||
/// `[rect.origin.col, rect.origin.col + gutter_w)` over the window's text
|
||||
/// rows (UX gutter arc). Numbers are 1-based, right-aligned with a single
|
||||
/// trailing pad cell; rows past end-of-buffer stay blank. Dimly styled so
|
||||
/// the gutter recedes behind the code. The caller guarantees `gutter_w >
|
||||
/// 0` and that it fits within `rect.size.cols`.
|
||||
fn paint_line_number_gutter(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
window: &crate::window::Window,
|
||||
rect: &crate::window::Rect,
|
||||
inner_rows: u32,
|
||||
gutter_w: u32,
|
||||
) {
|
||||
let line_count = window.text_view.line_count();
|
||||
let style = crate::cell::Style {
|
||||
fg: crate::cell::Color::Indexed(8),
|
||||
..crate::cell::Style::default()
|
||||
};
|
||||
// The number's rightmost digit sits at `field - 1`; the last gutter
|
||||
// cell (`gutter_w - 1`) is a trailing pad separating it from the code.
|
||||
let field = gutter_w.saturating_sub(1);
|
||||
for r in 0..inner_rows {
|
||||
let grid_row = rect.origin.row + r;
|
||||
// Blank + style the whole strip first, so a number that shrank a
|
||||
// digit (e.g. after a large delete) leaves no stale trailing glyph.
|
||||
for c in 0..gutter_w {
|
||||
let cell = grid.at(CellCoord::new(grid_row, rect.origin.col + c));
|
||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
||||
cell.style = style;
|
||||
cell.attachment = None;
|
||||
}
|
||||
let buffer_line = window.view_top + r as usize;
|
||||
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;
|
||||
let mut col = field;
|
||||
loop {
|
||||
col -= 1;
|
||||
let digit = (val % 10) as u8;
|
||||
grid.at(CellCoord::new(grid_row, rect.origin.col + col))
|
||||
.glyph = crate::cell::Glyph::Char((b'0' + digit) as char);
|
||||
val /= 10;
|
||||
if val == 0 || col == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_local_selection(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
buf: &crate::buffer::Buffer,
|
||||
window: &crate::window::Window,
|
||||
rect: &crate::window::Rect,
|
||||
inner_rows: u32,
|
||||
// UX gutter: the reserved left-strip width; selection cells are the
|
||||
// text-relative display column shifted right by this (Q#UX2). 0 when
|
||||
// the gutter is off, so this is a no-op then.
|
||||
gutter_w: u32,
|
||||
) {
|
||||
let Some((sel_start, sel_end)) = window.region() else {
|
||||
return;
|
||||
|
|
@ -1758,6 +1846,7 @@ fn paint_local_selection(
|
|||
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
|
||||
return;
|
||||
}
|
||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||
|
||||
let first_row = window.view_top;
|
||||
let last_row = first_row.saturating_add(inner_rows as usize);
|
||||
|
|
@ -1786,15 +1875,15 @@ fn paint_local_selection(
|
|||
}
|
||||
|
||||
let row_offset = display_row.saturating_sub(first_row) as u32;
|
||||
let start_col = start_coord.col.min(rect.size.cols);
|
||||
let end_col = end_coord.col.min(rect.size.cols);
|
||||
let start_col = start_coord.col.min(text_cols);
|
||||
let end_col = end_coord.col.min(text_cols);
|
||||
if start_col >= end_col {
|
||||
continue;
|
||||
}
|
||||
for col in start_col..end_col {
|
||||
let cell = grid.at(CellCoord::new(
|
||||
rect.origin.row + row_offset,
|
||||
rect.origin.col + col,
|
||||
rect.origin.col + gutter_w + col,
|
||||
));
|
||||
cell.style.reverse = true;
|
||||
}
|
||||
|
|
@ -2196,6 +2285,49 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::frontend::KeyEventKind;
|
||||
|
||||
#[test]
|
||||
fn line_number_gutter_renders_right_aligned_digits() {
|
||||
use crate::buffer::{Buffer, BufferId};
|
||||
use crate::cell::{Cell, CellGrid, CellSize, Glyph};
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{LineNumberMode, Window, WindowId};
|
||||
|
||||
// 12 lines → decimal_digits(12) = 2, gutter_w = 2 + PAD(2) = 4.
|
||||
let content = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n";
|
||||
let bid = BufferId::next();
|
||||
let buf = Buffer::from_bytes(bid, "test", content);
|
||||
let view = TextView::new(&buf);
|
||||
let mut window = Window::new(WindowId::next(), bid, view);
|
||||
window.line_numbers = LineNumberMode::Absolute;
|
||||
assert_eq!(window.gutter_width(), 4, "2-digit line count + 2 pad");
|
||||
|
||||
let (rows, cols) = (12u32, 20u32);
|
||||
let mut storage = vec![Cell::default(); (rows * cols) as usize];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut storage,
|
||||
stride: cols,
|
||||
size: CellSize::new(rows, cols),
|
||||
};
|
||||
let rect = Rect::new(0, 0, rows, cols);
|
||||
paint_line_number_gutter(&mut grid, &window, &rect, rows, 4);
|
||||
|
||||
let glyph = |r: u32, c: u32| storage[(r * cols + c) as usize].glyph.clone();
|
||||
// Row 0 = line 1: " 1 " (digit right-aligned at col 2, col 3 = pad).
|
||||
assert_eq!(glyph(0, 0), Glyph::Char(' '));
|
||||
assert_eq!(glyph(0, 1), Glyph::Char(' '));
|
||||
assert_eq!(glyph(0, 2), Glyph::Char('1'));
|
||||
assert_eq!(glyph(0, 3), Glyph::Char(' '));
|
||||
// Row 4 = line 5.
|
||||
assert_eq!(glyph(4, 2), Glyph::Char('5'));
|
||||
// Row 9 = line 10: two digits → col1='1', col2='0', col3 pad.
|
||||
assert_eq!(glyph(9, 1), Glyph::Char('1'));
|
||||
assert_eq!(glyph(9, 2), Glyph::Char('0'));
|
||||
assert_eq!(glyph(9, 3), Glyph::Char(' '));
|
||||
// Row 11 = line 12.
|
||||
assert_eq!(glyph(11, 1), Glyph::Char('1'));
|
||||
assert_eq!(glyph(11, 2), Glyph::Char('2'));
|
||||
}
|
||||
|
||||
fn fresh_with(content: &[u8]) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
let new_id = s
|
||||
|
|
|
|||
|
|
@ -10093,6 +10093,44 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// UX gutter: set the active window's line-number mode
|
||||
// ("off" | "absolute"). Per-window (Q#UX5); a friendly toggle
|
||||
// command wraps this in `builtin/`.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"set_line_numbers",
|
||||
lua.create_function(move |_, mode: String| {
|
||||
let m = match mode.as_str() {
|
||||
"off" | "none" => crate::window::LineNumberMode::Off,
|
||||
"absolute" | "abs" | "on" => crate::window::LineNumberMode::Absolute,
|
||||
other => {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"unknown line-number mode {other:?} (expected off|absolute)"
|
||||
)));
|
||||
}
|
||||
};
|
||||
cc.borrow_mut().active_window_mut().line_numbers = m;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Read the active window's line-number mode as a string.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"line_numbers",
|
||||
lua.create_function(move |_, ()| {
|
||||
let mode = match cc.borrow().active_window().line_numbers {
|
||||
crate::window::LineNumberMode::Off => "off",
|
||||
crate::window::LineNumberMode::Absolute => "absolute",
|
||||
};
|
||||
Ok(mode)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
|
|
|
|||
|
|
@ -135,6 +135,15 @@ pub fn paint_other_frontend_overlays(
|
|||
let Ok(buf) = reg.get(window.buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
// UX gutter: this window may reserve a left strip for line
|
||||
// numbers; a remote cursor/selection is a text-relative column
|
||||
// shifted right by that width (0 when the gutter is off). This
|
||||
// pass runs after `paint_frame`, so it learns the width here.
|
||||
let gutter_w = {
|
||||
let g = window.gutter_width();
|
||||
if g >= rect.size.cols { 0 } else { g }
|
||||
};
|
||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||
// Source's byte position → display coords via THIS
|
||||
// recipient window's text_view (the recipient's view
|
||||
// of the buffer).
|
||||
|
|
@ -154,11 +163,11 @@ pub fn paint_other_frontend_overlays(
|
|||
// Column bounds: disp.col is the buffer column; window
|
||||
// doesn't horizontally scroll in v1.0, so cells past
|
||||
// rect.size.cols are simply off-grid for this window.
|
||||
if disp.col >= rect.size.cols {
|
||||
if disp.col >= text_cols {
|
||||
continue;
|
||||
}
|
||||
let cursor_grid_row = rect.origin.row + row_in_window as u32;
|
||||
let cursor_grid_col = rect.origin.col + disp.col;
|
||||
let cursor_grid_col = rect.origin.col + gutter_w + disp.col;
|
||||
paint_cursor_cell(grid, cursor_grid_row, cursor_grid_col, color);
|
||||
if let Some(label_ch) = label {
|
||||
paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color);
|
||||
|
|
@ -171,7 +180,9 @@ pub fn paint_other_frontend_overlays(
|
|||
} else {
|
||||
(sel.active, sel.anchor)
|
||||
};
|
||||
paint_selection_in_window(grid, buf, window, rect, inner_rows, lo, hi, color);
|
||||
paint_selection_in_window(
|
||||
grid, buf, window, rect, inner_rows, gutter_w, lo, hi, color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +234,7 @@ fn paint_selection_in_window(
|
|||
window: &crate::window::Window,
|
||||
rect: Rect,
|
||||
inner_rows: u32,
|
||||
gutter_w: u32,
|
||||
lo: crate::rope::Position,
|
||||
hi: crate::rope::Position,
|
||||
color: Color,
|
||||
|
|
@ -230,6 +242,7 @@ fn paint_selection_in_window(
|
|||
if lo >= hi {
|
||||
return;
|
||||
}
|
||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||
// Walk byte positions from lo to hi, mapping each to a
|
||||
// display coord. Step in single-byte increments; pos_to_display
|
||||
// tolerates byte-boundary positions and returns None for
|
||||
|
|
@ -249,9 +262,9 @@ fn paint_selection_in_window(
|
|||
break;
|
||||
};
|
||||
match (disp.row as usize).checked_sub(window.view_top) {
|
||||
Some(r) if r < inner_rows as usize && disp.col < rect.size.cols => {
|
||||
Some(r) if r < inner_rows as usize && disp.col < text_cols => {
|
||||
let grid_row = rect.origin.row + r as u32;
|
||||
let grid_col = rect.origin.col + disp.col;
|
||||
let grid_col = rect.origin.col + gutter_w + disp.col;
|
||||
if grid_row < grid.size.rows && grid_col < grid.size.cols {
|
||||
let cell = grid.at(CellCoord::new(grid_row, grid_col));
|
||||
cell.style.underline = UnderlineStyle::Single;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,36 @@ pub struct Selection {
|
|||
pub anchor: Position,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[default]
|
||||
Off,
|
||||
/// Absolute 1-based line numbers, right-aligned in the gutter.
|
||||
Absolute,
|
||||
}
|
||||
|
||||
/// Cells of horizontal padding the line-number gutter adds around the
|
||||
/// digit field: a leading and a trailing blank, so `gutter_w = digits +
|
||||
/// PAD` (Q#UX3). Kept as a named constant so both frontends can share the
|
||||
/// convention (Q#UX7). `u32` to match the cell-grid column type.
|
||||
pub const LINE_NUMBER_GUTTER_PAD: u32 = 2;
|
||||
|
||||
/// Number of decimal digits in `n` (for `n >= 1`). Allocation-free.
|
||||
#[must_use]
|
||||
pub fn decimal_digits(mut n: usize) -> u32 {
|
||||
let mut d = 1u32;
|
||||
while n >= 10 {
|
||||
n /= 10;
|
||||
d += 1;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// One leaf of the window tree: a buffer plus per-window state.
|
||||
pub struct Window {
|
||||
/// Unique identifier.
|
||||
|
|
@ -158,6 +188,9 @@ pub struct Window {
|
|||
/// render. Updated by the renderer; consumed by `cursor.page-down`
|
||||
/// / `cursor.page-up`. `0` until the first render lands.
|
||||
pub last_visible_rows: u32,
|
||||
/// Line-number gutter mode for this window (UX gutter arc). `Off` by
|
||||
/// default → no gutter, no coordinate change.
|
||||
pub line_numbers: LineNumberMode,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
|
|
@ -175,6 +208,22 @@ impl Window {
|
|||
view_top: 0,
|
||||
goal_col: None,
|
||||
last_visible_rows: 0,
|
||||
line_numbers: LineNumberMode::Off,
|
||||
}
|
||||
}
|
||||
|
||||
/// Width in cells this window's line-number gutter occupies, or `0`
|
||||
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
|
||||
/// the renderer caps this against the window width and applies it as a
|
||||
/// left offset to the text area. Every gutter coordinate-math site
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +514,18 @@ fn collapse_single_child_splits(node: &mut LayoutNode) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decimal_digits_counts_correctly() {
|
||||
assert_eq!(decimal_digits(1), 1);
|
||||
assert_eq!(decimal_digits(9), 1);
|
||||
assert_eq!(decimal_digits(10), 2);
|
||||
assert_eq!(decimal_digits(99), 2);
|
||||
assert_eq!(decimal_digits(100), 3);
|
||||
assert_eq!(decimal_digits(1000), 4);
|
||||
// A 6-digit file → 6 digits + PAD gutter.
|
||||
assert_eq!(decimal_digits(123_456), 6);
|
||||
}
|
||||
|
||||
fn id() -> WindowId {
|
||||
WindowId::next()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue