diff --git a/docs/active-work.md b/docs/active-work.md index 234bb02..269aca3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,6 +270,362 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. +## Panel-pointer replay (parent acceptance 48) — ACTIVE, 1b's prerequisite + +**Written with the branch's FIRST commit**, per the standing correction +from #171 and #215 — the correction the 1b lane missed, honoured here. + +- **Branch `panel-pointer-replay`**, base `githubsucks/main` @ + **`72da24a`** exactly, in worktree + `/home/jeans/Repos/personal/pmacs-panel-replay`. + **`githubsucks/panel-pointer-replay` is the authoritative tip** (the + ref, not a SHA). Recover with + `git fetch githubsucks && git checkout panel-pointer-replay`. +- **No PR yet. Checkpoint: framing revision 16 APPROVED; + IMPLEMENTATION UNDER WAY.** Read the tip with + `git log --oneline githubsucks/main..HEAD`; no count or SHA is + recorded here, for the reason the §5b lane learned twice. + - **LANDED: Q#BP-R4's pre-effect disposition and the lifecycle + table** — `PanelPointerOutcome` (`Refused`/`Consumed`/`Accepted`) + decided before any target effect, with the resolution carried so + the daemon never re-derives. + - **LANDED: G5k's recorded gesture domain.** The first version was + reviewed and rejected: it drove tails through the mode-sensitive + adapter, which re-reads Shift, scroll position and the child's + modes per event — G5k's named mutation. The press now records + `PanelGestureDomain` (document / terminal-child-with-encoding / + terminal-local) and every tail and completion follows it. + - **LANDED: the record is SELF-CONTAINED.** `TerminalLocal` carries + the accepted content **viewport** (ambient geometry is `None` + exactly when a hidden panel needs completing); a press that anchors + nothing no longer arms on either target; and pointer routing goes + through the renderer's own `terminal_projection_size` clamp — a + band wider than `MAX_TERMINAL_COLS` painted fine while every click + inside it resolved to nothing. + - **LANDED: `PanelPointerDisposition` is an ENUM.** As + `{outcome, Option}` the invalid pair — refused, yet + carrying a target — stayed representable inside `editor.rs`. Now + `Refused` holds no target at all. + - **Witnesses: G5k(a)–(d), P1, P2, P3 both legs, P4, P5, P7, P8, P9, + P10, P11, P12**, each biting its own mutation, including G5k's + verbatim. **They do NOT all read a target effect, and an earlier + version of this line said they did.** Two kinds: + - **Effect rows** — G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8, + P11, P12 — read the child's byte stream (**exact bytes**, not a + count), the terminal drag state, or the document selection. + - **Arming-gate rows** — **P9 and P10** — read the LATCH, and that + is correct for them: the defect they fence is a record existing + for a gesture that never began, so the record IS the artifact. + Manufacturing an effect assertion for them would not distinguish + their mutations. + **P2 is a third case**: its FOCUS assertion is witnessed — + removing the buffer check accepts the press, which activates the + panel before replaying — and its classification is checked LAST so + the row still fails if it stops testing a refusal. Only its + controller and byte assertions are defence in depth, because that + mutation routes through a document buffer and touches neither. An + earlier version asserted the refusal first, which aborted the row + before dispatch and made every effect assertion unreachable; I + recorded that ordering limit as a limit of the type boundary, and + it was not one. + - **Every fixture asserts its own precondition** (the disposition is + `Accepted`, or is `Refused`) because four rows in these rounds + passed vacuously: cells that were out of grid, or that clamped to + byte 0, exercised a refusal instead of the path they named. + - **LANDED: the pending-release SLOT and its drain order (task 18).** + Cancellation parks the record instead of returning it into a + context that drops it — two of the three cancellation sites are + inside frame production, where no target effect can run. The drain + pays it **before any subsequent panel-pointer effect**, **before + detach teardown**, and **at the projection seam** between + `render_frame` returning and its messages being written. + - **A LIVE gesture is ended and PAID before a replacement press + lands.** The entry drain alone was not enough: it looks for an + OWED release, and a live gesture owes nothing yet — arming was + what cancelled it, which happens after the replacement has + already reached the target. The child saw `old press, new press, + old release`. + - **Witnessed: Q1, Q2, Q3, Q4, Q6.** Q3 asserts ORDER, not arrival. + Q6 sends a second press with the first still live and expects + exactly `old release, new press` in the child's stream. Both + layers of Q6 are proven separately: the invariant now asserts at + the point of ARMING (not inside cancellation) and fires in debug, + and with that assert compiled out the byte-order assertion + catches the same defect — which is what a release build relies + on. + - **Q5 is CLOSED.** The projection seam is extracted as + `project_semantic_frame`, which returns its messages **unwritten** + — so a caller holding them has by construction not sent the + successor frame, and a release already delivered at that moment + provably precedes it. The row bites its own drain and no other: + removing it fails Q5 while Q1–Q4 stay green on the other two + drain points. + - **LANDED: the authority-loss matrix (task 19).** §5b wired `Absent` + and left the other four transitions armed — inert while nothing + consumed the latch, defects the moment cancellation gained an + effect. Three are visible in the producer where the declaration is + built (**window replacement**, **buffer replacement**, and a + **geometry-epoch change including at an unchanged size**, which + needed a retained `geometry_epoch` because nothing else the + producer holds moves with it); **detach** cancels in the dispatcher + before any teardown. + - **Witnessed by a TABLE-DRIVEN matrix**: **five** transitions × + two families × two targets, **twenty quadrants** — `Absent` + included, both as the fifth row and as a CONTROL on §5b's own + cancellation, which a mutation confirms it catches. The count is + asserted in the row, because a loop that quietly stops covering a + combination passes exactly as loudly as one that covers them all. + Each quadrant **drains explicitly and asserts the effect** — the exact release bytes + for a reporting terminal, the cleared empty selection for a + document, and an empty slot afterwards. An earlier version + stopped at `has_pending_release()` and would have passed while + delivery was broken; the mutation that parks a release and never + delivers it now fails every row. + - **Mutation labels, stated correctly this time**: dropping the + BUFFER comparison misses the buffer transition; dropping the + WINDOW comparison misses the window transition; dropping the + geometry comparison misses that one; dropping detach's cancel + misses detach. Each fails the matrix. + - **G5m takes both composites the framing names** — changed-size + geometry (epoch **and** mapping generation) and a buffer + replacement that also moves the mapping — and reads the child's + stream rather than a cancellation count, because a count of one + proves the latch was taken once, not that one release went out. + It also **asserts the mapping generation actually advances**: + without that, a same-size geometry change passes as a + "composite" while being a single cause, and the row would prove + nothing about coincidence. Peeked rather than read through the + authoritative accessor, which would advance the key and + manufacture the second cause. + - **G5j has two legs and they differ**: an empty selection is + cleared without moving point, a REAL dragged region survives + anchor-and-cursor exact. Clearing every selection fails the + second. + - **One quadrant asserts less, and says so**: for window + replacement on a document, the window the gesture belonged to is + gone, so the completion has nothing left to clear and the + gesture ending is the whole effect. Written into the row rather + than left as a silently absent assertion. + - **GATE RUN 1, head-exact at `6142acc`: 15/16, red on `04-lib-crdt` + only.** `composition_overhead_under_ten_percent` (1.247× against + 1.10×) and `setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` + failed together; both green on isolated rerun. Recorded as **U12**. + `src/process.rs` is not touched by this branch at all. **The run + was knowingly taken on a machine that was quieter but not quiet** — + load 11.04 at start, 27.79 five-minute at end, two foreign `python` + processes throughout — so it is reported as a red under stated + conditions rather than dismissed. + - **REMAINING: a head-exact gate on a genuinely quiet machine, then + the PR.** The gate + wants a QUIET machine: a foreign C++/java build has been running at + load 114+ through this work, and the wall-clock rows + (`composition_overhead_under_ten_percent`, + `m6_2_pty_streaming_respects_byte_ceiling`, + `full_buffer_summary_flatten_scales_on_large_grammar_file`) redded + under it and were green in isolation every time. That is U6/U9/U10 + territory and running the gate into it would manufacture another + rotating-red incident. + - **Two test seams added for this:** an opt-in child-input tap + (`start_send_tap_for_test`) and a drag-state read + (`view_is_dragging_for_test`). Nothing else exposes what the child + actually received, which is what these rows must assert. §5a's **pre-merge** replay contract was approved at + revision 12; revisions 14–16 are the post-merge amendment now under + review. Revision 13 ruled Q#BP-R3 and blocked the lane on a + protocol-bearing mapping generation; **that block is DISCHARGED** — + the slice merged as #242 (`47b5463`). +- **MERGED main into this branch** at `b758c2e` rather than rebasing: + the lane's 12 commits include 10 framing revisions over the same + 800–1000 line doc regions, so a rebase meant twelve rounds of + large-block resolution — the operation that produced a committed + diff3 marker on the last lane. Workspace compiles clean, **1964 lib + and 284 GPU tests pass**. Base is no longer `72da24a`; read it with + `git merge-base githubsucks/main HEAD`. +- **THE MERGE CREATED ONE DEFECT AND SURFACED ONE COLLISION.** + - **Defect, fixed at `cf78385`: `b758c2e` DOES NOT COMPILE**, and its + message claims "Workspace compiles clean". **That claim is + withdrawn.** I staged the resolution, hit `cannot find value mods` + at the mapped arm, fixed it, re-checked clean — and committed + without re-staging, so the verification and the commit were of + different trees. Not amended away: `b758c2e` keeps its false claim + with the withdrawal attached, because erasing a bad record is worse + than carrying a corrected one. **Anything bisecting across + `b758c2e`..`3cd7b8a` will fail to build.** + - **Defect, fixed:** the merge kept BOTH copies of §5b — this + branch's stale pre-split one and main's authoritative one. I + discarded the uncommitted stub edit as obsolete and missed that its + **deletion** half was still owed. Removed; exactly one §5a and one + §5b remain. + - **Collision, ruled by revision 14 and NOW FIXED IN CODE** (see the + checkpoint above; this bullet records what it was): + §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` + different meanings — accepted-as-a-gesture versus consumed-here. A + mode-line press therefore **armed the latch** --- past tense: the + fix landed with Q#BP-R4, and P1 pins it. + **Q#BP-R4** rules a three-state `PanelPointerOutcome`, classified + **before** target effects. Only an `Accepted` `Down(Left)` arms; + left `Drag`/`Up` require a live record; an accepted `Up` performs + ordinary replay once, a consumed/chrome `Up` performs the recorded + completion once, and a refused `Up` preserves the record. +- **Revision 16 carries** the rows §5b's split table assigned here, a + **pending-release slot** for the cancellation record §5b leaves + nowhere to wait, and the **four transitions** that strand a live + gesture once effects attach. Drain order is executable: before the + next panel-pointer effect; before detach teardown; and, for a + projection-raised cancellation, after `render_frame` returns but + before any returned message is written. Ground truth is re-measured + at `2c0d3ff`. Document-panel horizontal scrolling is a named deferral + to GUI Stage 1b B1–B3; a horizontal tick whose terminal precedence + selects child reporting already emits SGR, and the local terminal + branch has no horizontal viewport effect. +- **THE BLOCKER, and why the earlier acceptance failed.** A + `PanelPointer` names a cell; nothing on the wire says which inverse + mapping the frontend saw, so the daemon inverts against whatever is + current. Revision 12 accepted that on three bounds and **all three + were wrong**: a **foreign** edit moves the mapping with `view_top` + untouched, the error is **unbounded** once ticks/folds/edits/reloads + accumulate, and the window lasts until the frontend **presents** the + new frame. §5b adds a **cell-mapping generation** — moves with the + inverse mapping, stable across focus/styling/cursor/selection-only + repaints so drags survive — as **appended** wire variants with + bilateral gating. +- **Chain: ~~§5b (protocol) →~~ panel replay → GUI arc 1b.** §5b took + **v25**, so 1e's `OpenTarget` is **v26**. **That edit is NOT owed by + the 1b branch** — this bullet said it was, and said making it here + would collide at 1b's rebase. **§5b made it, in + `docs/gui-stage1-input-framing.md`, and merged as #242**: a canonical + document saying v25 is false the moment v25 is taken, and an expected + rebase conflict was not grounds for leaving it wrong. Corrected here + because this bullet and the merged-#242 block below it were saying + opposite things. Commit one was the ground-truth + re-measurement; 6 added the four replay edges; **7 answers review of + 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN + (terminals reject all chrome kinds and raw chrome coords fail the + reporting bounds check), R-c2 retains the `Down` cell, A3–A5 add + positive SGR controls, and four witness seams are tightened. **New + ruling Q#BP-R2**: a chrome wheel over a terminal panel clamps into + content rather than dropping — a deliberate divergence from the TUI, + flagged for overrule — **and overruled in 9**. + **Revision 9** reverses two of 8's decisions: a terminal-chrome wheel + is **CONSUMED, not clamped** (SGR wheel input is coordinate-bearing, + so clamping fabricates a hit the user never made, and a wheel has no + liveness obligation), and the `Down`-cell fallback lives in a + **separate `gesture_last_content_cell`** rather than + `last_pointer_cell`, which is cleared on press precisely so the first + same-cell `Drag` reaches the daemon (`pmacs-gpu/src/main.rs:19841`). + **`Up` is the only crossing event promised unconditionally**; a + crossing `Drag` is normalized and then deduped. + **Revision 10** keeps Q#BP-R2's outcome and moves its **enforcement + point**: the GPU **cannot know a panel holds a terminal** — + `PanelFrame` has no target-kind field + (`pmacs-protocol/src/panel.rs:73`) and `state.terminal` is the + primary full-window terminal — so a producer-side rule needed a new + wire field this lane must not add. **The producer is target-blind** + and sends the chrome wheel for every panel; **the daemon** resolves + the side window and decides: document → `scroll_window`, terminal → + consume. Witness: one frontend across a document→terminal + replacement. + **Revision 11** fixes the ORDERING: the terminal-chrome wheel is + consumed **before activation**, not merely before + `apply_terminal_gesture`. `activates` is `!matches!(kind, Move)` for + a terminal (`src/editor.rs:2695`), so the wheel already writes focus + and `active_frontend` at `:2699` ahead of any replay decision — a + consume check below that would change focus while scrolling nothing + and claiming no controller. Four-step order, consumption at step 3; + the witness now asserts **focus and controller identity unchanged**. + **Revision 12** makes that setup discriminating: leg 2 must **start + PASSIVE** — primary document window active, terminal side window + distinct and passive, controller baseline captured — because "focus + unchanged" is vacuous if the panel is already focused, and the + below-activation mutation would then call `focus_window` on the + already-active window and pass. The two assertions are **not + interchangeable**: **focus** catches the ordering mutation; + **controller identity** catches the shared-path mutation, since + `apply_terminal_gesture` claims at `src/editor.rs:3571` before local + handling and activation alone claims nothing. +- **Why this lane exists, re-measured at `2c0d3ff`.** The branch now + replays document selection, terminal mouse reporting and vertical + wheels; the remaining acceptance-48 effect is **listview row + selection**. Q#BP-R4 and §5b's inherited rows still need + implementation: pre-effect disposition/latch ordering, fixed-domain + gesture tails, exact-once termination, cancellation effects and the + pending-release drains. **GUI arc 1b is BLOCKED on this lane and + rebases onto its merge commit.** +- **No new framing document.** Acceptance 48 is already ruled in + `docs/bottom-panel-framing.md`; §5a adds ground truth to it. +- **Current clause split.** DONE: click/focus and terminal activation; + focused-only auto-scroll with passive `view_top` preserved; lossless + and coalesced event delivery; panel document selection; terminal + child reporting/local selection; vertical document and terminal + wheel effects. MISSING here: listview row selection and the + lifecycle/cancellation effects above. Horizontal wheel is split: + child-reporting terminal ticks already emit codes 66/67; the local + terminal branch is deliberately inert; document-panel `view_left` + is explicitly GUI Stage 1b B1–B3's effect, matching the production + comment in `src/editor.rs:2999`–`:3003`. +- **The scoping hazard.** `set_cursor_byte` (`src/editor_core.rs:1216`), + `begin_selection` (`:4691`) and `clear_selection` are + **active-window scoped**; used naively they would move the + DOCUMENT's point, which AC48 forbids. Activation runs before replay + in the same dispatch — **necessary but NOT sufficient**, which is + what revision 5 got wrong. +- **FOUR REPLAY EDGES (revision 6), each a place a plausible + implementation is silently wrong. Three have a precedent in the tree + the panel path simply does not use.** + - **R-a — modifiers dropped.** The daemon destructures `mods` into + `..` (`src/daemon.rs:2425`) and the dispatcher has no modifier + parameter, but `apply_terminal_gesture` gates child reporting on + `!shift && … && modes.mouse_sgr` (`src/editor.rs:3534`). **Shift is + the user's local-selection override**, so a Shift-drag over a + reporting terminal panel would send SGR to the child. Thread + `mods`; row: Shift-drag selects locally, child receives no bytes. + - **R-b — `Drag`/`Up` do not activate**, and another frontend can + interleave between them, so replay must not read ambient + active-window state. Name a **side-window cell→byte adapter** and a + window-TARGETED selection path. `activate_and_position` + (`src/editor.rs:3795`) is the precedent *and* the trap: its + conversion is window-scoped, but it calls `set_active_window_id`. + Rows: interleaved frontend B between A's Down and Drag/Up; orphan + Drag/Up on a passive panel leaves the document mirror + byte-identical. + - **R-c — `panel_grid_size` is the FRAME, not the viewport.** Content + is `rows − 1` (`src/editor.rs:2499`–`:2500`) but `panel_hit_test` + reports across the whole frame (`pmacs-gpu/src/main.rs:7184`), so a + `PanelPointer` can name the **mode-line row**. Terminal viewport is + `rows − 1`. **The document rule is PER KIND, not "the row is + inert"** — the TUI guards `Down(Left)`/`Drag(Left)`/`Down(Right)` + and deliberately not `Up(Left)` or the wheel, so a blanket rule + would stop mode-line scrolling and leave a content-started gesture + unterminated. **The producer must also not arm** on a mode-line + press (`pmacs-gpu/src/main.rs:2878`); a receiver-only rule cannot + stop the resulting orphan. + - **R-d — replacement leaves the gesture latch armed.** `Absent` + clears `pointer_held`/`last_pointer_cell` + (`pmacs-gpu/src/main.rs:6909`) but **`Present`→`Present` does not** + (`:6913`). A press on A then A→B emits a Drag/release for B with no + B press, and **acceptance 49 cannot reject it** — the event carries + B's *current* epochs. The **divider** drag latch already + epoch-scopes itself (`:7288`); the pointer latch never did. **Both + epochs**: a font/scale change advances `geometry_epoch` while + `panel_epoch` holds (`pmacs-protocol/src/panel.rs:61`) and clears + neither field, so a held gesture resumes under a new grid with + valid epochs. **Both fields**: clearing only `pointer_held` leaves + the successor's first same-cell `Move` suppressed as a duplicate + (`:7238`). Four mutations, including the negative one — an ordinary + same-identity refresh must NOT cancel a live gesture. +- **RULED: Q#BP-R1** — a single click **SELECTS a listview row only**. + RET/SPC remain activation (`listview.lua:610`); no click-to-visit and + no double-click-to-visit. Keeps document navigation from becoming an + incidental consequence of replay. +- **Gates:** the four `bottom_panel_*` acceptance suites plus + `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. **No `--protocol`** — + `PanelPointer` and every `MouseKind` it carries already exist on the + wire. +- **Expected rebase conflict, flagged deliberately:** the `gui-stage1b` + branch inserts its own lane at this same position and corrects three + stale headers below (#239/#240 still marked OPEN, "1a is next"). + Those corrections are **left to that branch**; this lane does not + duplicate them. The conflict is a normal insertion collision. + ## Panel cell-mapping generation (v25) — MERGED as #242 (`47b5463`) - **MERGED 2026-08-20T17:25:01Z** at approved head `61f0faf`, merge diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index a00da80..641d94c 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1,5 +1,13 @@ # Bottom panel — framing (window placement + side windows) +**Revision 16 — 2026-08-20, AWAITING APPROVAL.** Revision 16 closes +review of 15: Q#BP-R4 is one pre-effect disposition/gesture-lifecycle +table; projection-raised releases drain after `render_frame` returns +and before its messages are written; horizontal-wheel ground truth is +split by target/domain and the missing document effect is explicitly +owned by GUI Stage 1b; superseded current-state text is removed from +this section and the active-work ledger. + **Revision 4 — pre-implementation, DRAFT after review round 3 plus landed-state audit. Ground truth: canonical `main` @ `ddaa80d` (documentation landing #152; runtime @ `0dd16a5`, GPU initial target / #148 after folding Stage 2 / #149), @@ -7,6 +15,204 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** +**Previously, revision 13 — 2026-08-14. Q#BP-R3 was OVERRULED: +the lane BLOCKS on a protocol-bearing cell-mapping generation (new +§5b).** Revision 12 accepted current-state hit semantics on three +bounds, and all three were wrong — a **foreign** edit moves the mapping +with `view_top` untouched, the error is **unbounded** once ticks, folds, +edits or reloads accumulate, and the stale window lasts until the +frontend **presents** the replacement frame rather than one round trip. +With the narrowness gone there is nothing to trade on. + +The follow-up is a **cell-mapping generation, not a per-frame token**: +it moves when the inverse mapping moves (viewport, folds, wrap/gutter +geometry, buffer content) and holds across focus, styling, cursor and +selection-only repaints, so ordinary drags survive. §5b frames it — +appended variants rather than widened structs, bilateral version +gating, and a drop-before-mutation check. **GUI arc 1e's `OpenTarget` +moves to the following protocol version.** + +**Everything else in revision 12 stands**, including Q#BP-R1, Q#BP-R2 +and the R-a…R-d edges. + +**Previously, revision 12 — APPROVED.** §5a is the acceptance-48 +ground truth and its rulings are settled: **Q#BP-R1** (a single click +selects a listview row only), **Q#BP-R2** (a terminal-chrome wheel is +consumed, daemon-side, before activation), the **R-a…R-d** replay +edges, and the witness matrices A1–A5, B1–B6, D1–D4. + +**§5a took eight review rounds (5–12), and the pattern is worth +keeping: every round found a witness or a rule that would have passed +against a broken implementation.** A test fixture cited as a production +handler; activation ordering mistaken for a safety proof; a mode-line +rule that was per-row when the tree's is per-kind, and document-only at +that; a producer rule the frontend had no information to obey; a +consume check that would have left focus stolen; and — twice — an +assertion added without a state in which it could fail. **No round +found a design disagreement.** The rulings from 8 and 9 survived +intact, and what changed each time was *where* a rule lived or *what a +witness could see*. + +Revision 12 answered review of 11, whose witness had a **vacuous +precondition**. + +"Focus unchanged" proves nothing if the terminal panel is **already +focused**: the below-activation mutation then calls `focus_window` on +the already-active side window, nothing changes, and the row passes. +Leg 2 must **start passive**, and assert it — primary document window +active, terminal side window distinct and passive, controller baseline +captured. The prose is also narrowed: **focus** catches the +below-activation ordering mutation; **controller identity** catches the +shared-path mutation, since `apply_terminal_gesture` claims the +controller at `src/editor.rs:3571` before local handling. Activation +alone claims nothing, so the two assertions are not interchangeable. + +**Previously, revision 11 — SUPERSEDED.** Answered review of 10. + +**The terminal-chrome wheel must be consumed BEFORE ACTIVATION**, not +merely before `apply_terminal_gesture`. `activates` is +`!matches!(kind, Move)` for a terminal panel +(`src/editor.rs:2695`), so the wheel already activates: focus and +`active_frontend` are written at `:2699` ahead of any replay decision. +A consume check below that block would leave the wheel **changing focus +while scrolling nothing and claiming no controller** — the precise +half-state AC48's activate-then-claim rule exists to prevent. §5a now +states the four-step order, with consumption at step 3, and the +document→terminal witness asserts **focus and controller identity +unchanged** alongside no bytes, no scrollback and no document +movement — assertions that are load-bearing, because the +consume-below-activation mutation moves nothing and is invisible +without them. + +**Previously, revision 10 — SUPERSEDED.** Answered review of 9, which +put a correct ruling **on the wrong side of the seam**. + +**The GPU cannot know whether a panel holds a terminal.** `PanelFrame` +carries `buffer_id`, both epochs, `size`, `cells`, `cursor` and +`focused` and **no target-kind discriminator** +(`pmacs-protocol/src/panel.rs:73`); `state.terminal` +(`pmacs-gpu/src/main.rs:1964`) is the **primary full-window** terminal, +not the side-window projection. So revision 9's producer rule +*"terminal chrome wheel: do not send"* was unimplementable without a +new wire field, which this lane must not add. + +**Q#BP-R2's OUTCOME is unchanged; its ENFORCEMENT POINT moves.** The +producer is now target-blind throughout — it claims the chrome wheel +and sends it for every panel — and the daemon, which resolves the side +window and knows the buffer kind, decides: document → `scroll_window`; +terminal → **consume**, with no child bytes, no local scrollback and no +document fallthrough. The producer/receiver tables are split by seam +side accordingly, and the witness is **one frontend across a +document→terminal replacement**, so only the target differs. + +**Previously, revision 9 — SUPERSEDED.** Both items reversed a decision +revision 8 made: + +- **Q#BP-R2 is OVERRULED — a terminal-chrome wheel is CONSUMED, not + clamped.** SGR wheel input is **coordinate-bearing**: `encode_mouse` + writes `coord.col + 1` / `coord.row + 1` into the sequence + (`src/terminal/input.rs:102`, `:146`), which A4 pins exactly. Clamping + **fabricates a hit on the final content row**, so a + position-routing application acts on a cell the user never pointed + at. And unlike `Up`, a wheel has **no liveness obligation** — + dropping one strands nothing. Consumed, never fallen through to the + document; TUI parity restored. +- **R-c2's field choice is corrected: a SEPARATE + `gesture_last_content_cell`.** Revision 8 said to retain the `Down` + cell in `last_pointer_cell`, which would break a **tested + guarantee** — that field is cleared on press precisely so the first + same-cell `Drag` reaches the daemon + (`pmacs-gpu/src/main.rs:19841`). Dedupe baseline and termination + fallback are different jobs with different lifetimes. +- **The crossing table follows:** a content-originated `Drag` over + chrome is **normalized and then subject to the ordinary dedupe**, not + promised as sent. **`Up` is the load-bearing crossing event** and the + only one promised unconditionally. + +**Previously, revision 8 — SUPERSEDED.** Answered review of 7: + +- **R-c is target × gesture-ORIGIN, not kind alone.** The TUI's + per-kind rule is **document-only** — for terminals it rejects every + kind on the mode line (`src/editor.rs:3273`) and passes a + content-sized viewport. Raw chrome coordinates are actively unsafe + there: `apply_terminal_gesture`'s reporting branch is bounds-checked + (`:3560`), so a chrome row **falls through to the LOCAL branch** — a + reporting child gets `Down` and no `Up`, and a chrome wheel becomes + local scrollback. **A content-originated gesture terminates at its + last valid CONTENT coordinate.** New: **Q#BP-R2**, ruling that a + chrome wheel over a terminal panel **clamps** rather than dropping — + a deliberate divergence from the TUI, flagged for overrule. +- **R-c2: the producer never remembers the `Down` cell.** Arming clears + `last_pointer_cell` (`pmacs-gpu/src/main.rs:7250`) and only motion + refills it, so once chrome stops being a `PanelCell`, a `Down` + + immediate release has **no fallback coordinate**. Retain it at arm + time; for a reporting terminal the row asserts the **exact child + release bytes**. +- **A1 was a negative row standing alone** — disabling reporting + entirely satisfies it. **A3–A5** add positive controls on the same + terminal: exact SGR bytes for `Down`/`Drag`/`Up` and for the wheel, + plus the non-reporting wheel's local scrollback effect. +- **Four witness seams tightened.** B2 reads the **raw `selection`**, + because `active_region()` answers `None` on both sides of the `Up` + (`src/editor_core.rs:4684`); B4 pins the **exact selected word**; B6 + runs its sentinel through **single and double** click; D4 uses a + **changed** frame with unchanged epochs — the `Down`'s own focus + repaint — since a byte-identical duplicate is suppressed before the + reset could run (`pmacs-gpu/src/main.rs:6918`). + +**Previously, revision 7 — SUPERSEDED.** Five contract gaps, three of +them corrections to 6's own rules: + +- **R-c's "the last row is inert" was wrong.** The TUI's rule is **per + KIND**: `inner_rows` guards `Down(Left)`/`Drag(Left)`/`Down(Right)` + and deliberately does not guard `Up(Left)` or the wheel. A blanket + rule would stop mode-line scrolling and leave a content-started + gesture unterminated. **And the producer arms before the receiver can + refuse** — a mode-line `Down` sets `pointer_held` locally + (`pmacs-gpu/src/main.rs:2878`), so a receiver-only rule cannot + prevent the orphan. R-c now carries a per-kind producer/receiver + table, both crossings, and an explicit wheel ruling. +- **R-d covered panel identity but not GEOMETRY identity.** A font or + scale change advances `geometry_epoch` while `panel_epoch` holds, and + the transition clears neither pointer field, so a held gesture + resumes under a new grid with *current, valid* epochs. Four + mutations now, including the **negative** one: an ordinary + same-identity refresh must not cancel a live gesture. +- **R-d also needed `last_pointer_cell` constrained separately** — + clearing only `pointer_held` leaves the successor's first same-cell + `Move` suppressed as a duplicate. +- **R-a witnessed only the terminal.** The document path consumes Shift + too, for the selection anchor, so threading modifiers into + `apply_terminal_gesture` alone would pass the proposed row while + breaking Shift-click in document and listview panels. +- **R-b's rows were satisfiable by doing nothing.** They now pin A's + anchor and cursor after the Drag, prove `Up` collapses an empty + click, and carry Q#BP16's multi-click and Context semantics plus a + listview visit sentinel for Q#BP-R1. + +**Previously, revision 6 — SUPERSEDED.** Answered review of 5. +**Q#BP-R1 is RULED: a single click SELECTS a listview row only**; +RET/SPC remain activation, and this lane adds no click-to-visit. +Revision 5 concluded that the activation ordering made replay safe; +**it is necessary but not sufficient**, and §5a now carries the four +edges it missed — **R-a** modifiers dropped at the daemon boundary, +which breaks Shift's local-selection override; **R-b** `Drag`/`Up` do +not activate and another frontend can interleave, so replay needs a +named side-window cell→byte adapter and a window-targeted path; +**R-c** `panel_grid_size` includes the mode-line row while content is +`rows − 1`, so the terminal viewport must be `rows − 1` and document +replay needs the TUI's "mode-line click: reserved" rule; **R-d** +`Present`→`Present` replacement leaves the pointer latch armed, which +acceptance 49 cannot catch because the orphan gesture carries current +epochs. + +**Previously, revision 5 — SUPERSEDED. Added §5a: acceptance 48 +re-measured against production at `72da24a`.** The finding stands: AC48 +is **half implemented** — focus, activation, the focused-only clamp and +the coalescing rules all landed; **listview row selection, panel +selection, terminal mouse reporting and wheel replay did not**. No +ruling in §3 or §5 changes. + Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window pinned to the bottom of the frame, resizable by dragging its divider, which feature code targets **by policy** instead of by stealing the selected window. @@ -1770,9 +1976,1026 @@ cannot preserve the old panel-focused attach leak. preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR then runs the full gate suite. +## 5a. Acceptance 48 — ground truth, RE-MEASURED at `2c0d3ff` (2026-08-20) + +**Status: revision 16 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 +and **blocked** this lane on a protocol-bearing mapping generation. +That slice was framed as §5b, approved at revision 16, and **merged as +#242 (`47b5463`)**, so **revision 13's block is DISCHARGED** and its +ruling stands as history rather than as a gate. Revision 14 answered +what the lane inherits from a substrate that changed underneath it. + +**Revision 16 closes review of 15.** It replaces the two competing +Q#BP-R4 matrices with one pre-effect disposition/lifecycle table; +names the projection drain at the only enforceable seam — after +`render_frame` returns and before its messages are written; splits +horizontal-wheel ground truth by target and gesture domain; records +GUI Stage 1b as the owner of document-panel horizontal scrolling; and +removes revision 14's now-false remeasurement obligation from the live +section and ledger. + +**Revision 15 answers review of 14, and four of its five changes are +corrections rather than additions:** + +- **Q#BP-R4's release half was bookkeeping wearing an effect's + clothes.** It said a chrome `Up` should "consume" the latch and + claimed that prevents a child left button-down. It prevents nothing: + the daemon drops the record, and terminal chrome returns before + replay. Now ruled as **TERMINATE**, with the row asserting the + child's release or the local selection's completion — and a + no-duplicate leg. +- **The pending-release slot had an invariant but no ordering.** The + seam replays before it arms, so the invariant could only fire after + the damage. **Drain order is now part of the ruling** — before any + subsequent panel-pointer effect, before detach teardown, before the + next frame — with an old-release-before-new-press row. +- **The ground truth was knowingly false and is now RE-MEASURED**, at + `2c0d3ff`, rather than deferred to implementation. Two clauses the + old table called MISSING are DONE, and a third — the horizontal + wheel — exposed an ownership gap that revision 16 now splits and + assigns explicitly. +- **"This lane MOVES the grade" was wrong.** Step 5/GPU is floored + `Partial` by 5(a) IME and step 8 is already `Works`; no journey cell + moves. The draft had read this section's own MISSING column as if it + were the scorecard. +- Q#BP-R4 also **drops a false TUI claim**: only semantic legacy and + mapped peers reach this dispatcher. + +**Why this section exists.** GUI arc Stage 1b's ground-truth pass found +that `PanelPointer` replayed nothing past focus. **That is no longer +the state of this branch**, and the paragraph that said so — with the +wheel "dead on both axes" — is superseded below. This lane has since +landed the document and terminal replay, and §5b merged underneath it. +1b still rebases onto this lane's merge. + +**RE-MEASURED at `2c0d3ff`, the tip this revision was written against.** +The 2026-08-14 measurement was taken at `72da24a`, **before this +branch's own implementation commits and before §5b**, so it described +neither the branch nor main. Every anchor in it had also moved. It is +kept below as history; **this table is the current one**: + +| clause | status at `2c0d3ff` | production anchor | +|---|---|---| +| click-to-focus | **DONE** | `activates` → `focus_window` (`src/editor.rs:2888`) | +| terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates`, split by `is_terminal` | +| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2698`, which cites "A2A-3 / parent 48" | +| move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `coalesce_kind` (`pmacs-gpu/src/attach.rs:338`) | +| **panel selection** | **DONE — was MISSING** | `replay_panel_document_gesture` (`src/editor.rs:2914`), called at `:2903`: `Down`/`Drag`/`Up(Left)`, shift-extend, double-click word select, right-press | +| **terminal mouse reporting** | **DONE — was MISSING** | `apply_terminal_gesture` called at `src/editor.rs:2899`, viewport `content_rows` | +| **vertical wheel moves the panel's viewport** | **DONE — was MISSING** | document `ScrollUp`/`ScrollDown` → `scroll_window` (`src/editor.rs:2925`); reporting and local terminal paths share `apply_terminal_gesture` | +| **horizontal wheel, terminal precedence selects child reporting** | **RECEIVER DONE; PRODUCER AWAITS GUI 1b** | `apply_terminal_gesture` reaches `encode_mouse`, whose `ScrollLeft`/`ScrollRight` codes are 66/67 (`src/terminal/input.rs:130`–`:131`) | +| **horizontal wheel, terminal precedence selects local handling** | **RULED INERT** | Shift, reporting-off or a scrolled-back view can select the local branch; terminal scrollback is vertical and there is no local horizontal viewport, so the shared path deliberately has no `ScrollLeft`/`ScrollRight` arm | +| **horizontal wheel, document panel** | **NAMED DEFERRAL: GUI Stage 1b B1–B3** | `ScrollLeft`/`ScrollRight` are claimed and dropped at `src/editor.rs:3004`; the comment at `:2999`–`:3003` already assigns horizontal panel scrolling to 1b, whose framing now explicitly includes the panel-document surface | +| **listview row selection** | **MISSING** | — no listview handling on the replay path; Q#BP-R1 ruled the semantics (single click selects only), and nothing implements them | +| without disturbing the document mirror | **the constraint on all of the above** | — | + +**Two findings the re-measurement produced**, neither of which a +line-number patch would have surfaced: + +- **"Horizontal wheel" was not one verdict.** A tick for which terminal + precedence selects child reporting already sends the horizontal SGR + code; the local terminal branch has no horizontal viewport to move; + only a document panel lacks an effect. That last leg is a **named + deferral to GUI Stage 1b B1–B3**, + not an unexplained catch-all: 1b owns horizontal tick production, + sign, per-surface residuals and the window-targeted `view_left` + effect. The panel-replay lane remains 1b's prerequisite. +- **Two of the four "MISSING" effects are DONE on this branch.** The + section had been telling every reader that replay does nothing while + the branch it describes had implemented most of it. + +### Superseded — the 2026-08-14 measurement at `72da24a` + +Kept because it is what the lane was scoped against, and because two of +its verdicts are now wrong rather than merely stale. Measured clause by +clause: + +| clause | status | production anchor | +|---|---|---| +| click-to-focus | **DONE** | `dispatch_semantic_panel_pointer`'s `activates` → `focus_window` (`src/editor.rs:2701`) | +| terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates` computation, split by `is_terminal` | +| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2568`–`:2571`, which already cites "A2A-3 / parent 48" | +| move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `pmacs-gpu/src/attach.rs:374`–`:381` — `Move` key 6, `Drag` key 7, everything else unkeyed | +| **listview row selection** | **MISSING** | — | +| **panel selection** | **MISSING** | — | +| **terminal mouse reporting** | **MISSING for panels** | the path exists: `apply_terminal_gesture` | +| **wheel moves the panel's viewport** | **MISSING** | the mechanism exists: `scroll_window` | +| without disturbing the document mirror | **the constraint on all of the above** | — | + +### The replay is mostly WIRING, and both mechanisms already exist + +**Terminals.** `apply_terminal_gesture` (`src/editor.rs:3525`) is +documented as *"The one terminal pointer path, shared by both frontend +kinds"* — TUI via crossterm, semantic frontend via +`FrontendEvent::TerminalPointer` — and it already drives child mouse +reporting, selection and scrollback. A panel terminal needs the **same +call**, not a second implementation: `side_window_for` +(`src/editor_core.rs:3146`) gives the window, `TerminalViewKey::new` +the key, and `panel_grid_size` — which the dispatcher **already +fetches** — the viewport size. **A wheel-only bridge here would be the +wrong shape**: the shared path handles every kind at once. + +**Documents.** `scroll_window` (`src/editor.rs:3845`) is window-scoped +throughout, cursor carry included, and says so: *"a wheel event names +the pane under the pointer and does NOT activate it, so the map must +come from `win_id`"*. Its cursor carry moves **that window's** point — +the panel's own, not the document mirror. + +### The scoping hazard, and why the activation rule already answers it + +**The selection and cursor API is ACTIVE-WINDOW scoped.** +`set_cursor_byte` (`src/editor_core.rs:1216`), `begin_selection` +(`:4691`) and `clear_selection` all write `active_window_mut()`. Used +naively from the panel path they would move the **document's** point — +precisely what "without disturbing the document mirror" forbids. + +**Activation ordering is NECESSARY BUT NOT SUFFICIENT — revision 5 +stopped here and was wrong to.** It holds only for the gestures that +activate, in a session that nothing interleaves with, on a +presentation that does not change mid-gesture, with modifiers intact +and the mode line excluded. **Each of those four provisos is an edge +revision 6 had to add** (R-a…R-d below). The ordering itself is real: + +- **Document panel** — `activates` on `Down(_)`. A drag-select is + Down → Drag → Up, and the Down focused the panel, so the later + gestures act on an active window legitimately. +- **Terminal panel** — `activates` on every non-`Move`, so reporting, + selection and wheel all run focused. This is AC48's own + controller-ownership clause. +- **The one gesture that does NOT activate** is a document panel's + wheel — and it needs only `scroll_window`, which is window-scoped. + +So the ordering rule is: **replay after activation, and anything +reachable without activation must use a window-scoped mechanism.** +That is the floor, not the contract — R-a through R-d are the rest of +it, and three of the four are places where the tree already contains +the right precedent and the panel path simply does not use it. + +### Q#BP-R1 — does a listview row visit on click? **RULED: no — single click SELECTS only** + +RET/SPC remain the activation path (`builtin/runtime/listview.lua:610`, +which reads `pmacs.editor.cursor_line()` and calls `on_visit`). **This +lane adds no click-to-visit and no double-click-to-visit.** That +follows acceptance 48's wording — it names row *selection* — and keeps +document navigation from becoming an incidental consequence of wiring +replay. + +#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: BLOCK on a protocol-bearing mapping generation** + +**Revision 12's answer — accept current-state semantics — is +OVERRULED, and all three of its bounds were wrong.** They are recorded +because each was the reason the trade looked cheap: + +| the bound I claimed | why it does not hold | +|---|---| +| "self-inflicted — the same frontend must move the view and then click" | **another frontend, or a background process, can edit the same buffer.** The cell→byte mapping changes with `view_top` untouched, and the clicking frontend did nothing | +| "bounded by `SCROLL_LINES`" | **unbounded.** Multiple wheel ticks, paging, folds, edits or a reload can all land before the new frame is seen | +| "one round trip" | **until the frontend actually PRESENTS the replacement frame** — a slow or backed-up frontend widens the window arbitrarily | + +With those gone there is no narrowness left to trade on, so **the lane +blocks on the wire fix** rather than shipping a mis-hit whose size and +cause are both unbounded. + +**And the follow-up is NOT a per-frame token.** A token that changed on +every frame would invalidate a gesture on every repaint, which is the +same mistake `panel_epoch` deliberately avoids — it would break drags +outright. What is needed is a **CELL-MAPPING GENERATION**: an identity +of the *inverse mapping*, not of the frame. + +- **It changes when the inverse mapping changes**: viewport (`view_top` + or grid size), folds, wrapping or gutter geometry, or buffer content. +- **It is stable across repaints that cannot move a cell's byte**: + focus, styling and theme, cursor movement, and selection-only + changes. That stability is what lets an ordinary drag survive the + repaints it provokes. + +See **§5b** for the slice. + +#### Superseded — revision 12's reasoning, kept for the record + +**The hole.** `PanelPointer` carries `geometry_epoch`, `panel_epoch`, +`buffer_id` and a `coord` — **and nothing identifying the frame CONTENT +the user was looking at** (`pmacs-protocol/src/message.rs:500`). +`panel_epoch` is deliberately *"stable across ordinary frames of one +continuously present window/buffer"* (`panel.rs:61`), so an ordinary +repaint changes no epoch at all. + +**The race that follows is real.** A document-panel wheel moves +`view_top` **daemon-side**. The daemon repaints and emits a new frame. +Before that frame reaches the GPU, the user clicks. Every validation +passes — same buffer, same epochs — and the daemon inverts the cell +through its **current** `view_top`, selecting a row the user never saw. +Off by up to `SCROLL_LINES` (3). **No existing gate can reject it**, +because nothing about the event is stale by any test the ladder +applies. + +**Why the epochs cannot be stretched to cover it.** Moving +`panel_epoch` on content change would invalidate a gesture on every +repaint, which breaks drags outright — the field is stable *by design*, +and that design is what makes selection possible. + +**Why a fix is not free.** Closing it properly needs a **per-frame +token** on `PanelFrame`, echoed by `PanelPointer` — **a wire change**, +which makes it a protocol-bearing slice. This lane is explicitly +non-protocol-bearing, and GUI arc 1b is blocked behind it. + +**A daemon-only mitigation was considered and does not work.** Having +the daemon invert against the `view_top` it used for its **last emitted +frame** sounds like it removes the wire dependency, but it does not: +the daemon still cannot know **which** emitted frame the user saw, and +the failing window — frames emitted after the one on screen — is +exactly the same one. Without a token echoed back, the information does +not exist on the receiving side. + +**Ruling: this lane accepts CURRENT-STATE hit semantics**, and says so +rather than leaving it undiscovered: + +- A panel cell is resolved against the daemon's state **at the moment + the event is processed**, not against the frame the frontend painted. +- **The window is narrow and self-inflicted**: it requires the same + frontend to change the panel's view and then click inside one + round-trip. It cannot arise from another frontend's activity, because + a foreign edit that moves `view_top` is not a thing panels do. +- **The magnitude is bounded** by whatever moved the view — one wheel + step, `SCROLL_LINES` rows. +- **The TUI is unaffected.** It has no round trip; the hazard is + structural to a remote frontend inverting cells against mutable + daemon state. + +**Named follow-up, not a shrug: `PanelFrame` gains a content token and +`PanelPointer` echoes it, in the next protocol-bearing slice.** The +daemon then drops a gesture whose token no longer matches — the same +shape as the epoch ladder, one level finer. It is recorded here so the +next wire slice inherits it rather than rediscovering the race. + +**Overrule this if you would rather block the lane on a protocol +slice.** The trade is explicit: a narrow, bounded, same-frontend +mis-hit now, against serializing this lane and 1b behind a v25 wire +change. + +### The four replay edges — none of which "activation ordering" covers + +§5a's first draft concluded that the activation rule made the replay +safe. **It does not, and each gap below is a place where a plausible +implementation is silently wrong.** + +#### R-a. Modifiers are dropped before they reach the shared path + +`FrontendEvent::PanelPointer` **carries `mods`**, and the daemon +**destructures them into `..`** (`src/daemon.rs:2425`); +`dispatch_semantic_panel_pointer` has no modifier parameter at all. + +**This breaks terminal precedence, not merely fidelity.** +`apply_terminal_gesture` opens with +`let shift = modifiers.contains(TerminalModifiers::SHIFT)` (`:3534`) +and gates child reporting on `!shift && … && modes.mouse_sgr`. **Shift +is the user's override for "select locally instead of talking to the +child."** Arriving with modifiers zeroed, a Shift-drag over a +reporting terminal panel sends SGR to the child instead of selecting. + +**And the terminal is not the only consumer.** The ordinary document +path reads Shift too: `dispatch_pointer`'s `Down` arm computes +`extending = mods.contains(SHIFT)` and `keep_anchor`, then either +**extends from the previous cursor** or collapses the selection to the +clicked byte (`src/editor.rs:3673`–`:3687`). So Shift-click in a +**document or listview panel** is selection extension, and threading +modifiers *only* into `apply_terminal_gesture` would **pass the +terminal row while leaving document panels broken** — the exact shape +of a witness that certifies half a fix. + +**Thread `mods` through** the destructure, the dispatcher signature, +and into **both** consumers. Two rows, and both mutations: + +| # | row | mutation | +|---|---|---| +| A1 | terminal panel, reporting enabled: **Shift**-drag selects locally, child receives **no bytes** | drop `mods` before `apply_terminal_gesture` → the child receives bytes | +| A2 | document/listview panel: **Shift**-click **extends** the selection from the prior cursor; unmodified click collapses it | drop `mods` before the document path → Shift-click collapses, A2 fails while A1 still passes | + +A2's mutation is deliberately separate from A1's: a single "drop +`mods`" at the boundary bites both, which proves the boundary matters +but not that **each consumer** is wired. + +**A1 is a NEGATIVE row and cannot stand alone.** It asserts bytes that +must *not* appear, and **disabling child reporting entirely satisfies +it** — as does never reaching `apply_terminal_gesture` at all. The +whole edge matrix could stay green against a replay that never reports. +Positive controls, on the **same terminal** as A1 so the pair is a +genuine discriminator: + +| # | row | mutation | +|---|---|---| +| A3 | unmodified content `Down`/`Drag`/`Up`, reporting enabled → the child receives the **exact SGR byte sequences** for each | bypass `apply_terminal_gesture` → no bytes, A3 fails while A1 still passes | +| A4 | unmodified content **wheel**, reporting enabled → the child receives the **exact SGR wheel bytes** | as A3 | +| A5 | same wheel, reporting **disabled** → **no bytes**, and the view's **local scrollback moves** | route the wheel around the shared path → the scrollback does not move | + +A3–A5 pin the *positive* half: that replay reaches the shared path and +the child hears what it should. A1 then pins the one case where it must +not. + +#### R-b. Activation does not make the document path target-safe + +`activates` is true for a document panel's `Down(_)` only, so **`Drag` +and `Up` do not activate**, and **another frontend's input can +interleave between them** — sessions are independent and nothing +freezes the active window for the duration of a gesture. A replay that +reads ambient active-window state for `Drag`/`Up` therefore acts on +whatever is active *then*, which may be a different window entirely. + +**The framing names the mechanism rather than leaving it to the +implementation: an explicit side-window cell→byte adapter, and +selection routed through a window-TARGETED path.** The precedent and +the trap are the same function — `activate_and_position` +(`src/editor.rs:3795`) is already window-scoped in its conversion (it +takes `win_id` and uses *that* window's `view_top` and fold map, per +the round-3 F1 correction) **but it also calls +`set_active_window_id`**, and the selection APIs it feeds +(`set_cursor_byte`, `begin_selection`) write `active_window_mut()`. +Panel replay needs the conversion **without** the ambient write. + +**The witnesses must pin the RESULT, not just the absence of collateral +damage.** "Only A's panel changes" is satisfied by an implementation +that drops the tail entirely and changes nothing anywhere. + +| # | row | what it pins | +|---|---|---| +| B1 | panel A `Down` → **frontend B input** → panel A `Drag` | A's **anchor is the Down cell's byte and A's cursor is the Drag cell's byte**, exactly; B's window and the document mirror are unchanged | +| B2 | panel A `Down` → `Up` at the **same** cell | the window's **raw `selection` is `None`** afterwards — see below | +| B3 | orphan `Drag`/`Up` on a **passive** panel, no preceding `Down` | the document mirror is **byte-identical**: cursor, selection, `view_top` | +| B4 | **repeated left `Down`s** at one cell | the **exact word** at that cell is selected after the second `Down` — the multi-click actually resolved, per Q#BP16 | +| B5 | `Down(Right)` on a panel cell | the **context menu** opens, per Q#BP16 — a right press is not a selection gesture | +| B6 | **single** click, then **repeated/double** click, on a listview row | the row is **selected** and the `on_visit` sentinel **never fires**, in *both* — Q#BP-R1 forbids double-click-to-visit too | + +**B2 must read the raw selection, not `active_region()`.** That helper +returns `None` *"if no region is set **or it is empty**"* +(`src/editor_core.rs:4684`–`:4688`), so it answers `None` **both before +and after** the `Up` and cannot see the residue at all. The residue +being guarded against is an **active-but-empty selection** — a +`Some(Selection { anchor })` whose anchor equals the cursor — which +would make the next shift-motion extend from a stale anchor. Inspect +the **owning window's** `selection` field directly. + +**B6 runs both click shapes** because the ruling forbids +double-click-to-visit as well as single. A sentinel that only tries a +single click leaves the more tempting implementation — visit on the +second click, like a file manager — completely uncovered. + +B4 and B5 are not new contracts: Q#BP16 already states that repeated +left `Down`s are what the click state reads as a multi-click and that +`Down(Right)` is the context gesture, *"so neither may collapse"* +(§3, Q#BP16). They are in this matrix because **replay is where those +statements first become executable** — until now nothing replayed, so +nothing could contradict them. + +B6 is the sentinel for the ruling above: selection without activation +is only meaningful if something fails when a click visits. + +#### R-c. `panel_grid_size` is the FRAME, not the terminal viewport + +The panel's last row is its **mode line**. Projection says so +explicitly: `content = Rect::new(0, 0, size.rows.saturating_sub(1), +size.cols)` against `outer` at full height (`src/editor.rs:2499`– +`:2500`). But **`panel_hit_test` reports cells across the whole +frame** — it passes `frame.size` (`pmacs-gpu/src/main.rs:7184`) — so a +`PanelPointer` **can name the mode-line row**. + +Passing `panel_grid_size` straight to `apply_terminal_gesture` as the +viewport therefore **makes the mode line a child terminal cell**: the +child is told about a row it does not own, and every coordinate below +it is off by the same row when the size is used for clamping. + +- **Terminal panels:** the viewport is **`rows − 1`**. +- **Document panels:** the mode line needs an explicit rule. + +**"The last row is inert" is WRONG, and revision 6 said it.** The TUI's +rule — which is the precedent — is **per kind**, not per row. +`inner_rows` guards `Down(Left)` (`src/editor.rs:3303`), `Drag(Left)` +(`:3331`) and `Down(Right)` (`:3348`), and **`Up(Left)` (`:3339`), +`ScrollUp` (`:3358`) and `ScrollDown` (`:3362`) are deliberately NOT +guarded.** A blanket +"inert" rule would break two things at once: a wheel over the mode line +would stop scrolling, and a gesture that begins in content and releases +over the mode line would **never terminate**. + +**And the producer arms before the receiver can refuse.** `PanelCell` +comes from `panel_hit_test`, which spans the whole frame, so a +mode-line `Down` runs `set_panel_pointer_held(true)` **locally** +(`pmacs-gpu/src/main.rs:2878`–`:2880`) before any daemon decision. +Dragging from there into content then emits a `Drag` with no accepted +`Down` — an orphan the daemon cannot distinguish from a real one. **A +receiver-only rule cannot fix this**; the producer must not arm on a +mode-line press. + +**Kind alone is still not enough. The rule is target × gesture +ORIGIN**, and revision 7's table had neither axis complete. + +**The TUI's per-kind rule is DOCUMENT-ONLY.** For a terminal window it +rejects **every** kind on the mode line before any per-kind match: +`if local_row >= inner_rows || … { self.mouse_click = None; return; }` +(`src/editor.rs:3273`), and it passes the content-sized viewport +`CellSize::new(inner_rows, …)` (`:3272`) to `dispatch_terminal_mouse`. +Terminals never see a chrome coordinate at all. + +**And raw chrome coordinates are actively unsafe for terminals**, +because `apply_terminal_gesture`'s reporting branch is bounds-checked: +`coord.row < screen_size.rows && coord.col < screen_size.cols` +(`src/editor.rs:3560`–`:3561`). A chrome row equals `screen_size.rows`, +so the check **fails and the gesture falls through to the LOCAL +branch**. Two concrete wrongnesses follow: a reporting child gets a +`Down` and then **no `Up`** (its release became a local +`finish_selection`), and a chrome wheel becomes **local scrollback** +instead of behaving as a content wheel does. + +##### The producer is TARGET-BLIND, and the table must respect that + +**Revision 9 split the producer column by target. The GPU cannot make +that split.** `PanelFrame` carries `buffer_id`, both epochs, `size`, +`cells`, `cursor` and `focused` — **and no target-kind discriminator** +(`pmacs-protocol/src/panel.rs:73` onward). The panel is an **opaque +cell projection** to the frontend. `state.terminal` +(`pmacs-gpu/src/main.rs:1964`) describes the **primary full-window** +terminal, not the side-window, so it cannot answer the question either. + +A producer rule reading "terminal chrome wheel: do not send" is +therefore **unimplementable without a new wire field**, and this lane +is explicitly non-protocol-bearing. **Every producer rule must be +target-blind; every target-dependent decision belongs to the daemon**, +which resolves the side window and knows the buffer kind +(`is_terminal(buffer_id)`). + +**Producer — by kind and origin only:** + +| kind | origin | producer | +|---|---|---| +| `Down(Left)`, `Down(Right)` | chrome | **do not arm, do not send** | +| `Drag(Left)` | chrome | not sent — never armed | +| `Drag(Left)` | content, now over chrome | normalize to the last valid CONTENT cell, then **ordinary dedupe** | +| `Up(Left)` | content, now over chrome | **always send**, normalized | +| wheel | chrome | **claim it and send**, carrying its valid frame coordinate | + +These collapse safely because the two targets want the **same producer +behaviour** everywhere except the wheel: a chrome press is reserved by +documents and dropped by terminals — both drop; a crossing `Up` must +arrive for both, to terminate a selection or to deliver a release. The +wheel is the sole divergence, and it is now decided receiver-side. + +**Receiver — after validation and resolving the side window:** + +| kind | document panel | terminal panel | +|---|---|---| +| chrome `Down`/`Drag` | reserved — drop | drop, matching the TUI | +| crossing `Drag` (normalized) | process when it arrives | process at the content coordinate | +| crossing `Up` (normalized) | terminate the gesture | **replay at the last valid CONTENT coordinate** — never the chrome row | +| chrome wheel | **process through `scroll_window`** | **CONSUME, before activation** — no focus change, no controller claim, no child bytes, no local scrollback, no document fallthrough (Q#BP-R2) | + +**The chrome wheel carries a chrome coordinate over the wire, and that +is fine**: it is a valid frame cell, the daemon's coord validation +accepts it, `scroll_window` is a window-level move that does not read +it, and the terminal branch never forwards it to a child. **The +coordinate is never fabricated and never reaches an application.** + +**`Up` is the load-bearing crossing event, and the only one promised +unconditionally.** A content-originated gesture terminates at its last +valid content coordinate — that is what keeps a reporting child from +being told about a row it does not own while still receiving its +release. + +**A crossing `Drag` is NOT promised as sent.** Once normalized, its +coordinate is frequently the one already reported, and the ordinary +motion dedupe suppresses it — correctly. Promising delivery would +oblige the producer to defeat its own dedupe for no gain: the daemon's +state after a suppressed same-cell `Drag` is identical. + +#### Q#BP-R2 — a chrome wheel over a TERMINAL panel **RULED: CONSUME, do not clamp** + +Revision 8 ruled *clamp*, on a consistency argument. **That was wrong, +and the reason is that SGR wheel input is COORDINATE-BEARING.** +`encode_mouse` takes a `coord` and writes `coord.col + 1` and +`coord.row + 1` into the sequence (`src/terminal/input.rs:102`, +`:146`–`:147`) — which A4 now pins exactly. **Clamping would fabricate +a hit on the terminal's final content row**, so an application that +routes wheel input by position could act on a cell the user never +pointed at. The consistency gain is cosmetic; the cost is a synthetic +coordinate delivered to a program as though it were real. + +**And the wheel carries no liveness obligation.** `Up` must be +normalized because a gesture left un-terminated hangs — the daemon +holds a button down forever. A wheel tick is self-contained: dropping +one ends nothing and strands nothing. + +**Ruling: a wheel over a terminal panel's chrome is CONSUMED** — not +reported, not scrolled locally, and **not fallen through to the +document**. The band owns the pixel either way. This also restores TUI +parity, which revision 8 traded away for the weaker argument. + +Document chrome is unchanged: a wheel there still scrolls the panel. + +**Enforced in the DAEMON, not the frontend.** Revision 9 wrote this as +a producer rule, which the GPU cannot implement — it has no way to know +the panel holds a terminal (see the seam note above). The producer +claims the chrome wheel and sends it for **every** panel; the daemon +resolves the side window and consumes it when the buffer is a terminal. + +##### And it is consumed BEFORE ACTIVATION, not merely before replay + +**"Consume before `apply_terminal_gesture`" is not early enough.** +Every non-`Move` terminal panel gesture activates the side window +first, and `activates` is computed as `!matches!(kind, Move)` +(`src/editor.rs:2695`–`:2698`), which **includes the wheel**. Focus and +`active_frontend` are written at `:2699`–`:2701`, *before* any replay +decision. + +A consume check placed after that block leaves the exact half-state +AC48's activate-then-claim rule exists to prevent: **the wheel changes +FOCUS while scrolling nothing and claiming no controller.** The panel +steals focus and does not move. + +**A terminal-chrome wheel is not a terminal gesture at all**, and the +dispatcher must treat it that way. The order is: + +1. Authenticate and validate the panel event (the existing ladder). +2. Resolve the side window and determine its **buffer kind**. +3. **If terminal + chrome wheel: consume IMMEDIATELY** — before + `focus_window`, before `active_frontend`, before any controller + claim, before any command-chain mutation, and before the shared + terminal path. +4. Otherwise, the existing activation and replay rules. + +Step 3 is implementable where it needs to be: `is_terminal` is resolved +from `buffer_id` **before** the activation block, so the kind is +already known at that point. + +**Witness — one frontend across a document→terminal replacement**, so +the two outcomes are separated by nothing but the buffer kind: + +1. panel shows a **document**; wheel over chrome → **the panel + scrolls**. +2. the panel is replaced by a **terminal**; wheel over chrome → + **nothing changes at all** — no child bytes, no scrollback movement, + no document scroll, **and the focused window and terminal controller + identity are unchanged**. + +##### Leg 2 must START PASSIVE, or "focus unchanged" is vacuous + +**A focus assertion proves nothing if the panel already has focus.** +With the terminal side window active, moving the consume check below +activation calls `focus_window` on **the window that is already +active** — nothing changes, and the row stays green against the very +mutation it exists to catch. + +**Preconditions, asserted before the wheel, not merely arranged:** + +- the **primary document window is active**; +- the **terminal side window is distinct from it and PASSIVE**; +- the **terminal controller identity is captured as a baseline**. + +Asserted rather than assumed, because each is exactly the kind of setup +detail a later edit silently changes — and each failure mode is a +witness that passes while proving nothing. + +**With that setup the two mutations separate cleanly, and each is +caught by a DIFFERENT assertion:** + +| mutation | what it does | caught by | +|---|---|---| +| the consume check sits **below** the activation block | `focus_window` runs and the panel takes focus; nothing scrolls, so every movement assertion still passes | **focus** — and focus alone | +| the terminal branch calls `apply_terminal_gesture` | `claims_control` is `!matches!(kind, Move)` (`src/editor.rs:3555`), so a wheel **claims the panel's controller at `:3571`, before any local handling** at `:3575`; the chrome coordinate then fails the reporting bounds check and drops into the local branch, which may also move scrollback | **controller identity** — plus scrollback when it moves | + +**The two assertions are not interchangeable**, which revision 11's +prose blurred by naming them together. **Focus catches the ordering +mutation; controller identity catches the shared-path mutation.** +Activation alone does not claim a controller — `focus_window` and +`claim_terminal_controller` are separate acts — so a row asserting only +controller identity would miss the below-activation ordering bug +entirely, and one asserting only focus would miss a replay that quietly +claims the terminal. + +Doing it in one frontend across a replacement is what makes it a +control rather than two unrelated observations: the geometry, the +pointer position and the producer path are identical, and only the +target differs. + +**Both crossings need witnesses, and they fail in opposite +directions:** + +- **Chrome → content:** press on the mode line, drag into content. + **No `Drag` reaches the daemon**, because nothing armed. *Mutation: + arm on a chrome press — the orphan appears.* +- **Content → chrome:** press in content, release over the mode line. + **The gesture terminates at the last content coordinate.** For a + **document** panel the selection commits and the latch clears; for a + **reporting terminal** the child receives the release **at the + content row**. *Mutation: reserve `Up` for documents — the gesture + hangs latched; pass the raw chrome coordinate for terminals — the + child receives no `Up` at all, because the bounds check drops it into + the local branch.* + +#### R-c2. The producer does not remember where the gesture began + +`set_panel_pointer_held` **clears `last_pointer_cell`** +(`pmacs-gpu/src/main.rs:7250`), and only `panel_motion_is_new` +(`:7238`) ever fills it. `panel_release_cell` falls back to that field +when the release is not over a cell (`:7225`–`:7230`). + +Today that fallback is rarely reached, because chrome *is* a +`PanelCell`. **Once R-c stops chrome being one, it becomes the normal +path** — and it is empty. A `Down` in content followed **immediately** +by a release over chrome or outside the band, with **no intervening +motion**, has no coordinate to fall back to, so the `Up` is either +dropped or sent with nothing. + +**Revision 8 said "retain the `Down` cell in that field". That would +break a tested guarantee.** `last_pointer_cell` is cleared on press +*deliberately*, and a live test says why: + +> *"A press or release re-arms it: the first drag after a press must +> reach the daemon even at the cell the press landed on."* +> (`pmacs-gpu/src/main.rs:19841`–`:19844`) + +Storing the `Down` cell there would make the press's own cell the +dedupe baseline and **suppress that first `Drag`**. + +**Ruled: a SEPARATE field, `gesture_last_content_cell`.** The two have +different jobs and conflating them was the error — +`last_pointer_cell` is a **wire dedupe baseline**, answering *"is this +motion worth sending?"*; the new field is a **termination fallback**, +answering *"where did this gesture last legitimately point?"*. They +have different lifetimes and different reset rules, so one field cannot +serve both without one job corrupting the other. + +- **Written** on arm (the `Down` cell) and on every accepted content + motion. +- **Reset** alongside the rest of the gesture state: on release, and on + **both** identity changes (R-d's D1 and D2). +- **Never consulted** by `panel_motion_is_new`, which keeps its own + baseline and its existing behaviour unchanged. + +*The alternative was considered and rejected:* ruling that `Down` +becomes the dedupe baseline, retiring the guarantee and its test. It +would need a justified mutation showing the first same-cell `Drag` is +redundant, and it is not obviously so — the daemon's drag state is +established by that event. **Preserving a tested contract beats +retiring one to save a field.** + +Row: `Down` in content → release over chrome, **no intervening +motion** → the `Up` carries the **`Down` cell's** coordinate. +*Mutation: reset `gesture_last_content_cell` on arm instead of writing +the `Down` cell — the release has no coordinate.* **For a reporting +terminal the row asserts the exact child release BYTES**, not merely +that the frontend latch cleared: a latch that clears while the child +never hears the release is the failure this row exists for. + +**A second mutation guards the separation itself:** make +`panel_motion_is_new` consult `gesture_last_content_cell`. The existing +first-`Drag`-after-press assertion (`:19841`) must fail — which is what +proves the new field did not quietly become the dedupe baseline after +all. + +#### R-d. Panel replacement leaves the frontend's gesture latch armed + +`PanelFramePayload::Absent` clears `pointer_held` and +`last_pointer_cell` (`pmacs-gpu/src/main.rs:6909`–`:6910`). **The +`Present` arm does not** (`:6913` onward) — it validates, swaps the +frame, rebuilds buffers, and leaves the latch exactly as it was. + +So: press on panel **A**, A is replaced by **B**, and the held latch +emits a `Drag` or release **for B with no B press**. **Acceptance 49 +cannot reject it** — the event carries B's *current* epochs, so it is +not stale by any test 49 applies. 49 is a staleness gate, and this is +not a stale event; it is a **well-formed event from a gesture that +belongs to a presentation that no longer exists.** + +**And panel identity is only half of it.** The two epochs move +independently, by design (`pmacs-protocol/src/panel.rs:61` onward): +`panel_epoch` is *"stable across ordinary frames of one continuously +present window/buffer"*, while `geometry_epoch` *"moves whenever the +frontend declares new effective cell geometry — including a font or +scale change that leaves `CellSize` identical"*. A font or scale change +therefore advances `geometry_epoch` with `panel_epoch` untouched, and +`next_geometry_declaration` (`pmacs-gpu/src/main.rs:6847`) advances it +**without clearing either pointer field**. + +So a held gesture **resumes under a new grid** — new cell advance, new +row heights, the same cells meaning different text — carrying epochs +that are *current and valid*. Acceptance 49's geometry-race check +cannot help: it rejects events bearing a **stale** `geometry_epoch`, +and this one bears the new one. + +**Reset the gesture latch on a change of EITHER identity.** The +precedent is in the same file: the **divider** drag latch already +carries both epochs and self-invalidates when the presented frame +differs in either (`:7288`). The pointer latch never got it. + +**Both fields, and both must be constrained separately.** Clearing only +`pointer_held` kills the orphan drag but leaves `last_pointer_cell` +set, and `panel_motion_is_new` (`:7238`–`:7242`) then **suppresses B's +first same-cell `Move` as a duplicate** — the successor's opening +motion silently vanishes. The fields fail differently and need +independently discriminating legs. + +| # | mutation | must bite | +|---|---|---| +| D1 | no reset on `Present`→`Present` (panel identity) | the replaced-panel orphan-drag row | +| D2 | no reset on `geometry_epoch` change | the font/scale held-gesture row | +| D3 | reset clears `pointer_held` only | the successor's first same-cell `Move` row | +| D4 | reset on **every** frame | the negative row below | + +**The negative leg is required**, or D1/D2 are satisfiable by resetting +unconditionally: an **ordinary same-identity frame refresh must NOT +cancel a live gesture**. A panel repaints constantly during a drag; +resetting on each frame would make selection impossible. + +**D4's frame must be a CHANGED frame with unchanged epochs**, and the +natural one is **the focus repaint the `Down` itself causes** — the +panel activates, its mode line and cursor render differently, and the +frame arrives mid-gesture with both epochs identical. A byte-identical +duplicate would not exercise the rule at all: production returns early +on one, *"A duplicate does no work — not even a reshape"* +(`pmacs-gpu/src/main.rs:6918`–`:6919`), so the reset code is never +reached and D4 passes against a broken implementation. + +R-d's orphan is distinct from R-b's: **R-b's arrives from a passive +panel, R-d's from a replaced or re-declared one**, and an +implementation can fix either alone. + +### Revisions 14–16 — what §5b changed underneath this lane + +#### Q#BP-R4 — what does `dispatch_semantic_panel_pointer`'s answer MEAN? **RULED: a three-state outcome, not a bool** + +**This question did not exist before the merge, and it was created by +two branches agreeing on a type while disagreeing on its meaning.** + +| branch | what `true` meant | +|---|---| +| §5b | the gesture was **ACCEPTED** — and §5b's review round 4 made the accepted-gesture latch follow exactly this answer | +| this lane | the event was **CONSUMED HERE** — including the chrome swallows Q#BP-R2 and R-c introduced | + +Both were right in their own tree. Merged, they are one `bool` that +means two things, and the daemon reads it as §5b's. **The consequence +is live in `b758c2e`:** a press on the band's **mode line** returns +`true`, so the latch **arms** for a gesture that never began in +content. That is the precise defect class §5b's round 4 found and +fixed, re-entering by merge rather than by edit. + +**Three candidates were considered.** + +| candidate | why not | +|---|---| +| keep `bool`, make chrome swallows return `false` | behaviourally correct **today**, and that is the whole problem — it silently merges "refused as malformed" with "handled and deliberately stopped", so the next author restores the collision without touching a test | +| keep `bool`, re-derive "was this content?" in the daemon | **violates the seam §5b established.** One authoritative derivation, read through one accessor, is why the mapping generation works at all; a second derivation beside the dispatcher is the same hole in a new place | +| **a three-state outcome** | **RULED** | + +```rust +/// How an authenticated panel event relates to the authoritative +/// panel surface, decided BEFORE any target effect. +pub enum PanelPointerOutcome { + /// Not addressable as this panel: no grid, out of grid, no side + /// window, or a buffer that is not the one shown there. + Refused, + /// This panel owns the cell/event, but it is deliberately not a + /// content gesture --- the chrome claims of Q#BP-R2 and R-c. + Consumed, + /// A content gesture for the resolved target. + Accepted, +} +``` + +**The outcome is a DISPOSITION, not proof that an effect already ran.** +It is decided before replay. The current function combines validation, +classification and mutation, but that ordering cannot implement §5b's +stale-tail rule: an `Up` or `Drag` with no accepted `Down` has already +reached the child/selection before the daemon consults the latch. The +implementation may split the function into classify/apply helpers or +use an equivalent single wrapper, but these two facts are fixed: + +1. the editor remains the **only** authority that derives + `Refused`/`Consumed`/`Accepted`; the daemon never re-derives chrome, + target kind or content bounds; +2. disposition and the live-gesture check both complete **before** a + left `Drag`/`Up` target effect. + +**One transition table is authoritative:** + +| event | `Refused` | `Consumed` | `Accepted` | +|---|---|---|---| +| `Down(Left)` | no effect; do not arm | no target effect; do not arm | apply the content press, then arm **from the effect result** so target/domain/encoding and `reached_child` describe what actually happened | +| `Drag(Left)` | no effect; retain any live record | no target effect; retain any live record | no live record → inert; live record → continue in the **recorded domain**, update its last valid content cell | +| `Up(Left)` | no effect; retain any live record for a later authoritative cancellation | no live record → inert; live record → deliver the **recorded completion** at its last valid content cell, then take it | no live record → inert; live record → apply the ordinary in-content completion **once**, then take it; do **not** also run recorded completion | +| every other kind | no effect; no latch change | no target effect; no latch change | apply its one-shot content effect; never arm or consume the left-gesture latch | + +This is the required asymmetry. A chrome press begins nothing. A +chrome release can end a live gesture, but a refused release cannot be +known to concern it. An accepted release already performs the ordinary +target effect; a consumed release has not, so it terminates from the +record. A stale `Drag`/`Up` with no accepted `Down` is inert rather than +sending a child tail or changing selection. + +**"TERMINATE", not "consume", and the distinction is the whole of +review finding 1.** An earlier draft of this ruling said consume, and +justified it by claiming it prevents a child left button-down. **It +prevents no such thing.** Emptying the latch is bookkeeping; +`update_accepted_gesture` currently does `let _ = +state.consume_accepted_gesture()` (`src/daemon.rs:1067`) — it **takes +the record and drops it**. And on the path that matters, terminal +chrome returns at `src/editor.rs:2862`–`:2870`, **before** +`apply_terminal_gesture` at `:2899`, so the child is never told +anything. A latch that empties while the child stays pressed is +*exactly* the failure the draft claimed to prevent, now invisible +because the bookkeeping looks right. + +**RULED: a live gesture plus non-`Refused` `Up(Left)` must produce the +target's completion effect exactly once, and the row asserts the +EFFECT, not the latch.** + +| target | required completion effect | +|---|---| +| terminal panel, child reporting on | the **release is reported to the child in the recorded encoding** — an accepted content `Up` uses its accepted cell; a consumed/chrome `Up` uses the gesture's last content cell per R-c2 | +| terminal panel, child reporting off | the **local terminal selection completes** — the selection the drag built is finalised, not abandoned | +| document panel | the document gesture completes at the last content cell; an empty selection is cleared **without moving point** (the effect §5b's split table already assigns here) | + +**And exactly once.** An accepted `Up` must not replay normally and +then also run the record-driven completion. Any completion must take +the record, so a later cancellation of the same gesture — reachable +through the four stranding transitions below — cannot repeat it. + +| # | mutation | must bite | +|---|---|---| +| P1 | chrome press returns `Accepted` | a chrome `Down(Left)` does not arm | +| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows plus their no-target-effect assertions | +| P3 | chrome `Up` empties the latch but delivers **no** completion | the child-release / selection-completion row — **the latch-only assertion must NOT satisfy this** | +| P4 | `Refused` release delivers a completion | a refused release leaves both the latch and the child untouched | +| P5 | an accepted content `Up` replays normally **and** invokes record-driven completion | the exact child stream contains one release, not two | +| P6 | completion delivered, then a later cancellation delivers another | the no-duplicate-release row | +| P7 | an accepted `Up` with no live record reaches the target | the stale-release row: no child bytes and no local/document selection mutation | +| P8 | an accepted `Drag` with no live record reaches the target | the orphan-drag row: no child bytes and no cursor/selection mutation | + +**P3 is written against the earlier draft's own weakness.** A row that +asserts only `has_accepted_gesture() == false` passes while the child +receives nothing, so the row must read the child's reported bytes or +the terminal's selection state. + +**That asymmetry is what earns the third state.** `Refused` preserves a +live gesture, `Consumed` owns the surface but runs only a recorded +completion, and `Accepted` reaches content. Each is behaviourally +distinct at the effect/lifecycle seam, and the single matrix above is +the witness contract. + +**R-c2 does not discharge P3.** The producer normalizes a release that +lands on chrome back to the last content cell, so a conforming +frontend should not send one — but the daemon's contract cannot rest +on the producer's good behaviour, and **a legacy `PanelPointer` peer +predates that normalization entirely**. Producer-side normalization and +daemon-side termination are **both** required, and the existing GPU row +(`a_press_on_the_bands_mode_line_neither_arms_nor_reports_content`) +covers only the producer half. + +**No TUI claim is made here.** An earlier draft said a TUI reaches the +same path; it does not. `dispatch_semantic_panel_pointer` has exactly +two callers, both `FrontendEvent` arms in `src/daemon.rs` (`:2609`, +`:2680`), so **only semantic legacy and mapped peers reach it** — the +TUI goes through `dispatch_mouse`. + +#### The rows §5b's split table assigned here + +§5b states its own split rather than leaving it to whoever runs the +tests, and this lane is the other column. Inherited, verbatim in +substance: + +- the **document cancellation effect** — an empty selection cleared + without moving point; +- **real stable-generation drag continuation**, G5a's **effects**, and + the v24/v25 replay-effect controls **G4b, G6c, G7c**; +- the **complete producer reset lifecycle** — geometry, `Absent`, + identity and detach — whose fields this lane introduces (**G5f**); +- **every** common legacy/mapped cancellation transition and the real + gesture/click lifecycle (**G5b–e, G5g, G5i–p**), exhaustion + cancellation (**G11b**), and both two-tick wheel effects (**G12**). + +**The IDs stay as §5b wrote them.** §5b deliberately pinned its own +three decisions under `g5_substrate_*` names precisely so these IDs +would be unclaimed here; taking them is the point, not a collision. + +#### The cancellation record has nowhere to wait + +§5b landed cancellation as a **saturating count**, and said why: a +queue drained by nobody grows one entry per cancelled drag. It also +left the record itself reachable — `cancel_accepted_gesture` **returns** +the `AcceptedPanelGesture` it ends. + +**The gap is not the count, it is the caller.** Two of the three +cancellation sites are inside `semantic_render.rs`, reached during +**frame production**, where a release cannot be delivered. So the +record is returned into a context that cannot act on it and is +dropped. + +**RULED: one pending-release SLOT per frontend, not a queue.** The +latch holds at most one gesture per frontend, so at most one release +can be owed at a time, and the slot is bounded by construction rather +than by a cap someone has to choose. + +**AND THE DRAIN ORDER IS PART OF THE RULING, because an invariant alone +is a detector, not a guard.** An earlier draft asserted only that +arming over a pending release is impossible. That check would sit +inside `arm_accepted_gesture` — and the seam replays **before** it: +`dispatch_semantic_panel_pointer` runs its effects and only then does +`update_accepted_gesture` arm (`src/daemon.rs:2608`–`:2616`). So the +invariant can fail **only after the new press has already reached the +child or moved the selection**. It would report the collision one +effect too late. + +**A pending termination DRAINS FIRST:** + +1. **before any subsequent panel-pointer effect** for that frontend — + the drain happens ahead of `dispatch_semantic_panel_pointer`, not + after it, so the old gesture's release reaches the child before the + new gesture's press does; +2. **before detach teardown**, so a frontend that goes away does not + take an owed release with it — detach is one of the four stranding + transitions below, and it is the one with no later opportunity; +3. **after semantic projection returns and before any message it + returned is filtered or written.** Mapping cancellation is + discovered *inside* `SemanticRenderState::render_frame`, while the + successor `PresentMapped` is being constructed, so "before that + frame is produced" is impossible. The enforceable daemon seam is + immediately after `sem.render_frame(editor)` returns + (`src/daemon.rs:1428`–`:1430`) and before `for msg in &messages` + (`:1591`). The pending termination drains there, so the successor + frame cannot overtake the release its new mapping required. + +| # | mutation | must bite | +|---|---|---| +| Q1 | drop the record instead of parking it | the cancelled-gesture release row | +| Q2 | park it but never drain | the same row, from the delivery side | +| Q3 | drain **after** `dispatch_semantic_panel_pointer` instead of before | the **old-release-before-new-press ordering row** — assert the child's byte stream carries the release ahead of the press, not merely that both arrive | +| Q4 | skip the drain on detach | the detach row: an owed release is delivered before teardown | +| Q5 | enter the returned-message write loop before draining a projection-raised release | the daemon-order witness records the termination effect before the first successor-frame write | +| Q6 | allow arming over a pending release | the invariant witness — kept, now as a **backstop** behind the ordering rows rather than as the guarantee | + +#### Four transitions strand a live gesture, and they become defects HERE + +§5b recorded this as inert and named the branch that owns it: a +**panel-epoch change**, a **buffer replacement**, a **same-size +geometry change** and a **detach** all leave the latch armed with a +release that can never be accepted. Only `Absent` was wired, because +`publish_absent_panel` already cleared input authority two lines +later and omitting it would have been an inconsistency inside one +function. + +**Inert became a defect the moment this lane attaches effects.** Each +of the four needs its own leg, and they fail differently — R-d already +established that for the producer side, where clearing `pointer_held` +without `last_pointer_cell` silently eats the successor's first +motion. The daemon side has the same shape and needs the same +independently discriminating legs rather than one shared "resets +something" assertion. + +#### Re-measurement closed in revision 16 + +The authoritative clause table is now the `2c0d3ff` table at the start +of §5a. The `72da24a` table is retained only under its explicit +**Superseded** heading. Revision 14's line-movement inventory did its +job by forcing a clause-by-clause scout; it is no longer a live +implementation obligation. + +#### Coherence impact (`COHERENCE.md` §20) + +- **Journey steps touched: 5** — Edit, clause (c) selection, kill and + yank — **and 8** — Open terminal, clause (b) input and output + round-trip. **NO JOURNEY-CELL GRADE MOVES**, and an earlier draft + claimed one. Checked against the authoritative scorecard rather than + inferred from this section's own MISSING column: + - **Step 5 / GPU is `Partial`, and stays `Partial`.** It is floored + by **5(a)** — no IME, no `set_ime_allowed`, so composed and CJK + input is impossible (`COHERENCE.md:468`). Completing 5(c) inside a + panel cannot lift a cell held down by a different clause. That is + §2a's aggregation rule working as designed. + - **Step 8 is already `Works` on all three columns** + (`COHERENCE.md:471`). There is no grade left to move; 8(b) is + hardened, not opened. + - The draft's error was reading §5a's MISSING column as if it were + the scorecard. **A section-local gap list is not a journey grade**, + and the one place that decides grades is `COHERENCE.md`. +- **Interaction islands: none added.** It **completes** the existing + panel island rather than opening a new one — the same gestures the + band already advertises, finally reaching their target. +- **Config registry: no entry.** Nothing here is tunable. +- **Background work: none started**, and no existing attribution + changes. + +#### What revisions 14–16 do NOT reopen + +It does not re-open Q#BP-R1 (single click selects only), Q#BP-R2 (a +terminal panel's chrome wheel is consumed, not clamped), or R-a/R-b/ +R-c/R-c2/R-d. Those are approved at revision 12 and unaffected by §5b +— **except** where R-c's chrome swallows now feed Q#BP-R4's outcome, +which changes their return value and not their behaviour. + ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) -**Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.** +**Status: revision 16 — APPROVED 2026-08-15. IMPLEMENTED AND MERGED as +#242 (`47b5463`), 2026-08-20.** The "Nothing implemented" this line +carried until then was left behind by the merge; the slice's own +"What the slice actually landed, row by row" subsection below had been +contradicting it. Revision 16 answers review of 15; three of its items reverse a rule 15 introduced: diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index fdf1360..7c7a7f7 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -963,3 +963,31 @@ tree. the measured `duration_ms` in the assertion message — is **owed its own small lane**. It was deliberately kept out of #242, whose diff does not touch `src/async_runtime.rs`. + +### U12 — U9's shape in `04-lib-crdt`: a budget test and a PTY test, together + +Recorded during the panel-replay lane's gate, 2026-08-21, on head +`6142acc`. **Filed rather than folded into U6 or U9**, because both of +those tell it to be: U6 says one of its selectors redding without the +other is a different incident, and this is `composition_overhead_under_ten_percent` +alone again; U9 is that same shape but in `11-sweep` with a different +PTY selector. + +| field | value | +| --- | --- | +| **selectors** | `--lib --features crdt editor::tests::composition_overhead_under_ten_percent` **and** `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`, failing in the same step | +| **job / step** | local (Linux), `scripts/gate` step `04-lib-crdt`, log `20260821T122852Z-2922631` | +| **required fragments** | `composition machinery added more than 10% overhead` / `live runtime probe` | +| **observed** | **1.247×** against the 1.10× budget (single 192493 ns, dispatch 240130 ns); the PTY row failed at `src/process.rs:5155`, where `active_reader_probe` found no live reader within the 2s `Started` window | +| **status** | **one occurrence, both selectors green on isolated rerun** | +| **the rest of the run** | **15 of 16 stages green**, including `sweep`, `m4`, `gpu`, `diff-check` and all eight touched acceptance suites | +| **what IS established** | both are timing-dependent by construction — one a wall-clock ratio, the other a 2-second liveness window — and each passed alone immediately afterwards. **`src/process.rs` is NOT touched by the observing branch at all**; `src/editor.rs` is, but only in the panel-replay paths, not in composition | +| **what is NOT** | that load caused it. Load was **11.04 at the gate's start and 27.79 (5-minute) at its end**, with two foreign `python` processes at ~2 cores throughout and an `apt`/`dpkg` install shortly before. Those are conditions, not a measurement of the mechanism, and the run was **knowingly taken on a machine that was quieter but not quiet** | +| **relation to U6** | its selector, alone again, in U6's own step. U6's instruction to judge that separately is honoured for the second time — see U9, which did the same | +| **relation to U9** | the same budget-plus-PTY co-failure, in `04-lib-crdt` rather than `11-sweep`, with `setsid_escapee…` where U9 had `m6_1_pty_raw_mode…` | + +**This family has now produced U6, U9, U10 and U12, and the +discriminating control U9 named is STILL UNRUN**: pin test-binary +concurrency to 1, and separately load a lone `--lib` binary. Four +incidents is enough evidence that the family will keep costing review +rounds until someone runs it. diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index b76f0ff..308bb75 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,6 +1,13 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 12 — APPROVED.** Revision 12 is §2's ground-truth +**Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a +revision 16.** Revision 13 changes no previously ruled outcome. It +makes B1–B3's “per surface” scope explicit for the panel-document +surface, closing the ownership gap exposed by §5a's remeasurement: +panel replay is the prerequisite, while GUI 1b owns horizontal tick +production, residuals and the document-panel `view_left` effect. + +**Previously, revision 12 — APPROVED.** Revision 12 is §2's ground-truth re-measurement for Stage 1a and changes no ruling; it carries two corrections to claims that were wrong at the original anchor too. @@ -308,9 +315,9 @@ The crate has **exactly one** executable `event_loop.exit()`, in | # | Contract | Witness | Mutation | |---|---|---|---| -| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump | -| B2 | Wheel-right raises leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row | -| B3 | Clamps at content bounds; never a negative origin | no horizontal scroll to clamp | remove clamp → **at-bounds row: origin goes negative and the view blanks** | +| B1 | Residual per **axis and surface** — primary document, panel document and minimap are distinct owners | deltas discarded | share one accumulator → primary↔panel or document↔minimap surface-switch jump | +| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert | +| B3 | Each document surface clamps at its own content bounds; never a negative origin, and wrap pins horizontal origin to zero | no horizontal scroll to clamp | remove a clamp → **at-bounds row: that surface's origin goes negative and the view blanks**; reuse primary bounds for a panel → unequal-width surface row fails | | B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only | | B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only | | B6 | Wheel over the minimap scrolls the **document viewport** with **its own residual accumulator**; click/drag remains scrub | **a FULL tick already scrolls today** — minimap pixels are `Elsewhere` (`main.rs:2061`) and the wheel falls through to `scroll_by_lines` (`main.rs:3373`). What fails is **fractional accumulation**, and **residual ownership distinct from the document's**: sub-tick minimap deltas are discarded, and a **surface-switch fractional witness** (part-tick over the minimap, then over the document) must not carry residue across | share the document's accumulator → the surface-switch fractional row jumps | @@ -322,6 +329,18 @@ blank**. The bound is *width − viewport*, saturating at zero for buffers narrower than the viewport, and **the right-bound witness asserts the final display column is still visible**. +**Panel-replay consequence, ruled after §5a's 2026-08-20 +re-measurement.** “Horizontal panel wheel” has three receiver outcomes, +not one. When terminal precedence selects child reporting it already +forwards `ScrollLeft` and `ScrollRight` as SGR codes 66/67. The local +terminal branch — selected by Shift, reporting-off or a scrolled-back +view — has no horizontal viewport and remains inert. **B1–B3 own the missing +document-panel leg**: fractional production/residuals in the GPU, the +lossless `PanelPointer` tick, and the daemon's window-targeted +`view_left` effect and clamp. This is a named deferral from panel replay, +whose source comment already assigned it here; it does not make the +prerequisite lane implement half of 1b early. + **Why B6 changed — and revision 5's reason was wrong.** Scrubbing on wheel is not *impossible*: the wheel handler already reads the cached `state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c232c73..9b2c113 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2224,6 +2224,16 @@ struct PanelBand { /// because the first drag after a press must reach the daemon even at the /// cell the press landed on. last_pointer_cell: Option, + /// Where the live gesture last legitimately pointed **inside content**, + /// used to normalize a release that lands on chrome or outside the band + /// (parent 48 R-c2). + /// + /// **Deliberately NOT `last_pointer_cell`.** That field is a wire + /// DEDUPE BASELINE and is cleared on press precisely so the first drag + /// after a press reaches the daemon; storing the press cell there would + /// suppress it. This one is a TERMINATION FALLBACK with a different + /// lifetime, and `panel_motion_is_new` never consults it. + gesture_last_content_cell: Option, /// §5b G9b — the mapping generation `last_pointer_cell` was measured /// under, so the dedupe re-arms across a mapping change. /// @@ -2891,6 +2901,25 @@ impl App { // so reporting a drag as a hover makes a selection // drag silently do nothing. let kind = state.panel_motion_kind(); + let is_chrome = state.panel_cell_is_chrome(coord); + if !is_chrome { + let state = self.state.as_mut().expect("checked above"); + state.panel.gesture_last_content_cell = Some(coord); + } + let state = self.state.as_mut().expect("checked above"); + if is_chrome { + // A crossing drag is normalized to the last content cell + // and then deduped like any other motion — usually + // suppressed, because that cell was already reported. + let Some(normalized) = state.panel.gesture_last_content_cell else { + return; + }; + if state.panel_motion_is_new(normalized) { + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at_cell(Some(normalized), kind, mods); + } + return; + } if state.panel_motion_is_new(coord) { let mods = translate_mods(self.modifiers); self.send_panel_pointer_at(x, y, kind, mods); @@ -3013,9 +3042,22 @@ impl App { // 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 { + if let PointerSurface::PanelCell(coord) = panel_surface { + // Parent 48 R-c: a press on the band's MODE LINE is + // reserved, and must not arm. Arming would let a drag + // into content emit a `Drag` with no accepted `Down` — + // an orphan the daemon cannot tell from a real gesture, + // and one no receiver-side rule can prevent, because the + // frontend has already latched. + if state.panel_cell_is_chrome(coord) { + return; + } let state = self.state.as_mut().expect("checked above"); state.set_panel_pointer_held(true); + // R-c2: the TERMINATION FALLBACK, not the dedupe + // baseline. `set_panel_pointer_held` just cleared the + // latter on purpose. + state.panel.gesture_last_content_cell = Some(coord); self.send_panel_pointer_at( x, y, @@ -4481,6 +4523,116 @@ mod input_routing_tests { ); } + /// Parent 48 R-c, the PRODUCER half — a press on the band's mode line + /// must neither send nor arm, and the row above it must do both. + /// + /// This drives the real `MouseInput` path and reads the wire, because + /// the hazard is precisely that the frontend latches BEFORE the daemon + /// can refuse: `panel_hit_test` reports across the whole frame, so a + /// chrome press is indistinguishable from a content press to the + /// arming code, and once armed, a drag into content emits a `Drag` + /// with no accepted `Down`. No receiver-side rule can undo that. + /// + /// The content leg is not decoration: without it, an implementation + /// that never arms anywhere passes the chrome half. + #[test] + fn a_press_on_the_bands_mode_line_neither_sends_nor_arms() { + let mut h = EffectHarness::new(); + let rows = 4; + { + // Inlined rather than shared: `present_panel` lives in the + // other test module. Same shape — wire on, one declaration, + // one `Present`. + let state = h.app.state.as_mut().expect("harness state"); + state.set_panel_wire(PANEL_MIN_VERSION); + // The harness already declared during setup, so the `Surface` + // trigger dedups; reuse the standing declaration rather than + // asserting a second one. + let geometry_epoch = state + .next_geometry_declaration(GeometryTrigger::Surface) + .map_or(state.panel.geometry_epoch, |(epoch, _)| epoch); + assert_ne!( + geometry_epoch, 0, + "a declaration must exist to present against" + ); + let cols = state.declared_cell_total().0.cols.max(1); + let frame = pmacs_protocol::panel::PanelFrame { + buffer_id: BufferId::from_raw(77), + panel_epoch: 1, + geometry_epoch, + size: CellSize::new(rows, cols), + cells: vec![pmacs_protocol::Cell::default(); (rows * cols) as usize], + cursor: None, + focused: true, + }; + assert!( + state.apply_panel_payload(pmacs_protocol::panel::PanelFramePayload::Present(frame)), + "installing a first frame changes the band" + ); + } + let (ox, oy, _, band_h) = h + .app + .state + .as_ref() + .expect("harness state") + .panel_content_rect() + .expect("a presented band has a rect"); + let row_h = band_h / rows as f32; + let press_at = |h: &mut EffectHarness, y: f32| { + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(f64::from(ox) + 0.5, f64::from(y)), + }); + h.feed(&WindowEvent::MouseInput { + device_id: DeviceId::dummy(), + state: ElementState::Pressed, + button: MouseButton::Left, + }) + }; + let panel_events = |step: &Step| { + step.outbound + .iter() + .filter(|event| matches!(event, pmacs_protocol::FrontendEvent::PanelPointer { .. })) + .count() + }; + + // Chrome: the band's LAST row. + let step = press_at(&mut h, oy + band_h - 0.5); + assert_eq!( + panel_events(&step), + 0, + "a press on the mode line is reserved and reaches no daemon" + ); + assert!( + !h.app + .state + .as_ref() + .expect("harness state") + .panel + .pointer_held, + "and it must not ARM — an armed chrome press turns the next \ + motion into a `Drag` with no accepted `Down`" + ); + + // Content: one row up, same column, same gesture. + let step = press_at(&mut h, oy + band_h - row_h - 0.5); + assert_eq!( + panel_events(&step), + 1, + "the row above chrome is content and must still work — without \ + this leg, never arming anywhere passes the half above" + ); + assert!( + h.app + .state + .as_ref() + .expect("harness state") + .panel + .pointer_held, + "a content press arms the gesture" + ); + } + /// P2, pointer — **the row that shows a route cannot stand in for an /// effect.** A wheel carries only a delta; whether it becomes a /// viewport update, a panel event, a terminal event or nothing at @@ -7053,6 +7205,7 @@ impl State { self.panel.hover_divider = false; self.panel.pointer_held = false; self.panel.last_pointer_cell = None; + self.panel.gesture_last_content_cell = None; self.panel.last_pointer_generation = None; had } @@ -7122,6 +7275,35 @@ impl State { // A duplicate does no work — not even a reshape. return false; } + // Parent 48 R-d: a held gesture belongs to the presentation + // it began on. `Absent` clears the latch, but a + // `Present` → `Present` REPLACEMENT did not, so a press on + // panel A could emit a drag or release for B with no B + // press — and acceptance 49 cannot reject that, because the + // event carries B's CURRENT epochs. Geometry counts too: a + // font or scale change moves `geometry_epoch` while + // `panel_epoch` holds, and the gesture would resume under a + // new grid. + // + // Only on a CHANGE of identity. A panel repaints constantly + // during a drag, and resetting on every frame would make + // selection impossible. + // + // Compared against the RETAINED frame, never `presented()`: + // that accessor filters on `geometry_epoch == self.panel + // .geometry_epoch`, and a geometry change advances the field + // FIRST, so by the time the matching frame arrives + // `presented()` is already `None` and an `is_some_and` + // predicate skips the reset — exactly the case D2 covers. + let identity_changed = self.panel.frame.as_ref().is_some_and(|current| { + current.panel_epoch != frame.panel_epoch + || current.geometry_epoch != frame.geometry_epoch + }); + if identity_changed { + self.panel.pointer_held = false; + self.panel.last_pointer_cell = None; + self.panel.gesture_last_content_cell = None; + } let plan = TerminalPaintPlan::build_grid( frame.size, &frame.cells, @@ -7426,12 +7608,29 @@ impl State { /// 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 { + // Parent 48 R-c/R-c2: `Up` is the load-bearing crossing event — a + // gesture whose release is dropped leaves the daemon holding a + // button down forever. It is therefore always sent, and always at + // a CONTENT coordinate: chrome and outside-the-band both fall back + // to where the gesture last legitimately pointed, which is set at + // press time so a release with no intervening motion still has one. match self.classify_pointer_surface(x, y) { - PointerSurface::PanelCell(coord) => Some(coord), - _ => self.panel.last_pointer_cell, + PointerSurface::PanelCell(coord) if !self.panel_cell_is_chrome(coord) => Some(coord), + _ => self.panel.gesture_last_content_cell, } } + /// Whether `coord` is the band's MODE-LINE row (parent 48 R-c). + /// + /// The daemon projects panel content as `rows - 1` and paints the last + /// row as chrome, but `panel_hit_test` reports across the whole frame, + /// so a hit is not automatically a content cell. + fn panel_cell_is_chrome(&self, coord: CellCoord) -> bool { + self.panel + .presented() + .is_some_and(|frame| coord.row + 1 >= frame.size.rows) + } + /// 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 @@ -7459,6 +7658,10 @@ impl State { fn set_panel_pointer_held(&mut self, held: bool) { self.panel.pointer_held = held; self.panel.last_pointer_cell = None; + // The termination fallback dies with the gesture (parent 48 R-c2). + // Safe in both directions: the press path rewrites it immediately + // after arming, and a release READS it before this runs. + self.panel.gesture_last_content_cell = None; self.panel.last_pointer_generation = None; } @@ -20475,6 +20678,198 @@ mod tests { ); } + /// Parent 48 R-c — the band's MODE-LINE row is chrome, and the + /// producer must not arm a gesture there. + /// + /// `panel_hit_test` reports across the whole frame, so a chrome press + /// looks exactly like a content press to the arming code. If it armed, + /// dragging into content would emit a `Drag` with no accepted `Down` — + /// an orphan the daemon cannot distinguish from a real gesture, and one + /// no receiver-side rule can prevent, because the latch is already set. + #[test] + fn a_press_on_the_bands_mode_line_neither_arms_nor_reports_content() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + let last = frame.size.rows - 1; + + assert!( + state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last, 0)), + "the band's last row is its mode line" + ); + assert!( + !state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last - 1, 0)), + "the row above it is content — without this the row proves nothing" + ); + + // A release that lands on chrome normalizes to the last CONTENT + // cell rather than reporting the mode line to the daemon. + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 2)); + let (ox, oy, _, h) = state + .panel_content_rect() + .expect("a presented band has a rect"); + let on_chrome_y = oy + h - 0.5; + assert_eq!( + state.panel_release_cell(ox + 0.5, on_chrome_y), + Some(pmacs_protocol::CellCoord::new(1, 2)), + "`Up` is the load-bearing crossing event: it must arrive, and at \ + a CONTENT coordinate — a chrome row would fail the terminal \ + reporting bounds check and drop into local handling" + ); + } + + /// Parent 48 R-c2 — a release with NO intervening motion still carries + /// a coordinate. + /// + /// The press cell is remembered in `gesture_last_content_cell`, not in + /// `last_pointer_cell`: that one is cleared on press precisely so the + /// first drag after a press reaches the daemon, and storing the press + /// cell there would suppress it. + #[test] + fn a_release_with_no_intervening_motion_carries_the_press_cell() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + present_panel(&mut state, 4); + let press = pmacs_protocol::CellCoord::new(1, 3); + + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(press); + + assert_eq!( + state.panel.last_pointer_cell, None, + "arming clears the DEDUPE baseline — the tested guarantee that \ + the first drag after a press is not suppressed" + ); + + // Released outside the band entirely, with NO motion in between — + // asserted BEFORE any motion probe below, because a probe would + // populate `last_pointer_cell` and let a conflated implementation + // pass. (It did, on the first run of this row.) + assert_eq!( + state.panel_release_cell(0.0, 0.0), + Some(press), + "without the press cell retained, this release has no coordinate \ + and the daemon is left holding a button down forever" + ); + + // Separation, checked after: the dedupe must not consult the + // fallback, so a drag at the press cell still reaches the daemon. + assert!( + state.panel_motion_is_new(press), + "the first drag after a press must not be suppressed" + ); + } + + /// Parent 48 R-d — a held gesture belongs to the presentation it began + /// on, and BOTH identities end it. + /// + /// `Absent` already cleared the latch; a `Present` → `Present` + /// replacement did not, and acceptance 49 cannot catch the resulting + /// orphan because it carries the successor's CURRENT epochs. Geometry + /// counts too: a font or scale change moves `geometry_epoch` while + /// `panel_epoch` holds. + #[test] + fn a_change_of_either_panel_identity_ends_a_held_gesture() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + + let live = pmacs_protocol::CellCoord::new(1, 1); + let arm = |state: &mut State| { + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(live); + // SEED THE DEDUPE BASELINE TOO. Arming clears it, so a leg that + // only arms leaves `last_pointer_cell` already `None` and its + // reset is unconstrained — deleting that line stays green. One + // accepted motion is what a real gesture would have produced by + // the time a replacement arrives. + assert!( + state.panel_motion_is_new(live), + "the first motion after a press is never a duplicate" + ); + assert_eq!(state.panel.last_pointer_cell, Some(live)); + }; + + // D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity + // must not cancel a live gesture. A panel repaints constantly during + // a drag, and a reset-every-frame implementation would make + // selection impossible while passing D1 and D2. + arm(&mut state); + let mut refreshed = frame.clone(); + refreshed.focused = !frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::Present(refreshed)), + "a focus repaint is a real frame, not a suppressed duplicate" + ); + assert!( + state.panel.pointer_held, + "a same-identity repaint must NOT end the gesture" + ); + assert!(state.panel.gesture_last_content_cell.is_some()); + + // D1 — panel identity. + arm(&mut state); + let mut replaced = frame.clone(); + replaced.panel_epoch = frame.panel_epoch + 1; + assert!(state.apply_panel_payload(PanelFramePayload::Present(replaced))); + assert!( + !state.panel.pointer_held, + "a replacement panel never saw the press" + ); + assert_eq!(state.panel.gesture_last_content_cell, None); + assert_eq!(state.panel.last_pointer_cell, None); + assert!( + state.panel_motion_is_new(live), + "the dedupe baseline goes too: the successor's first motion at \ + the predecessor's cell must reach the daemon, not be suppressed \ + as a duplicate of a gesture that belonged to another panel" + ); + + // D2 — geometry identity, with panel identity UNCHANGED. This is the + // font/scale case: the gesture would otherwise resume under a new + // grid carrying epochs that are current and perfectly valid. + // + // **Driven through the real declaration path.** Inventing a + // higher-epoch frame is not the production sequence: a font or + // scale change advances `self.panel.geometry_epoch` FIRST, and only + // then does the matching frame arrive. That ordering is what broke + // the first implementation — `presented()` filters on the epoch, so + // it answers `None` in exactly this window — and a witness that + // skips the declaration cannot see it. + let base = state.panel.frame.clone().expect("a frame is retained"); + arm(&mut state); + let (next_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("a metrics change re-declares geometry"); + assert_ne!(next_epoch, base.geometry_epoch, "the declaration advanced"); + assert!( + state.panel.presented().is_none(), + "and the retained frame no longer answers `presented()` — the \ + window in which a `presented()`-based reset silently skips" + ); + let mut regeometried = base.clone(); + regeometried.geometry_epoch = next_epoch; + regeometried.size = CellSize::new(base.size.rows, total.cols.max(1)); + regeometried.cells = vec![ + pmacs_protocol::Cell::default(); + (regeometried.size.rows * regeometried.size.cols) as usize + ]; + assert_eq!( + regeometried.panel_epoch, base.panel_epoch, + "the point of this leg is that PANEL identity holds" + ); + assert!(state.apply_panel_payload(PanelFramePayload::Present(regeometried))); + assert!( + !state.panel.pointer_held, + "a new grid is a new gesture context, even under the same panel" + ); + assert_eq!(state.panel.gesture_last_content_cell, None); + } + /// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe /// re-arms on every press and release. #[test] @@ -20515,7 +20910,13 @@ mod tests { state.panel_release_cell(ox + 0.5, oy + 0.5), Some(pmacs_protocol::CellCoord::new(0, 0)) ); + // The dedupe baseline and the TERMINATION FALLBACK are separate + // fields (parent 48 R-c2): `panel_motion_is_new` owns the first and + // a release reads only the second, so drive both exactly as the + // production motion path does. Conflating them is what would + // suppress the first drag after a press, asserted above. state.panel_motion_is_new(pmacs_protocol::CellCoord::new(1, 7)); + state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 7)); assert_eq!( state.panel_release_cell(ox + 0.5, 0.0), Some(pmacs_protocol::CellCoord::new(1, 7)), diff --git a/src/daemon.rs b/src/daemon.rs index 2345821..4394d99 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,6 +1019,228 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// Project one SEMANTIC frontend's frame, paying any release that +/// projection itself raised before the caller can write the result +/// (parent 48, drain point three). +/// +/// **The seam exists because the cancellation happens too deep to pay +/// itself.** A mapping-generation advance cancels the live gesture +/// INSIDE `render_frame`, while the successor `PresentMapped` is still +/// being built, so "before that frame is produced" is not a place that +/// exists. This is the first place that is: projection has returned, +/// and none of what it returned has been written. +/// +/// Returning the messages UNWRITTEN is what makes the ordering +/// testable — a caller holding them has, by construction, not yet sent +/// the successor frame, so a release already delivered at that moment +/// provably precedes it. +/// +/// Grid sessions do not come here: they hold no panel and no gesture. +fn project_semantic_frame( + editor: &mut EditorState, + semantic_states: &mut HashMap, + fid: FrontendId, +) -> Vec { + let messages = semantic_states + .get_mut(&fid) + .map(|sem| sem.render_frame(editor)) + .unwrap_or_default(); + drain_pending_release(editor, semantic_states, fid); + messages +} + +/// Deliver the release this frontend is owed, if any (parent 48). +/// +/// **Order is the ruling, not just the existence of a slot.** A +/// cancellation raised inside frame production cannot deliver its own +/// release, so the record is parked; this is where it is paid. It runs +/// at three points, and each is chosen against a specific way the +/// release would otherwise arrive too late or not at all: +/// +/// * **before any subsequent panel-pointer effect**, so the old +/// gesture's release reaches the child ahead of the new gesture's +/// press rather than after it; +/// * **before detach teardown**, because detach removes the state that +/// holds the record and there is no later opportunity; +/// * **after semantic projection returns and before its messages are +/// written**, so the successor frame cannot overtake the release its +/// own new mapping required. +/// +/// The synthetic release carries NO modifiers: nothing is physically +/// held, and inventing a modifier state would report a chord the user +/// never made. +fn drain_pending_release( + editor: &mut EditorState, + semantic_states: &mut HashMap, + source: FrontendId, +) { + let Some(record) = semantic_states + .get_mut(&source) + .and_then(crate::semantic_render::SemanticRenderState::take_pending_release) + else { + return; + }; + editor.complete_panel_gesture(source, &record, pmacs_protocol::Modifiers::default()); +} + +/// Parent 48 Q#BP-R4 — the authoritative lifecycle table. +/// +/// The disposition is decided BEFORE any target effect, and the live +/// record is consulted before a left tail reaches a child or a +/// selection. That ordering is the point: the old shape validated, +/// classified and mutated in one pass, so an `Up` or `Drag` with no +/// accepted `Down` had already landed by the time the latch was read. +/// +/// Exactly one completion per gesture. An `Accepted` release performs +/// the ordinary in-content completion and takes the record; a +/// `Consumed` release did not reach content, so it delivers the +/// RECORDED completion and takes the record. Running both is P5. +fn replay_panel_pointer( + editor: &mut EditorState, + semantic_states: &mut HashMap, + source: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, +) { + use crate::editor::PanelPointerOutcome as Outcome; + use pmacs_protocol::{MouseButton, MouseKind}; + + // DRAIN FIRST. An owed release has to reach the child before this + // gesture's press does; draining afterwards would put them on the + // wire in the wrong order, which reads to the child as a press + // followed by a release of the gesture BEFORE it. + drain_pending_release(editor, semantic_states, source); + + let disposition = editor.classify_panel_pointer(source, buffer_id, coord, kind); + let outcome = disposition.outcome(); + if outcome == Outcome::Refused { + // No effect, and the latch is left exactly as it was: a refused + // event cannot be known to concern the live gesture at all. + return; + } + let live = semantic_states + .get(&source) + .is_some_and(crate::semantic_render::SemanticRenderState::has_accepted_gesture); + + match kind { + MouseKind::Down(MouseButton::Left) => { + if outcome != Outcome::Accepted { + // A chrome press begins nothing. + return; + } + // A SECOND PRESS WITH THE FIRST STILL LIVE. The entry drain + // above saw nothing, because the old gesture had not been + // cancelled yet --- it was live, not owed. Arming used to do + // the cancelling, which happens AFTER this press has already + // reached the target, so the child received + // `old press, new press, old release`. + // + // End the old gesture and PAY it here, before the + // replacement lands, so the wire carries + // `old press, old release, new press`. + if live { + if let Some(state) = semantic_states.get_mut(&source) { + let _ = state.cancel_accepted_gesture(); + } + drain_pending_release(editor, semantic_states, source); + } + + // ARMED FROM THE EFFECT RESULT, and only if there was one. + // `None` means the target refused the press --- a terminal + // view that is gone, for instance --- and arming over that + // would record a gesture no tail can deliver. + let Some(domain) = editor.apply_panel_pointer(source, &disposition, coord, kind, mods) + else { + return; + }; + if let Some(state) = semantic_states.get_mut(&source) { + state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { + button: MouseButton::Left, + coord, + buffer_id, + domain, + }); + } + } + MouseKind::Drag(MouseButton::Left) => { + if outcome != Outcome::Accepted || !live { + // Stale tail, or a drag over chrome: inert. Any live + // record is retained rather than advanced, because the + // pointer is not over content. + return; + } + // THE TAIL FOLLOWS THE RECORD, not a fresh classification. + // The disposition above decided only whether this cell is + // ours and in content; WHERE the drag goes is the domain the + // press resolved (G5k). + let Some(domain) = semantic_states + .get(&source) + .and_then(crate::semantic_render::SemanticRenderState::accepted_gesture) + .map(|record| record.domain) + else { + return; + }; + editor.replay_panel_gesture_in_domain(source, domain, coord, kind, mods); + if let Some(state) = semantic_states.get_mut(&source) { + state.note_gesture_content_cell(coord); + } + } + MouseKind::Up(MouseButton::Left) => { + if !live { + // A release with no accepted press is inert. Letting it + // through would send a child tail or mutate a selection + // for a gesture that never began. + return; + } + match outcome { + Outcome::Accepted => { + // In content, so the ordinary completion runs --- but + // still in the RECORDED domain, or a mid-gesture mode + // flip would route this release away from the target + // that received the press (G5k). + let record = semantic_states.get_mut(&source).and_then( + crate::semantic_render::SemanticRenderState::consume_accepted_gesture, + ); + if let Some(record) = record { + // Taken WITHOUT counting a cancellation, and + // exactly one completion: this is the ordinary + // one, so `complete_panel_gesture` must not also + // run (P5). + editor.replay_panel_gesture_in_domain( + source, + record.domain, + coord, + kind, + mods, + ); + } + } + Outcome::Consumed => { + // The release landed on chrome, so the content path + // never ran. Terminate from the record, at its last + // valid content cell. + let record = semantic_states.get_mut(&source).and_then( + crate::semantic_render::SemanticRenderState::consume_accepted_gesture, + ); + if let Some(record) = record { + editor.complete_panel_gesture(source, &record, mods); + } + } + Outcome::Refused => unreachable!("refused returned above"), + } + } + _ => { + // Every other kind: a one-shot content effect, and it never + // touches the left-gesture latch. + if outcome == Outcome::Accepted { + let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + } + } + } +} + /// §5b — which panel-pointer family this session speaks. /// /// **Read from the AUTHENTICATED source, never from the payload's @@ -1029,50 +1251,11 @@ fn panel_event_epochs_are_current( /// Consulted BEFORE the payload is trusted, before any generation is /// validated and before any mutation: the family decides which variant /// is even admissible, so it cannot depend on the variant's contents. -/// §5b G5c/G5d/G5g — update the accepted-gesture latch for one -/// ACCEPTED panel gesture. /// -/// Called only after every gate has passed, so "accepted" means exactly -/// that. Three rules, and each closes its own hole: -/// -/// * only a left `Down` ARMS — a right press opens a menu and ends -/// there, and `Move`, wheel and the other buttons begin nothing, so -/// arming on them would manufacture a delayed release at the next -/// authority loss (G5g); -/// * a left `Up` CONSUMES, or a later invalidation finds a gesture it -/// believes live and duplicates its release (G5c); -/// * an `Up` with no armed gesture is INERT — it terminates nothing, -/// because nothing began (G5d). -fn update_accepted_gesture( - semantic_states: &mut HashMap, - source: FrontendId, - kind: pmacs_protocol::MouseKind, - coord: pmacs_protocol::CellCoord, - buffer_id: crate::buffer::BufferId, - reached_child: bool, -) { - use pmacs_protocol::{MouseButton, MouseKind}; - let Some(state) = semantic_states.get_mut(&source) else { - return; - }; - match kind { - MouseKind::Down(MouseButton::Left) => { - state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { - button: MouseButton::Left, - coord, - buffer_id, - reached_child, - }); - } - MouseKind::Up(MouseButton::Left) => { - // Inert when nothing was armed: `take` on `None` is the - // whole of G5d. - let _ = state.consume_accepted_gesture(); - } - _ => {} - } -} - +/// **Re-homed here.** This paragraph documented THIS function but sat +/// above `update_accepted_gesture`, whose own doc followed it in the +/// same block. Deleting that function's doc with it made the +/// misplacement visible. fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { session_registry .session_state(source) @@ -1425,8 +1608,8 @@ fn dispatcher_loop( // out locally); it still receives `CursorByte` below // (semantic implies `crdt_replica`) and participates in // presence. A grid session takes the M5.2 cell path. - let messages = if let Some(sem) = semantic_states.get_mut(fid) { - sem.render_frame(editor) + let messages = if semantic_states.contains_key(fid) { + project_semantic_frame(editor, &mut semantic_states, *fid) } else { // T M10.9 — gather other-frontend presences for the // overlay paint. Reads `last_broadcast` (updated by @@ -2550,6 +2733,13 @@ fn handle_dispatcher_event( buffer_id, coord, kind, + // Parent 48 R-a: modifiers are NOT decoration here. + // `apply_terminal_gesture` gates child reporting on + // `!shift`, so Shift is the user's "select locally + // instead of talking to the child" override, and the + // document path reads Shift to extend the selection. + // Dropping them into `..` inverted both. + mods, .. } => { // Bottom panel Q#BP16 — a gesture the frontend @@ -2598,19 +2788,15 @@ fn handle_dispatcher_event( // armed gesture, so the authority loss that // should have ended it finds nothing, and the // child holds the button down forever. - if editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind) { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - // Whether the press reached a child is - // replay's to know; until replay exists - // no press does. - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::PanelPointerMapped { @@ -2620,6 +2806,12 @@ fn handle_dispatcher_event( coord, kind, mapping_generation, + // Bound for the same reason as the legacy arm above, + // and NOT optional here: the mapped family carries + // the same modifiers, so leaving them in `..` would + // give a v25 session the inverted Shift behaviour + // that parent 48 R-a fixed for v24. + mods, .. } => { // §5b — the mapped family, in this order: @@ -2661,16 +2853,15 @@ fn handle_dispatcher_event( // survive the ladder; it does not make a // surviving one land, so this arm needs the same // gate. - if editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind) { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::Pointer { @@ -2824,6 +3015,16 @@ fn handle_dispatcher_event( } } DispatcherEvent::SessionDetached { frontend_id } => { + // Parent 48 G5b/G5i — DETACH is the fifth authority loss, + // and the only one with no later chance at all. End the live + // gesture, then pay it, and do BOTH before any teardown: the + // next line drops the state holding the record, and + // `detach_frontend_input` below releases the terminal + // controller the release still needs. + if let Some(state) = semantic_states.get_mut(&frontend_id) { + let _ = state.cancel_accepted_gesture(); + } + drain_pending_release(editor, semantic_states, frontend_id); render_states.remove(&frontend_id); semantic_states.remove(&frontend_id); streams.remove(&frontend_id); @@ -7301,7 +7502,7 @@ mod tests { button: pmacs_protocol::MouseButton::Left, coord: pmacs_protocol::CellCoord::new(0, 0), buffer_id: buffer_a, - reached_child: false, + domain: crate::editor::PanelGestureDomain::Document { window: panel_a }, }, ); @@ -7435,6 +7636,2049 @@ mod tests { } } + // ----------------------------------------------------------------- + // Parent 48 Q#BP-R4 — the lifecycle table's witnesses. + // + // Every row here reads a TARGET EFFECT, never the latch alone. The + // framing is explicit that a latch-only assertion must not satisfy + // these: emptying the latch is bookkeeping, and the defect being + // fenced is precisely a gesture whose bookkeeping looks right while + // the target heard nothing. + // ----------------------------------------------------------------- + + /// The panel's buffer, an in-content cell, and a cell on its CHROME + /// (the mode line, which is the grid's last row per R-c). + fn panel_buffer_and_chrome_coord( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + ) -> (crate::buffer::BufferId, pmacs_protocol::CellCoord) { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + let content_rows = grid.rows.saturating_sub(1); + assert!( + content_rows > 0, + "fixture: the panel must have content rows" + ); + ( + core.windows[&panel].buffer_id, + pmacs_protocol::CellCoord::new(content_rows, 0), + ) + } + + /// The side window's cursor, for reading a replayed effect. + fn panel_cursor(editor: &crate::editor::EditorState, panel: crate::window::WindowId) -> u64 { + editor.core.borrow().windows[&panel].cursor + } + + /// P1 — a press on the band's MODE LINE begins nothing. + /// + /// The merge made this arm the latch, because `Consumed` and + /// `Accepted` were the same `true`. The row reads the cursor as well + /// as the latch: a chrome press must not move point either. + #[test] + fn r4_p1_a_chrome_press_neither_arms_nor_moves_point() { + let fid = FrontendId(790); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let before = panel_cursor(&editor, panel); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + chrome, + press, + ), + ); + + assert!( + !states[&fid].has_accepted_gesture(), + "a chrome press must not arm: the mode line is not content, \ + and a gesture armed there would be cancelled or released \ + for a press the target never saw" + ); + assert_eq!( + panel_cursor(&editor, panel), + before, + "and it must not move point --- `Consumed` means the panel \ + claimed the cell, not that it replayed anything" + ); + } + + /// P7 — a release with no accepted press reaches nothing. + /// + /// This is the stale-tail case the pre-effect disposition exists + /// for. The document `Up` arm clears an ACTIVE BUT EMPTY selection, + /// so the fixture arms one and asserts it SURVIVES: an inert release + /// must not run that clear. + #[test] + fn r4_p7_a_release_with_no_accepted_press_is_inert() { + let fid = FrontendId(791); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let inside = pmacs_protocol::CellCoord::new(0, 0); + + // An empty selection is exactly what an accepted release would + // clear, so it is the discriminator for "did the effect run". + { + let mut core = editor.core.borrow_mut(); + let cursor = core.windows[&panel].cursor; + core.windows.get_mut(&panel).expect("panel").selection = + Some(crate::window::Selection { anchor: cursor }); + } + assert!( + !states[&fid].has_accepted_gesture(), + "fixture: no gesture is live" + ); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + inside, + release, + ), + ); + + assert!( + editor.core.borrow().windows[&panel].selection.is_some(), + "a release with no accepted press must reach NOTHING --- it \ + cleared a selection it never began, which on a terminal is \ + a child tail for a press that never happened" + ); + } + + /// P8 — a drag with no accepted press reaches nothing. + /// + /// The document `Drag` arm moves point unconditionally, so an + /// orphan drag used to drive the cursor from a gesture that never + /// began. + #[test] + fn r4_p8_an_orphan_drag_does_not_move_point() { + let fid = FrontendId(792); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); + // THE PANEL BUFFER MUST HAVE CONTENT. `*panel*` is created empty, + // so `panel_cell_byte` returns `None` for every interesting cell + // and the drag cannot move point whether it is gated or not — + // the row would pass vacuously. Caught by the gating mutation + // failing to bite. + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + // A cell the cursor is NOT already on, or the row cannot fail. + let elsewhere = pmacs_protocol::CellCoord::new(0, 6); + let before = panel_cursor(&editor, panel); + assert_eq!(before, 0, "fixture: point starts at the buffer head"); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + elsewhere, + drag, + ), + ); + + assert_eq!( + panel_cursor(&editor, panel), + before, + "an orphan drag must not move point" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and it must not manufacture a record by writing to one" + ); + } + + /// P3 — a chrome release on a TERMINAL panel completes the gesture + /// from the record. + /// + /// **The document leg cannot test this, and an earlier version of + /// this row tried.** R-c lets document chrome `Up` fall THROUGH to + /// content, so it classifies `Accepted` and takes the ordinary path; + /// the row passed with the recorded completion deleted. Only a + /// terminal panel returns `Consumed` for a chrome release — which is + /// exactly the path the framing named, where the dispatcher returns + /// before `apply_terminal_gesture`. + /// + /// The observable is the terminal view's DRAG STATE, not the latch: + /// `finish_selection` is what takes it, so a gesture that ended + /// without a delivered completion stays mid-drag forever. + #[test] + fn r4_p3_a_chrome_release_completes_a_terminal_gesture_from_the_record() { + use crate::terminal::{TerminalSpec, view::TerminalViewKey}; + + // LEGACY, deliberately. This row is about the Consumed/terminal + // completion, which is family-independent — and the mapped arm + // cannot express it, because reading the live mapping generation + // ADVANCES the key, and §5b wired a key advance to cancel the + // live gesture (G5a). The fixture would destroy the gesture it + // is trying to complete, which is how an earlier version of this + // row failed while the implementation was correct. + let fid = FrontendId(793); + let (mut editor, mut states, mut render, _document, panel, _epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = view; + } + // Re-ship the declaration so the epochs match the terminal panel. + let epochs = shipped_declaration(&editor, fid, &mut states); + let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel); + assert_eq!( + buffer_id, terminal_buffer, + "fixture: the panel is the terminal" + ); + let inside = pmacs_protocol::CellCoord::new(0, 0); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let key = TerminalViewKey::new(fid, panel, terminal_buffer); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut states, + &mut render, + arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, inside, press), + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: the content press armed" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: the press began a local terminal drag" + ); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut states, + &mut render, + arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, chrome, release), + ); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "the RECORDED completion must run: a terminal chrome release \ + returns before the replay path, so without it the drag never \ + finishes while the latch looks correctly empty" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and the record is taken" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "a completion is not a cancellation" + ); + } + + /// The exact SGR bytes a left press at cell (1, 2) produces: + /// `ESC [ < 0 ; col+1 ; row+1 M`. Written as a LITERAL rather than + /// built with the encoder's own formula, which would assert only + /// that the encoder agrees with itself. + const SGR_PRESS_1_2: &[u8] = b"\x1b[<0;3;2M"; + /// The matching release, `m` rather than `M`. + const SGR_RELEASE_1_2: &[u8] = b"\x1b[<0;3;2m"; + /// The same release with SHIFT held: the button code gains 4. + const SGR_RELEASE_1_2_SHIFT: &[u8] = b"\x1b[<4;3;2m"; + /// A left press at cell (2, 4), the replacement gesture's cell. + const SGR_PRESS_2_4: &[u8] = b"\x1b[<0;5;3M"; + + /// A panel session whose side window holds a live TERMINAL, with + /// the send tap armed. + /// + /// Legacy, deliberately: reading the live mapping generation + /// ADVANCES the key, and §5b wired a key advance to cancel the live + /// gesture, so a mapped fixture destroys the gesture these rows are + /// about. + type TerminalPanelFixture = ( + crate::editor::EditorState, + HashMap, + HashMap, + crate::window::WindowId, + crate::buffer::BufferId, + (u64, u64), + ); + + fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture { + terminal_panel_session_at(fid, reporting, LEGACY_PANEL_VERSION) + } + + /// The same fixture at a chosen negotiated version. + /// + /// G5b's matrix runs every transition across BOTH families, so the + /// version has to be a parameter rather than the legacy default the + /// earlier rows could assume. + fn terminal_panel_session_at( + fid: FrontendId, + reporting: bool, + version: u32, + ) -> TerminalPanelFixture { + use crate::terminal::TerminalSpec; + + let (editor, mut states, render, _document, panel, _epochs) = + panel_session_at(version, fid); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = view; + } + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(terminal_buffer, reporting); + editor.terminal_manager.borrow().start_send_tap_for_test(); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, terminal_buffer, epochs) + } + + /// Everything the child received, in order. + fn child_stream(editor: &crate::editor::EditorState) -> Vec> { + editor + .terminal_manager + .borrow() + .take_send_tap_for_test() + .into_iter() + .map(|(_, bytes)| bytes) + .collect() + } + + /// Send one legacy panel gesture. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every G5k leg" + )] + fn send_panel( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + let mut event = arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, coord, kind); + if let FrontendEvent::PanelPointer { mods: slot, .. } = &mut event { + *slot = mods; + } + dispatch_panel_event(editor, fid, LEGACY_PANEL_VERSION, states, render, event); + } + + // ----------------------------------------------------------------- + // G5k — the terminal gesture-domain matrix. + // + // Four legs. In each, the press resolves a domain and then the + // condition that chose it REVERSES before the tail. The gesture must + // finish in the domain it began in; re-reading Shift, the scroll + // position or the child's modes per event is the framing's named + // mutation, and it either strands a child press or sends the child + // an `Up` for a `Down` it never saw. + // ----------------------------------------------------------------- + + /// G5k(a) — child press, then the child turns REPORTING OFF: the + /// release still reaches the child, and no local selection forms. + #[test] + fn g5k_a_reporting_off_mid_gesture_still_releases_to_the_child() { + let fid = FrontendId(794); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); + assert!( + states[&fid] + .accepted_gesture() + .is_some_and(crate::semantic_render::AcceptedPanelGesture::reached_child), + "fixture: the record says the child owns this gesture" + ); + + // The child stops reporting MID-GESTURE. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, false); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the release must still reach the child AS A RELEASE at the \ + gesture's cell: it holds a button down that only this can \ + lift, and re-reading the modes here is what strands it" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started behind the child's back" + ); + } + + /// G5k(b) — child press, then SHIFT is held before the release: the + /// release still reaches the child. + #[test] + fn g5k_b_shift_before_the_release_still_releases_to_the_child() { + let fid = FrontendId(795); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + shift, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2_SHIFT.to_vec()], + "Shift is the LOCAL-HANDLING override for a NEW gesture, not \ + a way to abandon one already delivered to the child. The SGR \ + framing comes from the record; the modifier bits still report \ + live state, so the code is 4 rather than 0" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started" + ); + } + + /// G5k(c) — SHIFT press starts a LOCAL gesture; releasing without + /// Shift finishes locally and sends the child nothing. + #[test] + fn g5k_c_a_shift_started_gesture_finishes_locally() { + let fid = FrontendId(796); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + shift, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: a Shift press is handled locally" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // Shift released before the button. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING: it never saw the press, so \ + an Up here is a release for a Down that never happened" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local drag finishes" + ); + } + + /// G5k(d) — a press taken locally because reporting was OFF stays + /// local when the child turns reporting ON mid-gesture. + #[test] + fn g5k_d_reporting_on_mid_gesture_does_not_capture_a_local_gesture() { + let fid = FrontendId(797); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: reporting is off, so the press is local" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // The child turns reporting ON mid-gesture. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, true); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING --- it never saw this press" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local gesture still finishes locally" + ); + } + + /// P3, reporting leg — a chrome release still delivers the child's + /// release, in the RECORDED encoding. + #[test] + fn r4_p3_child_a_chrome_release_still_reaches_the_reporting_child() { + let fid = FrontendId(798); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let chrome = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows.saturating_sub(1), 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + chrome, + release, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the chrome release must terminate the child's gesture AT THE \ + GESTURE'S OWN CELL, not the chrome cell it landed on (R-c2). \ + The terminal path returns before replay, so without the \ + recorded completion the child holds the button forever" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and the record is taken" + ); + } + + /// P9 — a document press that ANCHORS NOTHING does not arm. + /// + /// `panel_cell_byte` is `None` past the end of a short line and on + /// an empty panel, and the press then places no cursor and opens no + /// selection. Arming over it records a gesture whose completion has + /// nothing to complete — and, once cancellations deliver effects, + /// one that fires a release for a press that did nothing. + #[test] + fn r4_p9_a_document_press_that_anchors_nothing_does_not_arm() { + let fid = FrontendId(802); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + // The panel grid is 4 rows, so content is rows 0..=2 and row 3 + // is the mode line. The `*panel*` buffer is created EMPTY --- one + // zero-length line --- so row 1 is IN CONTENT and still past the + // buffer's only line. + // + // Two cells that do NOT work, and the row asserts its way past + // both: a column past the end of row 0 clamps to the line end + // and yields byte 0, and any row >= 4 is out of grid, so the + // press is refused before the document path is reached at all. + let unanchorable = pmacs_protocol::CellCoord::new(1, 0); + assert!( + editor + .classify_panel_pointer(fid, buffer_id, unanchorable, press) + .outcome() + == crate::editor::PanelPointerOutcome::Accepted, + "fixture: the cell must be ACCEPTED content, or this row \ + passes because the press was refused for an unrelated reason" + ); + assert!( + editor + .panel_cell_byte_for_test(panel, unanchorable) + .is_none(), + "fixture: this cell must genuinely have no byte behind it" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + unanchorable, + press, + none, + ); + + assert!( + !states[&fid].has_accepted_gesture(), + "a press that anchored nothing must not arm" + ); + } + + /// P10 — a LOCAL terminal press that begins no drag does not arm. + /// + /// **The panel is wider than its terminal.** The fixture's band is + /// 80 columns while the child's screen is 20, so columns 20..79 are + /// painted padding: inside accepted panel content, and behind no + /// terminal cell. `anchor_at` refuses them + /// (`coord.col >= row.cells.len()`), `begin_selection` returns + /// `false`, and nothing begins. + /// + /// An earlier round recorded this gate as UNWITNESSED, claiming + /// `anchor_at` resolves every in-grid cell of a live view. That was + /// measured on ROWS and generalised to cells, which is a different + /// statement and a false one. + #[test] + fn r4_p10_a_local_press_that_begins_no_drag_does_not_arm() { + let fid = FrontendId(803); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + // Column 20 of an 80-column band over a 20-column child. + let padding = pmacs_protocol::CellCoord::new(1, 20); + assert_eq!( + editor + .classify_panel_pointer(fid, buffer_id, padding, press) + .outcome(), + crate::editor::PanelPointerOutcome::Accepted, + "fixture: the cell is ACCEPTED content, so a refusal cannot \ + be what this row observes" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + padding, + press, + none, + ); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: no local drag began --- the cell is painted padding" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "so nothing may be armed: the record would name a drag that \ + does not exist, and its completion would have nothing to \ + finish" + ); + } + + // ----------------------------------------------------------------- + // Q1-Q6 — the pending-release slot, and the ORDER it drains in. + // + // A cancellation raised inside frame production cannot deliver its + // own release. The slot is where the record waits; these rows are + // about it being paid, and paid at the right moment. + // ----------------------------------------------------------------- + + /// Drive a real `SessionDetached` through the dispatcher. + fn detach_session( + editor: &mut crate::editor::EditorState, + semantic_states: &mut HashMap, + render_states: &mut HashMap, + fid: FrontendId, + ) { + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session(fid, session(LEGACY_PANEL_VERSION, true)); + handle_dispatcher_event( + DispatcherEvent::SessionDetached { frontend_id: fid }, + editor, + render_states, + semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + /// Cancel the live gesture from INSIDE PROJECTION, by taking the + /// panel away. + /// + /// `publish_absent_panel` cancels while `render_frame` is building + /// the frame, which is the shape these rows are about: a + /// cancellation with nowhere to deliver its release. + /// + /// A mapping-generation advance is the other such trigger and would + /// read more naturally, but it cannot be driven on a TERMINAL + /// panel: that key tracks the screen and anchor, not the buffer, so + /// a foreign edit does not move it. `Absent` is family-independent + /// and reaches the same parking path. + /// Returns the epochs a later gesture must echo: re-showing the + /// panel ships a NEW declaration, and the old epochs are stale. + fn cancel_by_panel_absence( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) -> (u64, u64) { + editor.hide_panel_for_test(fid); + { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + // Re-shown and re-declared, because these rows are about what + // happens to the OWED RELEASE afterwards. A panel left `Absent` + // fails the inbound ladder, so no later gesture would reach the + // drain at all and the row would be observing the ladder rather + // than the slot. + editor.show_panel_for_test(fid); + shipped_declaration(editor, fid, states) + } + + /// Q1/Q2 — a cancelled gesture's release is PARKED and then PAID. + /// + /// The cancellation happens inside projection, where no target + /// effect can run; dropping the record there is how the child ended + /// up holding a button with nothing left to lift it. + #[test] + fn q1_q2_a_cancelled_gesture_release_is_parked_then_delivered() { + let fid = FrontendId(810); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child" + ); + + let epochs = cancel_by_panel_absence(&editor, &mut states, fid); + assert!( + !states[&fid].has_accepted_gesture(), + "fixture: the advance cancelled the gesture" + ); + assert!( + states[&fid].has_pending_release(), + "Q1: the record must be PARKED, not returned into a context \ + that drops it" + ); + assert!( + child_stream(&editor).is_empty(), + "and not delivered from inside projection, which cannot run \ + a target effect" + ); + + // A later panel event: the drain runs ahead of it. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + let sent = child_stream(&editor); + assert!( + sent.contains(&SGR_RELEASE_1_2.to_vec()), + "Q2: the parked release must be PAID --- parking it and never \ + draining leaves the child exactly as stranded, got {sent:?}" + ); + } + + /// Q3 — the owed release reaches the child BEFORE the next press. + /// + /// Draining after the dispatch instead of before puts them on the + /// wire reversed, which the child reads as a press followed by the + /// release of the gesture before it. + #[test] + fn q3_the_owed_release_precedes_the_next_press() { + let fid = FrontendId(811); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let epochs = cancel_by_panel_absence(&editor, &mut states, fid); + let _ = child_stream(&editor); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + let sent = child_stream(&editor); + let release_at = sent.iter().position(|b| b == SGR_RELEASE_1_2); + let press_at = sent.iter().position(|b| b == SGR_PRESS_1_2); + assert!( + release_at.is_some() && press_at.is_some(), + "fixture: both the owed release and the new press must be on \ + the wire, got {sent:?}" + ); + assert!( + release_at < press_at, + "Q3: ORDER, not merely arrival --- the old gesture's release \ + must precede the new gesture's press, got {sent:?}" + ); + } + + /// Q4 — detach pays what it owes before tearing the state down. + /// + /// `SessionDetached` drops `semantic_states` for the frontend, and + /// there is no later opportunity: a release not delivered here is + /// never delivered. + #[test] + fn q4_detach_delivers_an_owed_release_before_teardown() { + let fid = FrontendId(812); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + // Detach never sends another gesture, so the fresh epochs are + // not needed here --- only the parked release is. + let _ = cancel_by_panel_absence(&editor, &mut states, fid); + assert!( + states[&fid].has_pending_release(), + "fixture: a release is owed" + ); + let _ = child_stream(&editor); + + detach_session(&mut editor, &mut states, &mut render, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "Q4: the owed release must be paid before teardown --- the \ + next statement in that arm drops the state holding it" + ); + assert!( + !states.contains_key(&fid), + "fixture: and the teardown really did run" + ); + } + + /// Q5 — a release raised by PROJECTION is paid before the frame + /// that raised it can be written. + /// + /// This is the third drain, and the only one a unit row could not + /// reach before: it sits inside the daemon's per-frontend frame + /// loop. `project_semantic_frame` is that seam, extracted --- it + /// returns the messages UNWRITTEN, so a caller holding them has by + /// construction not sent the successor frame yet, and a release + /// already delivered at that moment provably precedes it. + /// + /// Recorded as OWED through tasks 18 and 19; this closes it. + #[test] + fn q5_a_projection_raised_release_precedes_the_frame_that_raised_it() { + let fid = FrontendId(870); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session_at(fid, true, PROTOCOL_VERSION); + let cell = pmacs_protocol::CellCoord::new(1, 2); + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + PROTOCOL_VERSION, + epochs, + buffer_id, + cell, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child" + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: the gesture is live" + ); + + // The panel stops being presentable. `publish_absent_panel` + // cancels from INSIDE projection, which cannot deliver. + editor.hide_panel_for_test(fid); + + let messages = project_semantic_frame(&mut editor, &mut states, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the release must already be delivered when projection hands \ + its messages back --- the caller has not written them, so \ + this is the release preceding the successor frame, not \ + merely both arriving" + ); + assert!( + messages.iter().any(|message| matches!( + message, + InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent) + )), + "fixture: the unwritten messages must contain THE SUCCESSOR \ + FRAME --- the `Absent` payload whose own transition raised \ + the release. A non-empty vec proves nothing: any unrelated \ + semantic message would satisfy it, and the row would then \ + assert an ordering against a frame that was never there. \ + Got {messages:?}" + ); + assert!( + !states[&fid].has_pending_release(), + "and nothing is left owed" + ); + } + + /// Q6 — a SECOND PRESS with the first still live: the old release + /// reaches the child before the new press. + /// + /// This is the ordering the entry drain alone cannot give. That + /// drain looks for an OWED release, and a live gesture owes + /// nothing yet — it is cancelled by arming, which happens after the + /// replacement press has already reached the target. The child then + /// saw `old press, new press, old release`: two presses + /// outstanding, and a release arriving for the wrong one. + /// + /// An earlier version of this row never sent a second press while + /// the first was live, so it could not observe any of that. + #[test] + fn q6_a_second_press_pays_the_first_before_it_lands() { + let fid = FrontendId(813); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let first = pmacs_protocol::CellCoord::new(1, 2); + let second = pmacs_protocol::CellCoord::new(2, 4); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + first, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the first press reached the child" + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: and it is still LIVE --- no release was sent" + ); + + // The second press, with the first never released. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + second, + press, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec(), SGR_PRESS_2_4.to_vec()], + "the first gesture's release must reach the child BEFORE the \ + replacement press --- the release is at the first gesture's \ + own cell, and it comes first" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and the replacement is armed" + ); + assert!( + !states[&fid].has_pending_release(), + "with nothing left owed" + ); + } + + // ----------------------------------------------------------------- + // G5b — the common authority-loss matrix, table-driven. + // + // Four transitions x two families x two targets. §5b wired `Absent` + // and left these armed --- inert while nothing consumed the latch, + // defects the moment cancellation gained an effect. + // + // Every quadrant asserts the EFFECT, drained explicitly: the exact + // release bytes for a reporting terminal, the cleared empty + // selection for a document, and an empty pending slot afterwards. + // Stopping at `has_pending_release()` would pass while delivery or + // recorded-domain routing was broken, which is what an earlier + // version of these rows did. + // ----------------------------------------------------------------- + + /// Which authority loss a matrix row drives. + #[derive(Clone, Copy, Debug)] + enum LossCause { + /// The panel stops being presentable at all. + /// + /// §5b wired this one, so it is the matrix's CONTROL as much as + /// its fifth row: if the four this lane added regressed while + /// `Absent` still worked, the matrix would say so. + Absent, + WindowReplaced, + BufferReplaced, + GeometrySameSize, + Detach, + } + + /// Which target the gesture was pressed on. + #[derive(Clone, Copy, Debug)] + enum LossTarget { + Document, + ReportingTerminal, + } + + /// A panel session of the requested target kind and family. + fn loss_fixture(fid: FrontendId, version: u32, target: LossTarget) -> TerminalPanelFixture { + match target { + LossTarget::ReportingTerminal => terminal_panel_session_at(fid, true, version), + LossTarget::Document => { + let (editor, mut states, render, _document, panel, _epochs) = + panel_session_at(version, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + // A press must ANCHOR, or the row never arms. + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, buffer_id, epochs) + } + } + } + + /// Send one press in whichever family `version` negotiated. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every matrix quadrant" + )] + fn send_press( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + version: u32, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + ) { + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + if version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION { + let generation = live_generation(PanelArm::Mapped, editor, states, fid); + let event = arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + coord, + press, + ); + dispatch_panel_event(editor, fid, version, states, render, event); + } else { + send_panel( + editor, states, render, fid, epochs, buffer_id, coord, press, none, + ); + } + } + + /// Replace the side window's buffer, keeping the window. + fn replace_panel_buffer(editor: &crate::editor::EditorState, panel: crate::window::WindowId) { + let mut core = editor.core.borrow_mut(); + let replacement = core.registry.borrow_mut().create("*replacement*"); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = replacement; + window.text_view = view; + } + + /// Close and reopen the panel on the same buffer: a new window. + fn replace_panel_window( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + buffer_id: crate::buffer::BufferId, + ) { + { + let mut core = editor.core.borrow_mut(); + core.active_frontend = fid; + core.focus_window(fid, panel); + assert!(core.close_active(), "fixture: the side window closes"); + } + editor.reconcile_panel_layout(fid); + { + let mut core = editor.core.borrow_mut(); + let mut request = crate::editor_core::DisplayRequest::new(buffer_id); + request.side = Some(crate::window::Side::Bottom); + request.height = Some(4); + core.display_buffer(fid, &request) + .expect("reopen the panel"); + } + editor.reconcile_panel_layout(fid); + assert_ne!( + editor.core.borrow().side_window_for(fid).expect("reopened"), + panel, + "fixture: the successor really is a different window" + ); + } + + /// G5b — every transition, every family, every target. + #[expect( + clippy::too_many_lines, + reason = "one table whose quadrants read together; splitting it \ + hides which combinations are covered" + )] + #[test] + fn g5b_the_authority_loss_matrix() { + use LossCause::{Absent, BufferReplaced, Detach, GeometrySameSize, WindowReplaced}; + use LossTarget::{Document, ReportingTerminal}; + + let mut next_fid = 830u64; + let mut quadrants = 0usize; + for cause in [ + Absent, + WindowReplaced, + BufferReplaced, + GeometrySameSize, + Detach, + ] { + for target in [Document, ReportingTerminal] { + for version in [LEGACY_PANEL_VERSION, PROTOCOL_VERSION] { + let fid = FrontendId(next_fid); + next_fid += 1; + let label = format!("{cause:?}/{target:?}/v{version}"); + quadrants += 1; + + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, version, target); + // The terminal legs use (1, 2), whose exact SGR + // bytes the constants above pin. A document panel + // uses row 0: `foreign_edit` replaces the buffer's + // contents without refreshing the window's cached + // line index, so only the first display row resolves + // to a byte --- and a press that anchors nothing does + // not arm, which would make every document leg + // vacuous. + let cell = match target { + ReportingTerminal => pmacs_protocol::CellCoord::new(1, 2), + Document => pmacs_protocol::CellCoord::new(0, 2), + }; + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + version, + epochs, + buffer_id, + cell, + ); + assert!( + states[&fid].has_accepted_gesture(), + "{label}: fixture --- the press must arm, or the row \ + proves nothing" + ); + if matches!(target, ReportingTerminal) { + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); + } + let selection_before = editor.core.borrow().windows[&panel].selection; + + match cause { + Absent => { + editor.hide_panel_for_test(fid); + render_and_redeclare(&editor, &mut states, fid); + } + WindowReplaced => { + replace_panel_window(&editor, fid, panel, buffer_id); + render_and_redeclare(&editor, &mut states, fid); + } + BufferReplaced => { + replace_panel_buffer(&editor, panel); + render_and_redeclare(&editor, &mut states, fid); + } + GeometrySameSize => { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + } + Detach => { + detach_session(&mut editor, &mut states, &mut render, fid); + } + } + // Detach drains inside its own teardown; the producer + // transitions park and are paid at the next drain. + if !matches!(cause, Detach) { + assert!( + !states[&fid].has_accepted_gesture(), + "{label}: the gesture must END --- it belongs to \ + a presentation that no longer exists" + ); + drain_pending_release(&mut editor, &mut states, fid); + assert!( + !states[&fid].has_pending_release(), + "{label}: and the slot must be empty afterwards" + ); + } + + match target { + ReportingTerminal => assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: EXACTLY the recorded release, to the \ + child the gesture was pressed on --- routing it \ + by what occupies the panel now would tell the \ + wrong child to lift a button" + ), + Document => { + assert!( + selection_before.is_some(), + "{label}: fixture --- the press anchored an \ + empty selection for the cancellation to clear" + ); + let core = editor.core.borrow(); + match core.windows.get(&panel) { + Some(window) => assert!( + window.selection.is_none(), + "{label}: the empty selection must be \ + cleared, or its stale anchor captures \ + the next shift-motion" + ), + // WINDOW REPLACED: the window the gesture + // belonged to is gone, so the completion + // has nothing left to clear and the + // gesture ENDING is the whole of the + // effect --- already asserted above. Said + // out loud rather than skipped, because a + // silently absent assertion is how a + // quadrant stops testing anything. + None => assert!( + matches!(cause, WindowReplaced), + "{label}: the panel window vanished for \ + a cause that should not remove it --- \ + `Absent` hides the panel and leaves the \ + window, so only a replacement may land here" + ), + } + } + } + } + } + } + assert_eq!( + quadrants, 20, + "FIVE transitions x two families x two targets. Asserted \ + because a loop that silently stops covering a combination \ + passes exactly as loudly as one that covers them all" + ); + } + + /// G5m — coincident invalidations produce ONE completion effect. + /// + /// Both composites the framing names, and each reads the effect + /// rather than the cancellation count: a count of one proves the + /// latch was taken once, not that exactly one release went out. + #[test] + fn g5m_coincident_invalidations_emit_one_release() { + // (a) changed-size geometry, which moves the geometry epoch AND + // the mapping generation; (b) buffer replacement, which + // moves the identity AND the mapping. + for (label, changed_size) in [("changed-size geometry", true), ("buffer replaced", false)] { + let fid = FrontendId(if changed_size { 850 } else { 851 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session_at(fid, true, PROTOCOL_VERSION); + let cell = pmacs_protocol::CellCoord::new(1, 2); + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + PROTOCOL_VERSION, + epochs, + buffer_id, + cell, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); + + // The composite is only a composite if the MAPPING moves + // too. Peeked, not read through the authoritative accessor, + // which would advance the key and manufacture the very + // second cause this row is supposed to observe. + let mapping_before = stamped_generation(&states, fid); + if changed_size { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(20, 60)); + } else { + replace_panel_buffer(&editor, panel); + } + render_and_redeclare(&editor, &mut states, fid); + assert_ne!( + stamped_generation(&states, fid), + mapping_before, + "{label}: fixture --- the mapping generation must actually \ + ADVANCE, or this is a single-cause transition wearing a \ + composite's name and the row proves nothing about \ + coincidence" + ); + drain_pending_release(&mut editor, &mut states, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: TWO causes, ONE release --- they take the same \ + latch, and a per-cause flag would send the child two \ + releases for one press" + ); + assert!( + !states[&fid].has_pending_release(), + "{label}: with nothing left owed" + ); + } + } + + /// G5j — document cancellation has two legs, and they differ. + /// + /// A press that never dragged leaves an ACTIVE BUT EMPTY selection + /// whose stale anchor would capture the next shift-motion, so it is + /// cleared. A press that DID drag leaves a real region the user + /// selected, and cancelling the gesture must not take it away. + #[test] + fn g5j_document_cancellation_clears_only_an_empty_selection() { + for dragged in [false, true] { + let fid = FrontendId(if dragged { 861 } else { 860 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, LEGACY_PANEL_VERSION, LossTarget::Document); + let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + LEGACY_PANEL_VERSION, + epochs, + buffer_id, + pmacs_protocol::CellCoord::new(0, 0), + ); + if dragged { + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + pmacs_protocol::CellCoord::new(0, 5), + drag, + none, + ); + } + let before = { + let core = editor.core.borrow(); + let window = &core.windows[&panel]; + (window.selection, window.cursor) + }; + assert!( + before.0.is_some(), + "fixture: a selection is anchored either way" + ); + + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + drain_pending_release(&mut editor, &mut states, fid); + + let after = { + let core = editor.core.borrow(); + let window = &core.windows[&panel]; + (window.selection, window.cursor) + }; + if dragged { + assert_eq!( + after, before, + "a REAL region survives cancellation, anchor and \ + cursor exactly --- the user selected it, and ending \ + the gesture is not a reason to discard it" + ); + } else { + assert!( + after.0.is_none(), + "an EMPTY selection is cleared, or its stale anchor \ + captures the next shift-motion" + ); + assert_eq!(after.1, before.1, "and clearing it does not move point"); + } + } + } + + /// Render one frame, which is where the producer notices an + /// authority loss. + fn render_and_redeclare( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// + /// A panel deliberately does not inherit the terminal's per-axis PTY + /// caps (Bet B5'): a 4K surface at a small font is legitimately + /// wider than `MAX_TERMINAL_COLS`, and the renderer clamps through + /// `terminal_projection_size` so the band paints. Pointer routing + /// passed the RAW panel width, and `view_status_for_size` refuses + /// anything over the cap — so on exactly those panels a click + /// squarely inside the visible terminal resolved to nothing and the + /// whole band was dead to the mouse while looking perfectly normal. + /// + /// A POSITIVE control: the gesture must land, not merely fail + /// safely. + #[test] + fn r4_p12_a_panel_wider_than_the_terminal_cap_still_routes_pointer_input() { + let fid = FrontendId(805); + let (mut editor, mut states, mut render, panel, buffer_id, _epochs) = + terminal_panel_session(fid, true); + + // Re-declare the surface far wider than MAX_TERMINAL_COLS. + let wide = u32::from(crate::terminal::MAX_TERMINAL_COLS) + 128; + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, wide)); + let epochs = shipped_declaration(&editor, fid, &mut states); + { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + assert!( + grid.cols > u32::from(crate::terminal::MAX_TERMINAL_COLS), + "fixture: the band must actually exceed the terminal cap, \ + got {} columns", + grid.cols + ); + } + + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let _ = child_stream(&editor); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "the press must reach the child on a wide band: routing has \ + to use the SAME projection clamp the renderer uses, or the \ + two disagree and the panel paints while ignoring the mouse" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must arm --- this is a positive control, so failing \ + safely is still failing" + ); + let _ = panel; + } + + /// P11 — a recorded LOCAL completion still runs with the panel + /// HIDDEN. + /// + /// `panel_grid_size` is `None` for a hidden panel, so a completion + /// that fetched the current geometry could not finish a drag during + /// exactly the cancellations that need finishing. The viewport is + /// recorded at the press for this reason, and the row hides the + /// panel between the press and the completion to prove it. + #[test] + fn r4_p11_a_recorded_local_completion_survives_a_hidden_panel() { + let fid = FrontendId(804); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: a local drag is live" + ); + let record = states[&fid] + .accepted_gesture() + .copied() + .expect("fixture: the gesture is armed"); + + // The panel goes away. `panel_grid_size` is now `None`. + editor.hide_panel_for_test(fid); + assert!( + editor.core.borrow().panel_grid_size(fid).is_none(), + "fixture: the panel is hidden, so ambient geometry is gone" + ); + + editor.complete_panel_gesture(fid, &record, none); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "the completion must still finish the drag --- it replays \ + against the viewport RECORDED at the press, because the \ + ambient one is exactly what a cancellation removes" + ); + } + + /// P2, effect half — a REFUSED press reaches no target at all. + /// + /// **The FOCUS assertion is witnessed.** Removing the buffer check + /// makes this press `Accepted`; it then activates the panel before + /// replaying, and the focus assertion fails. An earlier version + /// asserted the refusal BEFORE dispatch, which aborted the row + /// first and made every effect assertion unreachable — a limit of + /// ordering that I mistook for a limit of the type boundary. The + /// classification is now checked LAST, so it still catches a row + /// that has stopped testing a refusal. + /// + /// The controller and byte assertions remain **defence in depth**: + /// the mutation that reaches them routes through a document buffer, + /// which touches neither. + /// + /// §5b's four `g5_substrate_a_refused_*` rows read the latch and the + /// cancellation count; none of them reads the target. A refusal that + /// armed nothing while still sending the child a press, or starting + /// a local drag, would pass every one of them. + #[test] + fn r4_p2_a_refused_press_reaches_no_target() { + let fid = FrontendId(801); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + // THE REFUSAL LEVER IS A WRONG BUFFER, at an IN-CONTENT cell. + // + // An out-of-grid cell cannot serve here: with the row bound + // removed it becomes `on_chrome`, so the press classifies + // `Consumed` and still reaches no target. The row would "bite" + // on its own precondition while never exercising focus at all. + // A foreign buffer at a content cell is the refusal whose + // mis-gating yields `Accepted`, which is the misclassification + // these assertions are written against. + let foreign_buffer = editor + .core + .borrow() + .registry + .borrow_mut() + .create("*not-the-panel*"); + let in_content = pmacs_protocol::CellCoord::new(1, 2); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + // FOCUS is `views[&fid].active`, not `active_frontend`: the + // latter says which frontend is current, and `focus_window` + // moves the former. An earlier version of this row recorded the + // wrong one and would have watched a panel steal focus without + // noticing. + let focused_before = editor.core.borrow().views[&fid].active; + assert_ne!( + focused_before, panel, + "fixture: the panel must start PASSIVE, or 'focus did not \ + move to the panel' asserts nothing" + ); + let controller_before = editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid); + // CAPTURED, not asserted yet. Asserting the refusal here aborted + // the row before dispatch, so the effect assertions below could + // never fail under the one mutation that reaches them. The + // precondition still runs --- at the END --- so the row cannot go + // vacuous either. + let observed_outcome = editor + .classify_panel_pointer(fid, foreign_buffer, in_content, press) + .outcome(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + foreign_buffer, + in_content, + press, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "a refused press must send the child NOTHING --- a press it \ + receives is one it will expect a release for" + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + focused_before, + "and it must not FOCUS the panel: an accepted press \ + activates BEFORE it replays, so a misclassified one moves \ + focus to the panel whatever its replay then does with a \ + buffer that is not the one on screen" + ); + assert_eq!( + editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid), + controller_before, + "and it must not claim the terminal CONTROLLER" + ); + assert_eq!( + observed_outcome, + crate::editor::PanelPointerOutcome::Refused, + "and the press really was refused --- checked LAST so that a \ + mutation which accepts it is caught by the effects above \ + rather than aborting the row here, while still failing if \ + this row ever stops testing a refusal at all" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and it must not begin a local drag either" + ); + assert!(!states[&fid].has_accepted_gesture(), "and it must not arm"); + } + + /// P5 — an accepted in-content release delivers EXACTLY ONE child + /// release, never the ordinary one plus a record-driven one. + #[test] + fn r4_p5_an_accepted_release_reaches_the_child_exactly_once() { + let fid = FrontendId(799); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "EXACTLY ONE release, and its exact bytes: the in-content path \ + already completed the gesture, so running the recorded \ + completion as well sends the child two Ups for one Down" + ); + } + + /// P4 — a REFUSED release performs no completion and retains the + /// record, so a later authoritative cancellation can still end it. + #[test] + fn r4_p4_a_refused_release_neither_completes_nor_takes_the_record() { + let fid = FrontendId(800); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let outside = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows, 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + outside, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "a refused release must deliver NO completion --- the daemon \ + cannot tell it concerns this gesture at all" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must RETAIN the record, so an authoritative \ + cancellation can still end the gesture properly" + ); + } + /// The panel's buffer, and a coordinate one row past its grid. fn panel_buffer_and_outside_coord( editor: &crate::editor::EditorState, diff --git a/src/editor.rs b/src/editor.rs index d908259..7be75ce 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -408,6 +408,167 @@ impl PanelMappingSnapshot { } } +/// How an authenticated panel event relates to the authoritative panel +/// surface, decided BEFORE any target effect (parent 48 Q#BP-R4). +/// +/// Two branches once agreed on `bool` while disagreeing on its meaning +/// — §5b read it as "the gesture was accepted" and drove the +/// accepted-gesture latch off it, while panel replay read it as "the +/// event was consumed here", chrome swallows included. Merged, a press +/// on the band's mode line armed a gesture that never began in content. +/// **This type exists so that collision cannot be re-created silently:** +/// a two-state answer with the corrected meaning would be right today +/// and would let the next author restore the bug without touching a +/// test. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum PanelPointerOutcome { + /// Not addressable as this panel: no grid, out of grid, no side + /// window, or a buffer that is not the one shown there. + Refused, + /// This panel owns the cell/event, but it is deliberately not a + /// content gesture — the chrome claims of Q#BP-R2 and R-c. + Consumed, + /// A content gesture for the resolved target. + Accepted, +} + +/// The domain a live panel gesture was RESOLVED INTO at its accepted +/// press, and which every tail and its completion must follow +/// (parent 48 G5k). +/// +/// **Re-deciding this per event is G5k's forbidden mutation.** The +/// terminal adapter picks between reporting to the child and handling +/// locally by reading Shift, the scrollback position and the child's +/// current mouse modes — all three of which can change mid-gesture. +/// A press reported to the child followed by a release re-evaluated +/// after the child turned reporting off leaves that child holding a +/// button down forever; the reverse transition sends a child an `Up` +/// for a `Down` it never saw. +/// +/// So the press records the contract and the tails obey it. The +/// encoding travels with it for the same reason: the report must be +/// framed the way the press was framed, not the way the child would +/// ask for it now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelGestureDomain { + /// A document panel; every write is addressed to `window`. + Document { + /// The side window the gesture belongs to. + window: WindowId, + }, + /// A terminal panel whose press REACHED THE CHILD. + TerminalChild { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer the child is attached to. + buffer_id: crate::buffer::BufferId, + /// The modes the press was encoded under, replayed verbatim. + modes: crate::terminal::screen::TerminalModes, + }, + /// A terminal panel handled LOCALLY — Shift, reporting off, or a + /// scrolled-back view at press time. + TerminalLocal { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer whose local selection is being built. + buffer_id: crate::buffer::BufferId, + /// The CONTENT viewport the press was accepted against. + /// + /// Recorded, not re-fetched. `panel_grid_size` is `None` while + /// the panel is hidden or absent, so a completion that asked + /// for it could not finish a drag during exactly the + /// cancellations that need finishing — and a size-changing + /// cancellation would finish against the SUCCESSOR's geometry, + /// putting the selection somewhere the user never dragged. + viewport: CellSize, + }, +} + +impl PanelGestureDomain { + /// The side window this gesture is addressed to. + #[must_use] + pub fn window(&self) -> WindowId { + match self { + Self::Document { window } + | Self::TerminalChild { window, .. } + | Self::TerminalLocal { window, .. } => *window, + } + } + + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + matches!(self, Self::TerminalChild { .. }) + } +} + +/// Which way the shared terminal adapter routed one gesture. +/// +/// Returned rather than recomputed so a panel press can RECORD the +/// route it actually took (G5k). Other callers of the adapter ignore +/// it — they have no gesture latch to bind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalGestureRoute { + /// Nothing ran: the view is gone or has no status at that size. + None, + /// Reported to the child, under these modes. + Child(crate::terminal::screen::TerminalModes), + /// Handled locally — scrollback, local selection, or the menu. + Local, +} + +/// A classified panel gesture: the outcome, and the resolution it was +/// decided from. +/// +/// The resolution travels with the outcome so that +/// `EditorState::apply_panel_pointer` acts on the SAME derivation the +/// disposition was decided by. Q#BP-R4 fixes the editor as the only +/// authority for that derivation; handing the daemon a bare outcome +/// and letting it re-derive chrome or target kind would be the hole +/// §5b closed, reopened beside the dispatcher. +pub enum PanelPointerDisposition { + /// Not addressable as this panel. **Carries no target**, which is + /// the point: as two independent fields, `Refused` plus a resolved + /// target stayed representable inside this module, and the only + /// thing preventing a refused gesture from reaching a target was + /// that no code currently built that pair. + Refused, + /// Claimed by this panel, but deliberately not a content gesture. + Consumed(ResolvedPanelTarget), + /// A content gesture for the resolved target. + Accepted(ResolvedPanelTarget), +} + +impl PanelPointerDisposition { + /// The disposition, for the daemon's lifecycle table. + #[must_use] + pub fn outcome(&self) -> PanelPointerOutcome { + match self { + Self::Refused => PanelPointerOutcome::Refused, + Self::Consumed(_) => PanelPointerOutcome::Consumed, + Self::Accepted(_) => PanelPointerOutcome::Accepted, + } + } +} + +/// The panel target a disposition resolved to, derived once. +/// +/// **Public as a type, opaque as a value.** It has to be nameable +/// because `PanelPointerDisposition`'s variants carry it, but every +/// field stays private: the daemon receives a disposition and hands it +/// back, and cannot read the side window, the target kind or the +/// content bounds out of it. That is Q#BP-R4's first fixed fact — the +/// editor is the only authority for the derivation — enforced by +/// visibility rather than by convention. +pub struct ResolvedPanelTarget { + side: WindowId, + buffer_id: crate::buffer::BufferId, + is_terminal: bool, + /// The grid's rows MINUS the mode line — never the frame (R-c). + content_rows: u32, + cols: u32, +} + /// What decides the mapping BELOW the geometry, which differs by /// target kind. #[derive(Clone, PartialEq, Eq, Debug)] @@ -2769,7 +2930,34 @@ impl EditorState { .is_some_and(|now| now != current) } - /// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16). + /// The byte behind a panel cell, for P9. + #[doc(hidden)] + #[must_use] + pub fn panel_cell_byte_for_test(&self, win_id: WindowId, coord: CellCoord) -> Option { + self.panel_cell_byte(win_id, coord) + } + + /// Hide this frontend's panel, for P11. + /// + /// `panel_grid_size` returns `None` once hidden, which is the state + /// a recorded completion has to survive. + #[doc(hidden)] + pub fn hide_panel_for_test(&self, frontend_id: FrontendId) { + if let Some(view) = self.core.borrow_mut().views.get_mut(&frontend_id) { + view.panel_hidden = true; + } + } + + /// Re-show this frontend's panel, for the Q rows. + #[doc(hidden)] + pub fn show_panel_for_test(&self, frontend_id: FrontendId) { + if let Some(view) = self.core.borrow_mut().views.get_mut(&frontend_id) { + view.panel_hidden = false; + } + } + + /// Classify an authenticated panel gesture, WITHOUT applying it + /// (Q#BP-R4). /// /// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the /// daemon's own state — a live, non-hidden side window whose current @@ -2778,6 +2966,101 @@ impl EditorState { /// epochs) belong to the caller, because only the session holds the /// declaration the frontend was actually looking at. /// + /// **The answer is a DISPOSITION, decided before any target + /// effect.** Q#BP-R4 fixes two facts and this split exists to hold + /// them: the editor is the **only** authority that derives + /// `Refused`/`Consumed`/`Accepted`, and the disposition completes + /// before a left `Drag`/`Up` reaches a child or a selection. The old + /// single function could not honour the second — it validated, + /// classified and mutated in one pass, so a tail with no accepted + /// press had already landed by the time the daemon consulted the + /// latch. + /// + /// The returned value carries the resolution it was decided from, so + /// [`Self::apply_panel_pointer`] acts on the same derivation rather + /// than repeating it. **A second derivation is the hole §5b closed**; + /// reopening it beside the dispatcher would be the same defect in a + /// new place. + #[must_use] + pub fn classify_panel_pointer( + &self, + frontend_id: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + ) -> PanelPointerDisposition { + use pmacs_protocol::MouseKind as PKind; + + let refused = PanelPointerDisposition::Refused; + + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return refused; + }; + if coord.row >= size.rows || coord.col >= size.cols { + return refused; + } + // Parent 48 R-c: the panel's LAST ROW IS ITS MODE LINE. Projection + // derives content as `rows - 1` while the frontend hit-tests the + // whole frame, so a `PanelPointer` can legitimately name chrome — + // and chrome is never a content cell for either target. + let content_rows = size.rows.saturating_sub(1); + if content_rows == 0 { + return refused; + } + let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); + let side = { + let core = self.core.borrow(); + let Some(side) = core.side_window_for(frontend_id) else { + return refused; + }; + if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { + return refused; + } + side + }; + + let resolved = ResolvedPanelTarget { + side, + buffer_id, + is_terminal, + content_rows, + cols: size.cols, + }; + let on_chrome = coord.row >= content_rows; + let is_wheel = matches!( + kind, + PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight + ); + + // Everything below is CLAIMED by this panel — the cell is ours — + // and the only question left is whether it is a content gesture. + let consumed = if on_chrome { + // Q#BP-R2, step 3 of the ordering: a terminal panel's chrome + // wheel is consumed BEFORE focus, before `active_frontend`, + // and before any controller claim. Placing it after + // activation would leave the wheel changing focus while + // scrolling nothing. + // + // TUI parity for the rest: a terminal never sees a chrome + // coordinate (`dispatch_mouse` rejects every kind above + // `inner_rows`). Document chrome mirrors the TUI's PER-KIND + // rule — presses and motion are reserved, while `Up` and the + // wheel fall through, because an `Up` must still terminate a + // gesture begun in content and a chrome wheel still scrolls. + let _ = is_wheel; + is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) + } else { + false + }; + if consumed { + PanelPointerDisposition::Consumed(resolved) + } else { + PanelPointerDisposition::Accepted(resolved) + } + } + + /// Apply a classified panel gesture to its target (Q#BP-R4). + /// /// **Activation is not uniform, and Q#BP16 says so explicitly.** A /// **press** focuses any panel — that is click-to-focus, and /// `Down(Right)` is the context-menu gesture, so both buttons count. @@ -2796,47 +3079,342 @@ impl EditorState { /// Review round 1 (R2-5) found the terminal clause applied to both. /// Bare hover neither focuses nor claims, on either kind. /// - /// **Replay is out of scope in Stage 2B-2.** Driving selection, - /// listview rows, or child SGR reporting is parent acceptance 48, - /// which needs the GPU band and lands in Stage 2B-3. + /// **Only `Accepted` reaches a target.** A `Consumed` disposition + /// returns without effect: the claim was the effect. /// - /// Returns whether the gesture was accepted. - /// - /// `#[must_use]` because the accepted-gesture latch is driven off - /// this answer, and discarding it silently arms on rejected presses - /// and consumes on rejected releases. - #[must_use] - pub fn dispatch_semantic_panel_pointer( - &self, + /// Returns the [`PanelGestureDomain`] the effect RESOLVED INTO, so + /// an accepted press can record the contract its tails must follow + /// (G5k). `None` means nothing ran and nothing may be recorded. + pub fn apply_panel_pointer( + &mut self, frontend_id: FrontendId, - buffer_id: crate::buffer::BufferId, + disposition: &PanelPointerDisposition, coord: CellCoord, kind: pmacs_protocol::MouseKind, - ) -> bool { - let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { - return false; + mods: pmacs_protocol::Modifiers, + ) -> Option { + use pmacs_protocol::MouseKind as PKind; + + // Only `Accepted` carries a target into an effect, and the type + // is what enforces it: there is no `Refused` value holding one. + let PanelPointerDisposition::Accepted(target) = disposition else { + return None; }; - if coord.row >= size.rows || coord.col >= size.cols { - return false; - } - let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); - let mut core = self.core.borrow_mut(); - let Some(side) = core.side_window_for(frontend_id) else { - return false; - }; - if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { - return false; - } - let activates = if is_terminal { - !matches!(kind, pmacs_protocol::MouseKind::Move) + + let activates = if target.is_terminal { + !matches!(kind, PKind::Move) } else { - matches!(kind, pmacs_protocol::MouseKind::Down(_)) + matches!(kind, PKind::Down(_)) }; if activates { - core.focus_window(frontend_id, side); + let mut core = self.core.borrow_mut(); + core.focus_window(frontend_id, target.side); core.active_frontend = frontend_id; } - true + + if target.is_terminal { + // The ONE terminal pointer path, shared with the TUI and the + // document terminal. The viewport is `content_rows`, never the + // full grid: passing the frame would make the mode line a child + // cell and put every clamp one row out. + // THROUGH THE RENDERER'S OWN CLAMP. A panel deliberately does + // not inherit the terminal's per-axis PTY caps (Bet B5'), so + // a 4K surface at a small font is legitimately wider than + // `MAX_TERMINAL_COLS` — and `view_status_for_size` refuses + // anything over that cap. Passing the raw panel width made + // every gesture on such a panel resolve to `None`: a click + // squarely inside the visible terminal did nothing at all, + // while the same panel rendered fine because projection + // clamps and pointer routing did not agree. + let viewport = + terminal_projection_size(CellSize::new(target.content_rows, target.cols)); + let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); + return match self.apply_terminal_gesture( + key, + viewport, + coord, + kind, + mods, + (coord.row, coord.col), + ) { + TerminalGestureRoute::None => None, + TerminalGestureRoute::Child(modes) => Some(PanelGestureDomain::TerminalChild { + window: target.side, + buffer_id: target.buffer_id, + modes, + }), + TerminalGestureRoute::Local => Some(PanelGestureDomain::TerminalLocal { + window: target.side, + buffer_id: target.buffer_id, + viewport, + }), + }; + } + + // A press that placed no anchor began NOTHING, and arming over + // it records a gesture whose completion has nothing to + // complete. `panel_cell_byte` is `None` past the end of a short + // line and on an empty panel, which is not a rare shape. + self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods) + .then_some(PanelGestureDomain::Document { + window: target.side, + }) + } + + /// Replay a tail or a completion in the gesture's RECORDED domain + /// (parent 48 G5k). + /// + /// **Nothing here re-decides the domain.** The press resolved it and + /// the record carries it, so Shift going down mid-drag, the view + /// scrolling back, or the child flipping its mouse modes cannot move + /// a live gesture from one target to another. Re-evaluating those + /// per event is G5k's forbidden mutation, and it strands a child + /// press in one direction and fabricates an unmatched child release + /// in the other. + /// + /// **The window is read from the record too.** An earlier version + /// re-derived the current side window and returned when it had + /// changed — which is exactly the set of transitions the stranding + /// rows must terminate, so the completion they need would have been + /// the one thing that refused to run. + pub fn replay_panel_gesture_in_domain( + &mut self, + frontend_id: FrontendId, + domain: PanelGestureDomain, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + match domain { + PanelGestureDomain::Document { window } => { + self.replay_panel_document_gesture(frontend_id, window, coord, kind, mods); + } + PanelGestureDomain::TerminalChild { + window, + buffer_id, + modes, + } => { + // Encoded under the RECORDED modes, and gated on + // nothing: not Shift, not the scroll position, not the + // child's current modes. Those are precisely the three + // inputs G5k forbids re-reading. + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let _ = key; + if let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, mods, modes) + { + self.send_terminal_bytes(buffer_id, &bytes); + } + } + PanelGestureDomain::TerminalLocal { + window, + buffer_id, + viewport, + } => { + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let mut manager = self.terminal_manager.borrow_mut(); + match kind { + pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left) => { + let _ = manager.update_selection(key, viewport, coord); + } + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport, coord); + } + _ => {} + } + } + } + } + + /// Deliver the RECORDED completion for a gesture the ordinary path + /// did not complete (parent 48 Q#BP-R4). + /// + /// Used when a release is `Consumed` — it landed on chrome, so the + /// content path never ran — or when an authority loss cancels a + /// live gesture. An `Accepted` release must NOT come here: it + /// already performed the ordinary in-content completion, and doing + /// both is the duplicate P5 forbids. + /// + /// Delivery goes through the gesture's recorded domain at its last + /// valid content cell (R-c2), so the release is framed the way its + /// press was. + pub fn complete_panel_gesture( + &mut self, + frontend_id: FrontendId, + record: &crate::semantic_render::AcceptedPanelGesture, + mods: pmacs_protocol::Modifiers, + ) { + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + self.replay_panel_gesture_in_domain( + frontend_id, + record.domain, + record.coord, + release, + mods, + ); + } + + /// Replay one accepted gesture into a DOCUMENT panel (parent 48). + /// + /// Every write here is addressed to `side`, never to the ambient + /// active window. `Drag` and `Up` do not activate, and another + /// frontend's input can interleave between a `Down` and its tail, so + /// a replay reading `active_window_mut()` would act on whatever + /// happened to be active at that moment (R-b). + /// Returns whether a left press ANCHORED a gesture. Every other + /// kind returns `false`: only a press can begin one, so only a + /// press has an answer to give. + fn replay_panel_document_gesture( + &mut self, + frontend_id: FrontendId, + side: WindowId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) -> bool { + use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind}; + + match kind { + PKind::ScrollUp => self.scroll_window(side, -SCROLL_LINES), + PKind::ScrollDown => self.scroll_window(side, SCROLL_LINES), + + PKind::Down(PButton::Left) => { + self.core.borrow_mut().break_command_chain(frontend_id); + let is_double = self.is_double_click(frontend_id, side, coord); + let Some(byte) = self.panel_cell_byte(side, coord) else { + // No anchor: nothing began, so nothing may arm. + return false; + }; + let extending = mods.contains(pmacs_protocol::Modifiers::SHIFT); + let prev = self.core.borrow().windows[&side].cursor; + let keep_anchor = extending + && self + .core + .borrow() + .windows + .get(&side) + .is_some_and(|w| w.selection.is_some()); + self.panel_set_cursor(side, byte); + if is_double && !extending { + // Repeated left `Down`s are a multi-click (Q#BP16), and + // the second selects the word — the same rule the TUI + // applies, resolved against the SIDE window. + // Safe to use the active-window helper HERE and only + // here: `Down` activated the side window two statements + // ago, synchronously, so active == side. The tail + // (`Drag`/`Up`) does not activate and uses the + // window-targeted writers instead. + if self.core.borrow_mut().select_word_at_cursor() { + self.mouse_click = None; + // A double-click word selection IS an anchored + // gesture: its release still has to complete. + return true; + } + } + if extending { + if !keep_anchor { + self.panel_set_selection(side, Some(prev)); + } + } else { + self.panel_set_selection(side, Some(byte)); + } + self.mouse_click = Some(MouseClickState { + frontend_id, + window_id: side, + cell: coord, + at: Instant::now(), + }); + // Anchored. Stated here rather than inferred from + // selection state afterwards, which a later change + // could stop setting without anyone noticing arming had + // gone quiet. + return true; + } + PKind::Drag(PButton::Left) => { + self.mouse_click = None; + self.core.borrow_mut().break_command_chain(frontend_id); + if let Some(byte) = self.panel_cell_byte(side, coord) { + self.panel_set_cursor(side, byte); + } + } + PKind::Up(PButton::Left) => { + // A click without a drag leaves an ACTIVE BUT EMPTY + // selection whose stale anchor would capture the next + // shift-motion, so it is cleared rather than left set. + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&side) + && window + .selection + .is_some_and(|selection| selection.anchor == window.cursor) + { + window.selection = None; + } + } + PKind::Down(PButton::Right) => { + self.core.borrow_mut().break_command_chain(frontend_id); + if let Some(byte) = self.panel_cell_byte(side, coord) { + self.panel_set_cursor(side, byte); + } + self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col)); + } + // Claimed and dropped, for two different reasons kept in one + // arm because their bodies are identical: horizontal panel + // scrolling belongs to GUI arc Stage 1b's B-rows rather than + // parent 48, bare `Move` neither focuses nor claims, and the + // remaining buttons have no panel semantics at all. + PKind::ScrollLeft + | PKind::ScrollRight + | PKind::Move + | PKind::Down(_) + | PKind::Up(_) + | PKind::Drag(_) => {} + } + // Only a left press can anchor, and it returns `true` above. + // Every other kind reaching here handled something already live. + false + } + + /// Byte under a panel cell, resolved against the SIDE window's own + /// `view_top` and fold map. + /// + /// Deliberately not `activate_and_position`: that helper converts + /// correctly but also calls `set_active_window_id`, and a panel tail + /// must not re-activate a window on a frontend whose active window may + /// have moved since the `Down` (R-b). + fn panel_cell_byte(&self, win_id: WindowId, coord: CellCoord) -> Option { + let core = self.core.borrow(); + let window = core.windows.get(&win_id)?; + let buffer_id = window.buffer_id; + let view_top = window.view_top; + let folds = core.fold_map_for_window(win_id); + let display_row = match folds.as_ref() { + Some(map) => map.nth_visible_from(view_top, coord.row as usize), + None => view_top.saturating_add(coord.row as usize), + }; + let display_row = u32::try_from(display_row).ok()?; + let target = crate::view::DisplayCoord::new(display_row, coord.col); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(buffer_id).ok()?; + core.windows[&win_id] + .text_view + .display_to_pos(buf, target, core.layout_ctx(win_id)) + } + + /// Move ONE window's point, never the ambient active window's. + fn panel_set_cursor(&self, win_id: WindowId, byte: u64) { + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&win_id) { + window.cursor = byte; + window.goal_col = None; + } + } + + /// Set or clear ONE window's selection anchor. + fn panel_set_selection(&self, win_id: WindowId, anchor: Option) { + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&win_id) { + window.selection = anchor.map(|anchor| crate::window::Selection { anchor }); + } } /// Precompute owned terminal view snapshots before entering paint borrows. @@ -3657,6 +4235,12 @@ impl EditorState { /// A second copy of this precedence in the GPU lane is exactly how /// Shift-drag or scrolled-back selection would silently diverge /// between frontends. + /// Returns WHICH WAY it routed the gesture (G5k). + /// + /// The route has to be measured where the branch is taken, not + /// predicted from the modes beforehand: the report is gated on five + /// conditions and `encode_mouse` can still decline. A panel press + /// records the answer and binds its whole gesture to it. fn apply_terminal_gesture( &mut self, key: TerminalViewKey, @@ -3665,12 +4249,12 @@ impl EditorState { kind: TerminalMouseKind, modifiers: TerminalModifiers, global: (u32, u32), - ) { + ) -> TerminalGestureRoute { let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { let mut manager = self.terminal_manager.borrow_mut(); let Some(status) = manager.view_status_for_size(key, viewport_size) else { - return; + return TerminalGestureRoute::None; }; let modes = manager.modes_for_view(key).unwrap_or_default(); let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); @@ -3700,12 +4284,13 @@ impl EditorState { self.claim_terminal_controller(key); } self.send_terminal_bytes(key.buffer_id, &bytes); - return; + return TerminalGestureRoute::Child(modes); } if claims_control { self.claim_terminal_controller(key); } + let mut began_local_gesture = false; let mut manager = self.terminal_manager.borrow_mut(); match kind { TerminalMouseKind::ScrollUp => { @@ -3715,7 +4300,12 @@ impl EditorState { let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); } TerminalMouseKind::Down(TerminalMouseButton::Left) => { - let _ = manager.begin_selection(key, viewport_size, coord); + // The ONE local kind that begins a gesture, so its + // answer decides whether there is anything to arm. A + // press against a view that cannot start a drag began + // nothing, and arming over it records a gesture whose + // completion has nothing to finish. + began_local_gesture = manager.begin_selection(key, viewport_size, coord); } TerminalMouseKind::Drag(TerminalMouseButton::Left) => { let _ = manager.update_selection(key, viewport_size, coord); @@ -3731,6 +4321,19 @@ impl EditorState { } _ => {} } + // The local branch: scrollback, local selection or the menu. The + // child heard nothing. + // + // A left press reports `Local` only when it actually began a + // drag; otherwise it began nothing and the caller must not arm. + // Every other kind is not an arming event, so `Local` is the + // honest answer for it either way. + if matches!(kind, TerminalMouseKind::Down(TerminalMouseButton::Left)) + && !began_local_gesture + { + return TerminalGestureRoute::None; + } + TerminalGestureRoute::Local } fn is_double_click( diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 2f429f4..2484002 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -174,11 +174,24 @@ pub struct AcceptedPanelGesture { pub coord: pmacs_protocol::CellCoord, /// The panel buffer the gesture belongs to. pub buffer_id: BufferId, - /// Whether the press actually REACHED the child, for a reporting - /// terminal. A release is owed only if a press was delivered; - /// synthesising one for a press the child never saw is the same - /// defect in the other direction. - pub reached_child: bool, + /// The domain the accepted press RESOLVED INTO, and which every + /// tail and the completion must follow (G5k). + /// + /// This replaces a bare `reached_child` flag. The flag said whether + /// a release was owed to a child but not how to frame it, nor which + /// window to address, so a tail had to re-derive both from state + /// that moves mid-gesture — Shift, the scroll position, the child's + /// modes, and the panel's own identity. Recording the resolution + /// is what makes the tail independent of all four. + pub domain: crate::editor::PanelGestureDomain, +} + +impl AcceptedPanelGesture { + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + self.domain.reached_child() + } } /// Owns one `semantic_render` session's projection state: the last @@ -451,6 +464,21 @@ pub struct SemanticRenderState { /// match the press it ends, and a stale `Up` with no accepted /// `Down` must be inert rather than synthesising one. accepted_gesture: Option, + /// §5b/parent 48 — a release this frontend is OWED, parked until + /// somewhere that can deliver it. + /// + /// Two of the three cancellation sites are inside frame production + /// — the mapping-generation advance and `publish_absent_panel` — + /// where no target effect can run. `cancel_accepted_gesture` + /// returned the record into those contexts and they dropped it, so + /// the gesture ended with the child still holding its button. + /// + /// **A SLOT, not a queue.** The latch holds at most one gesture per + /// frontend, so at most one release can be owed, and the bound is + /// structural rather than a cap someone had to choose. The drain + /// runs ahead of the next panel-pointer effect, so a second + /// cancellation cannot arrive while one is still parked. + pending_release: Option, /// §5b — how many armed gestures an authority loss has ended this /// session. /// @@ -504,6 +532,15 @@ struct PanelPresentation { window_id: WindowId, buffer_id: BufferId, panel_epoch: u64, + /// The geometry epoch this presentation was shipped under. + /// + /// Retained for parent 48's authority-loss matrix: a geometry + /// change at an UNCHANGED `CellSize` moves nothing else the + /// producer holds — not the panel epoch, not the identity, and on a + /// legacy peer not a mapping key either — so without this the + /// transition is invisible and a live gesture survives a grid it no + /// longer belongs to. + geometry_epoch: u64, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start @@ -673,6 +710,7 @@ impl SemanticRenderState { panel_mapping: None, panel_mapping_exhausted: false, accepted_gesture: None, + pending_release: None, panel_gesture_cancellations: 0, panel_epoch_used: 0, panel_presentation: None, @@ -728,12 +766,30 @@ impl SemanticRenderState { /// per §5b's split table, because the release it denies does not /// exist on this branch. pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { - // A second accepted press while one is already armed means the - // first gesture's release never arrived — a dropped `Up`, or an - // outbox that closed under a stall. END it rather than - // overwrite it: overwriting discards the record silently, and - // once replay attaches effects to that record the child is left - // holding a button down with nothing left to release it. + // THE INVARIANT IS ASSERTED WHERE IT IS RELIED ON. A caller must + // have ended AND paid any live gesture before arming a + // replacement: a second accepted press while one is armed means + // the first gesture's release never arrived, and its release has + // to reach the target before this press does. Cancelling here + // instead is too late by exactly one effect --- the replacement + // has already landed. + // + // Checked at the point of arming rather than inside + // cancellation, because arming is what the ordering protects. + debug_assert!( + self.accepted_gesture.is_none(), + "arming over a LIVE gesture: the caller must cancel and \ + drain it first, or the replacement press overtakes the old \ + gesture's release" + ); + debug_assert!( + self.pending_release.is_none(), + "arming over an OWED release: the drain must run before the \ + effect that arms" + ); + // Defensive in release builds: ending it parks the record for a + // later drain, which is late but not lost. Overwriting would + // discard it outright and leave the child holding a button. self.cancel_accepted_gesture(); self.accepted_gesture = Some(gesture); } @@ -763,12 +819,61 @@ impl SemanticRenderState { /// without inventing a second cancellation. pub fn cancel_accepted_gesture(&mut self) -> Option { let cancelled = self.accepted_gesture.take(); - if cancelled.is_some() { + if let Some(record) = cancelled { self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1); + // PARKED, not just returned. Returning was the whole bug: + // the callers inside frame production cannot deliver a + // release, so the record went out of scope and the gesture + // ended with nothing terminated. + // + // Overwriting a still-parked release would lose one, so it + // is an invariant violation rather than a silent drop. It is + // a BACKSTOP: the ordering — drain before the next effect — + // is what actually prevents it. + debug_assert!( + self.pending_release.is_none(), + "a release was still owed when another gesture was \ + cancelled; the drain must run before any subsequent \ + panel-pointer effect" + ); + self.pending_release = Some(record); } cancelled } + /// Take the release this frontend is owed, if any. + /// + /// The RETURN of `cancel_accepted_gesture` is for inspection; THIS + /// is the delivery path. + pub fn take_pending_release(&mut self) -> Option { + self.pending_release.take() + } + + /// Whether a release is still owed, for assertions. + #[must_use] + pub fn has_pending_release(&self) -> bool { + self.pending_release.is_some() + } + + /// The live gesture record, if any — parent 48 Q#BP-R4's + /// "live record" test, and the source of the recorded completion. + #[must_use] + pub fn accepted_gesture(&self) -> Option<&AcceptedPanelGesture> { + self.accepted_gesture.as_ref() + } + + /// Q#BP-R4: an accepted `Drag` continues the gesture and moves its + /// LAST VALID CONTENT CELL, which is where a release that later + /// lands on chrome gets delivered (R-c2). + /// + /// Inert with nothing armed. A drag with no accepted press is a + /// stale tail and must not create a record by writing to one. + pub fn note_gesture_content_cell(&mut self, coord: pmacs_protocol::CellCoord) { + if let Some(gesture) = self.accepted_gesture.as_mut() { + gesture.coord = coord; + } + } + /// Whether a gesture is currently accepted, for assertions. #[must_use] pub fn has_accepted_gesture(&self) -> bool { @@ -1624,6 +1729,31 @@ impl SemanticRenderState { return; }; let identity = (projection.window_id, projection.buffer_id); + + // Parent 48 G5b — AUTHORITY LOSS. A live gesture belongs to the + // presentation it was pressed on. Three of the matrix's five + // transitions are visible right here, and each is a separate + // cause with one shared consequence: + // + // * the side WINDOW was replaced (panel-epoch change), + // * its BUFFER was replaced, + // * the GEOMETRY epoch moved, including at an unchanged size. + // + // §5b left these armed deliberately — inert while nothing + // consumed the latch — and they became defects the moment this + // lane gave cancellation an effect. Taking the latch parks the + // release; the drain pays it. + // + // G5m: coincident causes take the latch ONCE. `cancel_accepted_gesture` + // is idempotent on an empty latch, so a transition that trips + // two of these conditions still emits one release. + if let Some(presentation) = self.panel_presentation + && ((presentation.window_id, presentation.buffer_id) != identity + || presentation.geometry_epoch != geometry.geometry_epoch) + { + self.cancel_accepted_gesture(); + } + let panel_epoch = match self.panel_presentation { Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { Some(presentation.panel_epoch) @@ -1707,6 +1837,7 @@ impl SemanticRenderState { window_id: projection.window_id, buffer_id: projection.buffer_id, panel_epoch, + geometry_epoch: geometry.geometry_epoch, }); self.last_panel_payload = Some(payload.clone()); out.push(InstanceMessage::PanelFrame(payload)); @@ -1745,14 +1876,12 @@ impl SemanticRenderState { // wire work but must still leave no live gesture behind — the // clears below are idempotent for the same reason. // - // Only the producer half lands here. The framing's G5b matrix — - // the panel-epoch, buffer-replacement, same-size geometry and - // detach transitions, each with its v24 and v25 legs and its - // document and terminal effects — is `panel-pointer-replay`'s per - // §5b's split table. Those transitions leave the latch armed on - // this branch, which is inert here (nothing consumes it) and - // becomes a defect only once replay gives it effects, in the - // branch that owns the row. + // `Absent` is one of G5b's five transitions. THE OTHER FOUR ARE + // NOW WIRED TOO, on this branch: window replacement, buffer + // replacement and a geometry-epoch change are handled where the + // declaration is built above, and detach in the dispatcher's + // teardown. This comment used to say they were left armed — true + // of §5b, false here since the authority-loss matrix landed. self.cancel_accepted_gesture(); self.panel_presentation = None; // `Absent` also clears the peer's retained mode line. A later diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 516c5d7..abcc11c 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -619,6 +619,22 @@ impl TerminalScreen { pub fn modes(&self) -> TerminalModes { self.modes } + + /// Turn SGR mouse reporting on or off, for parent 48 G5k. + /// + /// The domain matrix needs a child that reports and then STOPS + /// reporting mid-gesture. Driving that through the ANSI parser + /// would make the row depend on escape-sequence handling it is not + /// testing; this sets the two modes the adapter actually reads. + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, enabled: bool) { + self.modes.mouse_sgr = enabled; + self.modes.mouse_tracking = if enabled { + MouseTrackingMode::Button + } else { + MouseTrackingMode::Off + }; + } /// Return whether the published active screen is alternate. #[must_use] pub fn alternate_active(&self) -> bool { diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 47fae56..2eddab0 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -247,10 +247,21 @@ pub(super) struct EscapeCache { pub(super) reported_invalid: Option, } +/// What each child was sent, in order, while the G5k tap is armed. +type ChildSendLog = Vec<(BufferId, Vec)>; + /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { pub(super) sessions: HashMap, + /// An OPT-IN tap on child input, for parent 48 G5k's witnesses. + /// + /// The gesture-domain rows have to read what the child actually + /// received --- a release delivered in the recorded encoding, and + /// exactly one of it --- and no other seam exposes that. Off by + /// default, so production pays one `is_some` check per send and + /// never accumulates. + send_tap: RefCell>, /// Total escape-key parses performed (Q#TC4c observability). escape_parses: u64, process_to_buffer: HashMap, @@ -564,11 +575,35 @@ impl TerminalManager { .sessions .get(&buffer_id) .ok_or(TerminalError::NotTerminal(buffer_id))?; + if let Some(tap) = self.send_tap.borrow_mut().as_mut() { + tap.push((buffer_id, bytes.to_vec())); + } supervisor .write_stdin(session.process_id, bytes) .map_err(TerminalError::Process) } + /// Begin recording child input for G5k's witnesses. + #[doc(hidden)] + pub fn start_send_tap_for_test(&self) { + *self.send_tap.borrow_mut() = Some(Vec::new()); + } + + /// Take everything sent to children since the tap was started. + /// + /// Returns the sends in ORDER, because "one release, not two" and + /// "the old gesture's release before the new gesture's press" are + /// both ordering claims that a set cannot express. + #[doc(hidden)] + #[must_use] + pub fn take_send_tap_for_test(&self) -> ChildSendLog { + self.send_tap + .borrow_mut() + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } + /// Resolve this terminal's effective escape chord, parsing at most /// once per `(terminal, config epoch)` (Q#TC4c). /// diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 414d607..8e47474 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -501,6 +501,22 @@ impl TerminalManager { moved || was_dragging } + /// Whether a view is mid-drag, for parent 48 Q#BP-R4's completion + /// witnesses. + /// + /// A local terminal gesture is "finished" exactly when + /// `finish_selection` takes `drag`, so this is the observable that + /// separates a delivered completion from a latch that merely + /// emptied — which is the distinction the framing requires those + /// rows to assert. + #[doc(hidden)] + #[must_use] + pub fn view_is_dragging_for_test(&self, key: TerminalViewKey) -> bool { + self.views + .get(&key) + .is_some_and(|state| state.drag.is_some()) + } + /// Clear one view's terminal selection without changing its scroll anchor. pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { let Some(state) = self.views.get_mut(&key) else { @@ -509,6 +525,14 @@ impl TerminalManager { state.selection.take().is_some() || state.drag.take().is_some() } + /// Turn SGR mouse reporting on or off for one session (G5k). + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, buffer_id: BufferId, enabled: bool) { + if let Some(session) = self.sessions.get_mut(&buffer_id) { + session.screen.set_mouse_reporting_for_test(enabled); + } + } + /// Current child input modes for one session. #[must_use] pub fn modes_for_view(&self, key: TerminalViewKey) -> Option {