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:
parent
937544cd61
commit
e5076ff277
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue