feat(gpu): relative + hybrid line numbers over protocol v14 (sub-arc 3, GPU half)
Carry the line-number mode to the GPU so it renders relative/hybrid, not
just on/off. The v13 wire carried `LineNumbers { enabled: bool }`
(off/absolute only); v14 carries the full mode.
- Protocol: `LineNumberMode {Off, Absolute, Relative, Hybrid}` moves into
pmacs-protocol (with `number_for`/`is_on`) so the wire, daemon, and both
frontends share ONE enum and ONE number rule (Q#UX7); `pmacs` re-exports
it as `crate:🪟:LineNumberMode`. `LineNumbers.enabled: bool` →
`mode: LineNumberMode`. PROTOCOL_VERSION 13 → 14, SUPPORTED → [6..14],
daemon-gated `< 14` (a v13 peer gets no LineNumbers, like the v10
SearchPrompt bump).
- Producer (`line_numbers_msg`): ships the window's mode (cached-suppress
on the mode now, seeded to Off).
- GPU: `line_numbers` field becomes the mode; `refresh_gutter_buffer`
computes each number via `mode.number_for(line, cursor_line)` against the
GPU's own cursor line (`cursor_line()` off `current_line_starts`). The
buffer rebuilds every render, so relative numbers track the cursor for
free. Gutter width unchanged (sized by line count → stable).
Tests: GPU headless render proves relative ≠ absolute with the cursor on
line 2; producer test asserts the mode ships; protocol version pins → 14.
fmt + clippy --all-targets clean both flavors + gpu; 1446 lib + 12 protocol
+ 55 pmacs-gpu tests pass. Needs a GPU eyeball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
parent
9ae381f740
commit
40ebdd8d7e
|
|
@ -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 { .. })
|
||||
|
|
|
|||
|
|
@ -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,46 +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).
|
||||
#[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,
|
||||
/// Distance from the cursor line (the cursor line shows `0`).
|
||||
Relative,
|
||||
/// Like `Relative`, but the cursor line shows its absolute 1-based
|
||||
/// number instead of `0` (Vim `number` + `relativenumber`).
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
impl LineNumberMode {
|
||||
/// Whether this mode reserves a gutter at all (everything but `Off`).
|
||||
#[must_use]
|
||||
pub fn is_on(self) -> bool {
|
||||
!matches!(self, Self::Off)
|
||||
}
|
||||
|
||||
/// The displayed 0-or-1-based number for a buffer `line` given the
|
||||
/// cursor's buffer line, or `None` in `Off`. `Relative`/`Hybrid` depend
|
||||
/// on `cursor_line`; `Absolute` ignores it.
|
||||
#[must_use]
|
||||
pub fn number_for(self, line: usize, cursor_line: usize) -> Option<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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
///
|
||||
/// 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 +
|
||||
|
|
|
|||
Loading…
Reference in New Issue