docs(framing): bottom-panel revision 6 --- four replay edges, and Q#BP-R1 ruled

Answers review of revision 5. Still framing only; no implementation.

**Q#BP-R1 IS RULED: a single click SELECTS a listview row only.**
RET/SPC remain the activation path (`listview.lua:610`); no
click-to-visit, no double-click-to-visit. That is acceptance 48's own
wording, and it keeps document navigation from arriving as an
incidental consequence of wiring replay.

**REVISION 5 CONCLUDED THAT ACTIVATION ORDERING MADE REPLAY SAFE. IT
IS NECESSARY BUT NOT SUFFICIENT.** The ordering holds only for gestures
that activate, in a session nothing interleaves with, on a presentation
that does not change mid-gesture, with modifiers intact and the mode
line excluded. Each of those four provisos is an edge:

**R-a --- modifiers are dropped before the shared path.** `PanelPointer`
carries `mods`; the daemon destructures them into `..`
(`src/daemon.rs:2425`) and the dispatcher has no modifier parameter.
This breaks PRECEDENCE, not just fidelity: `apply_terminal_gesture`
gates child reporting on `!shift && ... && modes.mouse_sgr`
(`src/editor.rs:3534`), so Shift is the user's "select locally instead
of talking to the child" override. Zeroed, a Shift-drag over a
reporting terminal panel sends SGR. Row: Shift-drag selects locally and
the child receives NO bytes. Mutation: drop `mods` at the boundary.

**R-b --- `Drag` and `Up` do not activate**, and another frontend's
input can interleave between a Down and its Drag, so a replay reading
ambient active-window state acts on whatever is active then. The
framing now NAMES the mechanism: an explicit side-window cell->byte
adapter, and selection through a window-TARGETED path.
`activate_and_position` (`src/editor.rs:3795`) is both the precedent
and the trap --- its conversion is properly window-scoped, but it calls
`set_active_window_id`. Rows: panel A Down -> frontend B input -> A
Drag/Up changes only A; an orphan Drag/Up on a passive panel leaves the
document mirror byte-identical.

**R-c --- `panel_grid_size` is the FRAME, not the terminal viewport.**
The panel's last row is its mode line: projection derives content as
`rows - 1` (`src/editor.rs:2499`) while `panel_hit_test` reports across
the whole frame (`pmacs-gpu/src/main.rs:7184`), so a `PanelPointer` can
name the mode-line row. Passing the full grid to
`apply_terminal_gesture` would make the mode line a child terminal
cell. Terminal viewport is `rows - 1`; document replay follows the
TUI's existing rule, "Mode-line click: reserved"
(`src/editor.rs:3304`). Rows must distinguish content from chrome or an
off-by-one passes.

**R-d --- replacement leaves the frontend's gesture latch armed.**
`Absent` clears `pointer_held` and `last_pointer_cell`
(`pmacs-gpu/src/main.rs:6909`); `Present`->`Present` does not
(`:6913`). Press on A, A replaced by B, and the latch emits a Drag or
release for B with no B press --- and acceptance 49 CANNOT reject it,
because the event carries B's current epochs. 49 is a staleness gate
and this is not a stale event; it is a well-formed event from a gesture
whose presentation no longer exists. The fix has a precedent in the
same file: the DIVIDER drag latch already carries both epochs and
self-invalidates (`:7288`). The pointer latch never got it.

Three of the four are places where the tree already contains the right
answer and the panel path does not use it, which is worth saying
plainly: this is wiring that has to be done carefully, not mechanism
that has to be invented.

Gates: all nine green under `env -u TMPDIR`, log 20260814T115257Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-14 13:57:37 +02:00
parent d97dcdfc46
commit c861d24e91
No known key found for this signature in database
2 changed files with 190 additions and 46 deletions

View File

@ -281,9 +281,9 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
**`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 5 (§5a) AWAITING APPROVAL;
NO IMPLEMENTATION WRITTEN.** The first commit is the ground-truth
re-measurement, per the standing method.
- **No PR yet. Checkpoint: framing revision 6 (§5a) AWAITING APPROVAL;
NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth
re-measurement; **commit two answers review of it.**
- **Why this lane exists.** `PanelPointer` **replays nothing**:
`dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates,
focuses, returns. A panel wheel is dead on both axes and so is every
@ -295,7 +295,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
had written the halves down separately.
- **DONE:** click-to-focus and the terminal activation rule
(`src/editor.rs:2701` and its `activates`); the focused-only
auto-scroll clamp with passive `view_top` preserved (`:2569`, which
auto-scroll clamp with passive `view_top` preserved (`:2568`–`:2571`, which
already cites parent 48); the coalescing rules — `Move`/`Drag`
tails coalesce, press/release/context/wheel lossless
(`pmacs-gpu/src/attach.rs:374`).
@ -310,18 +310,50 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
`scroll_window` (`:3845`) is window-scoped, cursor carry included.
**A wheel-only bridge is the wrong shape** — the shared path takes
every kind at once.
- **The scoping hazard, and why it needs no ruling.** `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**, and the gestures needing
that API are exactly the ones that activate. The only gesture that
does not activate — a document panel's wheel — needs only the
window-scoped `scroll_window`.
- **OPEN: Q#BP-R1** — does a listview row **visit** on click, or only
select? `listview.lua` binds visiting to RET/SPC (`:610`) and there
is no pointer precedent. **This lane implements selection and does
not invent activation.**
- **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`; document replay follows the TUI's *"Mode-line click:
reserved"* (`src/editor.rs:3304`–`:3306`). Rows must distinguish
content from chrome or an off-by-one passes.
- **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. Reset
on presentation-identity change, mutation-checked.
- **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

View File

@ -7,15 +7,28 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency
verification in §0.6: the folding dependency is cleared, and one geometry
caller-census error is corrected.**
**Revision 5 — 2026-08-14, AWAITING APPROVAL. Adds §5a: acceptance 48
re-measured against production at `72da24a`.** The finding is that AC48
**Revision 6 — 2026-08-14, AWAITING APPROVAL.** Answers review of 5.
**Q#BP-R1 is RULED: a single click SELECTS a listview row only**;
RET/SPC remain activation, and this lane adds no click-to-visit.
Revision 5 concluded that the activation ordering made replay safe;
**it is necessary but not sufficient**, and §5a now carries the four
edges it missed — **R-a** modifiers dropped at the daemon boundary,
which breaks Shift's local-selection override; **R-b** `Drag`/`Up` do
not activate and another frontend can interleave, so replay needs a
named side-window cell→byte adapter and a window-targeted path;
**R-c** `panel_grid_size` includes the mode-line row while content is
`rows − 1`, so the terminal viewport must be `rows − 1` and document
replay needs the TUI's "mode-line click: reserved" rule; **R-d**
`Present`→`Present` replacement leaves the pointer latch armed, which
acceptance 49 cannot catch because the orphan gesture carries current
epochs.
**Previously, revision 5 — SUPERSEDED. Added §5a: acceptance 48
re-measured against production at `72da24a`.** The finding stands: AC48
is **half implemented** — focus, activation, the focused-only clamp and
the coalescing rules all landed; **listview row selection, panel
selection, terminal mouse reporting and wheel replay did not**, so
`PanelPointer` reaches `dispatch_semantic_panel_pointer` and stops at
focus. No ruling in §3 or §5 changes. **One question is opened,
Q#BP-R1**, on whether a listview row visits on click; this lane
implements selection and does not answer it.
selection, terminal mouse reporting and wheel replay did not**. No
ruling in §3 or §5 changes.
Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window
pinned to the bottom of the frame, resizable by dragging its divider, which
@ -1797,7 +1810,7 @@ the halves were never separated in writing. Measured clause by clause:
|---|---|---|
| click-to-focus | **DONE** | `dispatch_semantic_panel_pointer`'s `activates` → `focus_window` (`src/editor.rs:2701`) |
| terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates` computation, split by `is_terminal` |
| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2569`, which already cites "A2A-3 / parent 48" |
| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2568`–`:2571`, which already cites "A2A-3 / parent 48" |
| move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `pmacs-gpu/src/attach.rs:374`–`:381` — `Move` key 6, `Drag` key 7, everything else unkeyed |
| **listview row selection** | **MISSING** | — |
| **panel selection** | **MISSING** | — |
@ -1832,9 +1845,12 @@ the panel's own, not the document mirror.
naively from the panel path they would move the **document's** point —
precisely what "without disturbing the document mirror" forbids.
**No new ruling is needed, because the ordering already rules it.**
Activation runs *before* replay in the same dispatch, and the gestures
that need the active-window API are exactly the ones that activate:
**Activation ordering is NECESSARY BUT NOT SUFFICIENT — revision 5
stopped here and was wrong to.** It holds only for the gestures that
activate, in a session that nothing interleaves with, on a
presentation that does not change mid-gesture, with modifiers intact
and the mode line excluded. **Each of those four provisos is an edge
revision 6 had to add** (R-a…R-d below). The ordering itself is real:
- **Document panel** — `activates` on `Down(_)`. A drag-select is
Down → Drag → Up, and the Down focused the panel, so the later
@ -1845,29 +1861,125 @@ that need the active-window API are exactly the ones that activate:
- **The one gesture that does NOT activate** is a document panel's
wheel — and it needs only `scroll_window`, which is window-scoped.
So the rule is: **replay after activation, and anything reachable
without activation must use a window-scoped mechanism.** Both halves
are satisfiable with what exists.
So the ordering rule is: **replay after activation, and anything
reachable without activation must use a window-scoped mechanism.**
That is the floor, not the contract — R-a through R-d are the rest of
it, and three of the four are places where the tree already contains
the right precedent and the panel path simply does not use it.
### Q#BP-R1 — is a listview row's activation a click or a double-click? **OPEN**
### Q#BP-R1 — does a listview row visit on click? **RULED: no — single click SELECTS only**
The one question the measurement could not answer from the tree.
`builtin/runtime/listview.lua` binds visiting to **RET/SPC**
(`:610`–`:611`), reading `pmacs.editor.cursor_line()` and calling
`on_visit`. There is no pointer precedent: nothing in the file mentions
a mouse.
RET/SPC remain the activation path (`builtin/runtime/listview.lua:610`,
which reads `pmacs.editor.cursor_line()` and calls `on_visit`). **This
lane adds no click-to-visit and no double-click-to-visit.** That
follows acceptance 48's wording — it names row *selection* — and keeps
document navigation from becoming an incidental consequence of wiring
replay.
So a pointer gesture can *move* to a row unambiguously, but **whether a
single click also VISITS is a product decision with no existing
answer** — single-click-visits matches a file tree, click-selects /
double-click-visits matches an editor list, and AC48's wording
("drives listview row selection") names selection only.
### The four replay edges — none of which "activation ordering" covers
**This lane implements row SELECTION and does not invent activation.**
If visiting on click is wanted it should be ruled explicitly rather
than arriving as a side effect of wiring, because it is the difference
between a click that navigates the user's document and one that does
not.
§5a's first draft concluded that the activation rule made the replay
safe. **It does not, and each gap below is a place where a plausible
implementation is silently wrong.**
#### R-a. Modifiers are dropped before they reach the shared path
`FrontendEvent::PanelPointer` **carries `mods`**, and the daemon
**destructures them into `..`** (`src/daemon.rs:2425`);
`dispatch_semantic_panel_pointer` has no modifier parameter at all.
**This breaks terminal precedence, not merely fidelity.**
`apply_terminal_gesture` opens with
`let shift = modifiers.contains(TerminalModifiers::SHIFT)` (`:3534`)
and gates child reporting on `!shift && … && modes.mouse_sgr`. **Shift
is the user's override for "select locally instead of talking to the
child."** Arriving with modifiers zeroed, a Shift-drag over a
reporting terminal panel sends SGR to the child instead of selecting.
**Thread `mods` through** the destructure, the dispatcher signature and
into `apply_terminal_gesture`. **Row:** with mouse reporting enabled,
a Shift-drag selects locally and the child receives **no bytes**.
*Mutation: drop `mods` at the daemon boundary — the child receives
bytes and the row fails.*
#### R-b. Activation does not make the document path target-safe
`activates` is true for a document panel's `Down(_)` only, so **`Drag`
and `Up` do not activate**, and **another frontend's input can
interleave between them** — sessions are independent and nothing
freezes the active window for the duration of a gesture. A replay that
reads ambient active-window state for `Drag`/`Up` therefore acts on
whatever is active *then*, which may be a different window entirely.
**The framing names the mechanism rather than leaving it to the
implementation: an explicit side-window cell→byte adapter, and
selection routed through a window-TARGETED path.** The precedent and
the trap are the same function — `activate_and_position`
(`src/editor.rs:3795`) is already window-scoped in its conversion (it
takes `win_id` and uses *that* window's `view_top` and fold map, per
the round-3 F1 correction) **but it also calls
`set_active_window_id`**, and the selection APIs it feeds
(`set_cursor_byte`, `begin_selection`) write `active_window_mut()`.
Panel replay needs the conversion **without** the ambient write.
Two rows, both of which a naive implementation fails:
- **Interleaved frontend:** panel A `Down` → **frontend B input** →
panel A `Drag`/`Up`. **Only A's panel changes.**
- **Orphan gesture:** a `Drag`/`Up` against a **passive** panel with no
preceding `Down`. **The document mirror is byte-identical
afterwards** — cursor, selection and `view_top`.
#### R-c. `panel_grid_size` is the FRAME, not the terminal viewport
The panel's last row is its **mode line**. Projection says so
explicitly: `content = Rect::new(0, 0, size.rows.saturating_sub(1),
size.cols)` against `outer` at full height (`src/editor.rs:2499`–
`:2500`). But **`panel_hit_test` reports cells across the whole
frame** — it passes `frame.size` (`pmacs-gpu/src/main.rs:7184`) — so a
`PanelPointer` **can name the mode-line row**.
Passing `panel_grid_size` straight to `apply_terminal_gesture` as the
viewport therefore **makes the mode line a child terminal cell**: the
child is told about a row it does not own, and every coordinate below
it is off by the same row when the size is used for clamping.
- **Terminal panels:** the viewport is **`rows − 1`**.
- **Document panels:** the mode line needs an **explicit rule**, and
the TUI already has one — `dispatch_mouse` returns early on
`local_row >= inner_rows` with the comment *"Mode-line click:
reserved."* (`src/editor.rs:3304`–`:3306`). **Panel replay follows it**: a
mode-line gesture is reserved, not a click at the nearest content
cell.
**Rows must distinguish content from chrome**: a gesture on the last
row is inert (and reaches no child), while the same gesture one row up
replays normally. Without that pair, an off-by-one passes.
#### R-d. Panel replacement leaves the frontend's gesture latch armed
`PanelFramePayload::Absent` clears `pointer_held` and
`last_pointer_cell` (`pmacs-gpu/src/main.rs:6909`–`:6910`). **The
`Present` arm does not** (`:6913` onward) — it validates, swaps the
frame, rebuilds buffers, and leaves the latch exactly as it was.
So: press on panel **A**, A is replaced by **B**, and the held latch
emits a `Drag` or release **for B with no B press**. **Acceptance 49
cannot reject it** — the event carries B's *current* epochs, so it is
not stale by any test 49 applies. 49 is a staleness gate, and this is
not a stale event; it is a **well-formed event from a gesture that
belongs to a presentation that no longer exists.**
**Reset the gesture latch on presentation-identity change.** The
precedent is in the same file: the **divider** drag latch already
carries `panel_epoch`/`geometry_epoch` and self-invalidates when the
presented frame's epochs differ (`:7288`). The pointer latch simply
never got the same treatment.
*Mutation: omit the reset on `Present`→`Present` — the orphan-drag row
fails.* Distinct from R-b's orphan row: that one arrives from a
**passive** panel, this one from a **replaced** one, and an
implementation can fix either alone.
## 6. Deferred (named)