From d03845e82693e238d47f31ae92d958240972a56d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 29 Jul 2026 17:42:08 -0400 Subject: [PATCH 01/11] feat(bottom-panel): activate protocol v21 by frontend counter-offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2B-3, part 1 of 3: the compatibility-preserving v21 activation mechanism and the negotiated `panel_capable` flip. 2B-1 reserved the v21 wire and 2B-2 built the daemon projection behind it, both dark, because the handshake is server-first: the daemon writes `Hello` before the frontend has said anything, and a frontend rejects a `protocol_version` outside its supported range *before* it can send `AttachRequest`. Advertising 21 there is therefore an incompatible act on its own, independent of whether one new message is ever exchanged. So the advertised version does not move. `ADVERTISED_PROTOCOL_VERSION` becomes a permanent compatibility BASELINE, and the session's real version is settled one message later, by the frontend: 1. the daemon advertises the baseline (20, unchanged); 2. the frontend answers `requested_protocol_version(baseline)` — its own `PROTOCOL_VERSION` when the baseline is the current one, and a verbatim echo of anything older; 3. the daemon records `negotiated_session_version(offer)`. A shipped v20 frontend echoes 20 and gets a v20 session, byte-for-byte as before — the real-daemon acceptance that emulates its rejection point still passes untouched. A current frontend offers up and gets v21. The `Hello` encoding and value are unchanged, which is why the old frontend never sees a version it must reject. `peer_declared_panel_support` gains the arm 2B-2 deliberately left off: a semantic session is panel-capable exactly when it negotiated `PANEL_MIN_VERSION` or later. The gate is on placement, not only transport, so a v6-v20 semantic session keeps the Stage 1 fallback. The GPU client's `server_protocol_version` splits into `session_protocol_version` (what the session speaks — every wire gate keys on this) and `baseline_protocol_version` (what `Hello` advertised). They now differ in the normal case, and that difference IS the compatibility property, so both headless probe reports emit both keys and the two ratchets that read them assert both directions: session 21 AND baseline 20. Asserting only the session version would pass if the baseline had been bumped too — the exact incompatible change this mechanism avoids. Also fixes a pre-existing `unused_mut` in a `crdt`-gated daemon test, dark to the standard clippy gate because that gate runs without the feature. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- pmacs-gpu/src/attach.rs | 77 +++++++++++++++++++++------ pmacs-gpu/src/main.rs | 46 +++++++++++----- pmacs-protocol/src/lib.rs | 2 +- pmacs-protocol/src/message.rs | 85 +++++++++++++++++++++++++++--- src/attach.rs | 16 ++++-- src/daemon.rs | 61 ++++++++++++++------- tests/gpu_invocation_acceptance.rs | 12 ++++- tests/vterm_stage3_acceptance.rs | 19 ++++++- 8 files changed, 260 insertions(+), 58 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 7dd97a9..75427fa 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -34,7 +34,7 @@ use pmacs_protocol::{ FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message, - write_message, + requested_protocol_version, write_message, }; use winit::event_loop::EventLoopProxy; @@ -557,8 +557,16 @@ fn connect_stream_with_sink( // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). + // Bottom-panel Stage 2B-3: the `Hello` version is a compatibility + // BASELINE, and this counter-offer is what activates anything above + // it. The handshake is server-first, so the daemon cannot advertise a + // version a shipped frontend might reject; the frontend is the only + // party that can safely name a higher one, because by this point it + // has already accepted the baseline. `requested_protocol_version` + // echoes anything older than the current baseline verbatim. + let session_protocol_version = requested_protocol_version(hello.protocol_version); let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: session_protocol_version, frontend_capabilities: FrontendCapabilities { synchronized_output: false, unicode_smp: true, @@ -678,7 +686,8 @@ fn connect_stream_with_sink( outbox, shutdown_handle, frontend_id: hello.assigned_frontend_id, - server_protocol_version: hello.protocol_version, + session_protocol_version, + baseline_protocol_version: hello.protocol_version, initial_message, }) } @@ -859,10 +868,25 @@ pub struct AttachClient { /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. frontend_id: FrontendId, - /// The daemon's `Hello.protocol_version`. Wire variants newer - /// than the daemon (e.g. `Pointer`, v5) must be gated on this — - /// an older daemon hard-errors decoding an unknown variant. - server_protocol_version: u32, + /// The version this session actually speaks: the frontend's + /// `AttachRequest` counter-offer, which the daemon adopts. Wire + /// variants newer than the session (e.g. `Pointer`, v5) must be gated + /// on this — a daemon below the variant's floor hard-errors decoding + /// an unknown discriminant. + /// + /// Bottom-panel Stage 2B-3: this is deliberately NOT + /// `Hello.protocol_version` any more. The server-first `Hello` carries + /// a compatibility *baseline* that no shipped frontend may be forced + /// to reject, so it under-reports what the pair can speak; the offer + /// this frontend made is the session's real ceiling. + session_protocol_version: u32, + /// The baseline the daemon advertised in its server-first `Hello`. + /// + /// Kept beside the negotiated version because they answer different + /// questions, and because "the daemon still advertises 20 while this + /// session runs 21" is precisely the compatibility property Stage + /// 2B-3 has to be able to demonstrate. + baseline_protocol_version: u32, /// Target snapshot retained across the pre-window readiness barrier. initial_message: Option, } @@ -914,7 +938,7 @@ impl AttachClient { /// Send a `FrontendEvent::Pointer` (session M-2): a locally /// hit-tested gesture in source bytes. Callers gate on - /// [`Self::server_protocol_version`] `>= 5`. + /// [`Self::session_protocol_version`] `>= 5`. pub fn send_pointer( &self, buffer_id: BufferId, @@ -944,7 +968,7 @@ impl AttachClient { /// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the /// terminal-cell geometry this frontend has on screen. Callers gate - /// on [`Self::server_protocol_version`] `>= 19`. + /// on [`Self::session_protocol_version`] `>= 19`. /// /// Cells, never pixels — the frontend divides its own drawable /// rectangle by its own metrics, keeping the no-pixels contract the @@ -963,7 +987,7 @@ impl AttachClient { /// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a /// gesture hit-tested locally to a terminal cell. Callers gate on - /// [`Self::server_protocol_version`] `>= 19`. + /// [`Self::session_protocol_version`] `>= 19`. pub fn send_terminal_pointer( &self, buffer_id: BufferId, @@ -996,9 +1020,22 @@ impl AttachClient { }) } - /// The daemon's negotiated wire version from `Hello`. - pub fn server_protocol_version(&self) -> u32 { - self.server_protocol_version + /// The version this session negotiated — the frontend's + /// `AttachRequest` offer, which the daemon adopts. + /// + /// Every "is this variant on the wire?" gate keys on this, never on + /// [`Self::baseline_protocol_version`]. + pub fn session_protocol_version(&self) -> u32 { + self.session_protocol_version + } + + /// The compatibility baseline the daemon advertised in `Hello`. + /// + /// Only the handshake itself needs this. It is *lower* than + /// [`Self::session_protocol_version`] whenever an additive family has + /// been activated by counter-offer, which is the normal case. + pub fn baseline_protocol_version(&self) -> u32 { + self.baseline_protocol_version } /// Send a locally-authored CRDT operation to the daemon. The GPU @@ -1385,7 +1422,8 @@ mod tests { outbox: Arc::new((Mutex::new(outbox), Condvar::new())), shutdown_handle: b, frontend_id: FrontendId::LOCAL, - server_protocol_version: PROTOCOL_VERSION, + session_protocol_version: PROTOCOL_VERSION, + baseline_protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, initial_message: None, }; // A send against the closed outbox fails *and* shuts the socket @@ -1546,7 +1584,16 @@ mod tests { .expect("transient sequence must attach"); assert_eq!(attempts, 4); assert!(managed.daemon.spawned_daemon()); - assert_eq!(managed.client.server_protocol_version(), PROTOCOL_VERSION); + assert_eq!( + managed.client.session_protocol_version(), + PROTOCOL_VERSION, + "a managed attach negotiates this binary's wire, not the Hello baseline" + ); + assert_eq!( + managed.client.baseline_protocol_version(), + pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, + "while the daemon's server-first Hello still advertises the baseline" + ); server.join().expect("handshake server"); } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3a8bb55..9dab1bf 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -751,7 +751,8 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { state.set_frontend_id(client.frontend_id()); let mut facts = ProbeFacts { - server_protocol_version: client.server_protocol_version(), + session_protocol_version: client.session_protocol_version(), + baseline_protocol_version: client.baseline_protocol_version(), ..ProbeFacts::default() }; @@ -889,8 +890,13 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let mut out = String::new(); let _ = writeln!( out, - "server_protocol_version={}", - facts.server_protocol_version + "session_protocol_version={}", + facts.session_protocol_version + ); + let _ = writeln!( + out, + "baseline_protocol_version={}", + facts.baseline_protocol_version ); let _ = writeln!(out, "declarations={}", facts.declarations); let _ = writeln!(out, "frames={}", facts.frames); @@ -974,7 +980,8 @@ fn run_headless_managed_probe( return 7; } let daemon = managed.daemon; - let protocol = client.server_protocol_version(); + let protocol = client.session_protocol_version(); + let baseline = client.baseline_protocol_version(); let (stdin_tx, stdin_rx) = mpsc::channel(); std::thread::Builder::new() @@ -998,6 +1005,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1028,6 +1036,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1061,6 +1070,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1081,6 +1091,7 @@ fn run_headless_managed_probe( report, "complete", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1138,6 +1149,7 @@ fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, + baseline: u32, daemon: &attach::ManagedDaemonFacts, buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, @@ -1146,7 +1158,8 @@ fn write_managed_probe_report( let mut out = String::new(); let _ = writeln!(out, "phase={phase}"); - let _ = writeln!(out, "server_protocol_version={protocol}"); + let _ = writeln!(out, "session_protocol_version={protocol}"); + let _ = writeln!(out, "baseline_protocol_version={baseline}"); let _ = writeln!(out, "buffer_snapshot=true"); let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots); let _ = writeln!( @@ -1181,7 +1194,16 @@ fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> { /// Named observations the headless probe reports back to the acceptance. #[derive(Default)] struct ProbeFacts { - server_protocol_version: u32, + /// The version the SESSION negotiated (this frontend's counter-offer). + session_protocol_version: u32, + /// The compatibility baseline the daemon advertised in `Hello`. + /// + /// Reported beside the negotiated version rather than instead of it: + /// Stage 2B-3's whole activation claim is that these two DIFFER — the + /// daemon still advertises a version every shipped frontend accepts + /// while this session speaks the newer wire — and a report carrying + /// only one of them cannot express that. + baseline_protocol_version: u32, declarations: u32, frames: u32, rendered_nonuniform_frames: u32, @@ -1993,14 +2015,14 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 5 { + if client.session_protocol_version() < 5 { return; } // TripleDown is a v7 variant; a pre-v7 instance would // hard-error decoding it. Downgrade to a plain Down — the // exact behavior the third click had before v7 (the chain // restarting). - let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 { + let kind = if kind == PointerKind::TripleDown && client.session_protocol_version() < 7 { PointerKind::Down } else { kind @@ -2008,7 +2030,7 @@ impl App { // Context (right-click, Q#CM1) is a v11 variant; a pre-v11 // instance can't open a menu, so drop the gesture rather than // sending an undecodable variant. - if kind == PointerKind::Context && client.server_protocol_version() < 11 { + if kind == PointerKind::Context && client.session_protocol_version() < 11 { return; } if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) { @@ -2030,7 +2052,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 19 { + if client.session_protocol_version() < 19 { return; } if let Err(e) = client.send_terminal_pointer(buffer_id, coord, kind, mods) { @@ -2047,7 +2069,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 19 { + if client.session_protocol_version() < 19 { return; } let Some(state) = self.state.as_mut() else { @@ -2089,7 +2111,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 11 { + if client.session_protocol_version() < 11 { return; } if let Err(e) = client.send_menu_pointer(index, invoke) { diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index e7e36e3..3d51c95 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -68,7 +68,7 @@ pub use message::{ PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, - negotiate_capabilities, + negotiate_capabilities, negotiated_session_version, requested_protocol_version, }; pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 0e4e815..b9a7428 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1667,14 +1667,87 @@ pub const PROTOCOL_VERSION: u32 = 21; /// Protocol version placed in the daemon's server-first [`Hello`]. /// -/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but -/// production attachment remains on v20 until the Stage 2B-3 capability -/// activation can preserve compatibility with existing v20 frontends. -/// Those frontends reject an unknown server-first version before they can -/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would -/// make the otherwise-dark protocol slice user-visible. +/// **This is a compatibility *baseline*, not a ceiling, and Stage 2B-3 +/// makes that permanent.** The handshake is server-first: the daemon +/// writes [`Hello`] before the frontend has said anything at all, and a +/// frontend rejects an unrecognized `protocol_version` *before* it can +/// send [`AttachRequest`]. Advertising [`PROTOCOL_VERSION`] here would +/// therefore lock out every already-shipped frontend whose supported +/// range ends lower — an incompatible act on its own, independent of +/// whether a single new message is ever exchanged. +/// +/// So the baseline stays at the highest version every shipped frontend +/// is known to accept, and the session's actual version is settled by +/// the frontend's [`AttachRequest`] instead: +/// +/// 1. the daemon advertises this baseline; +/// 2. the frontend answers with [`requested_protocol_version`] — its own +/// [`PROTOCOL_VERSION`] when the baseline is this constant, and a +/// verbatim echo of anything older; +/// 3. the daemon negotiates [`negotiated_session_version`] of that offer. +/// +/// A shipped baseline-version frontend echoes the baseline and gets a +/// baseline session, exactly as before. A current frontend offers up and +/// gets the current wire. Nothing about the `Hello` encoding or its +/// value changes, which is why the old frontend never sees a version it +/// must reject. +/// +/// Moving this constant is therefore a **deliberately incompatible** +/// act, reserved for a wire change that cannot be expressed additively. +/// An additive family — like the bottom panel's v21 shapes — never needs +/// it. pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20; +/// The version a frontend puts in its [`AttachRequest`], given the +/// server-first [`Hello`] baseline it just read. +/// +/// The counter-offer is confined to the *current* baseline on purpose. A +/// daemon advertising anything other than [`ADVERTISED_PROTOCOL_VERSION`] +/// is genuinely older than this ladder rung, so its baseline is echoed +/// verbatim and that attachment takes byte-for-byte the pre-Stage-2B-3 +/// path. Only the current baseline — the one every daemon built from +/// this ladder sends — is answered with this binary's own +/// [`PROTOCOL_VERSION`]. +/// +/// The offer is never *lower* than the baseline: a frontend that +/// supported less than the daemon advertised would already have rejected +/// the `Hello` via [`is_supported_protocol_version`]. +/// +/// # The one-way window this leaves open +/// +/// A daemon whose own `PROTOCOL_VERSION` *equals* the baseline also +/// advertises the baseline, and rejects an offer above its supported +/// range with [`GoodbyeReason::VersionMismatch`]. That is the price of a +/// server-first handshake with no client-first hint: compatibility can +/// be preserved for old *frontends* (the direction that matters, since +/// the daemon is what a user leaves running) or for old *daemons*, but a +/// single `AttachRequest` cannot mean both "I want 21" and "≤ 20" at +/// once. The window closes as soon as the running daemon is restarted on +/// a binary from this ladder rung or later, and it is one-connection +/// visible — [`GoodbyeReason::VersionMismatch`] names both versions. +#[must_use] +pub fn requested_protocol_version(server_baseline: u32) -> u32 { + if server_baseline == ADVERTISED_PROTOCOL_VERSION { + PROTOCOL_VERSION + } else { + server_baseline + } +} + +/// The version a daemon records for a session, given the frontend's +/// [`AttachRequest`] offer. +/// +/// The offer has already passed [`is_supported_protocol_version`], so +/// this clamp cannot bind today; it is here because "the session speaks +/// the lower of the two ceilings" is the *rule*, and leaving it implicit +/// in a membership test is how a future ladder widening (accepting a +/// version this binary cannot itself produce) would silently ship an +/// over-negotiated session. +#[must_use] +pub fn negotiated_session_version(frontend_offer: u32) -> u32 { + frontend_offer.min(PROTOCOL_VERSION) +} + /// 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 /// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat diff --git a/src/attach.rs b/src/attach.rs index 31245d9..e7f58e2 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -450,8 +450,15 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> { // its AttachRequest (the v0.1 daemon's strict-equality check will // accept). The frontend's runtime behavior on the wire is the // intersection of features both sides support. + // + // Bottom-panel Stage 2B-3: that echo is now a *floor*, not the whole + // rule. `Hello` is server-first, so the daemon must advertise a + // baseline every shipped frontend accepts; the session's real version + // is settled here, by this frontend counter-offering its own + // `PROTOCOL_VERSION` when the baseline is the current one. Anything + // older is still echoed verbatim. let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version), frontend_capabilities: build_capabilities(), initial_size, }; @@ -1914,9 +1921,12 @@ fn run_one_session( // T M10.5: match the server's protocol version so v1.0 frontends // attaching to v0.1 daemons advertise protocol_version=1. Same - // pattern as the local-socket path above. + // pattern as the local-socket path above, including Stage 2B-3's + // counter-offer: this path *spawns* the daemon from the running + // executable, so the peer is always this same ladder rung and the + // counter-offer is always accepted. let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version), frontend_capabilities: build_capabilities(), initial_size, }; diff --git a/src/daemon.rs b/src/daemon.rs index c97e032..b6f20d0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -735,6 +735,15 @@ fn per_attach_thread( daemon_debug(format!("received AttachRequest from {frontend_id:?}")); // T M10.5 version check. + // + // Bottom-panel Stage 2B-3: this is where a session's version is + // actually settled. `Hello` above carried only the compatibility + // BASELINE (`ADVERTISED_PROTOCOL_VERSION`) — it has to, because a + // server-first handshake reaches a shipped frontend before that + // frontend can say anything, and a version it does not recognize is + // rejected outright. The frontend's counter-offer is therefore the + // upper half of the negotiation, and this membership test is what + // bounds it. if !crate::protocol::is_supported_protocol_version(req.protocol_version) { let _ = write_message( &mut stream, @@ -812,8 +821,16 @@ fn per_attach_thread( u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0) }; - let session_state = - crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot); + // The session speaks the lower of the two ceilings. The membership + // test above already bounds the offer, so this clamp cannot bind + // today; it is applied through the shared rule anyway so a future + // ladder widening cannot silently record a version this binary is + // unable to produce. + let session_state = crate::presence::SessionState::new( + crate::protocol::negotiated_session_version(req.protocol_version), + negotiated_caps, + color_slot, + ); // Hand the write-half to the dispatcher; keep a read-half for // this thread's reader loop. **Reader loop starts immediately @@ -899,23 +916,30 @@ fn peer_declared_terminal_support( /// /// Grid sessions paint the whole cell grid the daemon composes, so a side /// window is just another leaf for them. A semantic session needs the GPU -/// band, which does not exist yet — so this still answers `false` for -/// every semantic peer, whatever it declares. No client-asserted +/// band, which Stage 2B-3 lands — so it is panel-capable exactly when it +/// negotiated a wire that can carry the band. No client-asserted /// standalone boolean is trusted: the answer is derived from the daemon's /// own negotiated state. /// -/// **Stage 2B-2 deliberately does not turn the version arm on.** The -/// daemon-side projection and epoch machine below are complete and -/// exercised through a test-only panel-capable view, but the production -/// flip (`semantic_render && negotiated_protocol_version >= -/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the -/// compatibility-preserving activation the server-first `Hello` requires: +/// **Stage 2B-3 turns the version arm on** (framing §3.5): `panel_capable` +/// is true for an authenticated semantic session that negotiated +/// [`PANEL_MIN_VERSION`] or later, and false for every earlier one. The +/// gate is on *placement*, not only on transport — denying the events +/// while still putting a pre-panel peer's window in a side panel it cannot +/// render would leave that window invisible, so a v6–v20 semantic session +/// keeps the Stage 1 fallback with every side-specific parameter +/// discarded (Q#BP2c). +/// +/// The version reaching this predicate is the *negotiated* one, which is +/// the frontend's `AttachRequest` counter-offer rather than the /// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION) -/// is still 20, so no session can negotiate 21 yet, and denying only the -/// events while still *placing* such a peer in a side window would leave -/// its window invisible. +/// baseline the daemon put in `Hello`. That distinction is the whole +/// activation mechanism: the baseline stays where every shipped frontend +/// can accept it, and only a frontend that named the newer wire itself +/// becomes panel-capable. fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render + || session_state.negotiated_protocol_version >= PANEL_MIN_VERSION } /// The same belt-and-braces write-loop gate for the additive @@ -1959,10 +1983,11 @@ fn handle_session_established( // collapses folds, a semantic one keeps raw-line reckoning until // Stage 3. // Bottom-panel arc (Q#BP13): panel capability comes from the SAME - // negotiated bit in this same transaction. Stage 1 ships the TUI - // side windows only, so a semantic session is not panel-capable and - // a `side` request falls back to its document target with every - // side-specific parameter discarded. + // negotiated state in this same transaction. Stage 2B-3 made the + // semantic arm live: a semantic session that negotiated + // `PANEL_MIN_VERSION` or later can render the GPU band and is + // panel-capable, while a v6-v20 semantic session still falls back to + // its document target with every side-specific parameter discarded. let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, @@ -5570,7 +5595,7 @@ mod tests { fn viewport_aligns_the_document_without_taking_focus_from_the_panel() { let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); let other = { - let mut core = editor.core.borrow_mut(); + let core = editor.core.borrow_mut(); core.registry.borrow_mut().create("*other*") }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 2f56e8f..153ed6f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -579,9 +579,19 @@ mod crdt { ); let facts = parse_report(&report); assert_eq!(facts.get("phase").map(String::as_str), Some("complete")); + // Stage 2B-3: the negotiated session version and the advertised + // baseline are different facts, and this managed spawn pins both. + // The daemon it spawned advertises the baseline; the client it + // handed back counter-offered this binary's own wire. assert_eq!( facts - .get("server_protocol_version") + .get("session_protocol_version") + .and_then(|value| value.parse::().ok()), + Some(PROTOCOL_VERSION) + ); + assert_eq!( + facts + .get("baseline_protocol_version") .and_then(|value| value.parse::().ok()), Some(ADVERTISED_PROTOCOL_VERSION) ); diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index f6b1f7f..e4eb3c1 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -717,10 +717,25 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .filter_map(|line| line.split_once('=')) .collect(); + // Bottom-panel Stage 2B-3 activated v21 by frontend counter-offer, so + // the two halves of the handshake now report DIFFERENT numbers and both + // are load-bearing: the daemon still advertises the v20 compatibility + // baseline in its server-first `Hello` (so a shipped v20 frontend is + // never handed a version it must reject), while this real client + // negotiated v21 and is therefore panel-capable. Asserting only the + // session version would pass if the baseline had been bumped too — + // which is exactly the incompatible change this mechanism exists to + // avoid — and asserting only the baseline would pass with the whole + // activation missing. assert_eq!( - facts.get("server_protocol_version").copied(), + facts.get("session_protocol_version").copied(), + Some("21"), + "the real client must negotiate the v21 panel wire: {text}" + ); + assert_eq!( + facts.get("baseline_protocol_version").copied(), Some("20"), - "the dark v21 wire slice must keep the real client on v20: {text}" + "while the daemon's server-first Hello still advertises v20: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), From 431d844322eafa1a50e87fb541de14c5c6f5572a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 29 Jul 2026 18:30:57 -0400 Subject: [PATCH 02/11] feat(bottom-panel): split the GPU document bottom into three boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2B-3, part 2 of 3: the pixel substrate for the band. `text_area_bottom` was three boundaries wearing one name — its own doc comment called it "the single source for every bottom-of-text computation" — and once a band can be installed they must diverge: status_band_top = max(0, height - status_band_height) geometry_capacity_bottom = max(0, status_band_top - divider_height) document_text_bottom = max(0, status_band_top - installed_band) The census is 29 matches: 20 production call sites, 1 definition, 8 test sites. All 20 were read in their enclosing function and classified individually — 8 status-owned, 12 document-owned. A blanket rewrite that subtracted the band from all of them would move the status chrome with the document and pass an "everything moved" assertion, which is why the classification is per site and the criterion asserts both directions. The three easiest to get wrong keep their named symptoms: document completion placement is document-owned (status-owned would overlap the band), minibuffer candidate clipping is status-owned (the minibuffer is global bufferless chrome anchored to the band, and clipping it at the document boundary would cut it off), and edge scrolling is document-owned (left on the old bottom it would auto-scroll from inside the panel). `geometry_capacity_bottom` reserves the divider even while the panel is absent. That asymmetry is what breaks the first-open cycle: the daemon sizes a panel from the capacity it was told about, so a capacity that ignored the divider would grant a first panel that does not fit once the divider appears beside it. The document loses no pixels until a `Present` frame is really on screen. `PanelBandInset` is a newtype, not an `f32`, because three boundaries here take a pixel height and only one takes this one. Alongside it, the band's own machinery: `PanelBand` with ONE derivation of "is a panel on screen" (`presented()` — retained valid frame, matching geometry epoch, latch clear), the frontend-owned epoch state machine with its fail-closed exhaustion latch, the `Absent`-is-authoritative receipt path, `panel_cell_capacity` (no per-axis cap — a panel may legitimately be wider than a PTY — plus the daemon's virtual status row), the stable normal-face probe for column count, and the divider strip whose paint rect IS its hit rect. `TerminalPaintPlan::build_grid` factors the shared cell planner so a panel and a terminal cannot disagree about a wide-continuation pair; terminal selection spans stay outside it rather than being faked as empty inside. `PANEL_MIN_VERSION` moves into `pmacs-protocol` so the GPU frontend aliases one definition instead of restating 21. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- pmacs-gpu/src/attach.rs | 92 +++ pmacs-gpu/src/main.rs | 632 ++++++++++++++++-- pmacs-gpu/src/terminal.rs | 111 ++- pmacs-protocol/src/lib.rs | 4 +- pmacs-protocol/src/panel.rs | 19 + src/protocol.rs | 15 - ...ottom_panel_stage2b_protocol_acceptance.rs | 10 +- 7 files changed, 809 insertions(+), 74 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 75427fa..21afdbc 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -355,6 +355,30 @@ fn coalesce_kind(event: &FrontendEvent) -> Option { kind: MouseKind::Drag(_), .. } => Some(3), + // Bottom panel Stage 2B-3 (framing §5.2) — four more tail-only + // tags, each with its own reason. + // + // Geometry is latest-wins because epochs need only INCREASE, not + // be consecutive: the daemon accepts a jump from 3 to 7 and the + // dropped declarations described geometry that no longer exists. + FrontendEvent::FrontendCellGeometry { .. } => Some(4), + // A resize drag coalesces over the complete event including its + // epochs, so a collapsed run cannot mix a new row count with an + // old presentation identity. + FrontendEvent::PanelResizeRows { .. } => Some(5), + // Panel move and drag mirror their terminal twins. Down / Up / + // wheel / context stay LOSSLESS and ordered: repeated left Downs + // are what the daemon's click state reads as a multi-click, and + // Down(Right) is the context-menu gesture, so collapsing either + // silently changes the gesture's meaning. + FrontendEvent::PanelPointer { + kind: MouseKind::Move, + .. + } => Some(6), + FrontendEvent::PanelPointer { + kind: MouseKind::Drag(_), + .. + } => Some(7), _ => None, } } @@ -1004,6 +1028,74 @@ impl AttachClient { }) } + /// Send a `FrontendEvent::FrontendCellGeometry` (Q#BP15a): this + /// frontend's authoritative whole-cell layout capacity. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// Valid **without** a side window on purpose — the daemon needs + /// columns before it can paint a first panel frame, so gating this on + /// panel presence would deadlock the first open. + pub fn send_frontend_cell_geometry( + &self, + geometry_epoch: u64, + total: CellSize, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::FrontendCellGeometry { + frontend_id: self.frontend_id, + geometry_epoch, + total, + }) + } + + /// Send a `FrontendEvent::PanelResizeRows` (Q#BP15a): the fixed panel + /// rows a divider drag is requesting. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// Both epochs ride along as identities, not geometry: the daemon + /// accepts the request only for the panel it most recently declared, + /// under the geometry it most recently accepted. + pub fn send_panel_resize_rows( + &self, + geometry_epoch: u64, + panel_epoch: u64, + rows: u32, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::PanelResizeRows { + frontend_id: self.frontend_id, + geometry_epoch, + panel_epoch, + rows, + }) + } + + /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture + /// hit-tested locally to a panel CELL. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other — the first catches an A→B buffer replacement, + /// the second a close/hide/reopen of the *same* buffer — so both are + /// carried rather than one being derived from the other. + pub fn send_panel_pointer( + &self, + geometry_epoch: u64, + panel_epoch: u64, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::PanelPointer { + frontend_id: self.frontend_id, + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mods, + }) + } + /// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu /// navigation hit-tested locally against the popup we drew. `index` /// is the row the pointer is over (`None` = off the menu); `invoke` diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 9dab1bf..db8b754 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -49,6 +49,7 @@ use pmacs_protocol::{ StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, + panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, }; use unicode_width::UnicodeWidthChar; use wgpu::MultisampleState; @@ -137,6 +138,14 @@ impl FontMetrics { fn status_band_height(self) -> f32 { BASE_STATUS_BAND_HEIGHT * self.scale } + /// The panel divider strip's thickness (Stage 2 framing §5.3). + /// + /// Scaled like the status band because it is row chrome, not a fixed + /// surface inset. The whole strip is both painted and hit-tested, so + /// paint geometry and drag geometry cannot drift apart. + fn divider_height(self) -> f32 { + BASE_DIVIDER_HEIGHT * self.scale + } fn status_font_size(self) -> f32 { BASE_STATUS_FONT_SIZE * self.scale } @@ -434,6 +443,14 @@ const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25 /// bottom — buffer name + modified star on the left, diagnostics / /// cursor / scroll readout on the right. const BASE_STATUS_BAND_HEIGHT: f32 = 26.0; +/// Panel divider (Stage 2 framing §5.3, decided open item): the rule +/// between the document and an installed panel band, at scale 1.0. +/// +/// A 1-2 px rule is adequate decoration but too fragile as a drag target; +/// 4 px still reads as a rule while giving the pointer something to grab. +const BASE_DIVIDER_HEIGHT: f32 = 4.0; +/// Fallback fill for the divider strip when no `ui.divider` face is set. +const DIVIDER_RGBA: [f32; 4] = [0.28, 0.28, 0.36, 1.0]; const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; const STATUS_TEXT_PAD: f32 = 10.0; const BASE_STATUS_FONT_SIZE: f32 = 13.0; @@ -1786,6 +1803,17 @@ struct State { /// explicit: `BufferSnapshot` always leaves terminal mode, a valid /// matching `TerminalFrame` always enters it. terminal: Option, + /// Bottom-panel arc Stage 2B-3: this frontend's panel band — the + /// retained frame, its geometry declaration, the divider drag, and the + /// exhaustion latch. Present on every `State`, inert until a session + /// negotiates the panel wire. + panel: PanelBand, + /// One shaped buffer per planned panel run. + panel_text_buffers: Vec, + /// Whether the negotiated session carries the panel wire at all. + /// + /// Keyed on the NEGOTIATED version, never the `Hello` baseline. + panel_wire: bool, /// The terminal geometry last declared to the daemon, with the /// buffer it described. Suppresses an unchanged re-declaration and /// forces a fresh one after a buffer switch. @@ -1819,6 +1847,113 @@ struct TerminalLocal { plan: TerminalPaintPlan, } +/// Why frame geometry is being (re-)declared (Q#BP2S1). +/// +/// The two arms differ in exactly one way — whether an *identical* +/// [`CellSize`] still advances the epoch — and that difference is the whole +/// reason the epoch is frontend-owned. Collapsing them into one call site +/// reintroduces the bug option 1 was chosen to avoid. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GeometryTrigger { + /// A surface resize, or the first declaration after attach. An + /// identical cell total means nothing the daemon can act on changed, + /// so it is not re-declared. + Surface, + /// A font family, size, or scale change. The cell total may be + /// **identical** while the pixels behind it are not, which is exactly + /// what daemon-side value dedup cannot see — so this always advances. + Metrics, +} + +/// A live divider drag (Q#BP15a, parent acceptance 47). +#[derive(Clone, Copy, Debug)] +struct PanelDrag { + /// Presentation the gesture started against. A drag that outlives its + /// panel is dropped rather than applied to the successor. + panel_epoch: u64, + /// Geometry declaration the gesture is measured against. + geometry_epoch: u64, + /// Rows the panel had when the drag started. + start_rows: u32, + /// Pointer y where the drag started, in surface pixels. + start_y: f32, + /// Last row count actually sent, so a drag that re-crosses the same + /// row boundary does not re-send it. + sent_rows: u32, +} + +/// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16). +struct PanelBand { + /// The last **valid** frame received, retained until an authoritative + /// `Absent`. + /// + /// Silence is not absence: the daemon must send `Absent` explicitly on + /// close *and* on hide, and until it does, this is what paints. An + /// invalid frame is rejected whole and leaves this untouched. + frame: Option, + /// This frontend's monotonic geometry declaration id. `0` means never + /// declared, which the wire rejects. + geometry_epoch: u64, + /// The cell total behind `geometry_epoch`, for the `Surface` dedup. + declared: Option, + /// Terminal exhaustion latch (framing §3.1). + /// + /// Once set: no further declaration is sent, and no retained frame + /// paints or hit-tests **however well its epoch still matches**. The + /// latch is what stops an old `Present` from resurrecting a band under + /// geometry this frontend has disowned; only a fresh session clears + /// it, because only a fresh session builds a fresh `PanelBand`. + exhausted: bool, + /// Live divider drag. One pointer, one gesture. + drag: Option, + /// Whether the pointer is currently over the divider strip, which + /// decides the `RowResize` cursor icon. + hover_divider: bool, + /// Cell-space paint data derived from `frame`, rebuilt on receipt so + /// the render path never re-derives it per frame. + plan: Option, +} + +impl Default for PanelBand { + fn default() -> Self { + Self { + frame: None, + geometry_epoch: 0, + declared: None, + exhausted: false, + drag: None, + hover_divider: false, + plan: None, + } + } +} + +impl PanelBand { + /// **The** single derivation of "is there a band on screen right now". + /// + /// Every consumer goes through this: the band inset the three + /// boundaries are computed from, the painter, the hit-tester, and the + /// drag. 2B-2's review found that two derivations of one panel + /// predicate is precisely how the renderer and the durable state come + /// to disagree, so there is exactly one here too. + /// + /// Three conditions, closing three different holes: + /// + /// * a retained valid frame exists (silence retains, `Absent` clears); + /// * its `geometry_epoch` matches the current declaration — after a + /// new declaration is sent, an older retained frame neither paints + /// nor accepts input until a matching `Present` arrives (parent 41); + /// * the exhaustion latch is clear. + fn presented(&self) -> Option<&PanelFrame> { + if self.exhausted { + return None; + } + self.frame + .as_ref() + .filter(|frame| frame.geometry_epoch == self.geometry_epoch) + } +} + /// Kind-glyph column for a completion row: the LSP /// `CompletionItemKind` numeric code → the single-char glyph the TUI /// popup uses (`crate::completion::CompletionItemKind::glyph`'s @@ -2506,8 +2641,12 @@ impl ApplicationHandler for App { } // Q#M7 — arm/disarm edge auto-scroll from the drag's // vertical position; `about_to_wait` runs the ticks. - state.edge_scroll_dir = - edge_scroll_direction(position.y as f32, state.config.height, state.fm); + state.edge_scroll_dir = edge_scroll_direction( + position.y as f32, + state.config.height, + state.fm, + state.band_inset(), + ); // Drag coalescing (predicted finding #4): pixel-rate // motion only ships when the hit byte changes. let Some(byte) = state.hit_test_source_byte(position.x, position.y) else { @@ -3447,6 +3586,9 @@ impl State { gutter_buffer, gutter_text_renderer, terminal: None, + panel: PanelBand::default(), + panel_text_buffers: Vec::new(), + panel_wire: false, last_terminal_size_sent: None, terminal_frame_error_latched: false, last_terminal_pointer_cell: None, @@ -4683,7 +4825,8 @@ impl State { fn terminal_cell_viewport(&self) -> Option { let (origin_x, origin_y) = Self::terminal_origin(); let width = self.config.width as f32 - origin_x; - let height = text_area_bottom(self.config.height, self.fm) - origin_y; + let height = + document_text_bottom(self.config.height, self.fm, self.band_inset()) - origin_y; crate::terminal::cell_viewport( width, height, @@ -4692,6 +4835,334 @@ impl State { ) } + // ----------------------------------------------------------------- + // Bottom panel band (Stage 2B-3) + // ----------------------------------------------------------------- + + /// Whether this session negotiated the panel wire. + /// + /// Set once from the negotiated session version, never from the + /// `Hello` baseline: the baseline stays at the compatibility floor + /// forever, so reading it here would leave the band permanently dark. + fn set_panel_wire(&mut self, session_protocol_version: u32) { + self.panel_wire = session_protocol_version >= PANEL_MIN_VERSION; + } + + /// The band inset the document boundary is computed from. + /// + /// Routed through [`PanelBand::presented`] rather than re-deriving + /// "is a panel visible" here, because a second derivation of that + /// predicate is how the renderer and the retained state drift apart. + fn band_inset(&self) -> PanelBandInset { + self.panel + .presented() + .map_or(PanelBandInset::ABSENT, |frame| { + PanelBandInset::installed(frame.size.rows, self.fm) + }) + } + + /// The panel band's content rectangle in surface pixels: + /// `(x, y, width, height)`, cells only — the divider sits above `y`. + fn panel_content_rect(&self) -> Option<(f32, f32, f32, f32)> { + let frame = self.panel.presented()?; + let band = PanelBandInset::installed(frame.size.rows, self.fm); + let cells_px = band.px() - self.fm.divider_height(); + if cells_px <= 0.0 { + return None; + } + let top = + document_text_bottom(self.config.height, self.fm, band) + self.fm.divider_height(); + Some(( + TEXT_LEFT, + top, + (self.config.width as f32 - TEXT_LEFT).max(0.0), + cells_px, + )) + } + + /// The divider strip: paint geometry AND hit geometry, one rect. + /// + /// Deliberately the same value for both. The framing decided a 4 px + /// strip precisely so the pointer has a usable target, and deriving + /// the hover band separately from the painted rule is how the two come + /// to disagree by a pixel that the user can see but not grab. + fn panel_divider_rect(&self) -> Option<(f32, f32, f32, f32)> { + let frame = self.panel.presented()?; + let band = PanelBandInset::installed(frame.size.rows, self.fm); + Some(( + 0.0, + document_text_bottom(self.config.height, self.fm, band), + self.config.width as f32, + self.fm.divider_height(), + )) + } + + /// The stable normal-face advance the geometry declaration uses + /// (framing §5.3, A2B-3). + /// + /// **Never [`Self::mono_advance`].** That falls back to the first + /// shaped glyph of the *document* buffer when no `FontFacts` probe has + /// been applied, which would make the panel's column count + /// document-dependent: two frontends with identical metrics showing + /// different files would derive different `total.cols`, and the same + /// frontend's panel width would change when its first glyph did. + /// + /// `None` when the family shapes no width — the caller declares zero + /// usable geometry rather than reaching for a document sample. + fn panel_probe_advance(&mut self) -> Option { + let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); + let family = self.resolved_family.clone(); + probe_mono_advance(&mut self.font_system, &family, metrics) + } + + /// This surface's whole-cell capacity as the daemon's layout model + /// sees it (Q#BP15a's pixel→cell conversion). + /// + /// Zero-sized on any degenerate input, which is the fail-closed arm + /// parent 41 requires: the daemon treats zero columns as + /// non-presentable and the panel hides, rather than a non-finite + /// metric producing an absurd row count and an oversized allocation. + fn declared_cell_total(&mut self) -> CellSize { + let Some(advance) = self.panel_probe_advance() else { + return CellSize::new(0, 0); + }; + let height = (geometry_capacity_bottom(self.config.height, self.fm) - TEXT_TOP).max(0.0); + let width = (self.config.width as f32 - TEXT_LEFT).max(0.0); + crate::terminal::panel_cell_capacity(width, height, advance, self.fm.code_line_height()) + .unwrap_or_else(|| CellSize::new(0, 0)) + } + + /// Advance the geometry declaration if this trigger calls for one, and + /// return what the caller must send. + /// + /// The decision lives here and the *send* lives at the seam that owns + /// the attach client, so the whole state machine — dedup, exhaustion, + /// the latch — is reachable without a daemon. + fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> { + if !self.panel_wire || self.panel.exhausted { + return None; + } + let total = self.declared_cell_total(); + if trigger == GeometryTrigger::Surface + && self.panel.geometry_epoch != 0 + && self.panel.declared == Some(total) + { + return None; + } + let Some(next) = self.panel.geometry_epoch.checked_add(1) else { + // Fail closed, and LATCH. Retaining the last declaration is not + // fail-closed: if the surface then resizes, the daemon would + // keep painting a panel sized to a frame that no longer exists. + // Dropping the retained frame alone is not enough either — an + // old `Present` whose epoch still matched would resurrect a + // band under geometry this frontend has disowned. + self.panel.exhausted = true; + self.panel.frame = None; + self.panel.plan = None; + self.panel.drag = None; + self.panel.hover_divider = false; + return None; + }; + self.panel.geometry_epoch = next; + self.panel.declared = Some(total); + Some((next, total)) + } + + /// Apply an inbound `PanelFrame` payload. + /// + /// Returns `true` when the band's appearance changed, so the caller + /// can request a redraw without guessing. + /// + /// Validation is atomic: a rejected frame leaves the retained one + /// exactly as it was, because `PanelFrame::validate` is pure and runs + /// before any state is touched. + fn apply_panel_payload(&mut self, payload: PanelFramePayload) -> bool { + match payload { + PanelFramePayload::Absent => { + // Authoritative removal, and always safe. Note this does + // NOT clear the geometry declaration: the frontend's frame + // capacity is unchanged by a panel closing, and discarding + // it would force a needless re-declaration before the next + // open. + let had = self.panel.presented().is_some(); + self.panel.frame = None; + self.panel.plan = None; + self.panel.drag = None; + self.panel.hover_divider = false; + had + } + PanelFramePayload::Present(frame) => { + if let Err(error) = frame.validate() { + eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}"); + return false; + } + if self.panel.frame.as_ref() == Some(&frame) { + // A duplicate does no work — not even a reshape. + return false; + } + let plan = TerminalPaintPlan::build_grid( + frame.size, + &frame.cells, + frame.cursor, + Self::terminal_palette(), + ); + self.panel.frame = Some(frame); + self.panel.plan = Some(plan); + self.rebuild_panel_text_buffers(); + true + } + } + } + + /// Reshape one cosmic-text buffer per planned panel run. + /// + /// One buffer per RUN for the same reason terminal mode does it: a + /// row-wide buffer would let a wide or cluster glyph's shaped advance + /// decide where the next column starts, and panel columns belong to + /// the daemon's grid, not to the shaper. + fn rebuild_panel_text_buffers(&mut self) { + let Some(plan) = self.panel.plan.as_ref() else { + self.panel_text_buffers.clear(); + return; + }; + let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); + let advance = self.mono_advance(); + let family = self.resolved_family.clone(); + let runs: Vec<(String, f32, bool, bool)> = plan + .runs + .iter() + .map(|run| { + ( + run.text.clone(), + run.cells as f32 * advance, + run.bold, + run.italic, + ) + }) + .collect(); + let mut buffers = Vec::with_capacity(runs.len()); + for (text, width, bold, italic) in runs { + let mut buffer = Buffer::new(&mut self.font_system, metrics); + buffer.set_wrap(&mut self.font_system, Wrap::None); + buffer.set_size( + &mut self.font_system, + Some(width.max(1.0)), + Some(metrics.line_height), + ); + let attrs = Attrs::new() + .family(Family::Name(&family)) + .weight(if bold { + glyphon::cosmic_text::Weight::BOLD + } else { + glyphon::cosmic_text::Weight::NORMAL + }) + .style(if italic { + glyphon::cosmic_text::Style::Italic + } else { + glyphon::cosmic_text::Style::Normal + }); + buffer.set_text( + &mut self.font_system, + &text, + &attrs, + Shaping::Advanced, + None, + ); + buffer.shape_until_scroll(&mut self.font_system, false); + buffers.push(buffer); + } + self.panel_text_buffers = buffers; + } + + /// Pixel rectangle of a cell run inside the panel band. + fn panel_run_rect(&self, run: crate::terminal::CellRun) -> Option<(f32, f32, f32, f32)> { + let (ox, oy, _, _) = self.panel_content_rect()?; + let advance = self.mono_advance(); + let line = self.fm.code_line_height(); + Some(( + ox + run.start_col as f32 * advance, + oy + run.row as f32 * line, + (run.end_col - run.start_col) as f32 * advance, + line, + )) + } + + /// The band's quad batch: the divider strip, cell backgrounds, and the + /// panel caret, drawn under the band's glyphs. + fn panel_quad_vertex_bytes(&self) -> Vec { + let mut rects = Vec::new(); + if let Some((x, y, w, h)) = self.panel_divider_rect() { + rects.push(MinimapRect { + x, + y, + w, + h, + color: self.face_wash_or("ui.divider", DIVIDER_RGBA), + }); + } + if let Some(plan) = self.panel.plan.as_ref() + && self.panel.presented().is_some() + { + let window_bg = Self::terminal_palette().default_bg; + for bg in &plan.backgrounds { + if bg.color == window_bg { + continue; + } + if let Some((x, y, w, h)) = self.panel_run_rect(bg.run) { + rects.push(MinimapRect { + x, + y, + w, + h, + color: rgb_to_quad(bg.color, 1.0), + }); + } + } + if let Some(cursor) = plan.cursor + && let Some((x, y, w, h)) = self.panel_run_rect(cursor) + { + rects.push(MinimapRect { + x, + y, + w, + h, + color: TERMINAL_CURSOR_RGBA, + }); + } + } + if rects.is_empty() { + return Vec::new(); + } + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// Which panel cell a surface pixel is over, if any (Q#BP16). + /// + /// Returns `None` outside the band's content rect, which is what keeps + /// a document gesture from being reported as a panel one. + fn panel_hit_test(&self, x: f32, y: f32) -> Option { + let frame = self.panel.presented()?; + let (ox, oy, w, h) = self.panel_content_rect()?; + if x < ox || x >= ox + w || y < oy || y >= oy + h { + return None; + } + crate::terminal::hit_test_cell( + x, + y, + (ox, oy), + self.mono_advance(), + self.fm.code_line_height(), + frame.size, + ) + } + + /// Whether a surface pixel is on the divider strip — the exact rect + /// that gets painted. + fn panel_divider_contains(&self, x: f32, y: f32) -> bool { + self.panel_divider_rect() + .is_some_and(|(rx, ry, rw, rh)| x >= rx && x < rx + rw && y >= ry && y < ry + rh) + } + /// Pixel rectangle of a cell run in the terminal grid. fn terminal_run_rect(&self, run: crate::terminal::CellRun) -> (f32, f32, f32, f32) { let (ox, oy) = Self::terminal_origin(); @@ -4966,7 +5437,8 @@ impl State { let cursor_line = line_starts .partition_point(|&s| s <= cursor) .saturating_sub(1); - let visible = estimated_visible_lines(self.config.height, self.fm).max(1); + let visible = + estimated_visible_lines(self.config.height, self.fm, self.band_inset()).max(1); let old = self.scroll_top; if cursor_line < self.scroll_top { self.scroll_top = cursor_line; @@ -5254,6 +5726,7 @@ impl State { self.config.width, self.config.height, self.fm, + self.band_inset(), ) } @@ -5297,9 +5770,11 @@ impl State { self.config.height, self.current_line_starts.len(), self.fm, + self.band_inset(), )?; - let centered = - target.saturating_sub(estimated_visible_lines(self.config.height, self.fm) / 2); + let centered = target.saturating_sub( + estimated_visible_lines(self.config.height, self.fm, self.band_inset()) / 2, + ); let delta = i64::try_from(centered).unwrap_or(i64::MAX) - i64::try_from(self.scroll_top).unwrap_or(i64::MAX); self.scroll_by_lines(delta) @@ -5883,7 +6358,7 @@ impl State { } readout.push_str(&format_scroll_indicator( self.scroll_top, - estimated_visible_lines(self.config.height, self.fm), + estimated_visible_lines(self.config.height, self.fm, self.band_inset()), self.current_line_starts.len(), cursor_row, )); @@ -6025,7 +6500,7 @@ impl State { .map_or(STATUS_BAND_BG, |(quad, _)| quad); let rect = MinimapRect { x: 0.0, - y: text_area_bottom(self.config.height, self.fm), + y: status_band_top(self.config.height, self.fm), w: self.config.width as f32, h: self.fm.status_band_height(), color, @@ -6120,7 +6595,7 @@ impl State { /// candidate-free, or too short for a row. See [`mb_dropdown_window`]. fn mb_visible_window(&self) -> Option<(usize, usize)> { let mb = self.minibuffer.as_ref()?; - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = status_band_top(self.config.height, self.fm); mb_dropdown_window( mb.candidates.len(), mb.selected.map_or(0, |s| s as usize), @@ -6144,7 +6619,7 @@ impl State { .map(|r| r.line_w) .fold(0.0_f32, f32::max); let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH); - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = status_band_top(self.config.height, self.fm); let top_y = band_top - count as f32 * self.fm.mb_drop_row_height(); Some((STATUS_TEXT_PAD, top_y, width)) } @@ -6235,7 +6710,7 @@ impl State { // caret-follow residual) counts as scrolled out. let (x, top, line_height) = self.code_byte_px(anchor)?; let y = TEXT_TOP + top; - let bottom = text_area_bottom(self.config.height, self.fm); + let bottom = document_text_bottom(self.config.height, self.fm, self.band_inset()); if y >= bottom || y + line_height <= TEXT_TOP { return None; } @@ -6257,7 +6732,7 @@ impl State { } let sel = comp.selected.map_or(0, |s| s as usize); let (ax, line_top, line_h) = self.completion_anchor_px()?; - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = document_text_bottom(self.config.height, self.fm, self.band_inset()); let below_px = band_top - (line_top + line_h); let above_px = line_top - TEXT_TOP; let max_below = (below_px / self.fm.mb_drop_row_height()).floor() as usize; @@ -6576,7 +7051,8 @@ impl State { let line_starts = &self.current_line_starts; let n = line_starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); - let span = estimated_visible_lines(self.config.height, self.fm).max(1) + SCROLL_OVERSCAN; + let span = estimated_visible_lines(self.config.height, self.fm, self.band_inset()).max(1) + + SCROLL_OVERSCAN; let vstart = line_starts[top]; let bottom = top.saturating_add(span).min(n); let vend = if bottom < n { @@ -6698,7 +7174,8 @@ impl State { let height = self.config.height as f32; let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height()); let code_width = (self.text_bounds_right() as f32 - self.text_left()).max(0.0); - let code_height = (text_area_bottom(self.config.height, fm) - TEXT_TOP).max(0.0); + let code_height = + (document_text_bottom(self.config.height, fm, self.band_inset()) - TEXT_TOP).max(0.0); let code_layout_changed = self.buffer.metrics() != code_metrics || self.buffer.size() != (Some(code_width), Some(code_height)); self.buffer.set_metrics_and_size( @@ -7251,7 +7728,7 @@ impl State { .map(|run| run.line_w) .fold(0.0_f32, f32::max); let status_left = self.config.width as f32 - STATUS_TEXT_PAD - status_width; - let status_top = text_area_bottom(self.config.height, self.fm) + let status_top = status_band_top(self.config.height, self.fm) + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the // main-text clip-left. Computed here as locals — calling `self.*` @@ -7291,7 +7768,8 @@ impl State { // Clip at the status band (Q#S3): a final // partially-visible line must not bleed // into the band. - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, default_color: Color::rgb(230, 230, 235), custom_glyphs: &[], @@ -7312,7 +7790,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height, self.fm).round() as i32, + top: status_band_top(self.config.height, self.fm).round() as i32, right: self.config.width.cast_signed(), bottom: self.config.height.cast_signed(), }, @@ -7329,7 +7807,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height, self.fm).round() as i32, + top: status_band_top(self.config.height, self.fm).round() as i32, // Stop at the right group's actual origin. right: status_left.max(0.0).round() as i32, bottom: self.config.height.cast_signed(), @@ -7359,7 +7837,8 @@ impl State { left: gutter_clip_left, top: 0, right: text_bounds_right, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, default_color: MATH_INK_COLOR, custom_glyphs: &[], @@ -7390,7 +7869,8 @@ impl State { left: 0, top: 0, right: gutter_clip_left, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, // Themes Q#TH5: ui.gutter's {fg} mask colors the digits. default_color: gutter_color, @@ -7468,7 +7948,7 @@ impl State { left: x as i32, top: top_y as i32, right: (x + width).round() as i32, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: status_band_top(self.config.height, self.fm).round() as i32, }, // Themes Q#TH5 (round 3 finding 1): the candidate // glyph layer is ui.minibuffer.candidate's GPU site; @@ -7531,6 +8011,10 @@ impl State { // its own cell origin and clipped to its declared footprint. // Per-run areas are the point: a row-wide area would let one // wide glyph's shaped advance shift every column after it. + // Hoisted out of the closure below: the band inset borrows `self` + // immutably, and the closure already holds one. + let document_clip_bottom = + document_text_bottom(self.config.height, self.fm, self.band_inset()).round() as i32; let terminal_areas: Vec