feat(gutter): daemon-owned line-number mode over protocol v13 (unified toggle)

Fix the control plane for the line-number gutter: M-x
window.toggle-line-numbers now works from EITHER frontend, each affecting
its own window.

Root cause (scores framing bet Q#UX1 false): rendering a gutter is
frontend-local, but the TOGGLE is a daemon command, so the mode has to
reach the GUI over the wire. My earlier GPU control (a --line-numbers flag)
left M-x-in-the-GUI a no-op and the two frontends' settings disconnected.

- Protocol: new additive `InstanceMessage::LineNumbers { buffer_id,
  enabled }`; PROTOCOL_VERSION 12 → 13, SUPPORTED grows to [6..13].
  Daemon-gated < 13 (a v12 peer keeps its gutter off), like every prior
  additive bump — no encoding break.
- Producer: SemanticRenderState::line_numbers_msg reads the frontend's
  active window mode (via active_window_for(frontend_id)) and emits on
  change; cached-compare suppression seeded to the frontend's `off`
  default, so a plain window adds zero traffic and existing frames are
  unchanged.
- Daemon: gate LineNumbers >= 13 in the write loop.
- TUI: drops LineNumbers silently (reads its window directly).
- GPU: consumes LineNumbers → drives local `line_numbers`; the
  --line-numbers flag retired.

Now the daemon Window.line_numbers is the single source of truth; both
frontends render locally from it.

Tests: line_numbers_msg emit-on-toggle/suppress-when-unchanged; protocol
version pins updated to 13. Validated: fmt + clippy --all-targets clean
both flavors; 1440 lib + 12 protocol + 53 pmacs-gpu tests pass.

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:
Levi Neuwirth 2026-07-06 14:27:44 -04:00
parent f7583e7994
commit 1ea2d5f17f
7 changed files with 377 additions and 25 deletions

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

@ -286,13 +286,7 @@ fn decimal_digits(mut n: usize) -> u32 {
fn main() {
env_logger::init();
// Filter the frontend-local `--line-numbers` flag out before the
// mode parser (UX gutter arc, Q#UX5) — it's orthogonal to the
// hello-world/attach mode and may appear in any position.
let mut args: Vec<String> = std::env::args().skip(1).collect();
let line_numbers = args.iter().any(|a| a == "--line-numbers");
args.retain(|a| a != "--line-numbers");
let mode = parse_args(args);
let mode = parse_args(std::env::args().skip(1).collect());
let event_loop = EventLoop::<AppEvent>::with_user_event()
.build()
.expect("create winit event loop");
@ -303,7 +297,6 @@ fn main() {
state: None,
attach_client: None,
modifiers: winit::keyboard::ModifiersState::empty(),
line_numbers,
};
event_loop
.run_app(&mut app)
@ -362,9 +355,6 @@ struct App {
/// delivers modifiers separately from key presses, so we track the
/// current set and apply it when a key is sent (session B1).
modifiers: winit::keyboard::ModifiersState,
/// Line-number gutter toggle from `--line-numbers` (UX gutter arc);
/// applied to `State` once it's built in `resumed`.
line_numbers: bool,
}
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
@ -798,9 +788,7 @@ impl ApplicationHandler<AppEvent> for App {
Mode::HelloWorld => HELLO_TEXT,
Mode::Attach { .. } => "(connecting...)",
};
let mut state = State::new(event_loop, initial_text);
state.line_numbers = self.line_numbers;
self.state = Some(state);
self.state = Some(State::new(event_loop, initial_text));
// In attach mode, kick off the connection now that the event
// loop is running and a proxy is available. Failure logs and
@ -2538,6 +2526,16 @@ 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;
self.request_redraw();
}
None
}
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
// `query: None` clears the band (search ended); `Some` shows
// `[Regex] I-search: <query> (n/m)` on the band's left side.
@ -5081,6 +5079,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
}
}

View File

@ -905,6 +905,19 @@ 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.
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`]).
@ -1180,7 +1193,13 @@ pub enum ResourceBody {
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations
/// 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
/// 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]`.
/// `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];
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
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1050,6 +1050,11 @@ 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.
let peer_knows_line_numbers = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 13);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1075,6 +1080,11 @@ fn dispatcher_loop(
{
continue;
}
if !peer_knows_line_numbers
&& matches!(msg, InstanceMessage::LineNumbers { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//

View File

@ -405,6 +405,10 @@ impl Frontend {
// surface; the TUI paints the minibuffer via its own bottom
// row, so it drops this silently too.
| 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 { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[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 /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1701,8 +1701,9 @@ mod tests {
// bumped 10→11 (`PointerKind::Context` + `MenuPointer` +
// `MenuPrompt`, all additive; the message daemon-gated). Q#MB1
// bumped 11→12 (`InstanceMessage::MinibufferPrompt`, additive +
// daemon-gated).
assert_eq!(PROTOCOL_VERSION, 12);
// daemon-gated). UX gutter bumped 12→13
// (`InstanceMessage::LineNumbers`, additive + daemon-gated).
assert_eq!(PROTOCOL_VERSION, 13);
}
#[test]
@ -1711,10 +1712,11 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1: the ladder resumes
// above that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 +
// v10 (`SearchPrompt` + regex/invalid), v11 (the context menu),
// v12 (the GUI minibuffer) all interoperate, so v6 through v12 talk.
// 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.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8));
@ -1722,10 +1724,11 @@ mod tests {
assert!(is_supported_protocol_version(10));
assert!(is_supported_protocol_version(11));
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!(
!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
/// `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 `SearchPrompt` payload per buffer, for
/// cached-compare suppression (see [`SearchPromptFacts`]).
last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
@ -240,6 +245,11 @@ impl SemanticRenderState {
last_minibuffer: None,
last_summary: 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(),
diag_line_cache: HashMap::new(),
}
@ -438,6 +448,8 @@ impl SemanticRenderState {
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
// --- StatusFacts (status band; Q#S1, protocol v8) ---
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) ---
out.extend(self.search_prompt_msg(state, vp.buffer_id));
// --- MenuPrompt (context menu; Q#CM1, protocol v11) ---
@ -685,6 +697,29 @@ impl SemanticRenderState {
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
/// nothing should be sent. The wire variant has no
/// `generation`/`full`/`segments`, so this is M11.2-level
@ -1694,6 +1729,45 @@ mod tests {
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
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
@ -1710,6 +1784,7 @@ mod tests {
| InstanceMessage::FileStyleSummary { .. }
| InstanceMessage::StatusFacts { .. }
| InstanceMessage::SearchPrompt { .. }
| InstanceMessage::LineNumbers { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);