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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-07 16:54:13 +02:00
parent 937544cd61
commit e5076ff277
No known key found for this signature in database
4 changed files with 153 additions and 10 deletions

View File

@ -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

View File

@ -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`].

View File

@ -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

View File

@ -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"
);
}
}