From 1ea2d5f17f86c47d0c40f5fb3a29642bfc70a399 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 14:27:44 -0400 Subject: [PATCH] feat(gutter): daemon-owned line-number mode over protocol v13 (unified toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- docs/ux-arc-framing.md | 242 ++++++++++++++++++++++++++++++++++ pmacs-gpu/src/main.rs | 27 ++-- pmacs-protocol/src/message.rs | 23 +++- src/daemon.rs | 10 ++ src/frontend.rs | 4 + src/protocol.rs | 21 +-- src/semantic_render.rs | 75 +++++++++++ 7 files changed, 377 insertions(+), 25 deletions(-) create mode 100644 docs/ux-arc-framing.md diff --git a/docs/ux-arc-framing.md b/docs/ux-arc-framing.md new file mode 100644 index 0000000..4897841 --- /dev/null +++ b/docs/ux-arc-framing.md @@ -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 #64–69), 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:499–586`) 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. + + diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b8e7961..48b85cb 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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 = 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::::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>>>; @@ -798,9 +788,7 @@ impl ApplicationHandler 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: (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", } } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index ddb4da3..54d97b5 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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`]. diff --git a/src/daemon.rs b/src/daemon.rs index 968c0c4..df334da 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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. // diff --git a/src/frontend.rs b/src/frontend.rs index 484297a..7ed5cc8 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 diff --git a/src/protocol.rs b/src/protocol.rs index b9f6c58..01da2bd 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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 v1–v5 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" ); } } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 8fbbdab..8356e3a 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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, + /// 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, /// Last emitted `SearchPrompt` payload per buffer, for /// cached-compare suppression (see [`SearchPromptFacts`]). last_search_prompt: HashMap, @@ -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 { + 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:?}" );