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:
commit
ccb0ff6021
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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 { .. })
|
||||
|
|
|
|||
|
|
@ -1812,6 +1812,10 @@ fn paint_line_number_gutter(
|
|||
gutter_w: u32,
|
||||
) {
|
||||
let line_count = window.text_view.line_count();
|
||||
// Relative/Hybrid measure distance from the cursor's buffer line;
|
||||
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
||||
// cursor motion, so this stays current).
|
||||
let cursor_line = window.text_view.line_at_offset(window.cursor);
|
||||
let style = crate::cell::Style {
|
||||
fg: crate::cell::Color::Indexed(8),
|
||||
..crate::cell::Style::default()
|
||||
|
|
@ -1833,10 +1837,14 @@ fn paint_line_number_gutter(
|
|||
if buffer_line >= line_count {
|
||||
continue; // past end-of-buffer: blank gutter
|
||||
}
|
||||
// Write the 1-based number right-aligned, rightmost digit first,
|
||||
// alloc-free. `field >= digits(line_count)` by construction, so
|
||||
// the leftmost digit always leaves at least a leading pad cell.
|
||||
let mut val = buffer_line + 1;
|
||||
// The mode picks the number: absolute (`line+1`), relative
|
||||
// distance, or hybrid (absolute on the cursor line, else relative).
|
||||
// Written right-aligned, rightmost digit first, alloc-free.
|
||||
// `field >= digits(line_count)` by construction, so the leftmost
|
||||
// digit always leaves at least a leading pad cell.
|
||||
let Some(mut val) = window.line_numbers.number_for(buffer_line, cursor_line) else {
|
||||
continue;
|
||||
};
|
||||
let mut col = field;
|
||||
loop {
|
||||
col -= 1;
|
||||
|
|
|
|||
|
|
@ -10104,9 +10104,12 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
let m = match mode.as_str() {
|
||||
"off" | "none" => crate::window::LineNumberMode::Off,
|
||||
"absolute" | "abs" | "on" => crate::window::LineNumberMode::Absolute,
|
||||
"relative" | "rel" => crate::window::LineNumberMode::Relative,
|
||||
"hybrid" => crate::window::LineNumberMode::Hybrid,
|
||||
other => {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"unknown line-number mode {other:?} (expected off|absolute)"
|
||||
"unknown line-number mode {other:?} \
|
||||
(expected off|absolute|relative|hybrid)"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
|
@ -10125,6 +10128,8 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
let mode = match cc.borrow().active_window().line_numbers {
|
||||
crate::window::LineNumberMode::Off => "off",
|
||||
crate::window::LineNumberMode::Absolute => "absolute",
|
||||
crate::window::LineNumberMode::Relative => "relative",
|
||||
crate::window::LineNumberMode::Hybrid => "hybrid",
|
||||
};
|
||||
Ok(mode)
|
||||
})?,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue