Merge pull request #86 from levineuwirth/session-ux-gutter-t1

feat(ux): line-number gutter — sub-arc 1 (TUI + GPU, protocol v13)
This commit is contained in:
Levi Neuwirth 2026-07-06 18:02:00 -04:00 committed by GitHub
commit 0bfe164203
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 835 additions and 31 deletions

View File

@ -219,6 +219,12 @@ cmd { name = "window.split-horizontal",
cmd { name = "window.split-vertical", cmd { name = "window.split-vertical",
description = "Split the active window vertically (children sit side-by-side).", description = "Split the active window vertically (children sit side-by-side).",
fn = function() pmacs.window.split_vertical() end } 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", cmd { name = "window.focus-next",
description = "Move focus to the next window in iteration order.", description = "Move focus to the next window in iteration order.",
fn = function() pmacs.window.focus_next() end } fn = function() pmacs.window.focus_next() end }

242
docs/ux-arc-framing.md Normal file
View File

@ -0,0 +1,242 @@
# UX arc — framing (the gutter, and what it unlocks)
A UX overhaul arc for pmacs. The diagnostic-surface work (colors, status
counts, wavy squiggles, minimap marks) already shipped (PRs #6469), so
the next frontier isn't polishing diagnostics — it's the **one layout
primitive both frontends still lack: a gutter column.** Line numbers,
diagnostic *gutter signs* (the last deferred Task #23 item), and later
fold/git-diff markers all live in the gutter. This doc frames the umbrella
UX arc and designs its keystone, the gutter, grounded in a coordinate-
system recon of both frontends.
## Why the gutter is the keystone
Two independently-requested features share one missing foundation:
- **Line numbers** — requested; every editor has them; pmacs has none.
- **Diagnostic gutter signs** — explicitly parked in the diagnostics arc:
*"no gutter column exists; adding one is a layout-level project,
deliberately not done."* Today the TUI fakes it with a col-0 background
marker (`diag.rs`) and the GPU proxies it with minimap marks.
Both need a reserved left column and the coordinate-math to go with it.
Build the column once (per frontend) and both features — plus future fold
markers, git-diff bars, breakpoint dots — become content that rides it.
## The load-bearing bet: no protocol change
The daemon is **pixel-pure** — it never learns screen geometry; the
frontend owns viewport and visual layout (the semantic-frontend contract).
The gutter is therefore **entirely frontend-local**, and the recon
confirms each frontend already holds the data it needs:
- **Line numbers** — the frontend owns the text, so it has line indices.
- **Diagnostic severity per line** — already frontend-side (TUI:
`DiagnosticView` over the diag store; GPU: `current_decorations`, the
same source the squiggles use). Map each diagnostic's `range.start` to a
line, take the max severity per line — no new wire data.
So **zero protocol change, zero daemon change.** The blast radius is two
frontend renderers. (This also means it's implemented *twice* — the TUI
grid and the GPU are separate codebases with no shared render code — so
the design fixes a shared *convention*, §Q#UX7, even though the code is
duplicated.)
## The reserved-width model
A gutter is a fixed-width strip carved from the **left**, mirroring how the
GPU already carves the **right** for the minimap. Text shrinks by the
gutter width; the gutter stays fixed while text scrolls (neither frontend
has horizontal scroll, so "fixed while scrolling" is free — only the
*numbers* printed change with the vertical scroll position).
**TUI** (recon: clean seam). The renderer paints everything relative to a
per-window `Viewport { cell_origin, cell_size }`. The whole gutter is
fundamentally *one shift* at the viewport-construction site
(`editor.rs:1610`): `cell_origin.col += gutter_w`, `cell_size.cols -=
gutter_w`, then paint the gutter into the reclaimed `[rect.origin.col,
+gutter_w)` strip. Every painter that consumes `viewport.cell_origin`
(base text, syntax, diagnostic underline, search) becomes gutter-agnostic
**for free**. The handful that read `rect.origin.col` *directly* each need
a manual `+gutter_w`:
- cursor placement (`editor.rs:1683`), local selection (`:1794`),
remote-presence cursor/selection (`overlay_paint.rs:160,253`);
- mouse hit-test (`dispatch_mouse`, `editor.rs:866`) subtracts `gutter_w`
from `local_col`, and clicks with `local_col < gutter_w` are gutter
clicks (§Q#UX6);
- the diagnostic col-0 sign (`diag.rs:499586`) relocates into the strip —
`gutter_glyph()` (`diag.rs:75`) is already defined and unused, waiting
for exactly this.
`pos_to_display`/`display_to_pos` (the byte↔display-column core) operate
in line-local space and need **no change** — the gutter is applied by
callers crossing into grid space, not in the mapping itself.
**GPU** (recon: one knob). All horizontal geometry hangs off `TEXT_LEFT =
16.0`. A gutter of width `G` is:
- **pixel→byte** — one site: `hit_test_source_byte` (`main.rs:2758`),
`x - TEXT_LEFT``x - TEXT_LEFT - G`;
- **byte→pixel** — four sites, each `TEXT_LEFT``TEXT_LEFT + G`: glyph
render origin (`TextArea.left`, `:3894`), text clip `bounds.left` (`:3898`,
`0``G`), caret (`:4350/4355`), washes+squiggles (`:4413`);
- **placement loop** — walk `layout_runs()` (`run.line_top`, `run.line_i`)
and draw gutter glyphs/quads at `x ∈ [0, G)`, exactly the minimap's
mirror on the left. Reserve the band for clicks like `in_minimap_band`.
Neither frontend has horizontal scroll or soft-wrap today, so there is no
scroll-offset interaction to reconcile — the single biggest simplifier.
## Forced decisions
**Q#UX1 — frontend-local, no protocol change. ✗ DISPROVEN (see below).**
Framed as: the data is local in both frontends, so no wire change. That was
half-right — *rendering* is local, but the **control** (`M-x
window.toggle-line-numbers`) lives daemon-side, so the mode must reach the
GUI over the wire. Corrected to: the toggle is daemon-owned per-window
state, shipped to the frontend via a new additive `InstanceMessage::
LineNumbers` variant (**protocol v13**, daemon-gated `< 13`). The GUI still
*renders* locally; it just receives the on/off flag. See the as-built
control-plane note.
**Q#UX2 — reserve on the left, mirror the minimap.** Text area shrinks;
the gutter is a fixed strip. TUI: shift the viewport at one site. GPU: add
`G` to the four byte→pixel sites, subtract at the one pixel→byte site, set
a `text_bounds_left`.
**Q#UX3 — dynamic width, digit-count driven.** `gutter_w = digits(line_
count) + padding` (padding = 1 leading + 1 trailing cell/space typical).
Recomputed as the line count crosses a power of ten. Rejected: fixed width
(wastes space on small files, truncates on huge ones). Line count is
already in hand at the width-computation site in both frontends.
**Q#UX4 — modes: `off | absolute | relative | hybrid`.** `absolute` =
line index + 1. `relative` = distance from the cursor line (Vim-style, for
fast `N j`/`N k`). `hybrid` = current line absolute, others relative (the
popular default). Ships incrementally: **absolute first** (proves the
column + width + coordinate math with zero cursor-coupling), relative/
hybrid as a follow-up (they add a cursor-line dependency + repaint-on-
cursor-move, which is why they come second).
**Q#UX5 — the setting hook + where state lives.** Model on the existing
`frame_target_ms` tunable (`async_runtime.rs:630` + Lua `_frame_target_ms`
/`_set_frame_target_ms`). Line-number mode is naturally **per-window**
(relative numbers frequently are), so the field lives on `struct Window`
(TUI) and is read at paint time; a `pmacs.window`/`pmacs.frontend` binding
sets it, wrapped by a friendly `builtin/` Lua chunk. The GPU reads an
equivalent local setting (it has no window tree, so a single frontend-wide
mode is fine there for v1). Consistency of *value* across frontends is a
convention, not shared code.
**Q#UX6 — gutter click behavior.** MVP: a click in the gutter band selects
the whole line (common editor affordance) — or, if that's too much for the
first cut, is consumed as a no-op (never mis-mapped to a text byte). The
recon shows both frontends can classify a gutter-band click cheaply
(`local_col < gutter_w` / `in_minimap_band`-style). Pick line-select if
it's a few lines; else no-op and defer.
**Q#UX7 — shared convention across the two renderers.** The code is
duplicated, so the design pins the *contract* both must honor: same width
formula (`digits + padding`), same number formatting (right-aligned,
1-based), same mode set, same diagnostic-sign glyphs/severity precedence,
gutter never overlaps the mode line / status band / minibuffer. A short
"gutter contract" section in each frontend's code comments points back
here so they don't drift.
## Sub-arc sequence (each its own PR, each green under both flavors)
1. **Gutter + absolute line numbers.** Introduce the reserved column and
the coordinate shift in *both* frontends; render absolute numbers. The
riskiest, most valuable step — it lands the foundation and the
coordinate math. Validation is a human eyeball per frontend (cursor
lands on the right glyph after a click; caret draws in the right place;
selection/overlays don't bleed into the gutter).
2. **Diagnostic gutter signs.** Relocate the TUI col-0 marker into the
gutter (`gutter_glyph()`) and add GPU gutter glyphs; max-severity per
line from data already present. Closes the last Task #23 item.
3. **Relative / hybrid line-number modes.** Add the cursor-line dependency
+ repaint-on-cursor-move; a mode toggle on the same machinery.
4. **The rest of the UX backlog** (below), sequenced later.
## The umbrella UX backlog (beyond the gutter)
Named now so the arc has a horizon; not committed, sequenced after the
gutter sub-arcs:
- **Minibuffer polish**`i/total` hint, Telescope-style preview pane,
candidate annotations (kind/docstring), multibyte-exact band caret.
- **Editing affordances** — current-line highlight refinements, whitespace
rendering, indent guides.
- **Folding** — needs a fold engine *and* gutter fold markers (rides the
gutter built here); big, greenfield.
- **Git-diff gutter markers** — needs a diff source; rides the gutter.
- **Context-menu polish** — submenus, kill-ring/clipboard history,
first-letter mnemonic jump.
## Categorical bets (score at the arc's close)
- **No protocol change holds. → SCORED FALSE.** *Rendering* is local, but
the toggle *command* lives daemon-side, so the mode has to cross the
wire. It cost one additive variant + a version bump (v13) — cheap and
routine here, but the bet was wrong: "renders locally" does not imply
"no protocol change" when control is daemon-owned. Lesson for the rest of
the arc: a frontend-rendered feature still needs a wire channel whenever
its *control* is a daemon command.
- **The coordinate shift is localized, not pervasive.** Recon says TUI = 1
viewport shift + ~5 manual sites; GPU = 1 pixel→byte + 4 byte→pixel
sites. If a gutter bug shows up somewhere *not* on those lists, the bet
was wrong and the mapping was less centralized than the recon found.
- **`pos_to_display` / cosmic-hit stay gutter-agnostic.** The mapping core
is untouched; only the grid-crossing callers change. If a mapping-core
edit becomes necessary, the seam was leakier than modeled.
- **Duplication is cheaper than abstraction here.** Two ~50-line gutter
implementations beat inventing a shared cross-frontend layout layer for
two consumers. Revisit only if a third frontend appears.
## Validation implications
Per sub-arc: `fmt` + `clippy --all-targets` + full tests under **both** Lua
flavors, as always. But the load-bearing validation is a **human eyeball
in each frontend** — the coordinate shift is precisely the class of change
where a unit test passes and the caret still lands one column off. Minimum
manual checklist for sub-arc 1: click-to-place lands correctly with the
gutter present; caret draws in the right cell/pixel; selection drag stays
out of the gutter; resize/scroll keep the gutter fixed; a >999-line file
grows the width without misaligning anything. GPU and TUI checked
separately.
## As-built
**Sub-arc 1 — gutter + absolute line numbers (TUI + GPU).**
- **TUI** (`window.rs`/`editor.rs`/`overlay_paint.rs`): `LineNumberMode
{Off, Absolute}` per-window field + `Window::gutter_width()`. One
viewport shift (`editor.rs`) makes text/syntax/diag/search painters
gutter-agnostic; ~5 direct-coordinate sites (cursor, selection, mouse
hit-test → line-start, remote presence) each add `gutter_w`.
`paint_line_number_gutter` writes right-aligned dim digits alloc-free.
- **GPU** (`pmacs-gpu/main.rs`): everything hangs off `TEXT_LEFT`; a
`text_left()` (= `TEXT_LEFT + gutter_width_px()`) is applied at the 4
byte→pixel sites (main text, caret, washes/squiggles) and subtracted at
the 1 pixel→byte hit-test. A dedicated `gutter_text_renderer`/buffer
draws right-aligned dim numbers reshaped per scroll, mirroring the
minimap's reserved column.
- **Control plane (the Q#UX1 correction).** M-x
`window.toggle-line-numbers` sets the active window's mode (daemon-side).
The TUI reads its window directly; the GUI receives the mode via the new
**`InstanceMessage::LineNumbers { buffer_id, enabled }`** (protocol
**v13**, additive, daemon-gated `< 13`). Producer:
`SemanticRenderState::line_numbers_msg` (cached-compare suppression,
seeded to the frontend's `off` default so a plain window adds no
traffic). Consumer: the GUI drives its local `line_numbers` from it. The
earlier `--line-numbers` GPU flag was retired. Now one `M-x` toggle works
in **both** frontends, each affecting its own window — a single source of
truth.
Validated: `fmt` + `clippy --all-targets` clean under both Lua flavors;
1440 lib tests (incl. the gutter render + `line_numbers_msg` emit/suppress
tests), 12 protocol, 53 pmacs-gpu (incl. the headless gutter render test on
the adapter). Both frontends eyeballed for coordinate correctness.
Deferred to later sub-arcs: relative/hybrid modes; diagnostic gutter signs
(sub-arc 2); the exact gutter padding is a tunable to eyeball.
<!-- next sub-arcs appended here -->

View File

@ -87,6 +87,15 @@ const MINIMAP_CODE_COLS: f32 = 100.0;
const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5; const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5;
const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0; const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0;
const CODE_LINE_HEIGHT: f32 = 22.0; const CODE_LINE_HEIGHT: f32 = 22.0;
/// Font size of the code buffer (and the line-number gutter, so their
/// line heights match and rows align).
const CODE_FONT_SIZE: f32 = 16.0;
/// Gap in px between the line-number gutter digits and the code
/// (UX gutter arc, GPU side of sub-arc 1 — mirrors the TUI gutter).
const GUTTER_GAP_PX: f32 = 10.0;
/// Fallback monospace advance in px when no shaped glyph is available to
/// measure (0.6 em at the 16px code font).
const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6;
const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92]; const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92];
const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82]; const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82];
const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18]; const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18];
@ -262,6 +271,19 @@ enum Mode {
Attach { socket: PathBuf }, Attach { socket: PathBuf },
} }
/// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes
/// the line-number gutter (UX gutter arc). Mirrors the TUI's
/// `pmacs::window::decimal_digits` — kept local since pmacs-gpu doesn't
/// depend on the `pmacs` crate.
fn decimal_digits(mut n: usize) -> u32 {
let mut d = 1u32;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
fn main() { fn main() {
env_logger::init(); env_logger::init();
let mode = parse_args(std::env::args().skip(1).collect()); let mode = parse_args(std::env::args().skip(1).collect());
@ -622,6 +644,15 @@ struct State {
/// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame. /// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>, minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
/// 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,
/// 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,
/// Dedicated renderer for the gutter number layer (like the menu / mb).
gutter_text_renderer: TextRenderer,
} }
/// The wire-authoritative status facts (Q#S1, protocol v8), /// The wire-authoritative status facts (Q#S1, protocol v8),
@ -1700,6 +1731,9 @@ impl State {
// Q#MB1 — a third renderer for the minibuffer dropdown layer. // Q#MB1 — a third renderer for the minibuffer dropdown layer.
let mb_text_renderer = let mb_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// UX gutter — a renderer for the line-number layer.
let gutter_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
let quad_renderer = QuadRenderer::new(&device, format); let quad_renderer = QuadRenderer::new(&device, format);
let squiggle_renderer = SquiggleRenderer::new(&device, format); let squiggle_renderer = SquiggleRenderer::new(&device, format);
@ -1749,6 +1783,17 @@ impl State {
Some(MB_DROP_MAX_WIDTH), Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32), Some(config.height as f32),
); );
// Line-number gutter buffer (UX gutter arc): same font size + line
// height as the code buffer so its rows align one-for-one.
let mut gutter_buffer = Buffer::new(
&mut font_system,
Metrics::new(CODE_FONT_SIZE, CODE_LINE_HEIGHT),
);
gutter_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(config.height as f32),
);
buffer.set_text( buffer.set_text(
&mut font_system, &mut font_system,
initial_text, initial_text,
@ -1831,6 +1876,9 @@ impl State {
mb_text_renderer, mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(), mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None, minimap_cache: None,
line_numbers: false,
gutter_buffer,
gutter_text_renderer,
} }
} }
@ -2478,6 +2526,16 @@ impl State {
self.request_redraw(); self.request_redraw();
None 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;
self.request_redraw();
}
None
}
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
// `query: None` clears the band (search ended); `Some` shows // `query: None` clears the band (search ended); `Some` shows
// `[Regex] I-search: <query> (n/m)` on the band's left side. // `[Regex] I-search: <query> (n/m)` on the band's left side.
@ -2732,6 +2790,66 @@ impl State {
self.scroll_top != old self.scroll_top != old
} }
/// Monospace glyph advance in px, read from the currently-shaped code
/// buffer (every glyph shares it in a monospace font), with a fallback
/// when the buffer has no glyphs yet. Used to size the line-number
/// gutter (UX gutter arc).
fn mono_advance(&self) -> f32 {
self.buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter())
.next()
.map_or(GUTTER_MONO_ADVANCE_FALLBACK, |g| g.w)
}
/// Width in px the line-number gutter reserves on the left, or 0 when
/// 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 {
return 0.0;
}
let lines = self.current_line_starts.len().max(1);
decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX
}
/// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every
/// byte→pixel x site adds this instead of the bare `TEXT_LEFT` (Q#UX2),
/// and the pixel→byte hit-test subtracts it.
fn text_left(&self) -> f32 {
TEXT_LEFT + self.gutter_width_px()
}
/// 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 {
return;
}
let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize;
let first = self.shaped_top;
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);
}
self.gutter_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.gutter_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Resolve a window-pixel position to an **absolute source byte** /// Resolve a window-pixel position to an **absolute source byte**
/// (Q#M2): pixel → cosmic-text hit (shaped line + byte within /// (Q#M2): pixel → cosmic-text hit (shaped line + byte within
/// line) → projected byte → run map → slice byte → + `vstart`. /// line) → projected byte → run map → slice byte → + `vstart`.
@ -2755,7 +2873,7 @@ impl State {
self.projected_line_starts = projected_line_starts; self.projected_line_starts = projected_line_starts;
self.hit_map_dirty = false; self.hit_map_dirty = false;
} }
let rel_x = x as f32 - TEXT_LEFT; let rel_x = x as f32 - self.text_left();
let rel_y = y as f32 - TEXT_TOP; let rel_y = y as f32 - TEXT_TOP;
let cursor = self.buffer.hit(rel_x, rel_y)?; let cursor = self.buffer.hit(rel_x, rel_y)?;
let line_start = *self.projected_line_starts.get(cursor.line)?; let line_start = *self.projected_line_starts.get(cursor.line)?;
@ -3640,6 +3758,14 @@ impl State {
Some(width as f32), Some(width as f32),
Some(STATUS_BAND_HEIGHT), Some(STATUS_BAND_HEIGHT),
); );
// UX gutter: resize the line-number buffer too, else it keeps its
// construction-time (800x200) height and `shape_until_scroll` only
// shapes the ~10 lines that fit — the "numbers stop at 10" bug.
self.gutter_buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
// A taller/shorter window changes the visible line count, so the // A taller/shorter window changes the visible line count, so the
// slice + scoped viewport change (session S1). // slice + scoped viewport change (session S1).
self.reshape(); self.reshape();
@ -3771,6 +3897,9 @@ impl State {
let frame_start = debug_frame().then(std::time::Instant::now); let frame_start = debug_frame().then(std::time::Instant::now);
self.refresh_status_line(); self.refresh_status_line();
self.refresh_menu_buffer(); self.refresh_menu_buffer();
// UX gutter: reshape the line-number layer to the current scroll
// (no-op when the gutter is off).
self.refresh_gutter_buffer();
// Q#CM1 — the context-menu popup quads (bg / highlight / // Q#CM1 — the context-menu popup quads (bg / highlight /
// separators), drawn as a top layer after everything else. // separators), drawn as a top layer after everything else.
let menu_vertices = self.menu_vertex_bytes(); let menu_vertices = self.menu_vertex_bytes();
@ -3881,6 +4010,15 @@ impl State {
(self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT);
let status_top = let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
// UX gutter: the code's left origin (past the gutter) and the
// 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 {
text_left.floor() as i32
} else {
0
};
self.text_renderer self.text_renderer
.prepare( .prepare(
&self.device, &self.device,
@ -3891,11 +4029,11 @@ impl State {
[ [
TextArea { TextArea {
buffer: &self.buffer, buffer: &self.buffer,
left: TEXT_LEFT, left: text_left,
top: TEXT_TOP, top: TEXT_TOP,
scale: 1.0, scale: 1.0,
bounds: TextBounds { bounds: TextBounds {
left: 0, left: gutter_clip_left,
top: 0, top: 0,
right: text_bounds_right, right: text_bounds_right,
// Clip at the status band (Q#S3): a final // Clip at the status band (Q#S3): a final
@ -3940,6 +4078,39 @@ impl State {
) )
.expect("text_renderer prepare"); .expect("text_renderer prepare");
// 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<TextArea> = if self.line_numbers {
vec![TextArea {
buffer: &self.gutter_buffer,
left: TEXT_LEFT,
top: TEXT_TOP,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: gutter_clip_left,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(120, 120, 135),
custom_glyphs: &[],
}]
} else {
Vec::new()
};
self.gutter_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
gutter_areas,
&mut self.swash_cache,
)
.expect("gutter_text_renderer prepare");
// Q#CM1 — prepare the menu glyphs in their own layer (empty when // Q#CM1 — prepare the menu glyphs in their own layer (empty when
// closed, so the renderer draws nothing). // closed, so the renderer draws nothing).
let menu_areas: Vec<TextArea> = self let menu_areas: Vec<TextArea> = self
@ -4052,6 +4223,11 @@ impl State {
self.text_renderer self.text_renderer
.render(&self.atlas, &self.viewport, &mut pass) .render(&self.atlas, &self.viewport, &mut pass)
.expect("text_renderer render"); .expect("text_renderer render");
// UX gutter: line numbers in the reserved left strip (empty
// layer when off).
self.gutter_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("gutter_text_renderer render");
// Caret over the text so the insertion point reads on top // Caret over the text so the insertion point reads on top
// of the glyph it sits before (session B1). // of the glyph it sits before (session B1).
if let Some(vertex_buffer) = caret_buffer.as_ref() { if let Some(vertex_buffer) = caret_buffer.as_ref() {
@ -4339,20 +4515,22 @@ impl State {
} }
let slice_cursor = cursor - vstart; let slice_cursor = cursor - vstart;
let (line_lo, _) = source_line_range(slice, slice_cursor); let (line_lo, _) = source_line_range(slice, slice_cursor);
// UX gutter: the caret sits in the code area, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() { for run in self.buffer.layout_runs() {
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo { if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
continue; continue;
} }
let line_base = line_lo; let line_base = line_lo;
let mut x = TEXT_LEFT; let mut x = text_left;
for glyph in run.glyphs { for glyph in run.glyphs {
if line_base + glyph.start as u64 >= slice_cursor { if line_base + glyph.start as u64 >= slice_cursor {
x = TEXT_LEFT + glyph.x; x = text_left + glyph.x;
break; break;
} }
// Cursor is past this glyph; track its right edge so a // Cursor is past this glyph; track its right edge so a
// cursor at line end lands after the final glyph. // cursor at line end lands after the final glyph.
x = TEXT_LEFT + glyph.x + glyph.w; x = text_left + glyph.x + glyph.w;
} }
return Some(MinimapRect { return Some(MinimapRect {
x, x,
@ -4383,6 +4561,8 @@ impl State {
if hi <= lo { if hi <= lo {
return; return;
} }
// UX gutter: washes/squiggles are code-relative, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() { for run in self.buffer.layout_runs() {
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0); let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
let mut min_x: Option<f32> = None; let mut min_x: Option<f32> = None;
@ -4410,7 +4590,7 @@ impl State {
None => (TEXT_TOP + run.line_top, run.line_height), None => (TEXT_TOP + run.line_top, run.line_height),
}; };
rects.push(MinimapRect { rects.push(MinimapRect {
x: TEXT_LEFT + x0, x: text_left + x0,
y, y,
w: x1 - x0, w: x1 - x0,
h, h,
@ -4907,6 +5087,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer", InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
InstanceMessage::DispatchIdle { .. } => "DispatchIdle", InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
} }
} }
@ -7147,4 +7328,24 @@ mod tests {
"text should paint visible ink (only {differing} bytes differ from the empty frame)" "text should paint visible ink (only {differing} bytes differ from the empty frame)"
); );
} }
#[test]
fn headless_line_number_gutter_changes_the_frame() {
// UX gutter: enabling line numbers must add ink on the left and
// shift the code right — the rendered frame must differ.
let Some(mut off) = headless_or_skip(400, 300, "alpha\nbeta\ngamma\ndelta\n") else {
return;
};
let off_px = off.render_offscreen();
let mut on = State::new_headless(400, 300, "alpha\nbeta\ngamma\ndelta\n")
.expect("adapter was just available");
on.line_numbers = true;
let on_px = on.render_offscreen();
assert_eq!(off_px.len(), on_px.len());
let differing = off_px.iter().zip(&on_px).filter(|(a, b)| a != b).count();
assert!(
differing > 200,
"the gutter should add ink + shift the text (only {differing} bytes differ)"
);
}
} }

View File

@ -905,6 +905,19 @@ pub enum InstanceMessage {
/// Total candidate count (the window is a slice of this). /// Total candidate count (the window is a slice of this).
total: u32, total: u32,
}, },
/// UX gutter (protocol v13) — the per-window line-number gutter mode
/// for the frontend's active window. A semantic frontend renders line
/// numbers *locally* (it owns the text), but the on/off toggle lives
/// daemon-side (`M-x window.toggle-line-numbers`), so the daemon ships
/// the mode. Additive + daemon-gated `>= 13` — an older peer would
/// hard-error decoding it, so it stays off wires negotiated below 13.
LineNumbers {
/// Buffer the active window shows (routing/consistency; the mode
/// is a window property, not a buffer one).
buffer_id: crate::BufferId,
/// Whether the line-number gutter is enabled for that window.
enabled: bool,
},
} }
/// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]). /// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]).
@ -1180,7 +1193,13 @@ pub enum ResourceBody {
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer /// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations /// negotiates v9 and simply receives no `SearchPrompt` (the decorations
/// still highlight), rather than mis-decoding the wider shape. /// still highlight), rather than mis-decoding the wider shape.
pub const PROTOCOL_VERSION: u32 = 12; ///
/// UX gutter: bumped 12 → 13 for [`InstanceMessage::LineNumbers`] — a new
/// additive variant carrying the per-window line-number gutter mode.
/// Daemon-gated `< 13`; a v12 peer negotiates v12 and receives no
/// `LineNumbers` (its gutter simply stays off), like every prior additive
/// bump.
pub const PROTOCOL_VERSION: u32 = 13;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on /// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1237,7 +1256,7 @@ pub const PROTOCOL_VERSION: u32 = 12;
/// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`. /// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`.
/// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per /// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per
/// session, so the ladder resumes again. /// session, so the ladder resumes again.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12]; pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13];
/// T M10.5: predicate for the handshake check. Returns `true` if /// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1050,6 +1050,11 @@ fn dispatcher_loop(
let peer_knows_minibuffer_prompt = session_registry let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid) .session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12); .is_some_and(|s| s.negotiated_protocol_version >= 12);
// UX gutter — `LineNumbers` is a v13 additive variant; a
// v12 peer keeps its gutter off rather than mis-decoding it.
let peer_knows_line_numbers = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 13);
for msg in &messages { for msg in &messages {
if !peer_knows_status_facts if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. }) && matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1075,6 +1080,11 @@ fn dispatcher_loop(
{ {
continue; continue;
} }
if !peer_knows_line_numbers
&& matches!(msg, InstanceMessage::LineNumbers { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1 // T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency. // jitter site: render-write latency.
// //

View File

@ -882,7 +882,20 @@ impl EditorState {
}; };
let inner_rows = rect.size.rows.saturating_sub(1); let inner_rows = rect.size.rows.saturating_sub(1);
let local_row = cell_row.saturating_sub(rect.origin.row); 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 { match ev.kind {
MouseEventKind::Down(MouseButton::Left) => { MouseEventKind::Down(MouseButton::Left) => {
@ -1608,11 +1621,20 @@ pub fn paint_frame(
continue; continue;
}; };
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); 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 { let viewport = Viewport {
buffer_start: viewport_buffer_start, buffer_start: viewport_buffer_start,
buffer_end: buf.len(), buffer_end: buf.len(),
cell_origin: rect.origin, cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols), cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
}; };
// Composition (T M2.9): base text_view paints first, then // Composition (T M2.9): base text_view paints first, then
// each overlay in attach order. See [`crate::view::View`]. // each overlay in attach order. See [`crate::view::View`].
@ -1620,7 +1642,10 @@ pub fn paint_frame(
for overlay in &mut window.overlays { for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid); 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 // Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity. // itself is always visible regardless of overlay activity.
let coord = window let coord = window
@ -1685,9 +1710,15 @@ pub fn paint_frame(
{ {
return None; 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 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 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)) 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) 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( fn paint_local_selection(
grid: &mut crate::cell::CellGrid<'_>, grid: &mut crate::cell::CellGrid<'_>,
buf: &crate::buffer::Buffer, buf: &crate::buffer::Buffer,
window: &crate::window::Window, window: &crate::window::Window,
rect: &crate::window::Rect, rect: &crate::window::Rect,
inner_rows: u32, 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 { let Some((sel_start, sel_end)) = window.region() else {
return; return;
@ -1758,6 +1846,7 @@ fn paint_local_selection(
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end { if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
return; return;
} }
let text_cols = rect.size.cols.saturating_sub(gutter_w);
let first_row = window.view_top; let first_row = window.view_top;
let last_row = first_row.saturating_add(inner_rows as usize); 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 row_offset = display_row.saturating_sub(first_row) as u32;
let start_col = start_coord.col.min(rect.size.cols); let start_col = start_coord.col.min(text_cols);
let end_col = end_coord.col.min(rect.size.cols); let end_col = end_coord.col.min(text_cols);
if start_col >= end_col { if start_col >= end_col {
continue; continue;
} }
for col in start_col..end_col { for col in start_col..end_col {
let cell = grid.at(CellCoord::new( let cell = grid.at(CellCoord::new(
rect.origin.row + row_offset, rect.origin.row + row_offset,
rect.origin.col + col, rect.origin.col + gutter_w + col,
)); ));
cell.style.reverse = true; cell.style.reverse = true;
} }
@ -2196,6 +2285,49 @@ mod tests {
use super::*; use super::*;
use crate::frontend::KeyEventKind; 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 { fn fresh_with(content: &[u8]) -> EditorState {
let s = EditorState::new(); let s = EditorState::new();
let new_id = s let new_id = s

View File

@ -405,6 +405,10 @@ impl Frontend {
// surface; the TUI paints the minibuffer via its own bottom // surface; the TUI paints the minibuffer via its own bottom
// row, so it drops this silently too. // row, so it drops this silently too.
| InstanceMessage::MinibufferPrompt { .. } | InstanceMessage::MinibufferPrompt { .. }
// UX gutter — LineNumbers is the semantic-frontend gutter
// toggle; the cell-grid TUI reads its window's mode directly,
// so it drops this silently like the other semantic families.
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ResourceOffer { .. } | InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s // T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path // optimistic-apply gate; if any reaches this render path

View File

@ -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(); let cc = core.clone();
win.set( win.set(

View File

@ -135,6 +135,15 @@ pub fn paint_other_frontend_overlays(
let Ok(buf) = reg.get(window.buffer_id) else { let Ok(buf) = reg.get(window.buffer_id) else {
continue; 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 // Source's byte position → display coords via THIS
// recipient window's text_view (the recipient's view // recipient window's text_view (the recipient's view
// of the buffer). // of the buffer).
@ -154,11 +163,11 @@ pub fn paint_other_frontend_overlays(
// Column bounds: disp.col is the buffer column; window // Column bounds: disp.col is the buffer column; window
// doesn't horizontally scroll in v1.0, so cells past // doesn't horizontally scroll in v1.0, so cells past
// rect.size.cols are simply off-grid for this window. // rect.size.cols are simply off-grid for this window.
if disp.col >= rect.size.cols { if disp.col >= text_cols {
continue; continue;
} }
let cursor_grid_row = rect.origin.row + row_in_window as u32; 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); paint_cursor_cell(grid, cursor_grid_row, cursor_grid_col, color);
if let Some(label_ch) = label { if let Some(label_ch) = label {
paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color); paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color);
@ -171,7 +180,9 @@ pub fn paint_other_frontend_overlays(
} else { } else {
(sel.active, sel.anchor) (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, window: &crate::window::Window,
rect: Rect, rect: Rect,
inner_rows: u32, inner_rows: u32,
gutter_w: u32,
lo: crate::rope::Position, lo: crate::rope::Position,
hi: crate::rope::Position, hi: crate::rope::Position,
color: Color, color: Color,
@ -230,6 +242,7 @@ fn paint_selection_in_window(
if lo >= hi { if lo >= hi {
return; return;
} }
let text_cols = rect.size.cols.saturating_sub(gutter_w);
// Walk byte positions from lo to hi, mapping each to a // Walk byte positions from lo to hi, mapping each to a
// display coord. Step in single-byte increments; pos_to_display // display coord. Step in single-byte increments; pos_to_display
// tolerates byte-boundary positions and returns None for // tolerates byte-boundary positions and returns None for
@ -249,9 +262,9 @@ fn paint_selection_in_window(
break; break;
}; };
match (disp.row as usize).checked_sub(window.view_top) { 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_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 { if grid_row < grid.size.rows && grid_col < grid.size.cols {
let cell = grid.at(CellCoord::new(grid_row, grid_col)); let cell = grid.at(CellCoord::new(grid_row, grid_col));
cell.style.underline = UnderlineStyle::Single; cell.style.underline = UnderlineStyle::Single;

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[test]
fn protocol_version_is_twelve_for_minibuffer() { fn protocol_version_is_thirteen_for_line_numbers() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6 // SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1701,8 +1701,9 @@ mod tests {
// bumped 10→11 (`PointerKind::Context` + `MenuPointer` + // bumped 10→11 (`PointerKind::Context` + `MenuPointer` +
// `MenuPrompt`, all additive; the message daemon-gated). Q#MB1 // `MenuPrompt`, all additive; the message daemon-gated). Q#MB1
// bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive + // bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive +
// daemon-gated). // daemon-gated). UX gutter bumped 12→13
assert_eq!(PROTOCOL_VERSION, 12); // (`InstanceMessage::LineNumbers`, additive + daemon-gated).
assert_eq!(PROTOCOL_VERSION, 13);
} }
#[test] #[test]
@ -1711,10 +1712,11 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder — // every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean // pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session. // VersionMismatch) rather than garbling postcard mid-session.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1: the ladder resumes // Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter: the
// above that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 + // ladder resumes above that floor — v7 (`TripleDown`), v8
// v10 (`SearchPrompt` + regex/invalid), v11 (the context menu), // (`StatusFacts`), v9 + v10 (`SearchPrompt` + regex/invalid), v11
// v12 (the GUI minibuffer) all interoperate, so v6 through v12 talk. // (the context menu), v12 (the GUI minibuffer), v13 (`LineNumbers`)
// all interoperate, so v6 through v13 talk.
assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8)); assert!(is_supported_protocol_version(8));
@ -1722,10 +1724,11 @@ mod tests {
assert!(is_supported_protocol_version(10)); assert!(is_supported_protocol_version(10));
assert!(is_supported_protocol_version(11)); assert!(is_supported_protocol_version(11));
assert!(is_supported_protocol_version(12)); assert!(is_supported_protocol_version(12));
for rejected in [0, 1, 2, 3, 4, 5, 13, u32::MAX] { assert!(is_supported_protocol_version(13));
for rejected in [0, 1, 2, 3, 4, 5, 14, u32::MAX] {
assert!( assert!(
!is_supported_protocol_version(rejected), !is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v12 binary" "v{rejected} must be rejected by a v13 binary"
); );
} }
} }

View File

@ -157,6 +157,11 @@ pub struct SemanticRenderState {
/// `(name, modified, diag_errors, diag_warnings)` last emitted as /// `(name, modified, diag_errors, diag_warnings)` last emitted as
/// `StatusFacts` (Q#S1) — cached-compare suppression. /// `StatusFacts` (Q#S1) — cached-compare suppression.
last_status: HashMap<BufferId, (String, bool, u32, u32)>, last_status: HashMap<BufferId, (String, bool, u32, u32)>,
/// Last-emitted line-number gutter enabled-flag (UX gutter arc,
/// protocol v13) — cached-compare suppression. Seeded to `Some(false)`
/// (the frontend's default) so an off gutter never emits. Per-frontend
/// (one value), since this state carries one frontend's `frontend_id`.
last_line_numbers: Option<bool>,
/// Last emitted `SearchPrompt` payload per buffer, for /// Last emitted `SearchPrompt` payload per buffer, for
/// cached-compare suppression (see [`SearchPromptFacts`]). /// cached-compare suppression (see [`SearchPromptFacts`]).
last_search_prompt: HashMap<BufferId, SearchPromptFacts>, last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
@ -240,6 +245,11 @@ impl SemanticRenderState {
last_minibuffer: None, last_minibuffer: None,
last_summary: HashMap::new(), last_summary: HashMap::new(),
last_status: HashMap::new(), last_status: HashMap::new(),
// Seed to the frontend's default (gutter off): a plain default
// window never emits `LineNumbers`, so the common case adds no
// traffic and the first frame is unchanged. Only an actual
// toggle-on (or later toggle-off) ships a message.
last_line_numbers: Some(false),
last_style_gate: HashMap::new(), last_style_gate: HashMap::new(),
diag_line_cache: HashMap::new(), diag_line_cache: HashMap::new(),
} }
@ -438,6 +448,8 @@ impl SemanticRenderState {
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
// --- StatusFacts (status band; Q#S1, protocol v8) --- // --- StatusFacts (status band; Q#S1, protocol v8) ---
out.extend(self.status_facts_msg(state, vp.buffer_id)); out.extend(self.status_facts_msg(state, vp.buffer_id));
// --- LineNumbers (gutter toggle; UX gutter arc, protocol v13) ---
out.extend(self.line_numbers_msg(state, vp.buffer_id));
// --- SearchPrompt (isearch band; Q#SR5, protocol v9) --- // --- SearchPrompt (isearch band; Q#SR5, protocol v9) ---
out.extend(self.search_prompt_msg(state, vp.buffer_id)); out.extend(self.search_prompt_msg(state, vp.buffer_id));
// --- MenuPrompt (context menu; Q#CM1, protocol v11) --- // --- MenuPrompt (context menu; Q#CM1, protocol v11) ---
@ -685,6 +697,29 @@ impl SemanticRenderState {
Some(msg) Some(msg)
} }
/// The `LineNumbers` message for this frame, or `None` when the gutter
/// mode hasn't changed (UX gutter arc, protocol v13). The toggle lives
/// on this frontend's active window (`M-x window.toggle-line-numbers`);
/// a semantic frontend renders the gutter locally but the daemon owns
/// the on/off state, so it ships the mode. The daemon's write loop
/// keeps the variant off wires negotiated `< 13`.
fn line_numbers_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let enabled = {
let core = state.core.borrow();
core.active_window_for(self.frontend_id)
.is_some_and(|w| w.line_numbers != crate::window::LineNumberMode::Off)
};
if self.last_line_numbers == Some(enabled) {
return None;
}
self.last_line_numbers = Some(enabled);
Some(InstanceMessage::LineNumbers { buffer_id, enabled })
}
/// The `InlineAdornments` message for this frame, or `None` when /// The `InlineAdornments` message for this frame, or `None` when
/// nothing should be sent. The wire variant has no /// nothing should be sent. The wire variant has no
/// `generation`/`full`/`segments`, so this is M11.2-level /// `generation`/`full`/`segments`, so this is M11.2-level
@ -1694,6 +1729,45 @@ mod tests {
state.core.borrow().active_window().buffer_id state.core.borrow().active_window().buffer_id
} }
#[test]
fn line_numbers_emitted_on_toggle_then_suppressed() {
// UX gutter (protocol v13): the daemon ships the per-window gutter
// mode. Off is the default → no message; toggling on emits
// `LineNumbers { enabled: true }`; an unchanged next frame suppresses.
let state = empty_state();
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
// Default (gutter off): no LineNumbers on the first frame.
let first = s.render_frame(&state);
assert!(
!first
.iter()
.any(|m| matches!(m, InstanceMessage::LineNumbers { .. })),
"off gutter must not emit LineNumbers"
);
// Toggle the active window on → next frame emits enabled = true.
state.core.borrow_mut().active_window_mut().line_numbers =
crate::window::LineNumberMode::Absolute;
let on = s.render_frame(&state);
assert!(
on.iter()
.any(|m| matches!(m, InstanceMessage::LineNumbers { enabled: true, .. })),
"toggling the gutter on must emit LineNumbers {{ enabled: true }}"
);
// No further change → suppressed.
let again = s.render_frame(&state);
assert!(
!again
.iter()
.any(|m| matches!(m, InstanceMessage::LineNumbers { .. })),
"an unchanged gutter mode must not re-emit"
);
}
/// All `InstanceMessage` variants the semantic projection may /// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt` /// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
@ -1710,6 +1784,7 @@ mod tests {
| InstanceMessage::FileStyleSummary { .. } | InstanceMessage::FileStyleSummary { .. }
| InstanceMessage::StatusFacts { .. } | InstanceMessage::StatusFacts { .. }
| InstanceMessage::SearchPrompt { .. } | InstanceMessage::SearchPrompt { .. }
| InstanceMessage::LineNumbers { .. }
), ),
"semantic projection emitted an unexpected variant: {m:?}" "semantic projection emitted an unexpected variant: {m:?}"
); );

View File

@ -129,6 +129,36 @@ pub struct Selection {
pub anchor: Position, 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. /// One leaf of the window tree: a buffer plus per-window state.
pub struct Window { pub struct Window {
/// Unique identifier. /// Unique identifier.
@ -158,6 +188,9 @@ pub struct Window {
/// render. Updated by the renderer; consumed by `cursor.page-down` /// render. Updated by the renderer; consumed by `cursor.page-down`
/// / `cursor.page-up`. `0` until the first render lands. /// / `cursor.page-up`. `0` until the first render lands.
pub last_visible_rows: u32, 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 { impl Window {
@ -175,6 +208,22 @@ impl Window {
view_top: 0, view_top: 0,
goal_col: None, goal_col: None,
last_visible_rows: 0, 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 { mod tests {
use super::*; 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 { fn id() -> WindowId {
WindowId::next() WindowId::next()
} }