From e5076ff2775eab1f65e432805c0a5c14d70b4139 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 7 Aug 2026 16:54:13 +0200 Subject: [PATCH] feat(protocol): LineWrapFacts at v22, and the GPU stops inheriting a default The GPU is not a grid consumer --- it ignores the CellDelta family and lays out locally --- so ui.line-wrap reaching the viewport reaches only the TUI. Without a wire message, setting truncate would change one frontend and leave the other wrapping: exactly the cross-frontend disagreement this stage exists to remove. LineWrapFacts is appended after PanelFrame, the final v21 variant, so no postcard discriminant moves. PROTOCOL_VERSION 21 -> 22; ADVERTISED_PROTOCOL_VERSION stays at 20, per its own doc --- moving the advertised baseline is reserved for changes that cannot be expressed additively, and this one can. A v21 frontend negotiates v21, never receives the variant, and keeps its behavior. It carries buffer_id because the mode is buffer-local. That is also why the daemon must resend on BUFFER SWITCH, not only on attach and config change: font size is global, wrap mode is not, so moving from a truncate buffer to a wrap buffer changes the effective mode with no config event at all. The GPU handler leans on that --- it ignores a message for any buffer other than the one on screen, rather than keeping a per-buffer cache. On the GPU side the document buffer had never called set_wrap, so it was running on cosmic-text's constructor default of WordOrGlyph: word wrap nobody chose. code_wrap makes it explicit in both directions and settles on Wrap::Glyph. Character wrap is what the grid can implement identically without pulling UAX #14 into it, and what Emacs does by default. GUI users lose word wrap --- a deliberate, documented trade for the two frontends agreeing, and it belongs in the release notes. Changing wrap reflows the document exactly like a font change, so the retained scroll anchor is repaired through the existing normalize_code_scroll rather than left pointing at a row that no longer exists. Three test updates that were NOT stale assertions. The version pin and the resume ladder both had to widen, and the GPU's byte-exact bootstrap test failed because SUPPORTED_PROTOCOL_VERSIONS still ended at 21 --- the handshake was genuinely rejecting v22. That test earned its keep. Two new GPU witnesses. the_gpu_honors_an_explicit_non_wrap_mode is the discriminating case framing section 7 asked for: the existing wrapped_caret test passes against a wrap nobody configured, so it cannot tell "honors the setting" from "the default happened to match". Comparing row counts across the two modes can. Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0, crdt 2097/0, protocol 25/0, pmacs-gpu 223/0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 93 +++++++++++++++++++++++++++++++++++ pmacs-protocol/src/message.rs | 47 ++++++++++++++++-- src/frontend.rs | 5 ++ src/protocol.rs | 18 ++++--- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 4ee1552..9a26803 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1713,6 +1713,10 @@ struct State { /// to cosmic-text so caret/wash byte offsets can be rebased onto /// it. scroll_top: usize, + /// The document buffer's wrap mode, explicit since protocol v22. + /// `Wrap::None` until the daemon says otherwise, so an unwrapped + /// frontend never silently inherits a library default again. + code_wrap: Wrap, /// Whole-file byte range `[vstart, vend)` of the slice the /// cosmic-text `buffer` currently holds (session S1). Everything /// the buffer renders is in slice coordinates (`file_byte - @@ -4023,6 +4027,7 @@ impl State { peer_presences: HashMap::new(), own_cursor: None, scroll_top: 0, + code_wrap: Wrap::None, view_range: (0, 0), last_viewport_sent: None, local_frontend_id: None, @@ -5151,6 +5156,10 @@ impl State { self.apply_terminal_frame(frame); None } + InstanceMessage::LineWrapFacts { buffer_id, wrap } => { + self.apply_line_wrap(buffer_id, wrap); + None + } InstanceMessage::PanelFrame(payload) => { // The band changes the DOCUMENT's pixel height, so a panel // that appears or disappears has to reshape the document @@ -7978,6 +7987,38 @@ impl State { } } + /// Honor a wrap mode for `buffer_id` (protocol v22). + /// + /// The document buffer has never set a wrap mode, so it has been + /// running on cosmic-text's constructor default, + /// `Wrap::WordOrGlyph` — word wrap that nobody chose. This makes the + /// mode explicit in both directions and settles it on + /// **`Wrap::Glyph`**: character wrap is what the grid renderer can + /// implement identically without pulling UAX #14 into it, and it is + /// what Emacs does by default. GUI users lose word wrap; that is a + /// deliberate, documented trade for the two frontends agreeing. + /// + /// Changing wrap reflows the whole document, exactly like a font + /// change, so the retained scroll anchor is repaired through + /// `normalize_code_scroll` rather than left pointing at a row that + /// no longer exists. + fn apply_line_wrap(&mut self, buffer_id: BufferId, wrap: bool) { + // Only the buffer on screen can be reflowed; the mode is + // buffer-local, so a message for anything else is not ours to + // apply. The daemon resends on buffer switch precisely so this + // stays correct rather than needing a per-buffer cache here. + if self.current_buffer_id != Some(buffer_id) { + return; + } + let want = if wrap { Wrap::Glyph } else { Wrap::None }; + if self.code_wrap == want { + return; + } + self.code_wrap = want; + self.buffer.set_wrap(&mut self.font_system, want); + self.reshape(); + } + fn reshape(&mut self) { self.rebuild_code_slice(); self.normalize_code_scroll(); @@ -10484,6 +10525,7 @@ fn debug_apply() -> bool { fn instance_message_label(msg: &InstanceMessage) -> &'static str { match msg { InstanceMessage::CellDelta { .. } => "CellDelta", + InstanceMessage::LineWrapFacts { .. } => "LineWrapFacts", InstanceMessage::Cursor(_) => "Cursor", InstanceMessage::ModeLine(_) => "ModeLine", InstanceMessage::Signal(_) => "Signal", @@ -15847,6 +15889,57 @@ mod tests { /// Acceptance 11 — a caret painted on a wrapped visual run /// survives 16px → 72px → 6px re-wraps, with the normalized + /// The discriminating witness framing §7 asked for. + /// + /// `wrapped_caret_survives_size_changes` passes today against a wrap + /// nobody configured — cosmic-text's constructor default — so it + /// cannot tell "honors the setting" from "the default happened to + /// match". The other value is what discriminates: with wrap OFF, an + /// overlong line must NOT occupy a second row. + #[test] + fn the_gpu_honors_an_explicit_non_wrap_mode() { + let long = "x".repeat(400); + let Some(mut state) = headless_or_skip(320, 400, &format!("{long}\nsecond\n")) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + + state.apply_line_wrap(bid, true); + state.reshape(); + let wrapped_rows = state.buffer.layout_runs().count(); + + state.apply_line_wrap(bid, false); + state.reshape(); + let truncated_rows = state.buffer.layout_runs().count(); + + assert!( + wrapped_rows > truncated_rows, + "wrap must produce more visual rows than truncate \ + (wrapped={wrapped_rows}, truncated={truncated_rows}); equal counts \ + would mean the mode reached nothing" + ); + } + + /// A mode for a buffer that is not on screen is not ours to apply. + /// The daemon resends on buffer switch precisely so this holds. + #[test] + fn a_wrap_message_for_another_buffer_is_ignored() { + let Some(mut state) = headless_or_skip(320, 200, "hello\n") else { + return; + }; + let mine = BufferId::next(); + let other = BufferId::next(); + state.current_buffer_id = Some(mine); + state.apply_line_wrap(mine, true); + let after_mine = state.code_wrap; + state.apply_line_wrap(other, false); + assert_eq!( + state.code_wrap, after_mine, + "another buffer's mode must not reflow this one" + ); + } + /// scroll invariant intact throughout. #[test] #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 73ba142..b4c8e7e 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1265,6 +1265,40 @@ pub enum InstanceMessage { /// Appended after [`Self::InitialTargetResult`], the final v20 /// variant, so no existing postcard discriminant moves. PanelFrame(crate::panel::PanelFramePayload), + /// Long lines (protocol v22): how the receiving frontend should show + /// a line wider than its viewport, for one buffer. + /// + /// # Why a message exists at all + /// + /// `ui.line-wrap` reaches the grid renderer through the viewport, + /// but `pmacs-gpu` is not a grid consumer — it ignores the + /// `CellDelta` family and lays out locally. Without this, setting + /// the mode would change the TUI and leave the GPU wrapping, + /// which is the cross-frontend disagreement the long-lines stage + /// exists to remove. + /// + /// # Why it names a buffer + /// + /// The setting is **buffer-local**, so "the current mode" is + /// meaningless without saying whose. It follows that the daemon + /// must resend on a **buffer switch** as well as on attach and on + /// config change: font size is global, but wrap mode is not, so + /// moving from a `Truncate` buffer to a `Wrap` one changes the + /// effective mode with no config event at all. A design that only + /// listens for config changes is silently wrong here and looks + /// correct in every single-buffer test. + /// + /// A v21-or-older frontend never receives this and keeps its own + /// behavior; that divergence is documented rather than silent. + /// + /// Appended after [`Self::PanelFrame`], the final v21 variant, so no + /// existing postcard discriminant moves. Daemon-gated `>= 22`. + LineWrapFacts { + /// Buffer the mode applies to. + buffer_id: crate::BufferId, + /// Whether that buffer's long lines wrap. + wrap: bool, + }, } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1697,7 +1731,7 @@ pub enum ResourceBody { /// directions: a v20 peer neither receives `PanelFrame` nor is placed in /// a side window, because denying only the events would leave its /// window invisible. -pub const PROTOCOL_VERSION: u32 = 21; +pub const PROTOCOL_VERSION: u32 = 22; /// Protocol version placed in the daemon's server-first [`Hello`]. /// @@ -1865,8 +1899,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 { /// [`Hello`]. The later capability-activation slice owns moving production /// negotiation to v21 without making existing v20 frontends reject the /// handshake. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; +/// +/// Long lines (framing Q#LL7): extended to `[6, ..., 22]` for +/// [`InstanceMessage::LineWrapFacts`]. Additive and daemon-gated, so +/// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend +/// negotiates v21, never receives the variant, and keeps its own +/// behavior. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[ + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, +]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/frontend.rs b/src/frontend.rs index 275f349..1d04e9f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -442,6 +442,11 @@ impl Frontend { // grid and negotiates no panel capability, so this cannot // legitimately reach here. | InstanceMessage::PanelFrame(_) + // Long lines: the grid TUI learns its wrap mode through the + // viewport the daemon already resolved for it, so this + // message is for semantic frontends that lay out locally + // and would otherwise never hear the setting at all. + | InstanceMessage::LineWrapFacts { .. } | 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 baf2709..1a1c723 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_twenty_one_for_the_bottom_panel_band() { + fn protocol_version_is_twenty_two_for_line_wrap_facts() { // 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 @@ -1728,7 +1728,11 @@ mod tests { // bump that gates in BOTH directions; all four appended after // their enum's final v20 variant, see the placement pins in // `bottom_panel_stage2b_protocol_acceptance`). - assert_eq!(PROTOCOL_VERSION, 21); + // Long lines bumps 21→22 (`InstanceMessage::LineWrapFacts`, + // daemon-gated, appended after the final v21 variant). The + // GPU lays out locally and would otherwise never hear the wrap + // setting; the advertised baseline is deliberately unmoved. + assert_eq!(PROTOCOL_VERSION, 22); } #[test] @@ -1804,18 +1808,18 @@ mod tests { // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), - // v20 (semantic initial-target bootstrap), and v21 (the bottom - // panel band) all interoperate. - for accepted in 6..=21 { + // v20 (semantic initial-target bootstrap), v21 (the bottom + // panel band), and v22 (`LineWrapFacts`) all interoperate. + for accepted in 6..=22 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v21 binary" + "v{rejected} must be rejected by a v22 binary" ); } }