diff --git a/COHERENCE.md b/COHERENCE.md index 28400b7..dd4a16e 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -107,7 +107,7 @@ remain open to them. | 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance | | 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept | | 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | -| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; bottom panel landed (#155) | +| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; bottom panel complete on BOTH frontends (#155 + Stage 2) | | 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | | 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | @@ -1280,10 +1280,17 @@ Primitive-by-primitive against the list above: vocabulary is fixed Rust-side). - **Contextual popup** ✓ — completion popup, context menu (each a shadow, §6). -- **Bottom/side panel** ✓ — landed as bottom-panel Stage 1 (#155): +- **Bottom/side panel** ✓ — Stage 1 (#155) gave the substrate: `WindowParams` side/fixed_rows/dedicated, `display = "current" | "panel"` adopted by listview/compile/terminal, quit-action, divider - drag. Stage 2 (GPU band) pending its own framing. + drag. **Stage 2 completed it on the second frontend** (2A #177, 2B-1 + #184, 2B-2 #187, 2B-3): the GPU frontend now renders a real panel + band with its own divider, pointer routing, and resize drag, and a + semantic session that negotiates the panel wire is panel-capable. + Both frontends therefore share one placement primitive rather than + the GPU silently taking the Stage 1 non-side fallback. Stage 3 — the + adopter default flip, so omitting `display` resolves to the panel + policy — is the remaining step. - **Task/progress view** △ — `*workers*` exists but joins nothing (§9). - **Help view** △ — exists twice (§5); needs unification, not @@ -1366,10 +1373,15 @@ its asks are already practiced.** - Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate encoding-breaking bumps, both-frontends support required per bump, and byte-pin discipline for appended variants (handoff §4). The v21 - bottom-panel family landed with Stage 2B-1 (#184) and is reserved but - dark: because `Hello` is server-first, the production daemon still - advertises v20 so shipped v20 clients remain attachable; compatible - v21 activation belongs to Stage 2B-3. + bottom-panel family landed with Stage 2B-1 (#184) and is **live in + production since Stage 2B-3** — activated without an incompatible + handshake change. Because `Hello` is server-first, the daemon's + advertised version is now a permanent compatibility **baseline** + (still 20) and the session's real version is settled one message + later by the frontend's `AttachRequest` counter-offer. That split — + advertise the floor, negotiate up — is the reusable pattern for every + future additive family, and it means bumping the advertised version is + reserved for a change that cannot be expressed additively at all. - Two genuine frontends share the conceptual model; CRDT concurrent editing with presence across them; remote attach + reconnect. - **Graceful per-frontend degradation is practiced, not aspirational**: @@ -1569,10 +1581,12 @@ and §18's floor ride on this. ### Priority 5: Finish the workbench convergence -**State: partial and moving (§14) — bottom panel Stage 1 landed, GPU -band pending; listview proven.** Remaining: the tree primitive (build -it before dired and the worker tree invent two), table/inspector/diff, -help unification. Wiring plus one modest model piece (the tree model). +**State: partial and moving (§14) — the bottom panel is now complete on +both frontends (Stage 1 #155 through Stage 2B-3), and listview is +proven.** Only the adopter default flip (Stage 3) remains on the panel +itself. Remaining elsewhere: the tree primitive (build it before dired +and the worker tree invent two), table/inspector/diff, help unification. +Wiring plus one modest model piece (the tree model). ### Priority 6: Productize configuration diff --git a/docs/active-work.md b/docs/active-work.md index 1486e81..a863a4f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-28.** This file records volatile work that has not +**Snapshot: 2026-07-29.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -378,24 +378,133 @@ The contract is now cargo test --test terminal_copy_mode_acceptance --features crdt ``` -## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT +## Bottom-panel lane (Arc 7) — 2B-3 OPEN; Stage 2 is COMPLETE with it -Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are -all on `main`**. Framing revision 5's three-way split of 2B was -explicitly approved on 2026-07-27; revision 6 records PR #184's review -correction. **2B-2 — the daemon panel projection and epoch machine — -landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**, -one review round of five findings on top of the implementation, 12/12 -green, 22/22 mutations biting. Its durable lessons are in -`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan. +Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and Stage 2B-2 are +all on `main`. **Stage 2B-3 — the GPU panel band, compatible protocol-v21 +activation, and the negotiated `panel_capable` flip — is this lane's open +work**, and it completes Stage 2. Framing revision 5's three-way split of +2B was explicitly approved on 2026-07-27; revision 6 records PR #184's +review correction. -**2B-2's boundaries, restated because they are easy to overrun:** the -production `Hello` stays at v20 and `panel_capable` stays `false`. The -slice is dark/test-only capability exactly as 2B-1 was. Compatible v21 -activation, the GPU band, and the negotiated capability flip are all -2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to -21. +- **Branch `bottom-panel-stage2b3`, worktree `../pmacs-bp-2b3`, cut fresh + from landed `main` @ `e003b81`** (`Merge pull request #190 from + levineuwirth/resource-op-delete-guard-impl`) — measured with + `git log --oneline -1 githubsucks/main`, not carried over from a + briefing. Not stacked on `../pmacs-bp-stage2b2`: each slice starts from + landed main, which is the house pattern and what 2B-2 did. +- **The activation mechanism, and why the alternatives fail.** The + constraint inherited from 2B-1's review is that the unsolicited `Hello` + may not become 21: it is server-first, so a shipped v20 frontend rejects + an unknown version *before* it can send `AttachRequest`. What 2B-3 ships + instead is a **frontend counter-offer**: + `ADVERTISED_PROTOCOL_VERSION` becomes a permanent compatibility + *baseline* (20), the frontend answers + `requested_protocol_version(baseline)` — its own `PROTOCOL_VERSION` when + the baseline is current, a verbatim echo of anything older — and the + daemon records `negotiated_session_version(offer)`. The daemon needed + **no change** to accept it; it already recorded `req.protocol_version`. + Rejected alternatives, each for a reason the server-first shape forces: + growing `Hello`/`InstanceCapabilities` (postcard structs are positional, + so it breaks the *other* direction symmetrically and cannot be verified + against shipped binaries); a client-first hint (an old client writes + nothing until it has read `Hello`, so any probe is a timeout); + sniffing `instance_identity.pmacs_version` (version-string branching on + a field documented as display-only); and a second post-`Hello` daemon + message (an old client reads it as an `InstanceMessage` and dies on the + unknown discriminant). +- **The window this leaves open is named, not hidden.** A daemon whose own + `PROTOCOL_VERSION` equals the baseline rejects an offer above its + supported range. A single `AttachRequest` cannot mean both "I want 21" + and "≤ 20", so compatibility is preserved for old *frontends* — the + direction that matters, since the daemon is what a user leaves running — + and the window closes on the next daemon restart. It surfaces as an + explicit `GoodbyeReason::VersionMismatch` naming both versions, pinned by + `an_unsupported_offer_is_refused_by_name`. +- **Review round 1 closed six findings at `PR #198`, four of them one + shape.** The panel layer was a *partial port* of the document/terminal + layer and the new tests asserted the declaration side only, so each + omission was invisible. Audited as a port rather than patched as a list, + which found two more gaps of the same shape that review had not named: + the headless probe never armed the panel wire at all (so nothing could + ever exercise a band), and a disconnect left the band on screen. + - **Geometry agreed in one place instead of three.** The declaration used + the stable probe while painting and hit-testing used the + document-dependent `mono_advance`, and the declaration inset its width + by `TEXT_LEFT` against the parent framing's explicit x=0 full-width + contract. The advance is now cached **behind the declaration** + (`PanelBand::declared_advance`), so the three cannot drift: there is one + value. Framing rev 7 R7-1/R7-2 records the widened contracts. + - **One classifier owns "does the band claim this pixel".** Four pointer + handlers each decided for themselves and three simply did not ask, so + right-click and wheel were applied to the document underneath and a held + left button was reported as a hover. `PointerSurface` / + `classify_pointer_surface` makes forgetting the band impossible to do + quietly, and makes the routing testable without a window or a daemon. + - **`focused` decides the caret.** The producer ships `cursor` for a + passive panel too, so painting it unconditionally put a second insertion + caret on screen. + - **Planned underlines were never consumed.** Straight forms now ride the + quad batch, curly the squiggle pipeline — the terminal path's split. + - **`VersionMismatch { server }` is the instance's `PROTOCOL_VERSION`.** + Reporting the advertised baseline told a frontend the daemon topped out + at 20 while it spoke 21. **The acceptance had pinned the wrong value**, + so the test was holding the bug in place; fixed and re-pinned. + - **Criterion 54 is satisfied, not caveated.** The panel-hosted probe is + `a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal` + — one real daemon, one real PTY child, real wgpu, through + `display = "panel"`. Opening a terminal and *moving* it was the first + attempt and is subtly wrong: the buffer ends up displayed twice and the + document window keeps projecting it, so the acceptance cannot tell a + panel-hosted child from a document one. +- **Round 1 verification: 41 bites, 41 OK.** The original 23 re-run after the + round-1 changes (none went vacuous) plus 18 new ones covering every changed + pin. The forbidden act is bited twice: mutating the constant trips a + const-block assertion at *build* time (stronger outcome, weaker evidence), + so a narrowed swap that compiles both ways mutates the daemon's `Hello` + value directly. **A probe bite must rebuild `pmacs-gpu`** — `cargo test` + does not rebuild the sibling binary the acceptance runs as a process, and + without that the mutation never reaches it and the bite reads as vacuous + for the wrong reason. +- **What 12/12 green did not catch.** The reviewer confirmed all twelve + checks, `bottom_panel_stage2b_gpu_acceptance` 4/4, and required GPU 216/216 + on the head that carried all six defects. The gate suite is not the check + on a slice like this; the assertions are. +- **Six commits:** the activation mechanism, the three-boundary split plus + the band's machinery, the paint/input wiring, the acceptance suites, the + bite fixes, and the round-1 port completion — plus docs. +- **Verification on the reviewed head** (all redirected to files and + checked by their own exit codes, never through a pipe): recorded in the + PR body and reproduced by the commands in `CLAUDE.md`. +- **23 falsifying mutations, all executed, all biting**, using an in-place + mutation runner rather than `scripts/bite` because several claims live in + the *same file* as their test and a whole-file swap would not compile. + **Three assertions were VACUOUS on the first pass and the mutation runs + are what found them** — a fixed-point contrast check, a pixel test that + passed with the band painting nothing, and a monospace fixture that could + not tell two glyph-advance derivations apart. A fourth finding was about + the code, not the tests: "no panel frame reaches a v20 session" is + defence in depth, not the placement gate, and passes with the capability + gate removed entirely. +- **Two real defects the new tests caught in the implementation**, both + recorded in `docs/agent-handoff.md` §1: `edge_scroll_direction` has no + upper bound, so reclassifying its boundary was necessary but not + sufficient; and `apply_panel_payload` ignored the exhaustion latch. +- **Cross-machine recovery (fresh clone):** + ```sh + git fetch githubsucks --prune + git switch --track -c bottom-panel-stage2b3 githubsucks/bottom-panel-stage2b3 + git rev-parse HEAD + ``` + +- **Stage 3 (the adopter default flip) is the arc's last step** and is not + started. This lane is not removed at 2B-3's merge: Stage 3 remains ahead + of it. + +- **Stage 2B-2 MERGED as #187.** One review round of five findings on top + of the implementation, 12/12 green, 22/22 mutations biting. Its durable + lessons are in `docs/agent-handoff.md` §1. - **What PR #187 shipped, dark by construction:** the semantic daemon's `FrontendCellGeometry` epoch machine; one reconciled panel grid derivation; `PanelFrame::{Present, Absent}` projection on both document @@ -441,7 +550,7 @@ activation, the GPU band, and the negotiated capability flip are all ``` #187 has landed, so `githubsucks/main` already contains this work and - the branch is retained only for provenance. Start 2B-3 from `main`. + the branch is retained only for provenance. 2B-3 was cut from `main`. - **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all twelve checks green on the reviewed head `5539b6e`; two review rounds plus a gate-found follow-up). Branch @@ -455,11 +564,11 @@ activation, the GPU band, and the negotiated capability flip are all `panel_capable` is still `false` for every semantic session, so the journey grade is unchanged and every shipped v20 client remains attachable. -- **2B-3 inherits a hard constraint from 2B-1's review**: it owns a - *compatibility-preserving* v21 activation mechanism and may **not** - simply change the unsolicited `Hello` to 21. The handshake is - server-first, so that one-line change locks out every shipped v20 - frontend before it can even send an `AttachRequest`. +- **2B-3 discharged the hard constraint 2B-1's review set**: the + *compatibility-preserving* activation is the frontend counter-offer + described at the top of this lane, and the unsolicited `Hello` is + unchanged. 2B-1's real-daemon acceptance for the v20 rejection point + still passes untouched. - **Two review rounds, and what each cost.** Round 1: `PanelFrame` needed an explicit `buffer_id`, the transport ratchet had to drive the real attach path rather than a detached codec assertion, and shared diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 2c39a36..e381de0 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the +**Last updated: 2026-07-29, as bottom-panel Stage 2B-3 — the GPU panel +band, compatible protocol-v21 activation, and the negotiated +`panel_capable` flip, completing Arc 7 Stage 2 — opens atop `e003b81`. +Beneath it, bottom-panel Stage 2B-2 (#187) — the daemon panel projection and the epoch machine — lands on `6c9e765`, which is the dired Stage 2 framing (#171) atop the resource-op delete guard framing (#186). Those two are framing-only: both are approved @@ -77,14 +80,13 @@ commands, read `docs/active-work.md` immediately after this file. #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). - **Protocol schema support is now `v6..=v21`, while the production - server-first `Hello` still advertises v20.** Those are two different - facts and #184 landed only the first: the v21 bottom-panel wire family - exists, is gated in both directions, and has no producer, no consumer, - and no capability behind it. Compatible v21 activation and the - production advertisement move belong to Stage 2B-3. The bullets below - describe the arcs in their own terms; this line is the head-of-`main` - anchor. + **Protocol schema support is `v6..=v21`, the production server-first + `Hello` advertises v20, and a current session nevertheless negotiates + v21.** All three are true at once, and Stage 2B-3 is what made them + compatible: the advertised version is a permanent **baseline** and the + session's real version is settled one message later by the frontend's + `AttachRequest` counter-offer. The bullets below describe the arcs in + their own terms; this line is the head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -457,14 +459,17 @@ commands, read `docs/active-work.md` immediately after this file. unchanged, which is the additivity gate for the `read_dir` change; M4 121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 across 93 suites. 15 claims bite-verified. -- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 = +- Canonical `main` is protocol **v21** (`SUPPORTED=[6..=21]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = the GPU initial-target semantic - bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21 - (`SUPPORTED=[6..=21]`), but its production daemon deliberately - advertises v20: the handshake is server-first, so advertising 21 - would make shipped v20 GPU/TUI clients reject before - `AttachRequest`. Stage 2B-3 owns compatible production activation. + bootstrap family, v21 = the bottom-panel band family). + **`ADVERTISED_PROTOCOL_VERSION` stays 20, permanently, and that is the + activation mechanism rather than a hedge** — see the Stage 2B-3 bullet. + The rule for every future additive family: advertise the baseline, + negotiate up from the frontend's `AttachRequest`. Moving the advertised + version is reserved for a change that cannot be expressed additively at + all, because a server-first `Hello` reaches a shipped frontend before it + can identify itself. - **Bottom panel Stage 1 (window placement + TUI side windows) LANDED — #155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now @@ -529,23 +534,21 @@ commands, read `docs/active-work.md` immediately after this file. `bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - - **Stage 2 (the GPU panel band) is FRAMED; its first two slices have - LANDED and its third is open as PR #187** — + - **Stage 2 (the GPU panel band) is COMPLETE: 2A #177, 2B-1 #184, + 2B-2 #187, and 2B-3 (this lane)** — `docs/bottom-panel-stage2-framing.md` rev 6, four framing review rounds, no open framing items; the rev-5 implementation split was explicitly approved 2026-07-27 and rev 6 records PR #184's server-first compatibility and gate correction. - It reserves protocol **v21** and ships as four serial + It reserved protocol **v21** and shipped as four serial implementation slices: **2A** classified census routing + per-window painter extraction (no wire change, #177), **2B-1** the wire (#184), **2B-2** the daemon projection and epoch machine - (implemented but not landed in PR #187), then **2B-3** the GPU band, - compatible v21 activation, and the - negotiated `panel_capable` flip. Production attachment remains v20 - through 2B-2. Parent acceptance 37–55 remains authoritative. - Stage 3 is the adopter default flip. **Do not start 2B-3 until PR - #187 lands.** Its branch, checkpoints, two review rounds, verification, - and exact recovery commands live in `docs/active-work.md`. + (#187), then **2B-3** the GPU band, compatible v21 activation, and + the negotiated `panel_capable` flip. Production attachment stayed + v20 through 2B-2 and negotiates v21 from 2B-3 on. Parent acceptance + 37–55 remains authoritative. **Stage 3, the adopter default flip, is + the arc's remaining step.** - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the Projection class (#1–#12, #21–#22) routes through `primary_document_window`; focus/input (#13–#15, #23), focus chrome @@ -567,9 +570,10 @@ commands, read `docs/active-work.md` immediately after this file. version is therefore an incompatible act on its own, independent of whether any new message is ever sent. A real-daemon acceptance emulates that exact rejection point and then requires the - attachment to reach its initial grid. **2B-3 must ship a - compatibility-preserving activation mechanism; it may not simply - change the unsolicited `Hello` to 21.** + attachment to reach its initial grid. **Stage 2B-3 discharged this + without touching the unsolicited `Hello`** — the advertisement is a + permanent baseline and the frontend counter-offers; see its bullet + below. That acceptance still passes unchanged, which is the point. - **One shared grid validator, split along a stated boundary** (`pmacs-protocol/src/wire_grid.rs`). Shared: checked area, the visible-cell bound (262,144), cell count, cursor bounds, glyph @@ -650,7 +654,91 @@ commands, read `docs/active-work.md` immediately after this file. stays a per-frame `Absent`, flagged rather than hidden, because making it durable needs a new "presentation permanently unavailable" reason in `FrontendView` for a state requiring 2^64 - presentations in one session. + presentations in one session. **Stage 2B-3 did not make it cheap** — + the frontend half latches instead, which is a different remedy, so + the daemon-side hole stays open as recorded. +- **Bottom panel Stage 2B-3 (the GPU band, compatible v21 activation, + and the negotiated capability flip) — the arc's Stage 2 is COMPLETE.** + This is the slice a user can see: a semantic GPU session now renders a + real panel band instead of taking the Stage 1 non-side fallback, which + is what closes the journey-steps-7–10 divergence §6 of the Stage 2 + framing names. Durable facts: + - **A server-first handshake is negotiated from the CLIENT side, not by + moving the advertisement.** `ADVERTISED_PROTOCOL_VERSION` is now a + permanent compatibility *baseline*; the frontend answers + `requested_protocol_version(baseline)` — its own `PROTOCOL_VERSION` + when the baseline is the current one, a verbatim echo of anything + older — and the daemon records + `negotiated_session_version(offer)`. A shipped baseline frontend + echoes and gets a baseline session, byte-for-byte as before; a + current frontend offers up and gets the current wire. The `Hello` + encoding and value never change, which is why the old frontend never + sees a version it must reject. **The daemon needed no change to + accept the offer** — it already recorded `req.protocol_version` as + the negotiated version, so the whole mechanism is one value the + frontend chooses plus a documented clamp. + - **The window this leaves open, named rather than hidden:** a daemon + whose own `PROTOCOL_VERSION` equals the baseline rejects an offer + above its supported range. Compatibility can be preserved for old + *frontends* or old *daemons* — a single `AttachRequest` cannot mean + both "I want 21" and "≤ 20" — and the frontend direction is the one + that matters, because the daemon is what a user leaves running. It + closes on the next daemon restart and surfaces as an explicit + `GoodbyeReason::VersionMismatch` naming both versions. + - **`server_protocol_version` split into two facts on the GPU client**: + `session_protocol_version` (what the session speaks — every wire gate + keys on this) and `baseline_protocol_version` (what `Hello` said). + They now DIFFER in the normal case, and that difference *is* the + compatibility property, so both headless probe reports emit both and + the two ratchets that read them assert both directions. Asserting + only the session version would pass if the baseline had been bumped + too, which is the exact incompatible change the mechanism avoids. + - **The GPU document bottom is three boundaries and each call site was + classified individually** — 20 production sites (8 status-owned, 12 + document-owned), 1 definition, 8 test sites, 29 matches, arithmetic + stated. `geometry_capacity_bottom` reserves the divider *while the + panel is absent* (that asymmetry is what breaks the first-open cycle) + while `document_text_bottom` costs the document nothing until a + `Present` frame paints. + - **Moving a boundary is not always sufficient.** `edge_scroll_direction` + has no upper bound, so a pixel *inside* the band still read as + "further down the document" and armed the document's auto-scroll — + the exact named symptom, surviving a correct reclassification. Any + consumer that treats "past the bottom" as unbounded needs the band as + an explicit exclusion, not just a moved boundary. + - **Three of the first-pass assertions were VACUOUS, and the mutation + runs are what found them.** (a) The contrast assertion compared + `status_band_top` before and after — a *fixed point* — so the blanket + rewrite it exists to prevent moved both readings together and passed; + it is now anchored to an independent formula. (b) The criterion-46 + pixel test passed with the band painting *nothing*, because + installing a panel reshapes the document and that produced the whole + diff; it now counts differing pixels in the divider and cell rows. + (c) The probe-versus-document-advance fixture compared two ASCII + documents, which in a monospace family have identical advances. + - **"No panel frame reaches a v20 session" is defence in depth, not the + placement gate.** The producer's peer flag and the write-loop filter + both suppress `PanelFrame` below the panel version independently of + `panel_capable`, so that assertion passes with the capability gate + removed entirely. The load-bearing claim is *placement*: the adopter's + buffer must land in the pre-panel session's own document window, + because a side window it cannot render is simply invisible. + - `PanelBand::presented()` is the ONE frontend-side derivation of "is a + band on screen" — retained valid frame, matching `geometry_epoch`, + latch clear — behind the band inset, the painter, the hit-tester, and + the drag. A latched frontend also stops absorbing payloads, so + `presented()` is not the only thing between a disowned declaration and + a painted band. + - Panel columns come from the **stable normal-face probe**, never + `mono_advance`'s document-glyph fallback, and `panel_cell_capacity` + carries the daemon's **virtual status row** (`frontend_area_rows` is + `total.rows - 1`) with **no per-axis cap**, because a panel may + legitimately be wider than a PTY. + - `PANEL_MIN_VERSION` moved into `pmacs-protocol` so the GPU frontend + aliases one definition instead of restating 21. + - **Stage 3 (the adopter default flip) is the arc's last step**, and it + is the only thing between today's state and omitting `display` + resolving to the panel policy. - **dired Stage 2 framing LANDED (document only) — #171** (`docs/dired-stage2-framing.md`, revision 9; seven review rounds). **Approved as a framing; no runtime code and no implementation diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index b3ce129..6f408be 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,7 +1,7 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 6 — PR #184 review correction; the underlying Stage 2 -framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both +**Revision 7 — Stage 2B-3 review round 1 corrections; the underlying +Stage 2 framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both merged, and 2B-2 is the next slice. Ground truth: canonical `main` @ `6bee09d`, where protocol schema support is `v6..=v21` while the production server-first handshake still deliberately advertises v20.** @@ -35,6 +35,52 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history +### 0.-1 Rev 6 → rev 7 — Stage 2B-3 review round 1, three stated +contracts corrected + +- **R7-1 (P1) — the stable-probe decision covers all THREE cell + computations, not only the declaration.** §5.3 decided that the panel + geometry *declaration* resolves its advance from + `probe_mono_advance` rather than `State::mono_advance`. That wording + let an implementation declare with the probe while *painting* and + *hit-testing* with the document-dependent advance — which is what + happened, and which means the daemon's column count, the painted + column positions, and the cell a click resolves to are three + different grids. The decision now reads: **the panel's advance is + resolved once, cached behind the declaration, and used by the + declaration, the painter, and the hit test.** They agree by + construction rather than by three call sites choosing correctly. + A test that asserts only the declaration cannot see this, so §7.2.3 + A2B-3 gains the painting and hit-testing halves explicitly. +- **R7-2 (P1) — the x = 0 full-width contract governs the band RECT, + not only `total.cols`.** Parent Q#BP15a already states that + `total.cols` describes "the full-width panel grid beginning at x=0; + document `TEXT_LEFT`/gutter padding is unrelated". Rev 6 left that + as a statement about the *declaration*, and an implementation + inset both the declared width and the painted rect by `TEXT_LEFT`. + The contract is restated here as covering the band's content + rectangle: **origin x = 0, full surface width**, with the fractional + right-edge remainder painted as band background that maps to no cell + and emits no `PanelPointer`. +- **R7-3 (P2) — `GoodbyeReason::VersionMismatch { server }` is the + instance's `PROTOCOL_VERSION`, never the advertised baseline.** Once + §3.5's activation split the two, a daemon reporting its baseline told + a frontend it topped out at 20 while it in fact spoke 21 — the + inverse of the upgrade advice the reason exists to give. The wire + field's own documentation already said "the instance's + `PROTOCOL_VERSION`"; this records that the split makes it load-bearing + rather than incidental. A *frontend* raising the same reason locally + can only report the baseline it was handed. +- **R7-4 — criterion 54's gate is named.** The panel-hosted + `--headless-probe` run lives in + `bottom_panel_stage2b_gpu_acceptance` as + `a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`, + and it drives the real `display = "panel"` adopter opt-in rather than + opening a terminal and moving it. Opening first and moving after + leaves the buffer displayed twice, the document window still + projecting it as a full-window terminal, and the acceptance unable to + tell a panel-hosted child from a document one. + ### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed - **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The @@ -843,7 +889,21 @@ attach. driving selection, terminal mouse reporting, and click-to-focus without disturbing the document mirror), **54** (the `--headless-probe` run: one real daemon, real PTY, real wgpu, through - a panel-hosted terminal), **55**. + a panel-hosted terminal — see rev 7 R7-4 for the exact fixture), + **55**. + **48 is the whole gesture set, and a partial port of it is invisible** + (rev 7): `Move` alone satisfies nothing. A left press must ARM the + gesture so motion becomes `Drag(Left)`; a release outside the band + must still end the gesture the press began, at the last reported cell, + or the daemon holds a button down forever; right-click and wheel must + consult the band before the document, or they are applied to the + surface underneath. Every handler resolves the band through **one** + classifier, because four handlers each deciding for themselves is how + three of them came to skip the question. A **passive** panel's caret is + not painted: the producer ships `cursor` for it too, so `focused` is + what decides. Planned underlines must actually render — straight forms + on the quad batch, curly on the squiggle pipeline, as in the terminal + path. - **41, the GPU half:** the pixel→cell conversion pinned at fractional widths and heights, and geometry refresh on window resize, font change, and scale change. @@ -858,11 +918,18 @@ attach. neither paints nor hit-tests until a matching `Present` arrives. This is the case daemon value dedup cannot see and is why option 1 was chosen. -- **A2B-3.** Panel columns are derived from the **stable normal-face +- **A2B-3.** Panel cells are derived from the **stable normal-face probe**, not `State::mono_advance`'s document-glyph fallback: two GPU frontends with identical metrics and different documents derive identical `total.cols`, and a probe returning `None` declares zero usable geometry rather than falling back to a document sample. + **All three consumers are asserted, not just the declaration** (rev 7 + R7-1): the declared total, the pixel a run is painted at, and the cell + a click hit-tests to must all follow the same advance, and the fixture + must make the two candidate advances differ so each assertion + discriminates. The band's rect is asserted at **x = 0 across the full + surface width**, with the right-edge remainder resolving to no cell + (R7-2). - **A2B-4 (contrast assertion).** Installing a panel moves **all twelve document-owned consumers** of §5.3 by exactly `installed_panel_height + divider_height`, **while all eight diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 7dd97a9..06f91bd 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; @@ -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, } } @@ -557,8 +581,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 +710,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 +892,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 +962,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 +992,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 +1011,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, @@ -980,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` @@ -996,9 +1112,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 +1514,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 @@ -1506,8 +1636,12 @@ mod tests { let socket = temp.path().join("managed.sock"); let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair"); let server = thread::spawn(move || { + // Advertises the BASELINE, like a real daemon: the session + // version is settled by the client's counter-offer below, and a + // fixture that advertised the current wire could not tell the + // two apart. let hello = Hello { - protocol_version: PROTOCOL_VERSION, + protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, assigned_frontend_id: FrontendId::LOCAL, instance_identity: InstanceIdentity { pmacs_version: "managed-retry-test".to_owned(), @@ -1546,7 +1680,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..4ee1552 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; @@ -749,11 +766,24 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { } }; state.set_frontend_id(client.frontend_id()); + // The panel wire is part of the real client, so the probe arms it exactly + // as the winit path does. Leaving it out is why nothing could exercise a + // panel-hosted terminal: with no declaration the daemon has no columns, + // and with no columns no panel is ever presentable. + state.set_panel_wire(client.session_protocol_version()); 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() }; + if let Some((geometry_epoch, total)) = state.next_geometry_declaration(GeometryTrigger::Surface) + && client + .send_frontend_cell_geometry(geometry_epoch, total) + .is_ok() + { + facts.panel_declarations += 1; + } // Ask the daemon to open the acceptance terminal in THIS frontend's // window. Going through a real key press is the point: the daemon's @@ -787,6 +817,13 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT") .ok() .filter(|value| !value.is_empty()); + // A panel fixture's evidence is text in the BAND, not in a full-window + // terminal. Naming it separately keeps one fixture's breadcrumb from + // satisfying another's loop exit — the leak that made a Vterm probe pass + // on its safety deadline. + let expected_panel_text = std::env::var("PMACS_GPU_PROBE_EXPECT_PANEL_TEXT") + .ok() + .filter(|value| !value.is_empty()); let quiet = observe_window.is_some(); let deadline = std::time::Instant::now() + observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20)); @@ -811,6 +848,26 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.last_frame_text = frame_probe_text(frame); facts.last_title.clone_from(&frame.title); } + match msg.as_ref() { + InstanceMessage::PanelFrame( + pmacs_protocol::panel::PanelFramePayload::Present(frame), + ) => { + facts.panel_frames += 1; + facts.panel_rows = frame.size.rows; + facts.panel_cols = frame.size.cols; + facts.panel_focused = frame.focused; + facts.panel_frame_text = grid_probe_text(&frame.cells); + if let Some(expected) = expected_panel_text.as_deref() + && facts.panel_frame_text.contains(expected) + { + facts.panel_text_observed = true; + } + } + InstanceMessage::PanelFrame( + pmacs_protocol::panel::PanelFramePayload::Absent, + ) => facts.panel_absent_observed = true, + _ => {} + } state.apply_attach_message(*msg); if is_snapshot { // The dual declaration: a byte viewport for a @@ -866,6 +923,40 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } + // A panel fixture's document window is NOT a terminal — the + // terminal lives in the band — so the arm above never fires + // and its resize/composite evidence never arrives. The band + // gets the same treatment against its own observations. + if state.panel.presented().is_some() { + let pixels = state.render_offscreen(); + let first = pixels.first().copied().unwrap_or_default(); + if pixels.iter().any(|&b| b != first) { + facts.rendered_nonuniform_frames += 1; + } + if !quiet && !sent_input && facts.panel_frames >= 1 { + sent_input = true; + let _ = + client.send_key(ProtocolKey::Char(PROBE_INPUT_CHAR), Modifiers::NONE); + let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); + } + if !quiet && !sent_resize && facts.panel_frames >= 2 { + sent_resize = true; + state.resize(700, 500); + if let Some((geometry_epoch, total)) = + state.next_geometry_declaration(GeometryTrigger::Surface) + && client + .send_frontend_cell_geometry(geometry_epoch, total) + .is_ok() + { + facts.panel_declarations += 1; + facts.panel_resized_cols = total.cols; + } + } + if facts.panel_resized_cols > 0 && facts.panel_cols == facts.panel_resized_cols + { + facts.panel_observed_resized_frame = true; + } + } let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else( || facts.input_echo_observed, |expected| facts.last_frame_text.contains(expected), @@ -874,7 +965,21 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { // first: that races the fixture's required PTY evidence and // produces a self-contradictory "successful" probe report // whose later acceptance assertion must reject it. - if !quiet + if expected_panel_text.is_some() { + // The panel fixture's completion, stated in its own terms. + // `panel_text_observed` alone is not enough: it would let a + // pass happen before the band ever composited or the resize + // round-tripped, and this acceptance is exactly about the + // band being real. + if !quiet + && facts.panel_text_observed + && facts.panel_observed_resized_frame + && facts.rendered_nonuniform_frames >= 2 + { + completion_observed = true; + break; + } + } else if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 && fixture_evidence_observed @@ -889,8 +994,31 @@ 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, "panel_declarations={}", facts.panel_declarations); + let _ = writeln!(out, "panel_frames={}", facts.panel_frames); + let _ = writeln!(out, "panel_resized_cols={}", facts.panel_resized_cols); + let _ = writeln!( + out, + "panel_observed_resized_frame={}", + facts.panel_observed_resized_frame + ); + let _ = writeln!(out, "panel_rows={}", facts.panel_rows); + let _ = writeln!(out, "panel_cols={}", facts.panel_cols); + let _ = writeln!(out, "panel_focused={}", facts.panel_focused); + let _ = writeln!(out, "panel_text_observed={}", facts.panel_text_observed); + let _ = writeln!(out, "panel_absent_observed={}", facts.panel_absent_observed); + let _ = writeln!( + out, + "panel_frame_text_hex={}", + hex_bytes(facts.panel_frame_text.as_bytes()) ); let _ = writeln!(out, "declarations={}", facts.declarations); let _ = writeln!(out, "frames={}", facts.frames); @@ -974,7 +1102,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 +1127,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1028,6 +1158,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1061,6 +1192,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1081,6 +1213,7 @@ fn run_headless_managed_probe( report, "complete", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1138,6 +1271,7 @@ fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, + baseline: u32, daemon: &attach::ManagedDaemonFacts, buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, @@ -1146,7 +1280,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!( @@ -1179,9 +1314,43 @@ fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> { } /// Named observations the headless probe reports back to the acceptance. +#[allow( + clippy::struct_excessive_bools, + reason = "a flat report of independently-latched observations, not a state machine" +)] #[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, + /// How many `Present` panel frames the daemon shipped. + panel_frames: u32, + /// The last `Present` band's grid, and whether it owned focus. + panel_rows: u32, + panel_cols: u32, + panel_focused: bool, + /// The band's own text, so an acceptance can prove the PTY child's output + /// landed IN THE PANEL rather than in a full-window document terminal. + panel_frame_text: String, + /// Latched across frames: a reflow can push the breadcrumb off the last + /// band, so "it arrived" and "it is still on the final band" are + /// different questions and only the first is what the fixture means. + panel_text_observed: bool, + /// Whether an authoritative `Absent` was seen. + panel_absent_observed: bool, + /// The panel geometry declarations this probe sent. + panel_declarations: u32, + /// The band's grid after the probe's resize, and whether a band at that + /// exact width was actually observed afterwards. + panel_resized_cols: u32, + panel_observed_resized_frame: bool, declarations: u32, frames: u32, rendered_nonuniform_frames: u32, @@ -1209,8 +1378,14 @@ const PROBE_INPUT_CHAR: char = 'x'; /// One-line printable text of a terminal frame, for probe reporting. fn frame_probe_text(frame: &TerminalFrame) -> String { + grid_probe_text(&frame.cells) +} + +/// The same flattening for any wire cell grid, so a panel frame and a +/// terminal frame are read the same way rather than by two near-copies. +fn grid_probe_text(cells: &[pmacs_protocol::Cell]) -> String { let mut text = String::new(); - for cell in &frame.cells { + for cell in cells { match &cell.glyph { pmacs_protocol::Glyph::Char(ch) => text.push(*ch), pmacs_protocol::Glyph::Cluster(bytes) => { @@ -1764,6 +1939,26 @@ 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, + /// Set when a font/scale transaction has invalidated the panel's + /// geometry declaration, so the caller that owns the client knows to + /// re-declare under a `Metrics` trigger. + /// + /// A flag rather than a direct send because `apply_message` cannot + /// reach the attach client, and because the distinction it carries — + /// `Metrics` versus `Surface` — is exactly what stops an identical + /// `CellSize` from being deduped away. + panel_metrics_changed: 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. @@ -1784,6 +1979,8 @@ struct State { /// layer with terminal clipping rather than through the document /// text pass. terminal_text_renderer: TextRenderer, + /// The band's own glyph layer. + panel_text_renderer: TextRenderer, } /// Vterm Stage 3 — the GPU's terminal mode. @@ -1797,6 +1994,154 @@ 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, +} + +/// One `PanelResizeRows` request a drag has decided to make. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PanelResizeRequest { + geometry_epoch: u64, + panel_epoch: u64, + rows: u32, +} + +/// Which surface a pointer pixel belongs to (Q#BP16). +/// +/// **One authority for "does the band claim this pixel", consulted by every +/// pointer handler.** Four handlers route gestures — motion, left +/// press/release, right press, and wheel — and the band has to be consulted +/// first in all four. When each handler decided for itself, three of them +/// simply did not ask, so right-click and wheel fell through to the document +/// underneath and a held left button was reported as a hover. A single +/// classifier makes forgetting the band impossible to do quietly, and makes +/// the routing testable without a window or a daemon. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PointerSurface { + /// The divider strip: a drag handle, never a cell gesture. + PanelDivider, + /// A cell inside the band. + PanelCell(CellCoord), + /// The band's fractional right-edge remainder, or anywhere else in the + /// band that maps to no cell. Distinct from `Elsewhere` because the band + /// still owns the pixel — it just emits no `PanelPointer`. + PanelBackground, + /// Not the band: the document, the terminal, the minimap, or the chrome. + Elsewhere, +} + +/// 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). +#[derive(Default)] +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, + /// The advance the declaration was computed with, retained so painting + /// and hit-testing resolve cells with the **same** number the daemon's + /// column count was derived from. + /// + /// Caching it is not an optimization. The declaration needs + /// `&mut FontSystem` to shape its probe, so a `&self` painter cannot + /// re-derive it and would reach for `mono_advance` — which is + /// document-dependent. The declared grid, the painted grid, and the + /// hit-tested grid would then be three different grids, and a test that + /// asserted only the declaration would not see it. One value behind the + /// declaration makes all three agree *by construction*. + declared_advance: 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 a left-button gesture that started INSIDE the band is still + /// held. Mirrors `pointer_drag_active` for the document: without it a + /// motion event cannot tell a hover from a drag, so `Drag(Left)` is never + /// emitted and panel selection cannot work at all. + pointer_held: bool, + /// Last cell a panel pointer gesture reported, so sub-cell motion does + /// not become pixel-rate wire traffic. Reset on every press and release, + /// because the first drag after a press must reach the daemon even at the + /// cell the press landed on. + last_pointer_cell: 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 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 @@ -1993,14 +2338,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 +2353,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 +2375,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 +2392,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 { @@ -2062,6 +2407,142 @@ impl App { } } + /// Ship a `FrontendCellGeometry` if this trigger calls for one + /// (Q#BP15a). + /// + /// The decision is `State`'s; only the send is here, because only this + /// side owns the attach client. Gated on the NEGOTIATED session + /// version — the `Hello` baseline stays at the compatibility floor + /// permanently, so gating on it would leave the band dark forever. + fn flush_panel_geometry(&mut self, trigger: GeometryTrigger) { + let Some(client) = self.attach_client.as_ref() else { + return; + }; + if client.session_protocol_version() < PANEL_MIN_VERSION { + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + let Some((geometry_epoch, total)) = state.next_geometry_declaration(trigger) else { + return; + }; + if let Err(e) = client.send_frontend_cell_geometry(geometry_epoch, total) { + eprintln!("pmacs-gpu: send_frontend_cell_geometry failed: {e}"); + } + } + + /// The panel cell a pixel is over, if the band can take a gesture at all. + fn panel_pointer_hit(&self, x: f64, y: f64) -> Option<(u64, u64, BufferId, CellCoord)> { + let client = self.attach_client.as_ref()?; + if client.session_protocol_version() < PANEL_MIN_VERSION { + return None; + } + let state = self.state.as_ref()?; + let frame = state.panel.presented()?; + let coord = state.panel_hit_test(x as f32, y as f32)?; + Some(( + frame.geometry_epoch, + frame.panel_epoch, + frame.buffer_id, + coord, + )) + } + + /// Ship one panel gesture at `(x, y)`, reporting whether the band claimed + /// it. + fn send_panel_pointer_at( + &mut self, + x: f64, + y: f64, + kind: ProtocolMouseKind, + mods: Modifiers, + ) -> bool { + let Some((geometry_epoch, panel_epoch, buffer_id, coord)) = self.panel_pointer_hit(x, y) + else { + return false; + }; + let Some(client) = self.attach_client.as_ref() else { + return false; + }; + if let Err(e) = + client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods) + { + eprintln!("pmacs-gpu: send_panel_pointer failed: {e}"); + } + true + } + + /// Ship a panel gesture at an explicitly chosen cell. + /// + /// Used for a release, whose cell may be the last one reported rather than + /// the one under the pointer: a panel selection drag routinely ends past + /// the band's edge, and dropping that release leaves the daemon holding a + /// button down forever. The terminal path drops such a release; the panel + /// must not. + fn send_panel_pointer_at_cell( + &mut self, + coord: Option, + kind: ProtocolMouseKind, + mods: Modifiers, + ) -> bool { + let Some(client) = self.attach_client.as_ref() else { + return false; + }; + if client.session_protocol_version() < PANEL_MIN_VERSION { + return false; + } + let Some(state) = self.state.as_ref() else { + return false; + }; + let Some(frame) = state.panel.presented() else { + return false; + }; + let Some(coord) = coord else { + return false; + }; + if let Err(e) = client.send_panel_pointer( + frame.geometry_epoch, + frame.panel_epoch, + frame.buffer_id, + coord, + kind, + mods, + ) { + eprintln!("pmacs-gpu: send_panel_pointer failed: {e}"); + } + true + } + + /// Advance a live divider drag, sending `PanelResizeRows` only when the + /// requested row count actually changes. + /// + /// Row counts, never pixels: the daemon clamps by `window.min-height`, + /// so the frontend's job is to name the rows the pointer is asking for, + /// not to enforce the floor itself. + fn advance_panel_drag(&mut self, y: f64) { + let Some(client) = self.attach_client.as_ref() else { + return; + }; + if client.session_protocol_version() < PANEL_MIN_VERSION { + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + let Some(request) = state.panel_drag_request(y as f32) else { + return; + }; + match client.send_panel_resize_rows( + request.geometry_epoch, + request.panel_epoch, + request.rows, + ) { + Ok(()) => state.note_panel_drag_sent(request.rows), + Err(e) => eprintln!("pmacs-gpu: send_panel_resize_rows failed: {e}"), + } + } + /// Resolve a pixel to a terminal cell, or `None` when this window is /// not in terminal mode or the pixel is outside the grid. /// @@ -2089,7 +2570,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) { @@ -2137,6 +2618,18 @@ impl App { // otherwise circular "need a frame to know to ask for // one" dependency. self.flush_terminal_declaration(); + // Q#BP2S1 — a font or scale transaction arrives as a + // message, so its geometry re-declaration is flushed here. + // `Metrics` rather than `Surface` on purpose: the cell total + // may be IDENTICAL while the pixels behind it are not, and + // the `Surface` dedup would drop exactly that case. + if self + .state + .as_mut() + .is_some_and(State::take_panel_metrics_changed) + { + self.flush_panel_geometry(GeometryTrigger::Metrics); + } let Some(state) = self.state.as_mut() else { return; }; @@ -2171,7 +2664,13 @@ impl ApplicationHandler for App { && let Some(state) = self.state.as_mut() { state.set_frontend_id(client.frontend_id()); + state.set_panel_wire(client.session_protocol_version()); } + // Q#BP15a: the first declaration rides the first surface this + // frontend actually has. The daemon needs columns before it can + // paint a first panel frame, so this is sent WITHOUT a side window + // — gating it on panel presence would deadlock the first open. + self.flush_panel_geometry(GeometryTrigger::Surface); // In attach mode, kick off the connection now that the event // loop is running and a proxy is available. Failure logs and @@ -2183,8 +2682,10 @@ impl ApplicationHandler for App { Ok(client) => { if let Some(state) = self.state.as_mut() { state.set_frontend_id(client.frontend_id()); + state.set_panel_wire(client.session_protocol_version()); } self.attach_client = Some(client); + self.flush_panel_geometry(GeometryTrigger::Surface); } Err(e) => { eprintln!("pmacs-gpu: attach failed: {e}"); @@ -2417,6 +2918,10 @@ impl ApplicationHandler for App { // daemon resizes the shared PTY only if this frontend is // the durable controller. self.flush_terminal_declaration(); + // Q#BP15a — and so is the panel's whole-frame capacity. An + // identical cell total is not re-declared: nothing the + // daemon can act on changed. + self.flush_panel_geometry(GeometryTrigger::Surface); } // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). WindowEvent::CursorMoved { position, .. } => { @@ -2437,10 +2942,50 @@ impl ApplicationHandler for App { } return; } + // Bottom panel Stage 2B-3 — the band is consumed BEFORE + // the terminal and document paths. It sits below + // `document_text_bottom`, so a band pixel cannot hit the + // document grid, but ordering it first is what makes that a + // stated rule rather than a consequence of the arithmetic. + if state.panel.drag.is_some() { + self.advance_panel_drag(position.y); + return; + } + let surface = state.classify_pointer_surface(position.x as f32, position.y as f32); + if state.set_panel_divider_hover(surface == PointerSurface::PanelDivider) { + state.apply_panel_cursor_icon(); + } + match surface { + PointerSurface::PanelDivider | PointerSurface::PanelBackground => return, + PointerSurface::PanelCell(coord) => { + // A held left button makes this a `Drag(Left)`, not a + // `Move`. That distinction is the whole of panel + // selection: `Move` never focuses or claims, while + // every non-`Move` gesture activates the panel first, + // so reporting a drag as a hover makes a selection + // drag silently do nothing. + let kind = state.panel_motion_kind(); + if state.panel_motion_is_new(coord) { + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at(position.x, position.y, kind, mods); + } + return; + } + PointerSurface::Elsewhere => { + if state.panel.pointer_held { + // A drag that wandered out of the band keeps + // belonging to the band until the button comes up. + return; + } + } + } // Vterm Stage 3 — inside the terminal clip, motion is a // terminal gesture. Consumed before minimap scrubbing // and document hit testing: terminal mode paints no // minimap and has no source bytes to resolve. + let Some(state) = self.state.as_mut() else { + return; + }; if state.terminal.is_some() { let dragging = state.pointer_drag_active; if let Some((buffer_id, coord)) = @@ -2484,8 +3029,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 { @@ -2530,6 +3079,59 @@ impl ApplicationHandler for App { return; } let mods = translate_mods(self.modifiers); + // Bottom panel Stage 2B-3 — the divider strip and the band + // claim the gesture before either document path sees it. + let panel_surface = state.classify_pointer_surface(x as f32, y as f32); + match button_state { + ElementState::Pressed => { + if panel_surface == PointerSurface::PanelDivider + && state.begin_panel_drag(x as f32, y as f32) + { + return; + } + // Arm the gesture BEFORE sending, and only when the + // press actually landed on a cell: arming on a miss + // would make a later in-band motion send a `Drag` with + // no preceding `Down`, and not arming at all means + // `Drag(Left)` is never emitted and panel selection + // cannot work at all. + if let PointerSurface::PanelCell(_) = panel_surface { + let state = self.state.as_mut().expect("checked above"); + state.set_panel_pointer_held(true); + self.send_panel_pointer_at( + x, + y, + ProtocolMouseKind::Down(ProtocolMouseButton::Left), + mods, + ); + return; + } + if state.panel.pointer_held { + let state = self.state.as_mut().expect("checked above"); + state.set_panel_pointer_held(false); + } + } + ElementState::Released => { + if state.end_panel_drag() { + return; + } + if state.panel.pointer_held { + let cell = state.panel_release_cell(x as f32, y as f32); + self.send_panel_pointer_at_cell( + cell, + ProtocolMouseKind::Up(ProtocolMouseButton::Left), + mods, + ); + if let Some(state) = self.state.as_mut() { + state.set_panel_pointer_held(false); + } + return; + } + } + } + let Some(state) = self.state.as_mut() else { + return; + }; if state.terminal.is_some() { let hit = self.terminal_pointer_hit(x, y); let state = self.state.as_mut().expect("checked above"); @@ -2630,6 +3232,31 @@ impl ApplicationHandler for App { self.send_menu_pointer(None, true); return; } + // Bottom panel Stage 2B-3 — a right-click in the band is a + // panel gesture, claimed before the terminal and document + // paths. The daemon decides between child mouse reporting and + // the editor context menu, so the anchor is remembered here + // exactly as for a document click; without this the band's + // context actions are unreachable and the click is applied to + // the document underneath instead. + if let PointerSurface::PanelCell(_) = + state.classify_pointer_surface(x as f32, y as f32) + { + if let Some(state) = self.state.as_mut() { + state.menu_anchor_px = (x, y); + } + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at( + x, + y, + ProtocolMouseKind::Down(ProtocolMouseButton::Right), + mods, + ); + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; // Vterm Stage 3 — a right-click in the terminal clip is // a terminal gesture; the daemon decides between child // reporting and the editor context menu, so the anchor @@ -2675,6 +3302,29 @@ impl ApplicationHandler for App { if lines == 0 { return; } + // Bottom panel Stage 2B-3 — a wheel tick over the band scrolls + // the PANEL's window, which is daemon-side state, so it + // crosses the wire instead of moving this frontend's local + // document `scroll_top`. Falling through would scroll the + // document while the pointer is inside the panel. + if let Some((x, y)) = state.pointer_pos + && matches!( + state.classify_pointer_surface(x as f32, y as f32), + PointerSurface::PanelCell(_) + ) + { + let mods = translate_mods(self.modifiers); + let kind = if lines < 0 { + ProtocolMouseKind::ScrollUp + } else { + ProtocolMouseKind::ScrollDown + }; + self.send_panel_pointer_at(x, y, kind, mods); + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; // Vterm Stage 3 — the terminal's scrollback belongs to // the daemon-side view, not to this frontend's local // scroll, so a wheel tick crosses the wire as a @@ -3247,6 +3897,11 @@ impl State { // subject them to the document's gutter offset and wrapping. let terminal_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Bottom panel Stage 2B-3 — the band's glyphs get their own layer + // and their own clip for the same reason: they answer to the + // daemon's panel grid, not to the document's gutter or wrapping. + let panel_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, format); let squiggle_renderer = SquiggleRenderer::new(&device, format); @@ -3425,11 +4080,16 @@ impl State { gutter_buffer, gutter_text_renderer, terminal: None, + panel: PanelBand::default(), + panel_text_buffers: Vec::new(), + panel_wire: false, + panel_metrics_changed: false, last_terminal_size_sent: None, terminal_frame_error_latched: false, last_terminal_pointer_cell: None, terminal_text_buffers: Vec::new(), terminal_text_renderer, + panel_text_renderer, }; // Real drawable dimensions from construction (framing Q#F6): // wrapping and `shape_until_cursor` must use the same clip the @@ -4478,6 +5138,12 @@ impl State { // the matching size arrives, so nothing paints at the // old advance under the new font. self.invalidate_terminal_shaping(); + // Q#BP2S1 / A2B-2: the panel's shaped runs were measured at + // the old advance, and the CELL total may be identical while + // the pixels behind it are not. The reshape is here; the new + // declaration is the caller's, because only it can send. + self.rebuild_panel_text_buffers(); + self.panel_metrics_changed = true; self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } @@ -4485,6 +5151,18 @@ impl State { self.apply_terminal_frame(frame); None } + InstanceMessage::PanelFrame(payload) => { + // The band changes the DOCUMENT's pixel height, so a panel + // that appears or disappears has to reshape the document + // buffers as well as request a repaint. Skipping the + // reshape leaves the code layer sized to the old boundary + // and the last lines painting under the band. + if self.apply_panel_payload(payload) { + self.sync_buffer_dimensions(); + self.request_redraw(); + } + None + } _ => None, } } @@ -4557,6 +5235,11 @@ impl State { /// too. fn on_daemon_disconnected(&mut self, notice: &str) { self.exit_terminal_mode(); + // A retained band is the daemon's projection of a window that is no + // longer being updated. Leaving it on screen beside a disconnect + // notice is the same "frozen, live-looking surface" the terminal arm + // above exists to prevent. + self.exit_panel_band(); if !self.set_text(notice) { // Byte-identical text still needs a repaint: the frame that // is on screen is the terminal's, not this notice. @@ -4574,6 +5257,16 @@ impl State { self.last_terminal_pointer_cell = None; } + /// Drop the band and every cache behind it. + /// + /// The geometry declaration goes too: the next session is a new session, + /// its epochs start from scratch, and a retained declaration would + /// describe a peer that is gone. + fn exit_panel_band(&mut self) { + self.panel = PanelBand::default(); + self.panel_text_buffers.clear(); + } + /// Drop shaping and geometry caches without leaving terminal mode. /// /// Used when the font changes: the installed frame is still the @@ -4661,7 +5354,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, @@ -4670,6 +5364,645 @@ 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(); + // Origin x = 0 and the FULL surface width, matching the declaration. + // Any fractional right-edge remainder past the last whole column is + // band background: it maps to no cell and emits no `PanelPointer`, + // which `hit_test_cell`'s column bound already enforces. + Some((0.0, top, self.config.width as f32, 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, Option) { + let Some(advance) = self.panel_probe_advance() else { + return (CellSize::new(0, 0), None); + }; + let height = (geometry_capacity_bottom(self.config.height, self.fm) - TEXT_TOP).max(0.0); + // **Full surface width from x = 0.** The panel grid is not inset by + // the document's `TEXT_LEFT` or gutter — those are document padding, + // and the band is a separate surface spanning the frame (parent + // framing Q#BP15a: "`total.cols` describes the full-width panel grid + // beginning at x=0; document `TEXT_LEFT`/gutter padding is + // unrelated"). Deducting `TEXT_LEFT` here under-declares columns and + // leaves a strip the daemon never fills. + let width = self.config.width as f32; + let total = crate::terminal::panel_cell_capacity( + width, + height, + advance, + self.fm.code_line_height(), + ) + .unwrap_or_else(|| CellSize::new(0, 0)); + (total, Some(advance)) + } + + /// The advance every panel cell computation must use: the one behind the + /// current declaration. + /// + /// `None` before a declaration exists — which is also when + /// [`PanelBand::presented`] is `None`, so no painter or hit test can be + /// reached without it. + fn panel_cell_advance(&self) -> Option { + self.panel + .declared_advance + .filter(|advance| advance.is_finite() && *advance > 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, advance) = 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; + self.panel.declared_advance = None; + return None; + }; + self.panel.geometry_epoch = next; + self.panel.declared = Some(total); + self.panel.declared_advance = advance; + 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 { + if self.panel.exhausted { + // A latched session has disowned its geometry for good, so a + // payload answering it describes nothing this frontend can + // present. Retaining the frame anyway would leave `presented()` + // as the only thing standing between a disowned declaration and + // a painted band, and would spend a reshape on every arriving + // frame for the rest of the session. + return false; + } + 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; + self.panel.pointer_held = false; + self.panel.last_pointer_cell = None; + 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()); + // The declaration's advance, never the document's: a run shaped to a + // different cell width than the daemon counted columns with drifts + // one column further off across the row. + let Some(advance) = self.panel_cell_advance() else { + self.panel_text_buffers.clear(); + return; + }; + 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.panel_cell_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. + #[allow( + clippy::too_many_lines, + reason = "one band's complete quad batch: divider, cell backgrounds, every straight underline form, caret" + )] + 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), + }); + } + } + // Only a FOCUSED panel paints its caret. The producer includes + // `cursor` for a passive panel too — it is the window's real + // point, and the daemon does not suppress it — so painting it + // unconditionally puts a second insertion caret on screen and + // makes focus ownership visually ambiguous. `focused` is exactly + // the presentation bit Q#BP14b reserves for this. + // Straight underline forms as fixed-cell quads, exactly as the + // terminal path does; curly rides the squiggle pipeline below, + // which owns the sine wave. Dropping these silently loses every + // diagnostic and styled-terminal underline inside the band. + for underline in &plan.underlines { + if underline.style == UnderlineStyle::Curly { + continue; + } + let Some((x, y, w, h)) = self.panel_run_rect(underline.run) else { + continue; + }; + let color = rgb_to_quad(underline.color, 1.0); + let thickness = TERMINAL_UNDERLINE_PX; + let baseline = y + h - thickness * 2.0; + match underline.style { + UnderlineStyle::Double => { + rects.push(MinimapRect { + x, + y: baseline, + w, + h: thickness, + color, + }); + rects.push(MinimapRect { + x, + y: baseline + thickness * 2.0, + w, + h: thickness, + color, + }); + } + UnderlineStyle::Dotted | UnderlineStyle::Dashed => { + let period = if underline.style == UnderlineStyle::Dotted { + TERMINAL_UNDERLINE_PX * 3.0 + } else { + TERMINAL_UNDERLINE_PX * 8.0 + }; + let duty = if underline.style == UnderlineStyle::Dotted { + 0.5 + } else { + 0.625 + }; + let mut dash_x = x; + while dash_x < x + w { + let dash_w = (period * duty).min(x + w - dash_x); + rects.push(MinimapRect { + x: dash_x, + y: baseline, + w: dash_w, + h: thickness, + color, + }); + dash_x += period; + } + } + UnderlineStyle::Single => rects.push(MinimapRect { + x, + y: baseline, + w, + h: thickness, + color, + }), + UnderlineStyle::Curly | UnderlineStyle::None => {} + } + } + if let Some(cursor) = plan.cursor + && self.panel.presented().is_some_and(|frame| frame.focused) + && 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) + } + + /// Curly underlines inside the band, on the squiggle pipeline — the same + /// split the terminal path makes, because the sine wave belongs to that + /// pipeline and a quad cannot express it. + fn panel_squiggle_vertex_bytes(&self) -> Vec { + let Some(plan) = self.panel.plan.as_ref() else { + return Vec::new(); + }; + if self.panel.presented().is_none() { + return Vec::new(); + } + let rects: Vec = plan + .underlines + .iter() + .filter(|underline| underline.style == UnderlineStyle::Curly) + .filter_map(|underline| { + let (x, y, w, h) = self.panel_run_rect(underline.run)?; + Some(MinimapRect { + x, + y: y + h - DIAG_SQUIGGLE_PX, + w, + h: DIAG_SQUIGGLE_PX, + color: rgb_to_quad(underline.color, 1.0), + }) + }) + .collect(); + squiggles_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.panel_cell_advance()?, + self.fm.code_line_height(), + frame.size, + ) + } + + /// Classify a pointer pixel against the band. + /// + /// The divider is tested before the cells because it sits directly above + /// them and a gesture on the strip is a resize, not a selection. + fn classify_pointer_surface(&self, x: f32, y: f32) -> PointerSurface { + if self.panel_divider_contains(x, y) { + return PointerSurface::PanelDivider; + } + let Some((ox, oy, w, h)) = self.panel_content_rect() else { + return PointerSurface::Elsewhere; + }; + if x < ox || x >= ox + w || y < oy || y >= oy + h { + return PointerSurface::Elsewhere; + } + match self.panel_hit_test(x, y) { + Some(coord) => PointerSurface::PanelCell(coord), + None => PointerSurface::PanelBackground, + } + } + + /// The gesture kind a panel motion carries: a held left button makes it a + /// drag, and that distinction is the whole of panel selection — `Move` + /// never focuses or claims, while every non-`Move` gesture activates the + /// panel first. + fn panel_motion_kind(&self) -> ProtocolMouseKind { + if self.panel.pointer_held { + ProtocolMouseKind::Drag(ProtocolMouseButton::Left) + } else { + ProtocolMouseKind::Move + } + } + + /// The cell a release belongs to: the one under the pointer while it is + /// still in the band, else the last cell the gesture reported. + /// + /// A panel selection drag routinely ends past the band's edge, and + /// dropping that release leaves the daemon holding a button down forever. + fn panel_release_cell(&self, x: f32, y: f32) -> Option { + match self.classify_pointer_surface(x, y) { + PointerSurface::PanelCell(coord) => Some(coord), + _ => self.panel.last_pointer_cell, + } + } + + /// Whether a panel motion at `coord` carries anything new, and latch it. + /// + /// Sub-cell motion resolves to the same cell and says nothing the daemon + /// can act on. Without this, pixel-rate motion becomes pixel-rate wire + /// traffic and every one of those is a daemon-side gesture — the same + /// reason the terminal path dedupes. + fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool { + if self.panel.last_pointer_cell == Some(coord) { + return false; + } + self.panel.last_pointer_cell = Some(coord); + true + } + + /// Arm or disarm the panel's left-button gesture, re-arming the motion + /// dedupe either way. + fn set_panel_pointer_held(&mut self, held: bool) { + self.panel.pointer_held = held; + self.panel.last_pointer_cell = None; + } + + /// Begin a divider drag at surface pixel `y`, if the pointer is on the + /// strip. Returns whether a drag was started. + fn begin_panel_drag(&mut self, x: f32, y: f32) -> bool { + if !self.panel_divider_contains(x, y) { + return false; + } + let Some(frame) = self.panel.presented() else { + return false; + }; + self.panel.drag = Some(PanelDrag { + panel_epoch: frame.panel_epoch, + geometry_epoch: frame.geometry_epoch, + start_rows: frame.size.rows, + start_y: y, + sent_rows: frame.size.rows, + }); + true + } + + /// End any live divider drag. + fn end_panel_drag(&mut self) -> bool { + self.panel.drag.take().is_some() + } + + /// The row request a live drag at pixel `y` implies, or `None` when + /// there is no drag, no presented panel, or the request is unchanged. + /// + /// The drag's own epochs are checked against the panel that is on + /// screen NOW: a gesture that outlives its presentation is dropped + /// rather than applied to the successor, which is the same rule the + /// daemon enforces on receipt. Both sides check because neither may + /// depend on the other having done it. + fn panel_drag_request(&mut self, y: f32) -> Option { + let drag = self.panel.drag?; + let frame = self.panel.presented()?; + if frame.panel_epoch != drag.panel_epoch || frame.geometry_epoch != drag.geometry_epoch { + self.panel.drag = None; + return None; + } + let line = self.fm.code_line_height(); + if !line.is_finite() || line <= 0.0 { + return None; + } + // Dragging the divider UP grows the panel, so a negative pixel + // delta is a positive row delta. + let delta_rows = ((drag.start_y - y) / line).round(); + if !delta_rows.is_finite() { + return None; + } + let rows = (i64::from(drag.start_rows) + delta_rows as i64).max(1); + let rows = u32::try_from(rows).unwrap_or(u32::MAX); + if rows == drag.sent_rows { + return None; + } + Some(PanelResizeRequest { + geometry_epoch: drag.geometry_epoch, + panel_epoch: drag.panel_epoch, + rows, + }) + } + + /// Record that a row request was actually sent, so re-crossing the same + /// row boundary does not re-send it. + fn note_panel_drag_sent(&mut self, rows: u32) { + if let Some(drag) = self.panel.drag.as_mut() { + drag.sent_rows = rows; + } + } + + /// Apply the divider hover cursor icon to the real window. + /// + /// `RowResize` while the pointer is on the strip, the default arrow + /// otherwise. Driven from the same `hover_divider` bit the hit test + /// sets, so the icon cannot advertise a drag target the press would + /// miss. + fn apply_panel_cursor_icon(&self) { + if let Some(window) = &self.window { + window.set_cursor(if self.panel.hover_divider { + winit::window::CursorIcon::RowResize + } else { + winit::window::CursorIcon::Default + }); + } + } + + /// Consume the "a font/scale change invalidated the declaration" flag. + fn take_panel_metrics_changed(&mut self) -> bool { + std::mem::take(&mut self.panel_metrics_changed) + } + + /// Update divider hover, reporting whether the cursor icon must change. + fn set_panel_divider_hover(&mut self, hovering: bool) -> bool { + if self.panel.hover_divider == hovering { + return false; + } + self.panel.hover_divider = hovering; + true + } + + /// 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(); @@ -4944,7 +6277,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; @@ -5232,6 +6566,7 @@ impl State { self.config.width, self.config.height, self.fm, + self.band_inset(), ) } @@ -5275,9 +6610,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) @@ -5861,7 +7198,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, )); @@ -6003,7 +7340,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, @@ -6098,7 +7435,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), @@ -6122,7 +7459,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)) } @@ -6213,7 +7550,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; } @@ -6235,7 +7572,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; @@ -6554,7 +7891,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 { @@ -6676,7 +8014,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( @@ -7140,6 +8479,12 @@ impl State { self.config.height, )); bg_vertices.extend(self.status_band_vertex_bytes()); + // Bottom panel Stage 2B-3 — the divider strip, the band's cell + // backgrounds, and the panel caret. Added in BOTH modes: the + // document may itself be a terminal while a panel is open, so + // gating this on `terminal_mode` would make the band vanish + // exactly when it is hosting the output the user asked for. + bg_vertices.extend(self.panel_quad_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self .bg_vertex_buffer @@ -7153,11 +8498,15 @@ impl State { // Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn // between the wash quads and the text (under the glyphs, the // z-slot the straight bar held). - let squiggle_vertices = if terminal_mode { + let mut squiggle_vertices = if terminal_mode { self.terminal_squiggle_vertex_bytes() } else { self.squiggle_vertex_bytes() }; + // The band's curly underlines, in BOTH modes for the same reason its + // quads are: the document may itself be a terminal while a panel is + // open. + squiggle_vertices.extend(self.panel_squiggle_vertex_bytes()); let squiggle_vertex_count = (squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32; let squiggle_buffer = self @@ -7229,7 +8578,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.*` @@ -7269,7 +8618,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: &[], @@ -7290,7 +8640,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(), }, @@ -7307,7 +8657,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(), @@ -7337,7 +8687,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: &[], @@ -7368,7 +8719,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, @@ -7446,7 +8798,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; @@ -7509,6 +8861,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