Merge pull request #243 from levineuwirth/panel-pointer-replay

Panel-pointer replay (parent acceptance 48)
This commit is contained in:
Levi Neuwirth 2026-08-29 12:37:10 +02:00 committed by GitHub
commit 6c9bae6948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 5211 additions and 133 deletions

View File

@ -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 that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming. 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<target>}` 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`) ## Panel cell-mapping generation (v25) — MERGED as #242 (`47b5463`)
- **MERGED 2026-08-20T17:25:01Z** at approved head `61f0faf`, merge - **MERGED 2026-08-20T17:25:01Z** at approved head `61f0faf`, merge

File diff suppressed because it is too large Load Diff

View File

@ -963,3 +963,31 @@ tree.
the measured `duration_ms` in the assertion message — is **owed its own 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 small lane**. It was deliberately kept out of #242, whose diff does not
touch `src/async_runtime.rs`. 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.

View File

@ -1,6 +1,13 @@
# GUI arc, Stage 1 — input foundation (framing) # 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 re-measurement for Stage 1a and changes no ruling; it carries two
corrections to claims that were wrong at the original anchor too. 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 | | # | Contract | Witness | Mutation |
|---|---|---|---| |---|---|---|---|
| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump | | 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 leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row | | 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 | 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** | | 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 | | 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 | | 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 | | 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 narrower than the viewport, and **the right-bound witness asserts the
final display column is still visible**. 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 **Why B6 changed — and revision 5's reason was wrong.** Scrubbing on
wheel is not *impossible*: the wheel handler already reads the cached wheel is not *impossible*: the wheel handler already reads the cached
`state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute `state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute

View File

@ -2224,6 +2224,16 @@ struct PanelBand {
/// because the first drag after a press must reach the daemon even at the /// because the first drag after a press must reach the daemon even at the
/// cell the press landed on. /// cell the press landed on.
last_pointer_cell: Option<CellCoord>, last_pointer_cell: Option<CellCoord>,
/// 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<CellCoord>,
/// §5b G9b — the mapping generation `last_pointer_cell` was measured /// §5b G9b — the mapping generation `last_pointer_cell` was measured
/// under, so the dedupe re-arms across a mapping change. /// 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 // so reporting a drag as a hover makes a selection
// drag silently do nothing. // drag silently do nothing.
let kind = state.panel_motion_kind(); 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) { if state.panel_motion_is_new(coord) {
let mods = translate_mods(self.modifiers); let mods = translate_mods(self.modifiers);
self.send_panel_pointer_at(x, y, kind, mods); self.send_panel_pointer_at(x, y, kind, mods);
@ -3013,9 +3042,22 @@ impl App {
// no preceding `Down`, and not arming at all means // no preceding `Down`, and not arming at all means
// `Drag(Left)` is never emitted and panel selection // `Drag(Left)` is never emitted and panel selection
// cannot work at all. // 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"); let state = self.state.as_mut().expect("checked above");
state.set_panel_pointer_held(true); 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( self.send_panel_pointer_at(
x, x,
y, 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 /// P2, pointer — **the row that shows a route cannot stand in for an
/// effect.** A wheel carries only a delta; whether it becomes a /// effect.** A wheel carries only a delta; whether it becomes a
/// viewport update, a panel event, a terminal event or nothing at /// viewport update, a panel event, a terminal event or nothing at
@ -7053,6 +7205,7 @@ impl State {
self.panel.hover_divider = false; self.panel.hover_divider = false;
self.panel.pointer_held = false; self.panel.pointer_held = false;
self.panel.last_pointer_cell = None; self.panel.last_pointer_cell = None;
self.panel.gesture_last_content_cell = None;
self.panel.last_pointer_generation = None; self.panel.last_pointer_generation = None;
had had
} }
@ -7122,6 +7275,35 @@ impl State {
// A duplicate does no work — not even a reshape. // A duplicate does no work — not even a reshape.
return false; 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( let plan = TerminalPaintPlan::build_grid(
frame.size, frame.size,
&frame.cells, &frame.cells,
@ -7426,12 +7608,29 @@ impl State {
/// A panel selection drag routinely ends past the band's edge, and /// A panel selection drag routinely ends past the band's edge, and
/// dropping that release leaves the daemon holding a button down forever. /// dropping that release leaves the daemon holding a button down forever.
fn panel_release_cell(&self, x: f32, y: f32) -> Option<CellCoord> { fn panel_release_cell(&self, x: f32, y: f32) -> Option<CellCoord> {
// 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) { match self.classify_pointer_surface(x, y) {
PointerSurface::PanelCell(coord) => Some(coord), PointerSurface::PanelCell(coord) if !self.panel_cell_is_chrome(coord) => Some(coord),
_ => self.panel.last_pointer_cell, _ => 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. /// 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 /// 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) { fn set_panel_pointer_held(&mut self, held: bool) {
self.panel.pointer_held = held; self.panel.pointer_held = held;
self.panel.last_pointer_cell = None; 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; 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 /// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe
/// re-arms on every press and release. /// re-arms on every press and release.
#[test] #[test]
@ -20515,7 +20910,13 @@ mod tests {
state.panel_release_cell(ox + 0.5, oy + 0.5), state.panel_release_cell(ox + 0.5, oy + 0.5),
Some(pmacs_protocol::CellCoord::new(0, 0)) 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_motion_is_new(pmacs_protocol::CellCoord::new(1, 7));
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 7));
assert_eq!( assert_eq!(
state.panel_release_cell(ox + 0.5, 0.0), state.panel_release_cell(ox + 0.5, 0.0),
Some(pmacs_protocol::CellCoord::new(1, 7)), Some(pmacs_protocol::CellCoord::new(1, 7)),

File diff suppressed because it is too large Load Diff

View File

@ -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 /// What decides the mapping BELOW the geometry, which differs by
/// target kind. /// target kind.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
@ -2769,7 +2930,34 @@ impl EditorState {
.is_some_and(|now| now != current) .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<u64> {
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 /// 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 /// 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 /// epochs) belong to the caller, because only the session holds the
/// declaration the frontend was actually looking at. /// 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 /// **Activation is not uniform, and Q#BP16 says so explicitly.** A
/// **press** focuses any panel — that is click-to-focus, and /// **press** focuses any panel — that is click-to-focus, and
/// `Down(Right)` is the context-menu gesture, so both buttons count. /// `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. /// Review round 1 (R2-5) found the terminal clause applied to both.
/// Bare hover neither focuses nor claims, on either kind. /// Bare hover neither focuses nor claims, on either kind.
/// ///
/// **Replay is out of scope in Stage 2B-2.** Driving selection, /// **Only `Accepted` reaches a target.** A `Consumed` disposition
/// listview rows, or child SGR reporting is parent acceptance 48, /// returns without effect: the claim was the effect.
/// which needs the GPU band and lands in Stage 2B-3.
/// ///
/// Returns whether the gesture was accepted. /// Returns the [`PanelGestureDomain`] the effect RESOLVED INTO, so
/// /// an accepted press can record the contract its tails must follow
/// `#[must_use]` because the accepted-gesture latch is driven off /// (G5k). `None` means nothing ran and nothing may be recorded.
/// this answer, and discarding it silently arms on rejected presses pub fn apply_panel_pointer(
/// and consumes on rejected releases. &mut self,
#[must_use]
pub fn dispatch_semantic_panel_pointer(
&self,
frontend_id: FrontendId, frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId, disposition: &PanelPointerDisposition,
coord: CellCoord, coord: CellCoord,
kind: pmacs_protocol::MouseKind, kind: pmacs_protocol::MouseKind,
) -> bool { mods: pmacs_protocol::Modifiers,
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { ) -> Option<PanelGestureDomain> {
return false; 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 activates = if target.is_terminal {
} !matches!(kind, PKind::Move)
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)
} else { } else {
matches!(kind, pmacs_protocol::MouseKind::Down(_)) matches!(kind, PKind::Down(_))
}; };
if activates { 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; 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<u64> {
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<u64>) {
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. /// 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 /// A second copy of this precedence in the GPU lane is exactly how
/// Shift-drag or scrolled-back selection would silently diverge /// Shift-drag or scrolled-back selection would silently diverge
/// between frontends. /// 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( fn apply_terminal_gesture(
&mut self, &mut self,
key: TerminalViewKey, key: TerminalViewKey,
@ -3665,12 +4249,12 @@ impl EditorState {
kind: TerminalMouseKind, kind: TerminalMouseKind,
modifiers: TerminalModifiers, modifiers: TerminalModifiers,
global: (u32, u32), global: (u32, u32),
) { ) -> TerminalGestureRoute {
let shift = modifiers.contains(TerminalModifiers::SHIFT); let shift = modifiers.contains(TerminalModifiers::SHIFT);
let (at_bottom, modes, screen_size) = { let (at_bottom, modes, screen_size) = {
let mut manager = self.terminal_manager.borrow_mut(); let mut manager = self.terminal_manager.borrow_mut();
let Some(status) = manager.view_status_for_size(key, viewport_size) else { 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 modes = manager.modes_for_view(key).unwrap_or_default();
let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); 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.claim_terminal_controller(key);
} }
self.send_terminal_bytes(key.buffer_id, &bytes); self.send_terminal_bytes(key.buffer_id, &bytes);
return; return TerminalGestureRoute::Child(modes);
} }
if claims_control { if claims_control {
self.claim_terminal_controller(key); self.claim_terminal_controller(key);
} }
let mut began_local_gesture = false;
let mut manager = self.terminal_manager.borrow_mut(); let mut manager = self.terminal_manager.borrow_mut();
match kind { match kind {
TerminalMouseKind::ScrollUp => { TerminalMouseKind::ScrollUp => {
@ -3715,7 +4300,12 @@ impl EditorState {
let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES);
} }
TerminalMouseKind::Down(TerminalMouseButton::Left) => { 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) => { TerminalMouseKind::Drag(TerminalMouseButton::Left) => {
let _ = manager.update_selection(key, viewport_size, coord); 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( fn is_double_click(

View File

@ -174,11 +174,24 @@ pub struct AcceptedPanelGesture {
pub coord: pmacs_protocol::CellCoord, pub coord: pmacs_protocol::CellCoord,
/// The panel buffer the gesture belongs to. /// The panel buffer the gesture belongs to.
pub buffer_id: BufferId, pub buffer_id: BufferId,
/// Whether the press actually REACHED the child, for a reporting /// The domain the accepted press RESOLVED INTO, and which every
/// terminal. A release is owed only if a press was delivered; /// tail and the completion must follow (G5k).
/// synthesising one for a press the child never saw is the same ///
/// defect in the other direction. /// This replaces a bare `reached_child` flag. The flag said whether
pub reached_child: bool, /// 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 /// 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 /// match the press it ends, and a stale `Up` with no accepted
/// `Down` must be inert rather than synthesising one. /// `Down` must be inert rather than synthesising one.
accepted_gesture: Option<AcceptedPanelGesture>, accepted_gesture: Option<AcceptedPanelGesture>,
/// §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<AcceptedPanelGesture>,
/// §5b — how many armed gestures an authority loss has ended this /// §5b — how many armed gestures an authority loss has ended this
/// session. /// session.
/// ///
@ -504,6 +532,15 @@ struct PanelPresentation {
window_id: WindowId, window_id: WindowId,
buffer_id: BufferId, buffer_id: BufferId,
panel_epoch: u64, 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 /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
@ -673,6 +710,7 @@ impl SemanticRenderState {
panel_mapping: None, panel_mapping: None,
panel_mapping_exhausted: false, panel_mapping_exhausted: false,
accepted_gesture: None, accepted_gesture: None,
pending_release: None,
panel_gesture_cancellations: 0, panel_gesture_cancellations: 0,
panel_epoch_used: 0, panel_epoch_used: 0,
panel_presentation: None, panel_presentation: None,
@ -728,12 +766,30 @@ impl SemanticRenderState {
/// per §5b's split table, because the release it denies does not /// per §5b's split table, because the release it denies does not
/// exist on this branch. /// exist on this branch.
pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) {
// A second accepted press while one is already armed means the // THE INVARIANT IS ASSERTED WHERE IT IS RELIED ON. A caller must
// first gesture's release never arrived — a dropped `Up`, or an // have ended AND paid any live gesture before arming a
// outbox that closed under a stall. END it rather than // replacement: a second accepted press while one is armed means
// overwrite it: overwriting discards the record silently, and // the first gesture's release never arrived, and its release has
// once replay attaches effects to that record the child is left // to reach the target before this press does. Cancelling here
// holding a button down with nothing left to release it. // 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.cancel_accepted_gesture();
self.accepted_gesture = Some(gesture); self.accepted_gesture = Some(gesture);
} }
@ -763,12 +819,61 @@ impl SemanticRenderState {
/// without inventing a second cancellation. /// without inventing a second cancellation.
pub fn cancel_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> { pub fn cancel_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> {
let cancelled = self.accepted_gesture.take(); 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); 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 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<AcceptedPanelGesture> {
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. /// Whether a gesture is currently accepted, for assertions.
#[must_use] #[must_use]
pub fn has_accepted_gesture(&self) -> bool { pub fn has_accepted_gesture(&self) -> bool {
@ -1624,6 +1729,31 @@ impl SemanticRenderState {
return; return;
}; };
let identity = (projection.window_id, projection.buffer_id); 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 { let panel_epoch = match self.panel_presentation {
Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => {
Some(presentation.panel_epoch) Some(presentation.panel_epoch)
@ -1707,6 +1837,7 @@ impl SemanticRenderState {
window_id: projection.window_id, window_id: projection.window_id,
buffer_id: projection.buffer_id, buffer_id: projection.buffer_id,
panel_epoch, panel_epoch,
geometry_epoch: geometry.geometry_epoch,
}); });
self.last_panel_payload = Some(payload.clone()); self.last_panel_payload = Some(payload.clone());
out.push(InstanceMessage::PanelFrame(payload)); out.push(InstanceMessage::PanelFrame(payload));
@ -1745,14 +1876,12 @@ impl SemanticRenderState {
// wire work but must still leave no live gesture behind — the // wire work but must still leave no live gesture behind — the
// clears below are idempotent for the same reason. // clears below are idempotent for the same reason.
// //
// Only the producer half lands here. The framing's G5b matrix — // `Absent` is one of G5b's five transitions. THE OTHER FOUR ARE
// the panel-epoch, buffer-replacement, same-size geometry and // NOW WIRED TOO, on this branch: window replacement, buffer
// detach transitions, each with its v24 and v25 legs and its // replacement and a geometry-epoch change are handled where the
// document and terminal effects — is `panel-pointer-replay`'s per // declaration is built above, and detach in the dispatcher's
// §5b's split table. Those transitions leave the latch armed on // teardown. This comment used to say they were left armed — true
// this branch, which is inert here (nothing consumes it) and // of §5b, false here since the authority-loss matrix landed.
// becomes a defect only once replay gives it effects, in the
// branch that owns the row.
self.cancel_accepted_gesture(); self.cancel_accepted_gesture();
self.panel_presentation = None; self.panel_presentation = None;
// `Absent` also clears the peer's retained mode line. A later // `Absent` also clears the peer's retained mode line. A later

View File

@ -619,6 +619,22 @@ impl TerminalScreen {
pub fn modes(&self) -> TerminalModes { pub fn modes(&self) -> TerminalModes {
self.modes 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. /// Return whether the published active screen is alternate.
#[must_use] #[must_use]
pub fn alternate_active(&self) -> bool { pub fn alternate_active(&self) -> bool {

View File

@ -247,10 +247,21 @@ pub(super) struct EscapeCache {
pub(super) reported_invalid: Option<String>, pub(super) reported_invalid: Option<String>,
} }
/// What each child was sent, in order, while the G5k tap is armed.
type ChildSendLog = Vec<(BufferId, Vec<u8>)>;
/// Owns the one-buffer/one-process/one-screen terminal registry. /// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)] #[derive(Default)]
pub struct TerminalManager { pub struct TerminalManager {
pub(super) sessions: HashMap<BufferId, TerminalSession>, pub(super) sessions: HashMap<BufferId, TerminalSession>,
/// 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<Option<ChildSendLog>>,
/// Total escape-key parses performed (Q#TC4c observability). /// Total escape-key parses performed (Q#TC4c observability).
escape_parses: u64, escape_parses: u64,
process_to_buffer: HashMap<ProcessId, BufferId>, process_to_buffer: HashMap<ProcessId, BufferId>,
@ -564,11 +575,35 @@ impl TerminalManager {
.sessions .sessions
.get(&buffer_id) .get(&buffer_id)
.ok_or(TerminalError::NotTerminal(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 supervisor
.write_stdin(session.process_id, bytes) .write_stdin(session.process_id, bytes)
.map_err(TerminalError::Process) .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 /// Resolve this terminal's effective escape chord, parsing at most
/// once per `(terminal, config epoch)` (Q#TC4c). /// once per `(terminal, config epoch)` (Q#TC4c).
/// ///

View File

@ -501,6 +501,22 @@ impl TerminalManager {
moved || was_dragging 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. /// Clear one view's terminal selection without changing its scroll anchor.
pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool {
let Some(state) = self.views.get_mut(&key) else { 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() 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. /// Current child input modes for one session.
#[must_use] #[must_use]
pub fn modes_for_view(&self, key: TerminalViewKey) -> Option<TerminalModes> { pub fn modes_for_view(&self, key: TerminalViewKey) -> Option<TerminalModes> {