Merge pull request #90 from levineuwirth/session-ux-gutter-t3

feat(ux): relative + hybrid line-number modes — sub-arc 3 (TUI + GPU, protocol v14)
This commit is contained in:
Levi Neuwirth 2026-07-07 10:17:53 -04:00 committed by GitHub
commit ccb0ff6021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 302 additions and 88 deletions

View File

@ -222,8 +222,27 @@ cmd { name = "window.split-vertical",
cmd { name = "window.toggle-line-numbers",
description = "Toggle the active window's line-number gutter (off / absolute).",
fn = function()
local cur = pmacs.window.line_numbers()
pmacs.window.set_line_numbers(cur == "off" and "absolute" or "off")
pmacs.window.set_line_numbers(
pmacs.window.line_numbers() == "off" and "absolute" or "off")
end }
-- Pick a line-number mode directly from the completion dropdown, rather
-- than cycling. Arrow-navigable candidates (off/absolute/relative/hybrid).
cmd { name = "window.set-line-numbers",
description = "Set the active window's line-number mode (off/absolute/relative/hybrid).",
fn = function()
pmacs.minibuffer.read {
prompt = "Line numbers: ",
source = function() return { "off", "absolute", "relative", "hybrid" } end,
history = "line-numbers",
on_accept = function(mode)
if mode == nil or mode == "" then return end
local ok, err = pcall(pmacs.window.set_line_numbers, mode)
if not ok then
pmacs.editor.set_status("line-numbers: " .. (tostring(err):match("^[^\n]*") or ""))
end
end,
}
end }
cmd { name = "window.focus-next",
description = "Move focus to the next window in iteration order.",

View File

@ -281,4 +281,48 @@ while eyeballing this sub-arc against the two-frontend setup.)
highlight sticks / doesn't wrap on arrow-up; reproduces on the "normal"
nav path but not the alternate one. Its own thread.
**Sub-arc 3 — relative + hybrid line-number modes (TUI + GPU, protocol v14).**
The last two of the framed modes (Q#UX4): **Relative** (each line shows its
distance from the cursor line; cursor = 0) and **Hybrid** (cursor line shows
its absolute number, others relative — Vim `number`+`relativenumber`).
- **One enum, one number rule.** `LineNumberMode {Off, Absolute, Relative,
Hybrid}` + `number_for(line, cursor_line)` live in **pmacs-protocol** so
the wire, daemon, TUI, and GPU all compute the same value (Q#UX7);
`pmacs` re-exports it as `crate::window::LineNumberMode`.
- **Protocol v14 (the scored-false Q#UX1 debt, part two).** Sub-arc 1
shipped `LineNumbers { enabled: bool }` (off/absolute) as a deliberate
placeholder; relative/hybrid need the mode, so the variant now carries
`mode: LineNumberMode`. Encoding change → 13 → 14, daemon-gated `< 14`
(a v13 peer gets no `LineNumbers`), same shape as the v10 `SearchPrompt`
bump.
- **Cursor-line dependency + repaint-on-move.** Relative numbers change as
the cursor moves, not just on scroll. TUI: the full-frame render already
repaints on cursor motion, and `paint_line_number_gutter` reads the
cursor's buffer line (`text_view.line_at_offset(cursor)`) — free. GPU:
`refresh_gutter_buffer` rebuilds every render (which cursor moves already
trigger) using `cursor_line()` off `current_line_starts` — also free.
- **Stable width.** `gutter_width` is sized by `digits(line_count)` for
every on-mode, so the text never jitters as the cursor moves.
- **Selection UX (chosen over a 4-way cycle):** `window.toggle-line-numbers`
stays a binary off/absolute toggle; `window.set-line-numbers` opens the
minibuffer with an arrow-navigable completion dropdown
(off|absolute|relative|hybrid) — dogfooding the #89 arrow-nav fix.
Validated: `fmt` + `clippy --all-targets` clean both flavors + gpu; 1446
lib (incl. `number_for` across all modes), 12 protocol, 55 pmacs-gpu (incl.
a headless render proving relative ≠ absolute). Both frontends eyeballed,
including live cursor-move renumbering.
## Arc close
All four sub-arcs shipped: 1) gutter + absolute (v13), 2) diagnostic signs,
3) relative/hybrid (v14). Q#UX1 ("no protocol change") **scored false**
the gutter cost two protocol bumps (v13 daemon-owned toggle, v14 mode) —
because the *control* is daemon-owned even though *rendering* is
frontend-local. Deferred, riding the gutter when someone wants them:
signs-without-numbers mode; whitespace/indent guides; folding placeholders;
git change markers.
<!-- next sub-arcs appended here -->

View File

@ -36,8 +36,8 @@ use loro::{ContainerTrait, ExportMode};
use pmacs_protocol::{
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind,
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal,
Key as ProtocolKey, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment,
StyleSpan,
Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot,
StyleSegment, StyleSpan,
cell::{Color as CellColor, Style as CellStyle},
};
use wgpu::MultisampleState;
@ -650,10 +650,12 @@ struct State {
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
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,
/// Line-number gutter mode (UX gutter arc, GPU side). Shipped by the
/// daemon over `InstanceMessage::LineNumbers` (protocol v14). `Off` ⇒
/// zero coordinate change: `gutter_width_px()` is 0 and every shift site
/// is a no-op. Relative/Hybrid are rendered locally against the GPU's
/// own cursor line.
line_numbers: LineNumberMode,
/// 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,
@ -1882,7 +1884,7 @@ impl State {
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
line_numbers: false,
line_numbers: LineNumberMode::Off,
gutter_buffer,
gutter_text_renderer,
}
@ -2532,12 +2534,12 @@ impl State {
self.request_redraw();
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;
// UX gutter (protocol v14): the daemon owns the per-window
// line-number mode; apply it to our local gutter state and
// repaint on change.
InstanceMessage::LineNumbers { mode, .. } => {
if self.line_numbers != mode {
self.line_numbers = mode;
self.request_redraw();
}
None
@ -2812,7 +2814,7 @@ impl State {
/// 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 {
if !self.line_numbers.is_on() {
return 0.0;
}
let lines = self.current_line_starts.len().max(1);
@ -2826,24 +2828,43 @@ impl State {
TEXT_LEFT + self.gutter_width_px()
}
/// The GPU's own cursor's 0-based buffer line, or `0` when there's no
/// own cursor in the displayed buffer (relative/hybrid then count from
/// the top — a rare transient). Derived from the whole-buffer line
/// table, so it's independent of the shaped slice.
fn cursor_line(&self) -> usize {
let byte = match self.own_cursor.as_ref() {
Some(c) if Some(c.buffer_id) == self.current_buffer_id => c.byte,
_ => 0,
};
self.current_line_starts
.partition_point(|&start| start <= byte)
.saturating_sub(1)
}
/// 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 {
if !self.line_numbers.is_on() {
return;
}
let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize;
let first = self.shaped_top;
// Relative/Hybrid measure distance from the cursor's buffer line;
// Absolute ignores it. Rebuilt every render, so it tracks the cursor.
let cursor_line = self.cursor_line();
let mode = self.line_numbers;
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);
let num = mode.number_for(first + i, cursor_line).unwrap_or(0);
let _ = write!(text, "{num:>digits$}");
}
self.gutter_buffer.set_text(
&mut self.font_system,
@ -4020,7 +4041,7 @@ impl State {
// 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 {
let gutter_clip_left = if self.line_numbers.is_on() {
text_left.floor() as i32
} else {
0
@ -4087,7 +4108,7 @@ impl State {
// 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 {
let gutter_areas: Vec<TextArea> = if self.line_numbers.is_on() {
vec![TextArea {
buffer: &self.gutter_buffer,
left: TEXT_LEFT,
@ -4360,7 +4381,7 @@ impl State {
vstart: u64,
vend: u64,
) {
if !self.line_numbers {
if !self.line_numbers.is_on() {
return;
}
let slice_len = vend - vstart;
@ -7415,7 +7436,7 @@ mod tests {
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;
on.line_numbers = LineNumberMode::Absolute;
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();
@ -7434,14 +7455,14 @@ mod tests {
let Some(mut plain) = headless_or_skip(400, 300, text) else {
return;
};
plain.line_numbers = true;
plain.line_numbers = LineNumberMode::Absolute;
plain.current_buffer_id = Some(BufferId::next());
plain.view_range = (0, text.len() as u64);
let plain_px = plain.render_offscreen();
let mut with_diag =
State::new_headless(400, 300, text).expect("adapter was just available");
with_diag.line_numbers = true;
with_diag.line_numbers = LineNumberMode::Absolute;
with_diag.current_buffer_id = Some(BufferId::next());
with_diag.view_range = (0, text.len() as u64);
with_diag.current_decorations.push(Decoration {
@ -7461,4 +7482,39 @@ mod tests {
"the diagnostic sign bar should add ink ({differing} bytes differ)"
);
}
#[test]
fn headless_relative_mode_renders_differently_from_absolute() {
// Sub-arc 3: with the cursor on line 2, relative numbering
// (2,1,0,1,2) must differ from absolute (1,2,3,4,5) — proving the
// GPU renders the mode against its own cursor line.
let text = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let bid = BufferId::next();
let cursor = OwnCursor {
buffer_id: bid,
byte: 13, // inside "gamma" (buffer line 2)
};
let Some(mut abs) = headless_or_skip(400, 300, text) else {
return;
};
abs.current_buffer_id = Some(bid);
abs.view_range = (0, text.len() as u64);
abs.own_cursor = Some(cursor);
abs.line_numbers = LineNumberMode::Absolute;
let abs_px = abs.render_offscreen();
let mut rel = State::new_headless(400, 300, text).expect("adapter was just available");
rel.current_buffer_id = Some(bid);
rel.view_range = (0, text.len() as u64);
rel.own_cursor = Some(cursor);
rel.line_numbers = LineNumberMode::Relative;
let rel_px = rel.render_offscreen();
assert_eq!(abs_px.len(), rel_px.len());
let differing = abs_px.iter().zip(&rel_px).filter(|(a, b)| a != b).count();
assert!(
differing > 20,
"relative numbering must differ from absolute ({differing} bytes differ)"
);
}
}

View File

@ -49,8 +49,9 @@ pub use message::{
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CursorState, Decoration,
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
KeyEvent, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
StyleSegment, StyleSpan, is_supported_protocol_version, negotiate_capabilities,
KeyEvent, LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan,
is_supported_protocol_version, negotiate_capabilities,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -905,21 +905,68 @@ pub enum InstanceMessage {
/// Total candidate count (the window is a slice of this).
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.
/// UX gutter — the per-window line-number gutter *mode* for the
/// frontend's active window. A semantic frontend renders line numbers
/// *locally* (it owns the text + its own cursor line, so it can draw
/// relative/hybrid without a round trip), but the mode toggle lives
/// daemon-side, so the daemon ships which mode. Bumped 13 → 14: the
/// v13 shape carried a bare `enabled: bool` (off/absolute only); v14
/// carries the full [`LineNumberMode`] so relative/hybrid can ride the
/// same message. Daemon-gated `>= 14` — a v13 peer negotiates v13 and
/// receives no `LineNumbers` (its gutter stays off) rather than
/// mis-decoding the wider shape.
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,
/// The line-number gutter mode for that window.
mode: LineNumberMode,
},
}
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
/// wire, the daemon, and both frontends so the *number rule* — what value
/// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports
/// this as `crate::window::LineNumberMode`.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineNumberMode {
/// No gutter; zero layout change (the default, Emacs tradition).
#[default]
Off,
/// Absolute 1-based line numbers.
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 number for a 0-based buffer `line` given the cursor's
/// 0-based 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)
}),
}
}
}
/// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]).
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MenuPromptRow {
@ -1199,7 +1246,13 @@ pub enum ResourceBody {
/// 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;
///
/// UX gutter modes: bumped 13 → 14 — `LineNumbers` swapped its `enabled:
/// bool` for a [`LineNumberMode`] enum so relative/hybrid ride the same
/// message. Encoding change to that variant; daemon-gated `< 14` (a v13
/// peer negotiates v13 and receives no `LineNumbers` rather than
/// mis-decoding the wider shape), same shape as the v10 `SearchPrompt` bump.
pub const PROTOCOL_VERSION: u32 = 14;
/// 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
@ -1256,7 +1309,7 @@ pub const PROTOCOL_VERSION: u32 = 13;
/// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`.
/// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per
/// session, so the ladder resumes again.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13];
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1050,11 +1050,12 @@ fn dispatcher_loop(
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.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.
// UX gutter — `LineNumbers` carries a `LineNumberMode` since
// v14 (was `enabled: bool` in v13); a peer below 14 keeps
// its gutter off rather than mis-decoding the wider shape.
let peer_knows_line_numbers = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 13);
.is_some_and(|s| s.negotiated_protocol_version >= 14);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })

View File

@ -1812,6 +1812,10 @@ fn paint_line_number_gutter(
gutter_w: u32,
) {
let line_count = window.text_view.line_count();
// Relative/Hybrid measure distance from the cursor's buffer line;
// Absolute ignores it. Computed once per frame (the gutter repaints on
// cursor motion, so this stays current).
let cursor_line = window.text_view.line_at_offset(window.cursor);
let style = crate::cell::Style {
fg: crate::cell::Color::Indexed(8),
..crate::cell::Style::default()
@ -1833,10 +1837,14 @@ fn paint_line_number_gutter(
if buffer_line >= line_count {
continue; // past end-of-buffer: blank gutter
}
// Write the 1-based number right-aligned, rightmost digit first,
// alloc-free. `field >= digits(line_count)` by construction, so
// the leftmost digit always leaves at least a leading pad cell.
let mut val = buffer_line + 1;
// The mode picks the number: absolute (`line+1`), relative
// distance, or hybrid (absolute on the cursor line, else relative).
// Written right-aligned, rightmost digit first, alloc-free.
// `field >= digits(line_count)` by construction, so the leftmost
// digit always leaves at least a leading pad cell.
let Some(mut val) = window.line_numbers.number_for(buffer_line, cursor_line) else {
continue;
};
let mut col = field;
loop {
col -= 1;

View File

@ -10104,9 +10104,12 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
let m = match mode.as_str() {
"off" | "none" => crate::window::LineNumberMode::Off,
"absolute" | "abs" | "on" => crate::window::LineNumberMode::Absolute,
"relative" | "rel" => crate::window::LineNumberMode::Relative,
"hybrid" => crate::window::LineNumberMode::Hybrid,
other => {
return Err(mlua::Error::external(format!(
"unknown line-number mode {other:?} (expected off|absolute)"
"unknown line-number mode {other:?} \
(expected off|absolute|relative|hybrid)"
)));
}
};
@ -10125,6 +10128,8 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
let mode = match cc.borrow().active_window().line_numbers {
crate::window::LineNumberMode::Off => "off",
crate::window::LineNumberMode::Absolute => "absolute",
crate::window::LineNumberMode::Relative => "relative",
crate::window::LineNumberMode::Hybrid => "hybrid",
};
Ok(mode)
})?,

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_thirteen_for_line_numbers() {
fn protocol_version_is_fourteen_for_line_number_modes() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1702,8 +1702,10 @@ mod tests {
// `MenuPrompt`, all additive; the message daemon-gated). Q#MB1
// bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive +
// daemon-gated). UX gutter bumped 12→13
// (`InstanceMessage::LineNumbers`, additive + daemon-gated).
assert_eq!(PROTOCOL_VERSION, 13);
// (`InstanceMessage::LineNumbers`, additive + daemon-gated). UX
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
assert_eq!(PROTOCOL_VERSION, 14);
}
#[test]
@ -1715,8 +1717,8 @@ mod tests {
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter: the
// ladder resumes above that floor — v7 (`TripleDown`), v8
// (`StatusFacts`), v9 + v10 (`SearchPrompt` + regex/invalid), v11
// (the context menu), v12 (the GUI minibuffer), v13 (`LineNumbers`)
// all interoperate, so v6 through v13 talk.
// (the context menu), v12 (the GUI minibuffer), v13 (`LineNumbers`),
// v14 (`LineNumberMode`) all interoperate, so v6 through v14 talk.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8));
@ -1725,10 +1727,11 @@ mod tests {
assert!(is_supported_protocol_version(11));
assert!(is_supported_protocol_version(12));
assert!(is_supported_protocol_version(13));
for rejected in [0, 1, 2, 3, 4, 5, 14, u32::MAX] {
assert!(is_supported_protocol_version(14));
for rejected in [0, 1, 2, 3, 4, 5, 15, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v13 binary"
"v{rejected} must be rejected by a v14 binary"
);
}
}

View File

@ -157,11 +157,11 @@ pub struct SemanticRenderState {
/// `(name, modified, diag_errors, diag_warnings)` last emitted as
/// `StatusFacts` (Q#S1) — cached-compare suppression.
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 line-number gutter mode (UX gutter arc, protocol v14) —
/// cached-compare suppression. Seeded to `Some(Off)` (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<crate::window::LineNumberMode>,
/// Last emitted `SearchPrompt` payload per buffer, for
/// cached-compare suppression (see [`SearchPromptFacts`]).
last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
@ -249,7 +249,7 @@ impl SemanticRenderState {
// 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_line_numbers: Some(crate::window::LineNumberMode::Off),
last_style_gate: HashMap::new(),
diag_line_cache: HashMap::new(),
}
@ -698,26 +698,26 @@ impl SemanticRenderState {
}
/// 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`.
/// mode hasn't changed (UX gutter arc, protocol v14). The mode lives on
/// this frontend's active window; a semantic frontend renders the gutter
/// locally (it owns the text + its cursor line, so relative/hybrid need
/// no round trip) but the daemon owns the mode, so it ships which one.
/// The daemon's write loop keeps the variant off wires negotiated `< 14`.
fn line_numbers_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let enabled = {
let mode = {
let core = state.core.borrow();
core.active_window_for(self.frontend_id)
.is_some_and(|w| w.line_numbers != crate::window::LineNumberMode::Off)
.map_or(crate::window::LineNumberMode::Off, |w| w.line_numbers)
};
if self.last_line_numbers == Some(enabled) {
if self.last_line_numbers == Some(mode) {
return None;
}
self.last_line_numbers = Some(enabled);
Some(InstanceMessage::LineNumbers { buffer_id, enabled })
self.last_line_numbers = Some(mode);
Some(InstanceMessage::LineNumbers { buffer_id, mode })
}
/// The `InlineAdornments` message for this frame, or `None` when
@ -1748,14 +1748,19 @@ mod tests {
"off gutter must not emit LineNumbers"
);
// Toggle the active window on → next frame emits enabled = true.
// Toggle the active window on → next frame emits the mode.
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 }}"
on.iter().any(|m| matches!(
m,
InstanceMessage::LineNumbers {
mode: crate::window::LineNumberMode::Absolute,
..
}
)),
"toggling the gutter on must emit LineNumbers with the mode"
);
// No further change → suppressed.

View File

@ -130,17 +130,12 @@ 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.
#[default]
Off,
/// Absolute 1-based line numbers, right-aligned in the gutter.
Absolute,
}
///
/// Defined in `pmacs-protocol` so the wire, the daemon, and both frontends
/// share one enum and one number rule ([`LineNumberMode::number_for`],
/// Q#UX7); re-exported here so `crate::window::LineNumberMode` stays the
/// in-crate path.
pub use pmacs_protocol::LineNumberMode;
/// Cells of horizontal padding the line-number gutter adds around the
/// digit field: a leading and a trailing blank, so `gutter_w = digits +
@ -219,11 +214,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 => {
// 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 +513,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);