Merge pull request #242 from levineuwirth/panel-mapping-generation
Panel cell-mapping generation (v25) — §5b
This commit is contained in:
commit
47b54631f4
|
|
@ -270,6 +270,558 @@ 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 cell-mapping generation (v25) — REBASED, FULL GATE GREEN, review round 4 fixed
|
||||||
|
|
||||||
|
- **BASE IS `5f2015c`**, current `githubsucks/main`. Rebased there
|
||||||
|
cleanly, no conflicts; the two commits picked up are docs-only (the
|
||||||
|
`rd_precondition` occurrence record and the handoff's `timeout`
|
||||||
|
rule).
|
||||||
|
- The **first** rebase, onto `f13506c` (post-#241 main), took one
|
||||||
|
conflict in this file: main's merged-#241 block and this lane's own
|
||||||
|
first-commit block landed at the same position; both kept, active
|
||||||
|
lane above merged. No code file has overlapped across either rebase —
|
||||||
|
#241 touched `scripts/`, `tests/common/` and two test suites; this
|
||||||
|
lane touches the protocol, daemon and GPU sources.
|
||||||
|
- **NO AHEAD-COUNT OR TIP SHA IS RECORDED HERE, deliberately.** Both
|
||||||
|
were wrong within one commit the last two times they were written,
|
||||||
|
because the commit that updates this file is itself the commit that
|
||||||
|
invalidates them. Read them instead:
|
||||||
|
`git log --oneline githubsucks/main..HEAD | wc -l` and
|
||||||
|
`git rev-parse HEAD`. Immutable EVENT shas — the rebase, a fix, a
|
||||||
|
merge — are recorded; the moving tip is not.
|
||||||
|
- **REVIEW ROUND 4 (2026-08-20) — three blockers, all fixed.**
|
||||||
|
- **FUNCTIONAL: rejected gestures mutated the accepted-gesture
|
||||||
|
latch.** Both inbound arms discarded the bool from
|
||||||
|
`dispatch_semantic_panel_pointer` and called
|
||||||
|
`update_accepted_gesture` unconditionally. The ladder authenticates
|
||||||
|
the SENDER; only the dispatcher re-derives the TARGET, so an event
|
||||||
|
can clear every rung and still be refused for an out-of-grid
|
||||||
|
coordinate, an absent side window, or a buffer that is no longer
|
||||||
|
the one in the side window. **A rejected `Down` therefore ARMED** —
|
||||||
|
so a later authority loss counted a cancellation for a gesture that
|
||||||
|
never began, and once replay attaches effects it would release a
|
||||||
|
child that was never pressed. **A rejected `Up` CONSUMED a real
|
||||||
|
armed gesture** — so the authority loss that should have ended it
|
||||||
|
found nothing, and that child holds the button down for good. A
|
||||||
|
rejected `Down` on top of a live gesture was worse still:
|
||||||
|
`arm_accepted_gesture` ends what it overwrites, so it also counted
|
||||||
|
a spurious cancellation.
|
||||||
|
- **Fixed** by gating both arms on the return, and by marking the
|
||||||
|
dispatcher `#[must_use]` so the class cannot recur silently —
|
||||||
|
clippy runs with `-D warnings`, so a future discarded answer is a
|
||||||
|
build failure rather than a review finding.
|
||||||
|
- **Four new rows**, `g5_substrate_a_refused_{press_never_arms,
|
||||||
|
release_never_consumes}_on_the_{legacy,mapped}_arm`. Each drives
|
||||||
|
the refusal from a coordinate ONE PAST the last grid row and **ends
|
||||||
|
in a positive control differing only in that coordinate** —
|
||||||
|
without it a row would pass just as well if an unrelated rung had
|
||||||
|
dropped the event, which is how a negative test of this shape rots.
|
||||||
|
Mapped rows read the generation through the validator's own
|
||||||
|
accessor, so a mapping-rung refusal cannot masquerade as a
|
||||||
|
dispatcher refusal. **Three mutations, each biting its named rows:**
|
||||||
|
ungating the legacy arm fails exactly the two legacy rows, ungating
|
||||||
|
the mapped arm exactly the two mapped rows, and relaxing the
|
||||||
|
dispatcher's `>=` to `>` fails all four.
|
||||||
|
- **DOCS: committed conflict residue** — an orphaned diff3
|
||||||
|
`|||||||` ancestor line survived an earlier resolution in this
|
||||||
|
file, with the other three markers removed and both sides' content
|
||||||
|
kept. Deleted; the section below it was the only copy.
|
||||||
|
- **THE GATE CANNOT SEE THAT CLASS, and this is not fixed here.**
|
||||||
|
`git diff --check` compares the WORKTREE against the index, so a
|
||||||
|
marker that is already committed passes a clean-worktree gate
|
||||||
|
forever. `git diff --check githubsucks/main...HEAD` exits 2 on it.
|
||||||
|
Widening the gate's check to the branch range is a **candidate
|
||||||
|
lane** — a gate change owes a framing doc, and it should not ride
|
||||||
|
in on a §5b review round.
|
||||||
|
- **The blocker is gone.** #241 fixed the `sweep-crdt` defect this lane
|
||||||
|
was held behind, and the SIGINT guard now refuses a gate run started
|
||||||
|
with `SIGINT` ignored — which is what made stage 15 unreachable.
|
||||||
|
- **FULL 16-STAGE GATE GREEN**, log `20260820T153017Z-147411`, one
|
||||||
|
unsplit `./scripts/gate --protocol` run with all six touched
|
||||||
|
acceptance suites. `sweep-crdt` reports **zero** failures.
|
||||||
|
- **THE TWO `m4_24_*` FAILURES WERE MY OWN MEASUREMENT ERROR, and the
|
||||||
|
"CI is the arbiter" framing built on them is WITHDRAWN.** I had run
|
||||||
|
the CRDT sweep OUTSIDE `scripts/gate`, so it inherited
|
||||||
|
`TMPDIR=/tmp` instead of the gate's isolated one, and I then reported
|
||||||
|
the local sweep as unable to go green for pre-existing reasons. It
|
||||||
|
goes green. CI never needed to substitute for it.
|
||||||
|
- **MECHANISM, and it is the exact hazard #240 fixed:** `/tmp/.git`
|
||||||
|
exists on this machine (an empty directory, 2026-08-15). Project
|
||||||
|
detection **walks upward**, so every `tempfile::tempdir()` under
|
||||||
|
`/tmp` inherits `/tmp` as a project root. Both failing rows are
|
||||||
|
base-resolution rows — `m4_24_bare_string_glob_stays_relative` and
|
||||||
|
`m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — so both
|
||||||
|
resolve against the wrong base and fail. CI's `/tmp` carries no
|
||||||
|
such marker, which is why they passed there.
|
||||||
|
- **The gate's `TMPDIR` is disk-backed under `<gate-root>/tmp/` on
|
||||||
|
btrfs, in a directory the gate owns**, with no stray ancestor.
|
||||||
|
Running the sweep by hand defeats exactly the isolation #240 built
|
||||||
|
for this signature.
|
||||||
|
- **`/tmp/.git` IS STILL THERE and is not mine to delete** — it is
|
||||||
|
outside the repo and I did not create it. Any hand-run test that
|
||||||
|
resolves a project root from a temp dir will keep failing on this
|
||||||
|
machine until it is removed.
|
||||||
|
- **HEAD-EXACT GATING IS BLOCKED BY THE BUDGET-ROW FAMILY, recorded as
|
||||||
|
`docs/ci-red-signatures.md` U10.** Two consecutive gate runs at
|
||||||
|
`70b334d`, worktree verified clean before and after each, were
|
||||||
|
**15/16 green each** — and the red **rotated**: run A took
|
||||||
|
`13-sweep` (`dired_open_renders_10k_entries_under_200ms`,
|
||||||
|
263.961465ms against 200ms), run B took `15-sweep-crdt`
|
||||||
|
(`criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`,
|
||||||
|
1.044609ms against 1ms). **Each red is green in the other run**, and
|
||||||
|
both are green isolated at load 9.34. Neither path is touched by this
|
||||||
|
branch. The earlier `20260820T153017Z-147411` all-green run was on
|
||||||
|
the same code, one docs commit earlier.
|
||||||
|
- **No PR was opened on that evidence.** The standing condition is a
|
||||||
|
head-exact GREEN gate, and 15/16-twice-with-a-rotating-red is not it,
|
||||||
|
however well the rotation is explained.
|
||||||
|
- **A prior gate attempt is NOT evidence**: I wrapped it in
|
||||||
|
`timeout 580` to fit the session's command cap, which killed the
|
||||||
|
sweep mid-run (`Terminated` in its log) and reported it as a stage
|
||||||
|
failure. Its two perf-budget failures also ran at load 36.9 with a
|
||||||
|
foreign workload present. Recorded so the log is not mistaken for a
|
||||||
|
result. The supported way past the cap is the tool-level background
|
||||||
|
launch, which measures `safe` under
|
||||||
|
`scripts/check-sigint-deliverable`; `docs/agent-handoff.md` §3 now
|
||||||
|
says so.
|
||||||
|
|
||||||
|
**Written with the branch's FIRST commit**, per the standing correction
|
||||||
|
from #171 and #215.
|
||||||
|
|
||||||
|
- **Branch `panel-mapping-generation`**, base `githubsucks/main` @
|
||||||
|
**`72da24a`**, worktree
|
||||||
|
`/home/jeans/Repos/personal/pmacs-mapping-gen`.
|
||||||
|
**`githubsucks/panel-mapping-generation` is the authoritative tip.**
|
||||||
|
Recover with `git fetch githubsucks && git checkout
|
||||||
|
panel-mapping-generation`.
|
||||||
|
- **No PR yet. Code-complete at `5174f73`; the slice-completion gate is
|
||||||
|
RED.** The ledger tip is **the branch head itself**
|
||||||
|
(`githubsucks/panel-mapping-generation`) — a literal SHA here goes
|
||||||
|
stale the moment the next commit lands, which is exactly how this
|
||||||
|
line came to name `fb40d88` while the branch was four commits past
|
||||||
|
it. Framing revision 16 (§5b)
|
||||||
|
APPROVED 2026-08-15 at `7e85a6f`, then eleven implementation commits:
|
||||||
|
wire shapes and pins (G0), the authoritative key with its terminal
|
||||||
|
half (G1–G4), outbound family selection, inbound family gating, the
|
||||||
|
GPU's negotiated family enum, and the nine family-gate rows
|
||||||
|
(G6a–b, G7a–b, G8). **Full `--protocol` gate green at `3c06176`.**
|
||||||
|
Then three more: G5a's trigger and its latch, the wheel exemption
|
||||||
|
with exhaustion and mapped coalescing, and the receiver rows.
|
||||||
|
**Every row this slice owns is now witnessed** — G0, G1–G4a, G5a plus
|
||||||
|
latch substrate, G6–G8, G9a–c, G10/G10a–c, G11a, G13a–b, G14a–b, G15
|
||||||
|
— with G4b, G5b–e/g/i–p, G6c, G7c, G11b and G12a–b left to the replay
|
||||||
|
lane per §5b's split table.
|
||||||
|
- **PROTOCOL-BEARING — v25, and it runs alone.**
|
||||||
|
`ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**.
|
||||||
|
- **Why it exists.** A `PanelPointer` names a cell and nothing on the
|
||||||
|
wire says which inverse mapping the frontend saw, so the daemon
|
||||||
|
inverts against whatever is current. `buffer_id` catches
|
||||||
|
replacement, `panel_epoch` close/reopen, `geometry_epoch` a
|
||||||
|
declaration race — **nothing catches "the text under that cell
|
||||||
|
changed"**. Q#BP-R3 first accepted this as narrow; that was
|
||||||
|
**overruled**, because a **foreign** edit moves the mapping with
|
||||||
|
`view_top` untouched, the error is unbounded, and the window lasts
|
||||||
|
until the frontend **presents** the new frame.
|
||||||
|
- **Semantic seam only.** The TUI hit-tests current daemon state
|
||||||
|
directly, receives no `PanelFrame`, and carries no mapping token. Its
|
||||||
|
existing panel click/drag/wheel effects are a structural control
|
||||||
|
against moving the generation gate into shared replay.
|
||||||
|
- **A generation, not a per-frame token.** A token moving with the
|
||||||
|
frame would cancel a drag on the next repaint — the mistake
|
||||||
|
`panel_epoch` is stable to avoid. The key moves with the inverse
|
||||||
|
mapping (`view_top`, **`view_left`**, grid size, folds, wrap/gutter
|
||||||
|
geometry, buffer content, terminal output/scrollback) and holds
|
||||||
|
across focus, styling, selection-only repaints and cursor motion
|
||||||
|
**that the follow rules absorb**.
|
||||||
|
- **One authoritative per-frontend key**, used by projection AND
|
||||||
|
inbound validation, advanced after any mapping mutation and
|
||||||
|
**before** the next inbound pointer — comparing against the last
|
||||||
|
emitted frame recreates the hole.
|
||||||
|
- **Gating is REFUSAL, not fallback.** A ≥ v25 session sending bare
|
||||||
|
`PanelPointer` is refused; a ≥ v25 frontend rejects legacy
|
||||||
|
`Present`. Only ≤ v24 sessions keep legacy semantics; `Absent` is
|
||||||
|
common. The reciprocal wrong-family cases are witnessed too: a v24
|
||||||
|
daemon refuses `PanelPointerMapped`, and a v24 frontend rejects
|
||||||
|
`PresentMapped`. Compiling the variant in the same crate never
|
||||||
|
overrides the negotiated family.
|
||||||
|
- **Stale tails TERMINATE, they do not vanish.** A dropped `Up` leaves
|
||||||
|
an empty selection armed and a reporting child holding a button
|
||||||
|
forever, so cancellation is a ruled outcome: producer latch reset,
|
||||||
|
daemon selection cleanup, and the child's release delivered.
|
||||||
|
Click-chain invalidation is a separate mapping-identity transition;
|
||||||
|
it also runs when no gesture is held.
|
||||||
|
- **Pins ACCUMULATE**: keep `PanelPointer`, add exact `TextInput`
|
||||||
|
bytes (previous-final `FrontendEvent`) and the complete nested
|
||||||
|
`PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`), then
|
||||||
|
add exact encode/decode pins for both new mapped variants with
|
||||||
|
distinct same-typed field values. Previous-final pins protect append
|
||||||
|
position; new-variant pins protect their own frozen field order.
|
||||||
|
**The `PanelPointer` pin DOES exist** at `src/protocol.rs:1975` —
|
||||||
|
revision 14 recorded it as missing, which was wrong: it lives in the
|
||||||
|
root crate's test module, not under `pmacs-protocol/` or `tests/`.
|
||||||
|
- **Appended means LAST**: `PresentMapped` after `Absent`,
|
||||||
|
`PanelPointerMapped` after `TextInput`. `mapping_generation` is a
|
||||||
|
`u64`, **zero invalid**, gated by `PANEL_MAPPING_MIN_VERSION = 25`.
|
||||||
|
- **Frontend generation is a session high-water mark.** Higher values
|
||||||
|
may skip, equal-generation style/selection repaints are valid, lower
|
||||||
|
values are rejected atomically, and `Absent` does not erase the
|
||||||
|
high-water mark. Detach does.
|
||||||
|
- **Coordinate-free wheels are EXEMPT** from the freshness check —
|
||||||
|
otherwise the first tick advances the key and the next queued tick is
|
||||||
|
refused, so the panel scrolls once and dies. Child-reported terminal
|
||||||
|
wheels keep the check, because SGR carries row and column.
|
||||||
|
- **Cancellation is PROACTIVE**, at the transition that revokes
|
||||||
|
authority: mapped-generation advance before its frame, before
|
||||||
|
daemon-authored `Absent` or panel/buffer replacement, when a new
|
||||||
|
geometry declaration is accepted, and at detach. The last four apply
|
||||||
|
to both v24 legacy and v25 mapped gestures. It is taken exactly once;
|
||||||
|
no producer cancellation tail is sent.
|
||||||
|
- **Producer reset has cause-specific signals**, not one generic
|
||||||
|
replacement frame: accept the valid generation/identity frame;
|
||||||
|
accept `Absent`; locally advance the geometry declaration; or tear
|
||||||
|
down the frontend. Invalid frames and same-identity repaints retain
|
||||||
|
the latch.
|
||||||
|
- **The accepted-gesture latch is left-button-only and common to both
|
||||||
|
wire families.** It arms only after accepted `Down(Left)` and dies on
|
||||||
|
accepted `Up(Left)` or any authority loss. Move, wheel, context, and
|
||||||
|
right/middle presses do not arm it; otherwise the GPU's press-only
|
||||||
|
right path manufactures a later release. A terminal Down also fixes
|
||||||
|
child-reporting versus local-selection domain through Drag/Up, so a
|
||||||
|
later Shift or reporting-mode change cannot orphan or invent a child
|
||||||
|
release.
|
||||||
|
- **Click-chain state is independent and per frontend.** It survives
|
||||||
|
an ordinary `Up` by design, but every mapping identity change clears
|
||||||
|
or re-keys it even when no gesture is held. A completed click followed
|
||||||
|
by a foreign edit and same-cell click is single, not double; an
|
||||||
|
intervening click from another frontend does not erase the first
|
||||||
|
frontend's legitimate chain.
|
||||||
|
- **Terminal identity EXCLUDES style.** Wire `Cell` equality covers
|
||||||
|
`glyph`, `style` and `attachment`
|
||||||
|
(`pmacs-protocol/src/cell.rs:153`), so keying on it would move the
|
||||||
|
revision on a recolour and contradict the stable control. It is
|
||||||
|
glyph/row topology plus the view anchor.
|
||||||
|
- **The terminal key is NOT `Screen`'s generation** — that advances
|
||||||
|
from 39 sites including style, bell, tab-stops and cursor-only
|
||||||
|
motion, none of which change what a coordinate denotes.
|
||||||
|
- **G5 is now an explicit transition matrix.** It crosses every common
|
||||||
|
authority loss with v24 and v25, distinguishes each producer reset
|
||||||
|
signal from invalid/same-identity controls, pins ordinary-Up
|
||||||
|
exact-once and left-only arming, and tests click identity without a
|
||||||
|
live gesture. This slice owns only the new mapping-generation signal
|
||||||
|
and mapped-frame state; the rebased replay lane owns the common
|
||||||
|
invalidations and every document, terminal, complete producer-latch
|
||||||
|
and click-chain effect through real call sites. No dead
|
||||||
|
classification helper is added on a base without replay.
|
||||||
|
- **G5's latch lands here under SUBSTRATE names, not the deferred
|
||||||
|
IDs.** G5a needs something to cancel, so the per-frontend
|
||||||
|
`AcceptedPanelGesture` latch and its arming from the accepted inbound
|
||||||
|
arms are this slice's. Writing that code decides which events arm,
|
||||||
|
that an ordinary `Up` consumes without counting as a cancellation,
|
||||||
|
and that the latch is per frontend — pinned as `g5_substrate_*`
|
||||||
|
rather than as G5c/G5d/G5g/G5p, which stay on the replay lane per
|
||||||
|
§5b's split table. **Claiming an ID in two branches is the merge
|
||||||
|
hazard**, and this file already records duplicate ids surviving a
|
||||||
|
clean merge once. Cancellations are **counted, not queued**: the
|
||||||
|
record queue is what replay drains, and landing it here would grow
|
||||||
|
one entry per cancelled drag with no reader. The other G5b
|
||||||
|
transitions (panel epoch, buffer replacement, same-size geometry,
|
||||||
|
detach) deliberately leave the latch armed on this base — inert
|
||||||
|
while nothing consumes it, and a defect only once replay supplies
|
||||||
|
effects, in the branch that owns the row.
|
||||||
|
- **Routing and effects are separate controls.** This slice can prove
|
||||||
|
v24/v25 events reach the existing focus path, but selection,
|
||||||
|
terminal reporting, stable-generation drag continuation, two-tick
|
||||||
|
wheels and exhaustion cancellation remain explicit replay-lane rows;
|
||||||
|
no classification-only result is reported as end-to-end input.
|
||||||
|
- **Review-process correction — carry into the next handoff
|
||||||
|
absorption.** Revision 16 replaces finding-at-a-time review with one
|
||||||
|
closure pass for this chain and future framings: verify cited ground
|
||||||
|
truth; enumerate trigger × protocol family × target × lifecycle;
|
||||||
|
separate producer, route, receiver and effect; prove every witness's
|
||||||
|
discriminating setup and positive control; check that each proposed
|
||||||
|
mutation is actually defective; and assign every row to the branch
|
||||||
|
that can reach its real call site. Protocol work also checks all four
|
||||||
|
wrong-family quadrants, exact pins and version fallout. Findings are
|
||||||
|
returned as one batch. If three revisions do not close a framing,
|
||||||
|
stop serial review and perform/fold this complete audit rather than
|
||||||
|
opening another one-finding round. The overdue #239/#240 handoff
|
||||||
|
absorption must promote this rule; this feature branch does not edit
|
||||||
|
the deliberately lagging canonical handoff.
|
||||||
|
- **Owns the version correction.** `docs/gui-stage1-input-framing.md`
|
||||||
|
moves 1e's `OpenTarget` to **v26** here — an expected rebase
|
||||||
|
conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving
|
||||||
|
the canonical document false.
|
||||||
|
- **Chain: this slice → `panel-pointer-replay` (rebases onto it) →
|
||||||
|
GUI arc 1b.**
|
||||||
|
- **CI-red observations on this branch, NOT yet in the registry — and
|
||||||
|
deliberately so.** `docs/ci-red-signatures.md` here ends at **U9**;
|
||||||
|
the `panel-pointer-replay` branch already added a **U10** that has
|
||||||
|
not merged. Adding a row from this branch would either duplicate that
|
||||||
|
id or invent U11 against an unseen neighbour — and this file's own
|
||||||
|
history records exactly that going wrong once already, when two
|
||||||
|
branches' entries "merged **without a conflict**, producing duplicate
|
||||||
|
ids across four sites". **The rows below are owed to the registry by
|
||||||
|
whichever branch merges second**, numbered after the other's.
|
||||||
|
- **R7, two occurrences on this branch** (2026-08-15, `gpu` step,
|
||||||
|
logs `20260815T095532Z` and `20260815T100719Z`). Fragments verified
|
||||||
|
both times: `transient sequence must attach: Attach(Handshake(Io(Os
|
||||||
|
{ code: 32, kind: BrokenPipe, message: "Broken pipe" })))` at
|
||||||
|
`pmacs-gpu/src/attach.rs:1728`. One machine, one day, one branch,
|
||||||
|
with a green full-gate run between them. Isolated reruns green.
|
||||||
|
- **`composition_overhead_under_ten_percent`, once** (log
|
||||||
|
`20260815T102527Z`, step **`04-lib-crdt`**). Fragment:
|
||||||
|
`composition machinery added more than 10% overhead: 1.146
|
||||||
|
(single=227350 ns, dispatch=260497 ns)`. **`criterion_1` ran in the
|
||||||
|
same step and PASSED**, so per U6's own instruction this is one
|
||||||
|
selector redding without the other and is a different incident.
|
||||||
|
**1.146× is the smallest margin this budget has ever failed by**
|
||||||
|
(U6 1.297×, U10 1.343×, U9 1.613×), and the "realistic" figure was
|
||||||
|
**negative** (−25.8%) in the same run. Isolated rerun green.
|
||||||
|
- **`setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`,
|
||||||
|
once** (log `20260815T184808Z`, step `08-sweep`). Fragment: `live
|
||||||
|
runtime probe` at `src/process.rs:5155` — `active_reader_probe`
|
||||||
|
returned `None` for a process that had just reported `Started`. A
|
||||||
|
live-process race under sweep load. Isolated rerun green. **Not in
|
||||||
|
the registry under any id**, so it is a new signature, owed like
|
||||||
|
the two above.
|
||||||
|
- **`composition_overhead_under_ten_percent`, a SECOND time**
|
||||||
|
(2026-08-15, plain `cargo test --lib`, not a gate step). Isolated
|
||||||
|
rerun green. Same budget as the occurrence above, now seen in two
|
||||||
|
different selectors on one branch in one day.
|
||||||
|
- **RESOLVED ATTRIBUTION: the sweep-crdt red is PRE-EXISTING ON
|
||||||
|
MAIN.** The identical `build-crdt && sweep-crdt` pair run at the
|
||||||
|
merge base **`72da24a`**, in the primary worktree with its own
|
||||||
|
target dir, fails the SAME test,
|
||||||
|
`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`: **119
|
||||||
|
green result summaries and TWO red binaries** —
|
||||||
|
`gpu_initial_target_acceptance` includes the suite as a module, so a
|
||||||
|
reproducing sweep reds twice (`…-2144707/09-sweep-crdt.log:3097`
|
||||||
|
and `:3131`). **This branch is not implicated**, and no branch can
|
||||||
|
pass this gate stage on this machine until the underlying defect is
|
||||||
|
fixed.
|
||||||
|
- **THE ONSET IS DATABLE.** `sweep-crdt` has 17 logs here. Counted
|
||||||
|
per test copy: **13** both copies `... ok`, **1** where neither
|
||||||
|
executed (the stage died compiling `pmacs`, `error[E0308]`), **3**
|
||||||
|
both `FAILED`. Last observed green `20260815T185708Z`, first
|
||||||
|
observed red `20260816T063330Z`; boot began 08-14 09:30, so no
|
||||||
|
reboot between. So "pre-existing on `main`" holds,
|
||||||
|
but **"always broken" is contradicted**. The window is **not yet a
|
||||||
|
bisect target**: cleanliness was captured at neither endpoint, so
|
||||||
|
the lane's first move is to reproduce `7599661` and `724b785`
|
||||||
|
clean, in isolated target dirs, and decide from that.
|
||||||
|
- **The red full-sweep count is SEVEN**, not five; each run is
|
||||||
|
enumerated with its own log digest in the teardown lane's
|
||||||
|
`docs/probe-sigint-evidence.md`.
|
||||||
|
- **The defect now has its own lane:
|
||||||
|
`gpu-probe-sigint-teardown`** (pushed; framing revision 9 at
|
||||||
|
`docs/gpu-probe-sigint-framing.md` (revision 9), run provenance at
|
||||||
|
`docs/probe-sigint-evidence.md`). **§5b is held behind it.** That
|
||||||
|
lane's framing supersedes every diagnostic claim below; the entries
|
||||||
|
here are kept only as the record of how it was found.
|
||||||
|
- **MECHANISM STILL NOT ESTABLISHED — and the RETRACTION below was
|
||||||
|
itself wrong.** Two corrections, in order:
|
||||||
|
- A "mechanism located" claim (launcher in `do_wait`, probe child
|
||||||
|
in `futex_do_wait`) was retracted on the argument that the
|
||||||
|
failing launcher "must live 8s or more" while the sampler's
|
||||||
|
longest-lived was 5s.
|
||||||
|
- **That arithmetic is false.** Both reproducing binaries finish in
|
||||||
|
**~5.19s including the 5s timeout** (`:3097`, `:3131`), so
|
||||||
|
`phase=ready` lands in about a tenth of a second and the failing
|
||||||
|
launcher lives roughly **5.1s** — inside what the sampler
|
||||||
|
observed. A ">6s selector", proposed as the remedy, would have
|
||||||
|
captured **nothing**.
|
||||||
|
- So the "located" claim is **not refuted by that argument**. It
|
||||||
|
stays **unproven for a different reason**: under `--features
|
||||||
|
crdt` the suite spawns root launchers from **six** call sites, so
|
||||||
|
a launcher captured by command line alone cannot be attributed to
|
||||||
|
this test.
|
||||||
|
- **Do not key on process age. Key on the PID the test records**,
|
||||||
|
with snapshots before and after its own `kill`.
|
||||||
|
- **Five further explanations were tested and REFUTED.** Recorded so
|
||||||
|
nobody re-runs them:
|
||||||
|
- *Machine load*: refuted. Red on a quiet machine (load 2.77 at
|
||||||
|
launch, foreign workload gone).
|
||||||
|
- *Memory pressure*: refuted, **by correcting my own instrument**.
|
||||||
|
A sampler showed free memory falling to 543 MB and that looked
|
||||||
|
damning — but it recorded `free`, which on Linux is not the
|
||||||
|
meaningful figure. `available` was **27 G**. The 543 MB was
|
||||||
|
reclaimable cache.
|
||||||
|
- *Leaked daemons*: refuted. Peak 58 during the sweep, up only 8
|
||||||
|
from the resting 50, and the green standalone runs already ran
|
||||||
|
at 46-50.
|
||||||
|
- *tmpfs starving the box*: refuted **by experiment**, not
|
||||||
|
argument. `/tmp` went from 21G used to 1.2G (available memory
|
||||||
|
27G -> 45G) and the sweep stayed red, same test. Note the
|
||||||
|
earlier `available`-based dismissal was itself unsound — tmpfs
|
||||||
|
pages are NOT reclaimable yet still appear in `buff/cache` — so
|
||||||
|
the hypothesis deserved the experiment it eventually got.
|
||||||
|
- *inotify exhaustion*: refuted. 47 instances in use of 1024.
|
||||||
|
- *`--workspace` artifact selection*: **NOT refuted.** Two targets
|
||||||
|
under `--workspace` are green, and the preceding targets without
|
||||||
|
`--workspace` are green, but neither run isolates the factor —
|
||||||
|
see below.
|
||||||
|
- *A specific preceding test*: **NOT refuted, and the earlier entry
|
||||||
|
here was wrong.** It claimed all 37 preceding targets plus the
|
||||||
|
suite run green "same binaries, same order, same tests". The
|
||||||
|
**compilations** were not the same: that run executed
|
||||||
|
`gpu_initial_target_acceptance-91f51d0b` and
|
||||||
|
`gpu_invocation_acceptance-6b4b8223`, while the failing sweeps
|
||||||
|
executed `-5d9105cb` and `-d4dae4f0`. Differing Cargo suffixes
|
||||||
|
mean differing metadata hashes, so command shape changed the
|
||||||
|
compilation and the comparison was never made. **Historical byte
|
||||||
|
identity is UNKNOWN** and is not claimed — target dirs have been
|
||||||
|
overwritten since.
|
||||||
|
- Also withdrawn: that other packages "cannot be implicated"
|
||||||
|
because their targets run after the failure. Later-selected
|
||||||
|
packages can affect Cargo's build graph and fingerprints
|
||||||
|
**before** their tests execute.
|
||||||
|
- Also withdrawn: that the cause is "cumulative across the
|
||||||
|
preceding 37 binaries". Nothing established that.
|
||||||
|
- Note the test exists in **two** binaries: `tests/gpu_initial_
|
||||||
|
target_acceptance.rs` includes it as a module, so a reproducing
|
||||||
|
sweep fails it twice, at log lines 3083 and 3117.
|
||||||
|
- **What the reductions established, and what they did NOT** — they
|
||||||
|
were never a bisect, since no run isolated a variable. Green in
|
||||||
|
every smaller context tried — the test alone (x3, 0.15s against its
|
||||||
|
5s deadline); its whole 15-test suite; a workspace-wide run
|
||||||
|
FILTERED to just this test; the lib binary (2145 tests) then the
|
||||||
|
suite; the three GPU suites in sweep order — and red in every full
|
||||||
|
workspace sweep, seven of them across two trees.
|
||||||
|
**No cumulative cause follows from that.** The subset runs and the
|
||||||
|
sweeps executed **different Cargo compilations** — differing
|
||||||
|
suffixes, hence differing metadata hashes — so the reductions never
|
||||||
|
made the comparison they appeared to make. (Calling them
|
||||||
|
"byte-different" is withdrawn: the bytes a historical run executed
|
||||||
|
are not knowable now, only the suffixes.) What is established is
|
||||||
|
only: reproducible in the full sweep, not reproduced in any subset
|
||||||
|
attempted so far.
|
||||||
|
- **`/tmp` is a 30G tmpfs holding 21G**, almost all stale
|
||||||
|
`levshell-*-target` cargo directories belonging to an unrelated
|
||||||
|
project, 3-4 days old. Recorded as an observation about this
|
||||||
|
machine, **not** as the cause — `available` memory refutes that.
|
||||||
|
Not touched: they are not this project's to delete.
|
||||||
|
- **The earlier signature entry below overstated its case** and is
|
||||||
|
kept only for its measurements. It read the isolation runs as
|
||||||
|
proof of an environmental cause; they establish intermittence at
|
||||||
|
most, and the base-comparison above is what actually settled
|
||||||
|
attribution.
|
||||||
|
- **`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`, a NEW
|
||||||
|
signature, red TWICE and reproducibly** (2026-08-16, logs
|
||||||
|
`20260816T063330Z` and `20260816T064549Z`, step `09-sweep-crdt`
|
||||||
|
both times). Fragment: `child did not exit within 5s` at
|
||||||
|
`tests/gpu_invocation_acceptance.rs:180`. **Not in
|
||||||
|
`docs/ci-red-signatures.md` under any id.** Green in isolation and
|
||||||
|
green with all fourteen of its suite siblings, the latter in
|
||||||
|
**0.15s against its own 5s deadline** — a 33x margin. It reds only
|
||||||
|
inside the full-workspace crdt sweep.
|
||||||
|
- **The row is marginal by construction, and the sweep is the
|
||||||
|
worst place for it.** It holds a FIXED 5s wall-clock deadline for
|
||||||
|
a spawned child to exit after a signal, and it runs inside the
|
||||||
|
heaviest stage the gate has — `cargo test --workspace --features
|
||||||
|
crdt`, which saturates all 16 cores by itself. Nothing about it
|
||||||
|
touches panels or the wire.
|
||||||
|
- **Sweep contention could not be separated from foreign load**,
|
||||||
|
and the attempt is recorded as inconclusive rather than dressed
|
||||||
|
up: running `09-sweep-crdt`'s exact command alone red the same
|
||||||
|
row, but `uptime` hit **59.51** during that run, so the control
|
||||||
|
proved nothing it was meant to. Part of that number is the
|
||||||
|
sweep's own parallelism.
|
||||||
|
- **Three wall-clock-deadline rows red in ONE run, under a MEASURED
|
||||||
|
foreign load** (2026-08-16, log `20260816T063330Z`). Steps
|
||||||
|
`04-lib-crdt` and `09-sweep-crdt`. Fragments:
|
||||||
|
`criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`
|
||||||
|
— "per-keystroke orchestrator time **1.001196ms** exceeds 1ms",
|
||||||
|
**0.12% over**, the smallest margin any budget on this lane has
|
||||||
|
failed by; `m6_1_pty_mode_lifecycle_started_then_exited` — "must
|
||||||
|
observe stdout 'done'"; and
|
||||||
|
`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` — "child
|
||||||
|
did not exit within 5s". All three green in isolation, the last in
|
||||||
|
**0.15s against its 5s deadline**, a 33x margin.
|
||||||
|
- **This is the first entry on this lane with a NAMED confound rather
|
||||||
|
than the standing uncontrolled one.** `uptime` during the run:
|
||||||
|
load average **14.02 → 28.35**, from an unrelated `turso` test
|
||||||
|
suite on the same machine (`./verify_task_state.sh
|
||||||
|
turso-without-rowid`, target dir `/opt/target`, one test binary at
|
||||||
|
**693% CPU**). It is not a controlled experiment, but it is the
|
||||||
|
same evidence U9's synthetic-load control was meant to produce, and
|
||||||
|
it points at load. U9 stays owed; its value is now lower.
|
||||||
|
- **Two process traps this cost, both worth carrying forward.** The
|
||||||
|
Bash tool caps a command at 10 minutes and SIGTERMs it, which the
|
||||||
|
gate reports as `FAILED (exit 143)` on whatever stage was running —
|
||||||
|
indistinguishable from a real failure in the summary line. Run the
|
||||||
|
protocol gate under `setsid` and watch the log. And `pkill -f
|
||||||
|
<pattern>` kills the invoking shell when the pattern appears in its
|
||||||
|
own command line, so the intended target survives and the operator
|
||||||
|
believes it died. **Both mistakes were made here**; the second
|
||||||
|
nearly killed an unrelated project's build, because
|
||||||
|
`pkill -f "cargo test"` would have matched it. Identify by PID.
|
||||||
|
- **Three consecutive full-gate runs, three DIFFERENT unrelated
|
||||||
|
failures** (composition budget in `04-lib-crdt`, then composition
|
||||||
|
again in `10-sweep-crdt`, then this in `08-sweep`), against a diff
|
||||||
|
that touches panels and the wire. Recorded as a rate observation
|
||||||
|
only: no mechanism is claimed, and the standing leaked-daemon
|
||||||
|
confound is uncontrolled as always.
|
||||||
|
- **Cost, stated plainly:** four `--protocol` gate runs on one commit,
|
||||||
|
three of them lost to these two signatures. U9's synthetic-load
|
||||||
|
control remains unrun and is the cheapest thing that would either
|
||||||
|
implicate load or clear it.
|
||||||
|
- **Rustdoc split, FOUR occurrences on this branch** (`screen_size`,
|
||||||
|
`peer_may_send_panel_events`, `send_panel_pointer`, and
|
||||||
|
`SemanticRenderState`). Always the same mechanism: inserting an item
|
||||||
|
at what reads as a blank gap when the lines directly above are the
|
||||||
|
NEXT item's doc comment, which the insertion then adopts. The fourth
|
||||||
|
also stole an `#[allow(clippy::struct_excessive_bools)]`, silently
|
||||||
|
un-suppressing a lint on the struct that needed it. Three were caught
|
||||||
|
by the user in review, one by a `missing_docs` warning. **The check
|
||||||
|
is to look UP from the insertion point before writing, not down.**
|
||||||
|
- **SLICE-COMPLETION GATE STATUS — read before assuming this is
|
||||||
|
mergeable.** Nine of ten stages green at `5174f73`; `09-sweep-crdt`
|
||||||
|
red on the row above, twice. **A gate with a red stage is a red gate,
|
||||||
|
and no PR was opened on it.**
|
||||||
|
- **The cause is UNRESOLVED.** The row is **intermittent**: green
|
||||||
|
alone, green with all fourteen of its suite siblings, red inside
|
||||||
|
the full-workspace crdt sweep. Foreign load is a **measured
|
||||||
|
confound**, not an explanation — establishing intermittence is not
|
||||||
|
establishing an environmental cause, and no experiment here
|
||||||
|
separated sweep contention, foreign load, and a genuine defect in
|
||||||
|
the row. Do not record it as environmental until something does.
|
||||||
|
- **The gate cannot pass on this machine for ANY branch**, main
|
||||||
|
included, until the pre-existing sweep-crdt defect is fixed. That
|
||||||
|
is a decision point, not a thing to keep re-running: either the
|
||||||
|
defect gets its own lane, or this lane's readiness bar is restated
|
||||||
|
against a gate that main itself can pass.
|
||||||
|
- **Clean evidence is not obtainable on this machine right
|
||||||
|
now** — an unrelated `turso` workload
|
||||||
|
(`./verify_task_state.sh turso-without-rowid`, target `/opt/target`)
|
||||||
|
is running in a LOOP, holding a 16-core box at load 20-60. The
|
||||||
|
remaining work on this lane is one clean gate run, on a quiet
|
||||||
|
machine, and nothing else.
|
||||||
|
- **Gates — THE EXACT INVOCATION, not a list of suites.** Naming the
|
||||||
|
suites without the flag form is what let two full runs go by with
|
||||||
|
bare `--protocol` and **no acceptance stage at all**; the suites were
|
||||||
|
verified by hand instead, which is not the gate and is exactly the
|
||||||
|
substitution these records exist to prevent. `--acceptance` is an
|
||||||
|
EXPLICIT repeated flag — the gate derives nothing from the diff.
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/gate --protocol \
|
||||||
|
--acceptance bottom_panel_stage1_acceptance \
|
||||||
|
--acceptance bottom_panel_stage2a_acceptance \
|
||||||
|
--acceptance bottom_panel_stage2b_daemon_acceptance \
|
||||||
|
--acceptance bottom_panel_stage2b_gpu_acceptance \
|
||||||
|
--acceptance bottom_panel_stage2b_protocol_acceptance \
|
||||||
|
--acceptance gui_stage1a_wire_acceptance
|
||||||
|
```
|
||||||
|
|
||||||
|
Sixteen stages: ten from `--protocol` plus one per suite. **A run
|
||||||
|
that prints ten stages is missing every acceptance stage**, and a run
|
||||||
|
that prints eleven is the older single-suite form.
|
||||||
|
- **The "four `bottom_panel_*` suites" phrasing was WRONG — there are
|
||||||
|
five.** `bottom_panel_stage2b_protocol_acceptance` is the fifth and
|
||||||
|
belongs in a protocol-bearing lane above all others. The block above
|
||||||
|
runs all five rather than guessing which four an earlier writer
|
||||||
|
meant; a superset is the safe reading of an ambiguous record.
|
||||||
|
|
||||||
## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`)
|
## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`)
|
||||||
|
|
||||||
- **MERGED 2026-08-20** at approved head `5089715`, merge commit
|
- **MERGED 2026-08-20** at approved head `5089715`, merge commit
|
||||||
|
|
|
||||||
|
|
@ -1770,6 +1770,800 @@ cannot preserve the old panel-focused attach leak.
|
||||||
preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR
|
preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR
|
||||||
then runs the full gate suite.
|
then runs the full gate suite.
|
||||||
|
|
||||||
|
## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3)
|
||||||
|
|
||||||
|
**Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.**
|
||||||
|
Revision 16 answers review of 15; three of its items reverse a rule 15
|
||||||
|
introduced:
|
||||||
|
|
||||||
|
- **The mapped-generation producer rule contradicted proactive
|
||||||
|
cancellation.** Since the daemon cancels *before* emitting that
|
||||||
|
successor frame, the frontend simply **clears its latch on accepting
|
||||||
|
the frame and sends nothing**. 15's "emit a tail or retain the
|
||||||
|
latch" was redundant in the first case and **actively harmful** in
|
||||||
|
the second — retaining it manufactures a `Drag` under the new
|
||||||
|
generation with no accepted `Down`, the exact orphan the section
|
||||||
|
exists to prevent. Other authority losses have their own reset
|
||||||
|
signals below.
|
||||||
|
- **The latch had one trigger and needed five.** Cancellation runs on
|
||||||
|
every loss of gesture authority: generation advance, `Absent`, panel
|
||||||
|
epoch or buffer identity change, geometry-epoch change **even at an
|
||||||
|
unchanged cell total**, and detach. The last four apply to the legacy
|
||||||
|
and mapped families alike; generation advance exists only in the
|
||||||
|
mapped family. **An ordinary accepted `Up(Left)` must clear the
|
||||||
|
latch**, or a later invalidation synthesises a duplicate release.
|
||||||
|
- **"The replacement frame" was not one producer signal.** A mapped
|
||||||
|
generation or panel/buffer replacement clears on atomic acceptance of
|
||||||
|
its valid frame; `Absent` clears when accepted; a local geometry
|
||||||
|
declaration and detach clear at their own transition, before any
|
||||||
|
replacement frame can be required. Invalid frame payloads and
|
||||||
|
same-identity repaints retain the latch.
|
||||||
|
- **Gesture liveness and click counting are different state.** The
|
||||||
|
accepted-gesture latch covers an accepted `Down(Left)` only and is
|
||||||
|
consumed by `Up(Left)` or loss of authority. `MouseClickState`
|
||||||
|
deliberately survives an ordinary `Up` for multi-click detection,
|
||||||
|
but its mapping identity must be cleared or re-keyed on every mapping
|
||||||
|
invalidation even when no gesture is held.
|
||||||
|
- **Routing controls had been written as effects.** This base can prove
|
||||||
|
legacy and mapped events reach the already-landed focus path; it
|
||||||
|
cannot prove selection, terminal reporting, drag continuation,
|
||||||
|
two-tick scrolling, or exhaustion cleanup before replay exists.
|
||||||
|
Those controls are split, and the effect halves are obligations of
|
||||||
|
the rebased replay lane rather than claims made by this slice.
|
||||||
|
- **Bilateral gating needed all four wrong-family legs.** The v25
|
||||||
|
refusal rows did not constrain a v24 session sending or accepting the
|
||||||
|
mapped variants. Same-crate peers can encode them regardless of what
|
||||||
|
they negotiated, so both inbound and outbound are now witnessed at
|
||||||
|
v24 and v25, with exact-family positive controls.
|
||||||
|
- **G9b's mutation was a valid implementation.** Keying dedupe by
|
||||||
|
`(mapping_generation, coord)` is a correct design, so requiring it to
|
||||||
|
fail would have forbidden a good one. Replaced with two real defects.
|
||||||
|
- **"Projected cell identity" contradicted the styling control** — wire
|
||||||
|
`Cell` equality includes `style`. Terminal identity is now glyph and
|
||||||
|
row topology plus the view anchor, **excluding face, style and
|
||||||
|
cursor**, with a same-glyph/different-style control.
|
||||||
|
- Zero-generation rows added **in both directions**; G7 split into
|
||||||
|
outbound and inbound legs; G2's grid and fold composites split;
|
||||||
|
journey steps **5** and **8** named in §20.
|
||||||
|
- **The folded closure pass made the remaining implicit contracts
|
||||||
|
executable in one matrix:** authenticated-session family authority;
|
||||||
|
a frontend generation high-water mark surviving `Absent`; exact pins
|
||||||
|
for the new variants as well as their previous finals; left-only and
|
||||||
|
fixed-domain terminal gestures; exact-once coincident invalidations;
|
||||||
|
independent click identity; the semantic/TUI boundary; and explicit
|
||||||
|
ownership of every route versus effect row (G0–G15).
|
||||||
|
|
||||||
|
**Previously, revision 15 — SUPERSEDED.**
|
||||||
|
Revision 15 answers review of 14, and four of its items reverse
|
||||||
|
something 14 asserted:
|
||||||
|
|
||||||
|
- **"Beside `Present`/`PanelPointer`" was positionally dangerous.**
|
||||||
|
Appended means **last**: `PresentMapped` after **`Absent`**,
|
||||||
|
`PanelPointerMapped` after **`TextInput`**. Exact field order, `u64`,
|
||||||
|
**zero invalid**, and `PANEL_MAPPING_MIN_VERSION` are now stated.
|
||||||
|
- **Blanket refusal stopped the wheel after one tick.** The first
|
||||||
|
effective wheel advances the key and the next queued tick carries the
|
||||||
|
old one. **Coordinate-free** gestures — document wheel, local
|
||||||
|
scrollback — are **exempt**; **child-reported** wheels keep the
|
||||||
|
check, because SGR carries row and column. Two-tick witnesses added.
|
||||||
|
- **Cancellation is now PROACTIVE and has a latch.** Reactive
|
||||||
|
cancellation loses a race: a replacement frame arriving before the
|
||||||
|
physical `Up` makes the producer suppress the very event that would
|
||||||
|
have cancelled. It triggers on the key advancing, and the daemon
|
||||||
|
keeps an accepted-gesture latch — was the `Down` accepted, did it
|
||||||
|
reach the child, with which coordinate, button and encoding. A stale
|
||||||
|
`Up` with no accepted `Down` is inert, and cancellation never
|
||||||
|
reclaims a controller another frontend took.
|
||||||
|
- **The existing screen generation cannot be the terminal key** — it
|
||||||
|
advances from 39 sites including style, bell, tab-stops and
|
||||||
|
cursor-only motion. A **dedicated terminal mapping revision** is
|
||||||
|
defined, with those events as **stable controls**.
|
||||||
|
- **G5's effects moved to the rebased replay lane**, which is the only
|
||||||
|
branch where they exist; **G5a stays here**, so the proactive rule
|
||||||
|
keeps a witness in the slice that introduces it.
|
||||||
|
- **The `PanelPointer` pin exists** (`src/protocol.rs:1975`) — revision
|
||||||
|
14's claim that it could not be found was wrong.
|
||||||
|
|
||||||
|
**Previously, revision 14 — SUPERSEDED.** This
|
||||||
|
slice **blocks** panel-pointer replay (`panel-pointer-replay`), which
|
||||||
|
blocks GUI arc 1b. It is protocol-bearing and runs alone.
|
||||||
|
|
||||||
|
### What it fixes
|
||||||
|
|
||||||
|
A `PanelPointer` names a **cell**; the daemon must invert that to a
|
||||||
|
**byte**. Nothing on the wire says which inverse mapping the frontend
|
||||||
|
was looking at, so the daemon inverts against whatever is current — and
|
||||||
|
the mapping can move for reasons the clicking frontend neither caused
|
||||||
|
nor can observe.
|
||||||
|
|
||||||
|
This is the one hole in the ladder: `buffer_id` catches replacement,
|
||||||
|
`panel_epoch` catches close/reopen, `geometry_epoch` catches a
|
||||||
|
declaration race, and **nothing catches "the text under that cell
|
||||||
|
changed"**.
|
||||||
|
|
||||||
|
The hole exists only across the **semantic frontend's asynchronous
|
||||||
|
projection/inbound seam**. The TUI hit-tests and dispatches against its
|
||||||
|
current daemon state in one process; it neither receives `PanelFrame`
|
||||||
|
nor echoes a mapping generation. This slice adds no TUI token, gate or
|
||||||
|
gesture state, and the existing direct TUI panel effects remain a
|
||||||
|
regression control. Applying the mapped-family check inside the shared
|
||||||
|
replay body instead of at the semantic-session boundary would break
|
||||||
|
that control.
|
||||||
|
|
||||||
|
**Revision 12 accepted this as narrow. It is not.** A **foreign** edit
|
||||||
|
moves the mapping with `view_top` untouched; ticks, paging, folds,
|
||||||
|
edits and reloads accumulate without bound; and the window lasts until
|
||||||
|
the frontend **presents** the replacement frame, which a backed-up
|
||||||
|
frontend widens arbitrarily.
|
||||||
|
|
||||||
|
### The generation, and why not a token
|
||||||
|
|
||||||
|
**A per-frame token is the wrong object.** Panels repaint constantly,
|
||||||
|
and a token that moved with the frame would invalidate a live drag on
|
||||||
|
the next repaint — the mistake `panel_epoch` is stable to avoid.
|
||||||
|
|
||||||
|
**`mapping_generation` identifies the INVERSE MAPPING.**
|
||||||
|
|
||||||
|
| changes it | leaves it alone |
|
||||||
|
|---|---|
|
||||||
|
| `view_top` | focus gained or lost |
|
||||||
|
| **`view_left`** — 1b makes horizontal scrolling real | styling, theme, face changes |
|
||||||
|
| panel grid size | selection-only changes |
|
||||||
|
| fold state | a re-emitted identical frame |
|
||||||
|
| wrap mode, gutter geometry | **cursor motion that moves nothing else** |
|
||||||
|
| **buffer content — any edit, from any source** | |
|
||||||
|
| **terminal output or scrollback movement** (terminal panels) | **terminal buffer revision** (see below) |
|
||||||
|
|
||||||
|
**"Cursor movement is stable" is CONDITIONAL, and revision 13 stated it
|
||||||
|
flatly.** A cursor move that triggers vertical or horizontal follow
|
||||||
|
changes `view_top` or `view_left`, and therefore **does** change the
|
||||||
|
generation. The stable case is a cursor move the follow rules absorb.
|
||||||
|
|
||||||
|
**Terminal panels need their OWN mapping revision, and the existing
|
||||||
|
screen generation cannot supply it.** What a coordinate denotes there
|
||||||
|
is decided by the terminal's screen — so output and scrollback movement
|
||||||
|
change the mapping, while the buffer's revision counter tracks
|
||||||
|
something else entirely.
|
||||||
|
|
||||||
|
But `Screen::changed()` is **not** that signal.
|
||||||
|
(`src/terminal/screen.rs:1467`) bumps a single generation from **39
|
||||||
|
call sites**, including `SetStyle` (`:270`), `Bell` (`:287`), the
|
||||||
|
tab-stop operations (`:309`–`:319`), cursor-only motion (`:324`) and
|
||||||
|
`SetTitle` (`:449`). **None of those changes what a coordinate
|
||||||
|
denotes**, and keying on it would cancel a drag every time the child
|
||||||
|
recoloured a character or rang the bell.
|
||||||
|
|
||||||
|
**Define a dedicated terminal mapping revision** over the things that
|
||||||
|
actually decide the inverse:
|
||||||
|
|
||||||
|
- **glyph and row TOPOLOGY** — which glyphs occupy which cells, and
|
||||||
|
which rows the projection holds;
|
||||||
|
- **retained-row identity** — scrollback rows entering or leaving the
|
||||||
|
projection;
|
||||||
|
- **the per-view scroll anchor**.
|
||||||
|
|
||||||
|
**Explicitly EXCLUDING face, style and cursor**, and revision 15's
|
||||||
|
"projected cell identity" got this wrong. The wire `Cell` derives
|
||||||
|
`PartialEq` over `glyph`, **`style`** and `attachment`
|
||||||
|
(`pmacs-protocol/src/cell.rs:153`), so an identity keyed on cell
|
||||||
|
equality **moves on a pure recolour** — which contradicts the very
|
||||||
|
stable control that rules style out, in the same section. The two
|
||||||
|
statements could not both hold.
|
||||||
|
|
||||||
|
**Control row: same glyphs, different style** — the child repaints the
|
||||||
|
projection in a new colour and the revision **does not move**, so a
|
||||||
|
drag survives it. That is the row that catches an implementation
|
||||||
|
reaching for `Cell` equality because it is right there.
|
||||||
|
|
||||||
|
**Stable controls are required, not optional**: style, title, bell,
|
||||||
|
tab-stop and cursor-only operations each get a row asserting the
|
||||||
|
revision **does not move**. Those are precisely the events the
|
||||||
|
convenient existing counter would have caught, so a reader who later
|
||||||
|
reaches for it fails a test instead of shipping a cancelled drag.
|
||||||
|
|
||||||
|
**The stability half is load-bearing, not an optimisation.** A drag
|
||||||
|
provokes selection repaints on every motion; a generation that moved
|
||||||
|
with them would cancel the drag after one step.
|
||||||
|
|
||||||
|
### Ownership and refresh timing
|
||||||
|
|
||||||
|
**One authoritative per-frontend mapping key**, owned by the daemon,
|
||||||
|
**used by both projection and inbound validation**. Not two derivations
|
||||||
|
that agree by inspection.
|
||||||
|
|
||||||
|
- It **advances after any mapping mutation and BEFORE the next inbound
|
||||||
|
pointer is handled**, whether or not a frame has been rendered or
|
||||||
|
emitted since.
|
||||||
|
- **Comparing against the last EMITTED frame recreates the hole.** A
|
||||||
|
mutation that has not yet been painted still changes the inverse
|
||||||
|
mapping, and a gesture arriving in that gap must be refused.
|
||||||
|
- Projection stamps the frame with the same key it validates against,
|
||||||
|
so "what the frontend was shown" and "what the daemon checks" cannot
|
||||||
|
drift.
|
||||||
|
|
||||||
|
### Wire shape — appended at the END, with the field order stated
|
||||||
|
|
||||||
|
Postcard encodes enums **positionally**, so a shipped variant's field
|
||||||
|
list is frozen **and so is every discriminant**. Revision 14 said
|
||||||
|
"beside `Present`" and "beside `PanelPointer`", which is **positionally
|
||||||
|
dangerous** — "beside" reads as *adjacent*, and inserting adjacent to
|
||||||
|
an existing variant shifts every discriminant below it. **Appended
|
||||||
|
means LAST.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
PanelFramePayload FrontendEvent
|
||||||
|
0 Present(PanelFrame) …
|
||||||
|
1 Absent n-1 PanelPointer (v21)
|
||||||
|
2 PresentMapped <- NEW n TextInput (v24)
|
||||||
|
n+1 PanelPointerMapped <- NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
`PresentMapped` goes **after `Absent`**, not after `Present`;
|
||||||
|
`PanelPointerMapped` goes **after `TextInput`**, not after
|
||||||
|
`PanelPointer`.
|
||||||
|
|
||||||
|
**Exact shapes**, field order frozen on landing:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
PanelFramePayload::PresentMapped {
|
||||||
|
frame: PanelFrame, // unchanged, reused whole
|
||||||
|
mapping_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
FrontendEvent::PanelPointerMapped {
|
||||||
|
frontend_id: FrontendId, // untrusted, as every inbound variant
|
||||||
|
geometry_epoch: u64,
|
||||||
|
panel_epoch: u64,
|
||||||
|
buffer_id: BufferId,
|
||||||
|
coord: CellCoord,
|
||||||
|
kind: MouseKind,
|
||||||
|
mods: Modifiers,
|
||||||
|
mapping_generation: u64, // appended last within the variant
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PanelPointerMapped` deliberately mirrors `PanelPointer`'s field order
|
||||||
|
with the generation **appended**, so the two are diffable by eye and a
|
||||||
|
future reader can see that nothing was reordered.
|
||||||
|
|
||||||
|
- **`mapping_generation` is `u64`.**
|
||||||
|
- **Zero is INVALID** and is refused like a mismatch: it is the value a
|
||||||
|
default-constructed or partially-initialised sender produces, and
|
||||||
|
accepting it would let a peer opt out of the check by sending
|
||||||
|
nothing. A live key starts at 1.
|
||||||
|
- It is **monotonic for the authenticated frontend session**. A
|
||||||
|
frontend accepts a valid generation above its retained high-water
|
||||||
|
mark (gaps are fine), and accepts an equal-generation repaint because
|
||||||
|
styling, focus and selection may legitimately change while the
|
||||||
|
mapping holds. It rejects a lower generation atomically. `Absent`
|
||||||
|
clears the presentation and pointer state but **retains the
|
||||||
|
high-water mark**; otherwise a delayed pre-`Absent` frame can roll
|
||||||
|
authority backward. Detach ends the session and its high-water mark.
|
||||||
|
- **`PANEL_MAPPING_MIN_VERSION = 25`**, beside the existing family
|
||||||
|
constants, and the gate reads that constant rather than a literal.
|
||||||
|
|
||||||
|
`Absent` is unchanged and **common to both families** — hiding a band
|
||||||
|
carries no mapping.
|
||||||
|
|
||||||
|
### Bilateral gating — REFUSAL, not fallback
|
||||||
|
|
||||||
|
Revision 13 said an unmapped event from a new peer is "handled under
|
||||||
|
the old semantics". **That is a bypass**: it leaves the exact hole the
|
||||||
|
slice exists to close, reachable by omitting a field.
|
||||||
|
|
||||||
|
| negotiated | daemon sends | daemon accepts | frontend sends | frontend accepts |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **≤ v24** | `Present` | `PanelPointer` | `PanelPointer` | `Present` |
|
||||||
|
| **≥ v25** | `PresentMapped` | `PanelPointerMapped` **only** | `PanelPointerMapped` | `PresentMapped` **only** |
|
||||||
|
|
||||||
|
Both choices use the **authenticated session's negotiated version**.
|
||||||
|
Inbound code never derives the family gate from the payload's claimed
|
||||||
|
`frontend_id`; like every inbound variant, that field is untrusted and
|
||||||
|
is validated separately. Outbound projection uses the destination
|
||||||
|
session's negotiated version, not the crate's compile-time ability to
|
||||||
|
encode either discriminant. The family gate runs before focus,
|
||||||
|
controller, click-chain, gesture-latch or document mutation.
|
||||||
|
|
||||||
|
- A **≥ v25 session sending bare `PanelPointer` is REFUSED**, dropped
|
||||||
|
before any mutation, exactly as an out-of-epoch event is.
|
||||||
|
- A **≥ v25 frontend receiving legacy `Present` REJECTS it** rather
|
||||||
|
than painting a band it cannot safely hit-test.
|
||||||
|
- Only a negotiated **≤ v24** session retains legacy semantics.
|
||||||
|
- `Absent` is accepted from either family.
|
||||||
|
|
||||||
|
### Enforcement, and the liveness it must not break
|
||||||
|
|
||||||
|
On `PanelPointerMapped`, the daemon compares the echoed generation with
|
||||||
|
the authoritative key and refuses the gesture before any mutation when
|
||||||
|
they differ.
|
||||||
|
|
||||||
|
#### The wheel exception — or scrolling stops after one tick
|
||||||
|
|
||||||
|
**A blanket refusal breaks the wheel, and revision 14's was blanket.**
|
||||||
|
The first effective document wheel changes `view_top`, which *is* a
|
||||||
|
mapping change, so the key advances. A second wheel event already
|
||||||
|
queued behind it carries the **old** generation and would be refused —
|
||||||
|
**the panel scrolls exactly one tick and then goes dead** until the
|
||||||
|
frontend observes the new frame. Local terminal scrollback has the same
|
||||||
|
shape, moving its view anchor.
|
||||||
|
|
||||||
|
The discriminator is **whether the gesture uses its coordinate**:
|
||||||
|
|
||||||
|
| gesture | uses `coord`? | generation check |
|
||||||
|
|---|---|---|
|
||||||
|
| document wheel | **no** — `scroll_window` is window-level | **EXEMPT** |
|
||||||
|
| terminal wheel, **local scrollback** (not reporting) | **no** — moves the view anchor | **EXEMPT** |
|
||||||
|
| terminal wheel, **child-reported** | **yes** — SGR carries row and column | **REQUIRED** |
|
||||||
|
| every press, drag, release, context gesture | yes | **REQUIRED** |
|
||||||
|
|
||||||
|
**Coordinate-free gestures cannot be mis-aimed by a stale mapping**,
|
||||||
|
because they never invert a cell. Refusing them buys nothing and costs
|
||||||
|
the feature. A **child-reported** wheel is the opposite case: it puts a
|
||||||
|
row and column into the child's input stream, so a stale one aims an
|
||||||
|
application action at a cell the user never pointed at — the same
|
||||||
|
hazard as a stale click, and it keeps the check.
|
||||||
|
|
||||||
|
The exemption skips **only generation equality**. It does not bypass
|
||||||
|
the authenticated family gate, the nonzero-generation requirement,
|
||||||
|
frontend/panel/geometry/buffer identity, coordinate bounds, or target
|
||||||
|
resolution. In particular, a zero-generation coordinate-free wheel is
|
||||||
|
still refused; zero means "no mapping identity was supplied", not "one
|
||||||
|
was supplied and has since gone stale".
|
||||||
|
|
||||||
|
**Witnesses: two ticks under ONE generation.** A document wheel and a
|
||||||
|
non-reporting terminal wheel must each scroll **twice** when the second
|
||||||
|
event carries the generation the first invalidated. Without a
|
||||||
|
two-tick row, a blanket-refusal implementation passes every
|
||||||
|
single-event row in the matrix.
|
||||||
|
|
||||||
|
**But a blanket drop breaks liveness, and revision 13's did.** A
|
||||||
|
refused **tail** is not the same as a refused **beginning**:
|
||||||
|
|
||||||
|
- **Stale BEGINNINGS may simply drop.** A `Down` that never took effect
|
||||||
|
leaves nothing behind.
|
||||||
|
- **Stale TAILS must TERMINATE the gesture, not vanish.** A dropped
|
||||||
|
`Up` leaves an empty document selection armed with a stale anchor,
|
||||||
|
and leaves a **reporting terminal child holding a button forever**.
|
||||||
|
|
||||||
|
**Cancellation is therefore ruled as a first-class outcome — and it is
|
||||||
|
PROACTIVE, driven by the key advancing, not reactive to a refused
|
||||||
|
event.**
|
||||||
|
|
||||||
|
Revision 14 made it reactive, and that has a race it cannot win: if the
|
||||||
|
replacement mapped frame reaches the frontend **before** the physical
|
||||||
|
button comes up, the producer resets `pointer_held`, **suppresses its
|
||||||
|
own `Up`**, and the daemon is never told anything — so the selection
|
||||||
|
stays armed and the child keeps holding its button. **The cancelling
|
||||||
|
event never arrives.**
|
||||||
|
|
||||||
|
So the daemon cancels at the transition that revokes authority, not at
|
||||||
|
the next pointer event:
|
||||||
|
|
||||||
|
| authority loss | daemon cancellation point | protocol families |
|
||||||
|
|---|---|---|
|
||||||
|
| mapping generation advances | after the mapping mutation and **before** publishing the next `PresentMapped` | mapped, ≥ v25 only |
|
||||||
|
| `Absent` | **before** publishing `Absent` | legacy and mapped |
|
||||||
|
| panel epoch or buffer identity changes | before publishing the successor `Present`/`PresentMapped` | legacy and mapped |
|
||||||
|
| geometry epoch changes | when the new frontend geometry declaration is accepted, even when its rows and columns equal the old declaration | legacy and mapped |
|
||||||
|
| detach | in that frontend's teardown, before its pointer state is discarded | legacy and mapped |
|
||||||
|
|
||||||
|
If an accepted left-button gesture is live, cancellation is taken
|
||||||
|
**exactly once**:
|
||||||
|
|
||||||
|
- **document:** clear an empty selection, preserve an already-nonempty
|
||||||
|
region, and apply no cursor move;
|
||||||
|
- **terminal:** deliver the child's **release**, at the last coordinate
|
||||||
|
known valid, with the left button and encoding the accepted press
|
||||||
|
used.
|
||||||
|
|
||||||
|
The click chain is invalidated at the same authority transitions but is
|
||||||
|
**not conditional on a live gesture**; its lifetime is specified
|
||||||
|
separately below.
|
||||||
|
|
||||||
|
Authority losses may coincide. A changed-size geometry declaration, for
|
||||||
|
example, advances both `geometry_epoch` and the mapping generation;
|
||||||
|
buffer/panel replacement can also change the projected mapping. All
|
||||||
|
causes take the **same** accepted-gesture latch, so the first
|
||||||
|
cancellation settles it and later causes observe no live gesture.
|
||||||
|
Click-chain invalidation is likewise idempotent. Trigger ordering may
|
||||||
|
not create two releases or two selection cleanups.
|
||||||
|
|
||||||
|
**Revision 15 asked the producer to emit a cancellation tail or retain
|
||||||
|
the latch. Both are wrong, and the second is actively harmful.** A tail
|
||||||
|
is redundant — the daemon cancelled first and would receive a release
|
||||||
|
for a gesture it has already settled, which is the duplicate release
|
||||||
|
the latch exists to prevent. **Retaining the latch is worse: it
|
||||||
|
manufactures a `Drag` under the NEW generation with no accepted
|
||||||
|
`Down`** — precisely the orphan this whole section is about, created by
|
||||||
|
the rule meant to avoid it.
|
||||||
|
|
||||||
|
**There is no single producer-side "replacement frame" transition.**
|
||||||
|
The producer clears its local held-gesture state at the first
|
||||||
|
authoritative signal available for the cause:
|
||||||
|
|
||||||
|
| cause | producer reset point |
|
||||||
|
|---|---|
|
||||||
|
| mapped generation changes | atomically accepting the valid successor `PresentMapped` |
|
||||||
|
| panel epoch or buffer identity changes | atomically accepting the valid successor `Present`/`PresentMapped` |
|
||||||
|
| `Absent` | accepting `Absent` |
|
||||||
|
| geometry epoch changes | locally, when `next_geometry_declaration` advances the epoch (`pmacs-gpu/src/main.rs:6847`), **before** sending the declaration |
|
||||||
|
| detach | frontend teardown |
|
||||||
|
|
||||||
|
An invalid frame and a repaint with the same generation and identities
|
||||||
|
**do not clear** the producer latch. A local geometry declaration is
|
||||||
|
different: advancing the local epoch has already revoked the old grid,
|
||||||
|
so the producer clears before the daemon can accept or refuse the
|
||||||
|
declaration. For the daemon-authored frame cases, ordering is cancel
|
||||||
|
then emit; the frame's arrival is the producer's signal, so no second
|
||||||
|
channel is needed. For geometry and detach, the local transition is
|
||||||
|
itself the signal and no replacement frame is required.
|
||||||
|
|
||||||
|
After any reset, subsequent motion and the physical `Up` produce **no
|
||||||
|
new drag and no duplicate release**. The producer never emits a
|
||||||
|
cancellation tail and never carries the held state into the successor
|
||||||
|
identity.
|
||||||
|
|
||||||
|
##### The latch's full lifecycle
|
||||||
|
|
||||||
|
**Cancellation runs on EVERY loss of gesture authority, not only a
|
||||||
|
mapping-generation advance.** Revision 15 named one trigger and left
|
||||||
|
the rest to be inferred:
|
||||||
|
|
||||||
|
| trigger | why it ends the gesture |
|
||||||
|
|---|---|
|
||||||
|
| **mapping generation advances** | the cells mean different bytes |
|
||||||
|
| **`Absent`** | the band the gesture belongs to is gone |
|
||||||
|
| **panel epoch or buffer identity change** | the successor never saw the press |
|
||||||
|
| **geometry-epoch change** | a new declaration, **even when the cell total is unchanged** — the frontend re-declared, so the grid it hit-tested against is not the one in force |
|
||||||
|
| **detach** | there is no frontend left to finish the gesture |
|
||||||
|
|
||||||
|
**And an ordinary accepted `Up` MUST clear the latch.** Otherwise a
|
||||||
|
later invalidation finds a gesture it believes is still live and
|
||||||
|
**synthesises a duplicate release** for a button already up. That is
|
||||||
|
the same orphan race the replay lane's D1/D2 producer resets expose,
|
||||||
|
arriving from the daemon's side instead of the frontend's.
|
||||||
|
|
||||||
|
**The daemon needs an ACCEPTED-GESTURE LATCH** to do any of this, and
|
||||||
|
revision 14 assumed state that does not exist. The latch is common to
|
||||||
|
legacy `PanelPointer` and mapped `PanelPointerMapped`, because four of
|
||||||
|
the five authority losses are common. Per frontend it records:
|
||||||
|
|
||||||
|
- whether a **`Down(Left)` was ACCEPTED**, and for a terminal whether
|
||||||
|
it actually reached the child;
|
||||||
|
- the last valid coordinate and the encoding the child was told — a
|
||||||
|
release must match the press it terminates;
|
||||||
|
- enough identity to know the gesture is still the one that began.
|
||||||
|
|
||||||
|
It arms only **after** `Down(Left)` is accepted, updates its last valid
|
||||||
|
coordinate on an accepted drag, and is taken by either an ordinary
|
||||||
|
accepted `Up(Left)` or one authority-loss transition. `Move`, wheel,
|
||||||
|
context, and right/middle press events never arm it. `MouseKind` has
|
||||||
|
right and middle `Down` variants (`pmacs-protocol/src/message.rs:220`),
|
||||||
|
and the GPU panel path currently emits `Down(Right)` from the
|
||||||
|
press-only `apply_right_press` route
|
||||||
|
(`pmacs-gpu/src/main.rs:2997`, `:3029`). Treating every `Down` as held
|
||||||
|
would therefore manufacture a delayed release at the next
|
||||||
|
invalidation. If right/middle release semantics are added, they need
|
||||||
|
their own complete producer/replay contract rather than silently
|
||||||
|
borrowing this left-drag latch.
|
||||||
|
|
||||||
|
For a terminal, the accepted `Down(Left)` also fixes the gesture's
|
||||||
|
**domain** until it ends: child-reported, or local terminal selection.
|
||||||
|
Shift precedence and the child's reporting mode decide the domain at
|
||||||
|
the beginning; later modifier or mode changes do not switch a live
|
||||||
|
gesture into a path that never saw its Down. Thus a child-reported
|
||||||
|
gesture sends Drag/Up using the recorded reporting contract and exact
|
||||||
|
release encoding even if reporting turns off or Shift is pressed, and
|
||||||
|
a locally-started gesture stays local even if Shift is released or
|
||||||
|
reporting turns on. This does not freeze wheel precedence: a wheel is
|
||||||
|
self-contained and never arms the gesture latch.
|
||||||
|
|
||||||
|
Two consequences fall out of the latch and are ruled here:
|
||||||
|
|
||||||
|
- **A stale `Up` with no accepted `Down` is INERT.** It terminates
|
||||||
|
nothing, because nothing began; it must not synthesise a release or
|
||||||
|
clear another gesture's state.
|
||||||
|
- **Cancellation never reclaims a controller another frontend has since
|
||||||
|
taken.** The release settles *this* gesture; if ownership moved on,
|
||||||
|
the claim is not taken back. Reclaiming would make a stale gesture
|
||||||
|
steal a live one's terminal.
|
||||||
|
|
||||||
|
Detach has an additional ordering constraint: if the departing
|
||||||
|
frontend still owns the controller, its child's matching release is
|
||||||
|
delivered **before** normal detach teardown releases that claim and
|
||||||
|
discards the gesture record. If another frontend owns it, the release
|
||||||
|
still settles the recorded gesture without changing that newer claim.
|
||||||
|
Clearing controller/gesture state first would make the teardown path
|
||||||
|
look clean while leaving the child button down.
|
||||||
|
|
||||||
|
**A cancelled gesture is not a replayed one**: the release is delivered
|
||||||
|
for liveness, and no selection or scroll effect is applied from the
|
||||||
|
stale event.
|
||||||
|
|
||||||
|
##### Click-chain identity is independent
|
||||||
|
|
||||||
|
`MouseClickState` (`src/editor.rs:352`) is **not** the
|
||||||
|
accepted-gesture latch. An ordinary
|
||||||
|
`Up(Left)` consumes the gesture latch, while click state intentionally
|
||||||
|
survives long enough to recognize a second click. Consequently, only
|
||||||
|
clearing click state from the cancellation path is insufficient: after
|
||||||
|
a completed click there may be no live gesture to cancel.
|
||||||
|
|
||||||
|
The click chain therefore carries, or is cleared against, the complete
|
||||||
|
mapping identity: frontend, target window and buffer, panel epoch,
|
||||||
|
geometry epoch, and — for mapped sessions — mapping generation. Every
|
||||||
|
authority transition in the table above invalidates it independently
|
||||||
|
of the gesture latch. A completed click followed by a foreign edit and
|
||||||
|
the successor mapped frame, then a `Down` at the same cell inside the
|
||||||
|
multi-click interval, is a **single** click under the new mapping, not
|
||||||
|
a double-click on unrelated text. The same rule holds for the common
|
||||||
|
legacy identity transitions even though v24 has no mapping generation.
|
||||||
|
|
||||||
|
This extends rather than replaces Q#M5 and the existing TUI event
|
||||||
|
lifetime: only an **unmodified `Down(Left)`** writes click state;
|
||||||
|
Shift-Down never enters the double-click chain. Drag, Move, wheel and
|
||||||
|
right/context gestures clear it, while ordinary `Up(Left)` and a
|
||||||
|
same-identity repaint preserve it. Mapping invalidation is an
|
||||||
|
additional clear, not permission to couple click state to every frame
|
||||||
|
or to the accepted-gesture latch.
|
||||||
|
|
||||||
|
Semantic click chains are **keyed per frontend**, then carry the target
|
||||||
|
identity above. Merely storing `frontend_id` inside one global slot
|
||||||
|
prevents a false cross-frontend double-click but lets B's click erase
|
||||||
|
A's legitimate chain. The adjacent `window_drag` comment already names
|
||||||
|
that exact defect and uses a `HashMap<FrontendId, ..>` because
|
||||||
|
concurrent frontend gestures are legal (`src/editor.rs:298`). Detach
|
||||||
|
removes only the departing frontend's entry.
|
||||||
|
|
||||||
|
### Motion must stay coalesced
|
||||||
|
|
||||||
|
`PanelPointerMapped` carrying `Move` or `Drag` **takes the same
|
||||||
|
tail-coalescing tags as `PanelPointer`** (`pmacs-gpu/src/attach.rs:374`
|
||||||
|
onward). A new variant that fell through to the lossless default would
|
||||||
|
put pixel-rate motion on a bounded queue — the failure the coalescing
|
||||||
|
tags exist to prevent. `Down`/`Up`/wheel/context stay lossless and
|
||||||
|
ordered, as today.
|
||||||
|
|
||||||
|
### Pins ACCUMULATE
|
||||||
|
|
||||||
|
**They are not moved.** Revision 13 said the pin "moves to the previous
|
||||||
|
final variant", which would delete coverage of the shape it was
|
||||||
|
protecting.
|
||||||
|
|
||||||
|
| pin | covers |
|
||||||
|
|---|---|
|
||||||
|
| existing `FrontendEvent::PanelPointer` | retained, unchanged |
|
||||||
|
| **new**: exact `FrontendEvent::TextInput` bytes | the previous-final `FrontendEvent`, appended by 1a and never pinned |
|
||||||
|
| **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::Absent)` | the previous-final `PanelFramePayload` variant |
|
||||||
|
| **new**: exact `FrontendEvent::PanelPointerMapped` bytes, encoded **and decoded**, with distinct values in every same-typed field | the new discriminant and its frozen field order; a same-type field swap still round-trips against itself, so round-trip alone is insufficient |
|
||||||
|
| **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::PresentMapped { .. })`, encoded **and decoded** | the new payload discriminant, nesting and `frame`-then-generation order |
|
||||||
|
|
||||||
|
**Correction: the `PanelPointer` pin DOES exist**, at
|
||||||
|
`src/protocol.rs:1975`
|
||||||
|
(`panel_pointer_encoding_is_unchanged_by_the_v24_build`), and revision
|
||||||
|
14 said it could not be found. It is in the **root crate's** test
|
||||||
|
module rather than under `pmacs-protocol/` or `tests/`, which is
|
||||||
|
exactly where the search did not look. `message.rs:524` was right and
|
||||||
|
the doubt was mine. The pin is **retained**, and the four new pins are
|
||||||
|
added beside it. The previous-final pins catch an insertion on the
|
||||||
|
wrong side of the append boundary; the new-variant pins catch
|
||||||
|
reordering inside the variants themselves. Neither substitutes for the
|
||||||
|
other.
|
||||||
|
|
||||||
|
### Acceptance and mutation matrix
|
||||||
|
|
||||||
|
| # | row | mutation |
|
||||||
|
|---|---|---|
|
||||||
|
| G0a | the existing `PanelPointer` pin and the new exact previous-final `TextInput` and nested `PanelFrame(Absent)` pins all remain byte-identical | insert a wedge before each protected previous-final variant → only the pin whose discriminant moved fails |
|
||||||
|
| G0b | exact encode **and decode** bytes for `PanelPointerMapped` and nested `PresentMapped`, using unequal values for every adjacent same-typed field | reorder two `u64` fields or reverse `frame`/generation → a self-consistent round-trip stays green but the exact pin fails |
|
||||||
|
| G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit |
|
||||||
|
| G2 | every **changing** entry moves the key, one row each — and the composites are **split**: `view_top`; `view_left`; grid **rows** and **columns** separately; **fold-map content** and the owning frontend's **`fold_projection` policy** separately; **wrap** and **gutter geometry** separately; buffer content; terminal **projected content** and **scrollback movement** separately | omit that one entry from the key |
|
||||||
|
| G3 | every **stable** entry leaves it unchanged, one row each — focus; styling; selection-only; absorbed cursor motion; and the terminal controls **style, title, bell, tab-stop, cursor-only** | include that one entry → drags cancel on a repaint |
|
||||||
|
| G4a | a **selection repaint** preserves the generation | as G3 |
|
||||||
|
| G4b | after that repaint, an in-flight drag reaches real replay and **continues** | route only or clear the latch despite the stable generation → G4a stays green while the gesture dies |
|
||||||
|
| G5a | **mapped generation**: the key advancing with an accepted left gesture live raises cancellation before the successor frame, without waiting for another pointer event | make cancellation reactive to a refused event → nothing happens when the frame lands before the physical `Up` |
|
||||||
|
| G5b | **table-driven common authority-loss matrix**: `Absent`, panel-epoch change, buffer replacement, same-size geometry-epoch change, and detach each cancel a live left gesture; every entry has legacy-v24 and mapped-v25 legs, and the rebased effect matrix applies each to a document and a reporting terminal | omit that transition, arm the latch only for the mapped family, or cancel only one target kind → its named leg leaves the gesture live |
|
||||||
|
| G5c | an ordinary accepted `Down(Left)` → `Up(Left)` consumes the latch; a later authority loss emits **no second** document cleanup or child release, with v24 same-size geometry and v25 generation legs | leave the latch armed after `Up` → the later transition duplicates the release |
|
||||||
|
| G5d | a stale `Up` with **no accepted `Down`** is **inert** | synthesise a release anyway → an unpressed button is released |
|
||||||
|
| G5e | cancellation **does not reclaim** a controller another frontend has since taken | reclaim it → a stale gesture steals a live one's terminal |
|
||||||
|
| G5f | **producer reset matrix**: mapped generation and panel/buffer replacement clear only on atomic acceptance of the valid successor frame; `Absent` clears on acceptance; same-size geometry clears at local declaration; detach clears in teardown; structurally invalid, stale, zero-generation and wrong-family frames plus same-identity repaints retain | funnel every reset through frame arrival → geometry/detach stay armed; clear before validation or on every repaint → the negative controls lose a valid drag |
|
||||||
|
| G5g | only an accepted **`Down(Left)`** arms cancellation; table-driven Move, wheel, context and right/middle controls followed by an authority loss emit no synthetic release | arm on every `Down` or every accepted pointer event → the press-only right path or another non-gesture event manufactures a delayed release |
|
||||||
|
| G5h | **click-chain identity is independent**: complete a click, then independently drive `Absent`→Present, panel-epoch change, buffer replacement, same-size geometry change, and detach→reattach with no live gesture; a same-cell click inside the interval is single. A foreign content edit plus successor generation is the additional mapped-v25 leg | clear/re-key `MouseClickState` only while cancelling a held gesture, or omit one identity → unrelated text is treated as the second/third click |
|
||||||
|
| G5i | reporting-terminal detach delivers the recorded child release before dropping the old gesture/controller state, then leaves no controller claim owned by the detached frontend; a newer owner's claim remains untouched | tear down controller or gesture state before cancellation → no release bytes; reclaim/release unconditionally → the newer owner is disturbed |
|
||||||
|
| G5j | document cancellation has two legs: Down with no accepted Drag clears the empty selection; Down plus accepted Drag preserves the nonempty region's exact anchor/cursor while ending the gesture | clear every selection → the user's completed range disappears; preserve every selection → the empty armed anchor captures later motion |
|
||||||
|
| G5k | **terminal gesture-domain matrix**: child-reported Down then (a) reporting off or (b) Shift held before Drag/Up still yields child Drag/release in the recorded encoding and no local selection; local Down via (c) Shift or (d) reporting-off then the condition reverses still finishes locally with no child tail | re-evaluate Shift/reporting independently on every event → the child keeps a button down or receives an Up for a Down it never saw |
|
||||||
|
| G5l | **click-chain stability control**: complete a click, accept a same-generation style/selection repaint with every identity unchanged, then click the same cell inside the interval → it remains the second click | clear click state on every frame or couple it to the ordinary-Up gesture-latch clear → multi-click never survives a harmless repaint/release |
|
||||||
|
| G5m | **coincident invalidations cancel once**: changed-size geometry (geometry epoch + mapping generation) and panel/buffer replacement that also changes the mapping each produce exactly one document cleanup/child release | keep per-cause live flags or inspect without taking the shared latch → one transition emits duplicate tails |
|
||||||
|
| G5n | **click event-lifetime table**: unmodified left Down writes; Shift-Down does not; Drag, Move, wheel and right/context clear; ordinary left Up preserves | write on Shift-Down or omit one clear arm → Q#M5 or the successor click is misclassified; clear on Up → every double-click becomes two singles |
|
||||||
|
| G5o | **per-frontend click ownership**: A click, B click, then A same-cell click inside A's interval still produces A's double-click, while detaching B removes only B's chain | keep one global `Option<MouseClickState>` or clear the whole map on detach → B erases A's valid sequence |
|
||||||
|
| G5p | **per-frontend gesture ownership**: A and B hold gestures on distinct panels; B's panel-epoch change or detach cancels B exactly once while A's next valid Drag still applies | keep one global accepted-gesture latch or clear the whole map on one frontend transition → B's lifecycle cancels or erases A |
|
||||||
|
| G6a | **v24 positive control, OUTBOUND** — a v24 peer receives exactly legacy `Present`, never `PresentMapped` | send the mapped family or both → an old negotiated session receives a discriminant it did not declare |
|
||||||
|
| G6b | **v24 routing control, INBOUND** — a current legacy `Down(Left)` reaches the semantic dispatcher and performs the already-landed focus activation | gate v24 off or drop after decoding → the old peer's event is mapped but inert |
|
||||||
|
| G6c | **v24 effect control after replay rebase** — the same event performs its document/terminal replay effect | keep only the pre-existing focus path → G6b stays green while replay is dead |
|
||||||
|
| G7a | **v25 positive control, OUTBOUND** — a v25 peer receives exactly `PresentMapped`, carrying a live generation, never legacy `Present` | withhold `PresentMapped`, send legacy, or send both → the mapped frame contract is absent or ambiguous |
|
||||||
|
| G7b | **v25 routing control, INBOUND** — a current `PanelPointerMapped` carrying `Down(Left)` reaches the semantic dispatcher and performs the already-landed focus activation | ignore the mapped variant inbound → every refusal row still passes while nothing routes |
|
||||||
|
| G7c | **v25 effect control after replay rebase** — that current mapped event performs its document/terminal replay effect | stop after focus → G7b stays green while replay is dead |
|
||||||
|
| G8a | a **bare `PanelPointer` from a ≥ v25 session is REFUSED before mutation** | accept it → the bypass returns inbound |
|
||||||
|
| G8b | a **legacy `Present` at a ≥ v25 frontend is REJECTED**, atomically retaining its mapped frame, generation and pointer state | paint it or clear state first → the frontend hit-tests a band it cannot map or loses a valid gesture |
|
||||||
|
| G8c | a **`PanelPointerMapped` from a ≤ v24 session is REFUSED before mutation**, even though a peer compiled from this crate can encode it | accept it → inbound negotiation is treated as a sender convention rather than a gate |
|
||||||
|
| G8d | a **`PresentMapped` at a ≤ v24 frontend is REJECTED**, retaining the previous legacy frame atomically | paint it → outbound negotiation is treated as a sender convention rather than a gate |
|
||||||
|
| G8e | **authenticated-session authority**: in both inbound wrong-family cases, claiming another frontend whose session negotiated the desired family still refuses | gate by the payload's `frontend_id` lookup → the forged cross-session claim bypasses negotiation |
|
||||||
|
| G9a | **daemon emission**: identical cells across a generation change are still **emitted** | dedupe daemon-side across the change → the second gesture is suppressed |
|
||||||
|
| G9b | **frontend motion dedupe** suppresses a repeat within one generation and **re-arms across one** | (a) compare **only the cell**, never keying or resetting by generation → the first post-change motion is **eaten**; (b) reset on **every same-generation repaint** → repeated same-cell motion **returns** as pixel-rate traffic |
|
||||||
|
| G9c | **frontend acceptance**: a byte-identical `PanelFrame` with a higher generation still updates the high-water/current generation and resets pointer state atomically; it may report no visual redraw | return early on frame equality before applying generation → G9a passes because the message arrived, but the producer keeps echoing stale authority |
|
||||||
|
| G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown |
|
||||||
|
| G10a | a **structurally valid `PresentMapped` carrying generation ZERO is REJECTED**, atomically — previous frame and generation both retained | accept zero → a sender that never initialised the field disables the check for the whole session |
|
||||||
|
| G10b | a **`PanelPointerMapped` carrying generation ZERO is REFUSED** before mutation, including a coordinate-free document and local-terminal wheel | apply the wheel exemption before the nonzero check → the inbound opt-out returns through the exempt path |
|
||||||
|
| G10c | mapped-frame generation is nondecreasing: a higher generation may skip values, an equal-generation style/selection repaint is accepted, and a lower generation is rejected atomically; after `PresentMapped(9)` → `Absent`, a valid `PresentMapped(8)` is still rejected | require only nonzero, or clear the high-water mark on `Absent` → a delayed frame rolls the producer's authority backward |
|
||||||
|
| G11a | **generation exhaustion fails CLOSED, and does not leave a zombie band**: the daemon publishes `Absent`, clears input authority, and latches exhaustion for the session | fail open → the hole returns at the boundary; refuse input only → a stale panel stays painted and permanently inert; omit the latch → the next frame resurrects it |
|
||||||
|
| G11b | exhaustion with an accepted left gesture also performs the ordinary `Absent` cancellation effects **before** publishing it | publish `Absent` and discard authority without replay cancellation → G11a stays green while the gesture is orphaned |
|
||||||
|
| G12a | one mapped generation carrying **two document wheel ticks** applies both window-level scrolls even though the first changes the mapping | check every mapped coordinate indiscriminately → only the first tick applies |
|
||||||
|
| G12b | one mapped generation carrying **two non-reporting terminal wheel ticks** applies both local-scrollback moves even though the first changes the view anchor | remove the terminal coordinate-free exemption → only the first tick applies |
|
||||||
|
| G13a | mapped `Move` and `Drag` retain their respective tail-coalescing behaviour, including replacement by the latest coordinates/generation without crossing an intervening lossless event | let the new variant fall through to the default → pixel-rate traffic fills the bounded queue |
|
||||||
|
| G13b | mapped press, release and all wheel kinds remain lossless and ordered | coalesce every mapped pointer → multi-click, release or wheel ticks silently disappear |
|
||||||
|
| G14a | version tripwire: `PROTOCOL_VERSION == 25`, `SUPPORTED_PROTOCOL_VERSIONS` reaches it, `PANEL_MAPPING_MIN_VERSION == 25`, and `ADVERTISED_PROTOCOL_VERSION == 20` remains pinned | bump only the wire constant or move the advertised baseline → self-handshake fails or the permanent baseline drifts |
|
||||||
|
| G14b | the supported set accepts every version through `PROTOCOL_VERSION`, rejects `PROTOCOL_VERSION + 1`, and the literal boundary routes v24 legacy versus v25 mapped | end the set at 24 or express the boundary arithmetically against a moving ceiling → the current wire is unsupported or a later bump silently moves this feature |
|
||||||
|
| G15 | **TUI structural control** — the existing direct panel click/drag/wheel paths keep their effects with no generation token | move the generation requirement into shared replay instead of the authenticated semantic boundary → local TUI input is refused |
|
||||||
|
|
||||||
|
**G9b's mutation in revision 15 was a valid implementation, not a
|
||||||
|
defect.** Keying the dedupe by `(mapping_generation, coord)` preserves
|
||||||
|
same-generation suppression *and* naturally admits the first motion
|
||||||
|
under a new generation — it is one correct way to satisfy the row, so
|
||||||
|
requiring it to fail would have forbidden a good design. The two
|
||||||
|
mutations above are actual defects, one in each direction.
|
||||||
|
|
||||||
|
G10a and G10b are **independent rows in opposite directions**, not one
|
||||||
|
row seen twice: zero can arrive from either peer, and G10's atomicity
|
||||||
|
row exercises neither.
|
||||||
|
|
||||||
|
G2 and G3 are enumerated per entry deliberately: one row asserting "the
|
||||||
|
generation changed" cannot show **which** input moved it, and a key
|
||||||
|
that ignores `view_left` passes every row that only scrolls
|
||||||
|
vertically.
|
||||||
|
|
||||||
|
The mutation column states each row's **minimum required bite**, not a
|
||||||
|
prediction that no legitimate dependent can fail. Alternatives `(a)`,
|
||||||
|
`(b)`, or distinct omitted table arms are separate mutations; one
|
||||||
|
mega-mutation cannot establish their individual coverage. Record the
|
||||||
|
actual bite graph only after each compiling mutant runs. A proposed
|
||||||
|
mutation that is itself a valid implementation is replaced, as G9b
|
||||||
|
was, rather than used to reject the implementation.
|
||||||
|
|
||||||
|
### What this slice can witness, and what the replay lane owes
|
||||||
|
|
||||||
|
**Gesture EFFECTS cannot be proven on this branch, and earlier
|
||||||
|
revisions claimed some anyway.** Cancellation's observable outcomes —
|
||||||
|
the complete producer latch, the panel's selection, the click chain and
|
||||||
|
the child's release — plus drag continuation, replay positive controls
|
||||||
|
and two-tick scrolling are all reached through **panel replay, which
|
||||||
|
does not exist at this base**.
|
||||||
|
`gesture_last_content_cell` and the document/terminal replay live on
|
||||||
|
`panel-pointer-replay` (`pmacs-gpu/src/main.rs:2143` there); the same
|
||||||
|
struct here (`:2124`) has no such field, and
|
||||||
|
`dispatch_semantic_panel_pointer` still validates and returns.
|
||||||
|
|
||||||
|
So the obligations split, and the split is stated rather than left to
|
||||||
|
whoever runs the tests:
|
||||||
|
|
||||||
|
| owned by **this slice** | owed by the **rebased replay lane** |
|
||||||
|
|---|---|
|
||||||
|
| wire shapes, positions, pins and field order (G0), plus the bidirectional family gate (G6a–b, G7a–b, G8) | document cancellation effect — empty selection cleared without moving point |
|
||||||
|
| authoritative-key changing/stable inputs (G1–G4a) and the **mapping-generation** authority-loss signal before frame emission (G5a) | real stable-generation drag continuation, G5a's effects, and the v24/v25 replay-effect controls (G4b, G6c, G7c) |
|
||||||
|
| mapped-frame atomicity and generation-aware producer state (the mapped-frame legs of G5f) | complete producer reset lifecycle, including the geometry, `Absent`, identity and detach paths whose fields are introduced by replay (G5f) |
|
||||||
|
| motion coalescing, dedupe, invalid-frame atomicity, zero rejection, exhaustion state, version fallout and the TUI structural control (G9–G11a, G13–G15) | every common legacy/mapped cancellation transition and real gesture/click lifecycle (G5b–e, G5g, G5i–p), exhaustion cancellation (G11b), and both two-tick wheel effects (G12) |
|
||||||
|
|
||||||
|
Only the **new mapping-generation** transition stays here on purpose,
|
||||||
|
and its classification does **not** discharge the effect row. The
|
||||||
|
common invalidations do not get a dead classification helper on this
|
||||||
|
base; replay owns them where accepted gesture state exists. The rebased
|
||||||
|
lane must run the whole G5 matrix through real document and terminal
|
||||||
|
call sites. This is the same producer-emits/receiver-discards blind
|
||||||
|
spot that forced the replay prerequisite in the first place.
|
||||||
|
|
||||||
|
#### The latch substrate that lands here, and what it does not claim
|
||||||
|
|
||||||
|
G5a needs something to cancel, so the accepted-gesture latch itself —
|
||||||
|
`AcceptedPanelGesture` and the per-frontend slot on
|
||||||
|
`SemanticRenderState` — lands on this slice, armed from the daemon's
|
||||||
|
accepted inbound arms. Writing that code decides three things the
|
||||||
|
deferred rows later assert about, so those decisions are pinned here
|
||||||
|
under **substrate** names (`g5_substrate_*`) rather than under G5c,
|
||||||
|
G5d, G5g or G5p:
|
||||||
|
|
||||||
|
- which events arm and which consume (`update_accepted_gesture`);
|
||||||
|
- that an ordinary `Up` consumes **without** counting as a
|
||||||
|
cancellation;
|
||||||
|
- that the latch is per frontend, so one frontend's loss cannot empty
|
||||||
|
another's.
|
||||||
|
|
||||||
|
The framing rows keep their IDs on `panel-pointer-replay`, because each
|
||||||
|
of them asserts something about a synthetic release or a real drag
|
||||||
|
continuation that does not exist on this base. Two IDs claimed in two
|
||||||
|
branches is the merge hazard; substrate names avoid it.
|
||||||
|
|
||||||
|
Two consequences are recorded rather than fixed:
|
||||||
|
|
||||||
|
- **Cancellations are counted, not queued.** A queue of records is what
|
||||||
|
replay drains to deliver each release; landing it here would grow one
|
||||||
|
entry per cancelled drag with nothing ever draining it. A saturating
|
||||||
|
count is bounded and still separates a consume from a cancellation.
|
||||||
|
- **The other G5b transitions leave the latch armed on this base.** A
|
||||||
|
panel-epoch change, a buffer replacement, a same-size geometry change
|
||||||
|
and a detach all strand a live gesture — its release can never be
|
||||||
|
accepted. That is inert here, because nothing consumes the latch, and
|
||||||
|
becomes a defect exactly when replay gives it effects, in the branch
|
||||||
|
that owns the row. `Absent` is wired anyway, because
|
||||||
|
`publish_absent_panel` already clears input authority two lines
|
||||||
|
later and leaving that one out would be an inconsistency inside a
|
||||||
|
single function rather than a clean deferral.
|
||||||
|
|
||||||
|
#### What the slice actually landed, row by row
|
||||||
|
|
||||||
|
Recorded because the split table above states the *intent*, and a
|
||||||
|
reader auditing this later needs the *outcome* — including the two
|
||||||
|
places where writing the witnesses changed the design.
|
||||||
|
|
||||||
|
**Landed here:** G0 (wire shapes, positions, field order, accumulated
|
||||||
|
pins), G1–G4a (the authoritative key, document and terminal halves),
|
||||||
|
G5a plus the latch substrate, G6a–b/G7a–b/G8a–e (both directions, all
|
||||||
|
four wrong-family quadrants, forged identity), G9a/G9b/G9c, G10 and
|
||||||
|
G10a–c, G11a, G13a–b, G14a–b, G15.
|
||||||
|
|
||||||
|
**Still the replay lane's**, unchanged: G4b, G5b–e, G5g, G5i–p, G6c,
|
||||||
|
G7c, G11b, G12a–b.
|
||||||
|
|
||||||
|
Two corrections the mutation pass forced, both of which had passed
|
||||||
|
their first review as written:
|
||||||
|
|
||||||
|
- **G10a proved nothing about zero.** Asserted with a generation
|
||||||
|
already held, zero is also *lower* than it, so the nondecreasing
|
||||||
|
clause did the refusing and deleting the zero check left the row
|
||||||
|
green. Zero is only isolable before any authority exists — which is
|
||||||
|
also the case the framing names, a sender that never initialised the
|
||||||
|
field. Split into its own row with that setup.
|
||||||
|
- **G11a's exhaustion latch had no proven job.** The overflow path
|
||||||
|
already returns before storing the ceiling snapshot, so the next read
|
||||||
|
re-takes the changed arm and the band stays down without any latch.
|
||||||
|
Measured, the two are alternatives: only removing both resurrects the
|
||||||
|
band. The latch is kept, and now earns its place through `peek`,
|
||||||
|
which honours it so the peek and the authoritative read agree that an
|
||||||
|
exhausted session has no key rather than reporting the ceiling.
|
||||||
|
|
||||||
|
One witness-shape note, since it recurs: the two G10b rows call
|
||||||
|
`panel_mapping_is_current` directly. A wheel has **no
|
||||||
|
dispatcher-visible effect on this base** — a document panel focuses on
|
||||||
|
`Down` only, and no panel pointer coordinate is consumed anywhere — so
|
||||||
|
a row asserting focus for a wheel would be green whatever the gate did.
|
||||||
|
Each row carries a press leg alongside, which does have an effect, to
|
||||||
|
show the predicate is wired into the production arm rather than merely
|
||||||
|
correct in isolation.
|
||||||
|
|
||||||
|
### Coherence impact (`COHERENCE.md` §20)
|
||||||
|
|
||||||
|
- **Journey steps touched: 5** (document-panel editing and selection)
|
||||||
|
and **8** (terminal-panel interaction). **Neither changes grade** —
|
||||||
|
this hardens steps that already work rather than opening one.
|
||||||
|
Naming them matters even so: a reader auditing §20 by grade movement
|
||||||
|
alone would conclude this slice touches no journey at all.
|
||||||
|
- **Interaction islands: none added.** It **hardens an existing panel
|
||||||
|
island** — the same gestures, refused when their mapping is stale.
|
||||||
|
- **Config registry: no entry.** Nothing here is tunable; a generation
|
||||||
|
is an identity, not a threshold.
|
||||||
|
- **Background work: none started**, and no existing work changes
|
||||||
|
attribution. The key advances synchronously with the mutations that
|
||||||
|
move it.
|
||||||
|
|
||||||
|
Recorded because §20 asks for it per slice, and because "hardens an
|
||||||
|
existing island" is the kind of impact that gets omitted precisely
|
||||||
|
*because* the census does not move.
|
||||||
|
|
||||||
|
### Consequence for the GUI arc
|
||||||
|
|
||||||
|
This slice takes **v25**, so GUI arc **1e's `OpenTarget` moves to
|
||||||
|
v26** — corrected in `docs/gui-stage1-input-framing.md` **by this
|
||||||
|
slice**, because a canonical document that says v25 is false the moment
|
||||||
|
this lands. `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**.
|
||||||
|
|
||||||
## 6. Deferred (named)
|
## 6. Deferred (named)
|
||||||
|
|
||||||
Left / right / top side windows; multiple slots per side; **rehoming a leaf
|
Left / right / top side windows; multiple slots per side; **rehoming a leaf
|
||||||
|
|
|
||||||
|
|
@ -496,12 +496,39 @@ Stage 4; the lane touches no `pmacs-gpu` code at all.
|
||||||
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
|
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
|
||||||
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
|
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
|
||||||
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
|
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
|
||||||
| **status** | **FOURTH OCCURRENCE 2026-08-13 — causal status still UNRESOLVED. A NEW candidate mechanism is introduced by the observing lane and is NOT excluded (see below)** |
|
| **status** | **FIFTH OCCURRENCE 2026-08-15 — causal status still UNRESOLVED.** The fifth carries the strongest tree exclusion this row has had: a **documentation-only diff** |
|
||||||
| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below |
|
| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below |
|
||||||
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
|
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
|
||||||
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion |
|
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion |
|
||||||
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
|
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
|
||||||
|
|
||||||
|
**Fifth occurrence — panel cell-mapping generation (§5b) framing,
|
||||||
|
2026-08-15, local (Linux).** The `scripts/gate` **`gpu` step** again,
|
||||||
|
the same flavor as occurrence 2, inside a `--protocol` run
|
||||||
|
(log `20260815T072601Z-2230169`).
|
||||||
|
|
||||||
|
* **All three fragments verified** from the durable log, not a filtered
|
||||||
|
stream: `transient sequence must attach: Attach(Handshake(Io(Os {
|
||||||
|
code: 32, kind: BrokenPipe, message: "Broken pipe" })))`.
|
||||||
|
* **The line moved and that is not a fragment.** It is
|
||||||
|
`pmacs-gpu/src/attach.rs:1728` here against `:1680` in the earlier
|
||||||
|
occurrences — `attach.rs` has changed since, and this row's
|
||||||
|
convention already treats a `:LINE` suffix as occurrence-specific.
|
||||||
|
* **The tree exclusion is the strongest available in this row's
|
||||||
|
history: the branch's entire diff is DOCUMENTATION.** No Rust, no
|
||||||
|
wire surface, no `pmacs-gpu` file. Occurrences 1 and 4 argued
|
||||||
|
"unrelated lane"; this one cannot be related at all.
|
||||||
|
* **Rerun: isolated selector green** (`1 passed`, 0.01 s). Per this
|
||||||
|
file's rerun rule that establishes **intermittence only** and does
|
||||||
|
not exonerate the tree — though here there is no tree change to
|
||||||
|
exonerate.
|
||||||
|
|
||||||
|
**What five occurrences now support, stated carefully:** the failure is
|
||||||
|
**not lane-correlated**. It has appeared under three flavors across
|
||||||
|
five unrelated lanes, once on a diff that touches no code whatsoever.
|
||||||
|
That is evidence about *where the cause is not*, and still says nothing
|
||||||
|
about what it is. **The retirement condition is unchanged.**
|
||||||
|
|
||||||
**Not attributed to the observing lane**, and in neither case is the
|
**Not attributed to the observing lane**, and in neither case is the
|
||||||
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
|
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
|
||||||
wire surface, no protocol version change, and touched no file in
|
wire surface, no protocol version change, and touched no file in
|
||||||
|
|
@ -866,3 +893,39 @@ claim is the one a later reader would otherwise reach for.*
|
||||||
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible |
|
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible |
|
||||||
| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed |
|
| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed |
|
||||||
| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |
|
| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |
|
||||||
|
|
||||||
|
### U10 — the budget red ROTATES between two consecutive runs of one commit
|
||||||
|
|
||||||
|
Recorded during §5b review round 4, 2026-08-20. **Two consecutive
|
||||||
|
`scripts/gate` runs at the same commit with a clean worktree verified
|
||||||
|
at both ends of each run** — `70b334d`, `git status --porcelain` empty
|
||||||
|
before and after, both times. Each run was 15/16 green. Each red is a
|
||||||
|
**wall-clock budget assertion in a different step**, and **each is
|
||||||
|
green in the other run**.
|
||||||
|
|
||||||
|
| field | value |
|
||||||
|
| --- | --- |
|
||||||
|
| **run A** | log `20260820T155616Z-359755`, step `13-sweep` red: `dired_open_renders_10k_entries_under_200ms`, **263.961465ms against 200ms** (32% over). Step `15-sweep-crdt` **green** |
|
||||||
|
| **run B** | log `20260820T160806Z-578046`, step `15-sweep-crdt` red: `optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`, **1.044609ms against 1ms** (4.5% over), 2148 passed. Step `13-sweep` **green** |
|
||||||
|
| **required fragments** | `M8.2 spec: 10K entries must render within 200ms; took ` / `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms` |
|
||||||
|
| **status** | **two occurrences, neither reproduced; every selector green on rerun** |
|
||||||
|
| **isolated controls** | both green in an isolated rerun of their own selector at load average 9.34, through the gate's target directory — the same control shape U6 and U7 each used |
|
||||||
|
| **what IS established** | **the tree is excluded, as strongly as this repository can exclude it.** Not "the diff touches no render path" — the **same commit** produced a pass and a fail of each row, with the worktree verified clean at both ends of both runs. Neither failing path is touched by the branch under test (`src/optimistic.rs`, `tests/m8_2_acceptance.rs` and the dired paths are all absent from `git diff --name-only githubsucks/main...HEAD`) |
|
||||||
|
| **what is NOT** | **that load caused it.** Load was **not sampled during either failing step**. A 76.63 reading exists for run A but was taken later in the same run, while step 15 was compiling; run B began at 23.46. Neither figure measures the failing moment, and this row does not pretend otherwise |
|
||||||
|
| **rival CLOSED since U7** | the shared `CARGO_TARGET_DIR` confound U7 left "real and again unmeasured". Each worktree now gets its own gate target directory (`pmacs-mapping-gen-8cb089c8`); no sibling shared it. Excluded **for these occurrences only** — it says nothing about U7's |
|
||||||
|
| **machine context, NOT a cause** | 150 leaked `pmacs` daemons were live throughout, 1.9 GB resident, oldest ~6.3 days — the standing "Leaked daemons — NEEDS A LANE" item. Their instantaneous CPU sampled at ~0%. Recorded because it is true of the machine, **not** because anything here shows it mattered |
|
||||||
|
| **relation to U7 — its escalation rule, honoured and CUT BOTH WAYS** | U7 says "a future run that reds the **same** one of these twice is a different incident and should be judged as one." Run A redded `dired_open_renders_10k_entries_under_200ms`, which was U7's run-1 selector, so that selector has now redded twice, 11 days apart — filed here rather than appended to U7, as instructed. **But within this pair the selector ROTATED**, which is U7's own core signature, and run B's selector is U6's. The repeat and the rotation are both true, and this row asserts neither as the finding |
|
||||||
|
| **relation to U6** | run B's selector is one of U6's two, redding **without** `composition_overhead_under_ten_percent`. U6 instructs that one-without-the-other is a different incident; honoured here |
|
||||||
|
| **what this row does NOT assert** | a shared mechanism between the two rows, or any mechanism at all. **The signature is the rotation across an identical commit** — not either name |
|
||||||
|
|
||||||
|
**Why this family keeps recurring, stated plainly.** Every row in it is
|
||||||
|
a wall-clock budget asserted **inside a workspace-wide parallel test
|
||||||
|
run**. `cargo test --workspace` starts many test binaries at once, so
|
||||||
|
each budget competes with the rest of the sweep in **every** run,
|
||||||
|
including the ones that pass. A 4.5% overshoot on a 1ms budget is not a
|
||||||
|
signal about the code. **U9 already named the discriminating control**
|
||||||
|
— pin test-binary concurrency to 1 and separately load a lone `--lib`
|
||||||
|
binary — and it remains unrun. Until it runs, this family should not
|
||||||
|
consume another review round.
|
||||||
|
|
||||||
|
**Widening a budget is not the fix**, and R1 already rejected it.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ review overturned; revision 11 retracts it and P2 is implemented as
|
||||||
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
|
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
|
||||||
IMPLEMENTED**; 1a onward may begin from this document.
|
IMPLEMENTED**; 1a onward may begin from this document.
|
||||||
|
|
||||||
|
**v26, not v25 — corrected by the panel mapping-generation slice.**
|
||||||
|
That slice (`docs/bottom-panel-framing.md` §5b) takes **v25** for
|
||||||
|
`PanelFramePayload::PresentMapped` / `FrontendEvent::PanelPointerMapped`,
|
||||||
|
and it lands ahead of 1e because panel-pointer replay blocks 1b.
|
||||||
|
Protocol slices stay serialized; one was inserted in front.
|
||||||
|
`ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**.
|
||||||
|
|
||||||
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
|
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
|
||||||
the tip after 1-pre; it was originally taken at `a994f37`. Sections
|
the tip after 1-pre; it was originally taken at `a994f37`. Sections
|
||||||
other than §2 were written against `a994f37` and their *rulings* are
|
other than §2 were written against `a994f37` and their *rulings* are
|
||||||
|
|
@ -350,7 +357,7 @@ says nothing about glyphs or hit tests staying at scale 1.
|
||||||
| D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text |
|
| D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text |
|
||||||
| D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction |
|
| D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction |
|
||||||
|
|
||||||
### 1e — `OpenTarget` (v25)
|
### 1e — `OpenTarget` (v26)
|
||||||
|
|
||||||
| # | Contract | Witness (fails today because) | Mutation |
|
| # | Contract | Witness (fails today because) | Mutation |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|
@ -560,11 +567,11 @@ and flushed to the socket**. **Bound: 250 ms.**
|
||||||
|
|
||||||
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** |
|
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **floor** | **v24** | **v25**, after v24, serialized |
|
| **floor** | **v24** | **v26**, after v25's mapping generation, serialized |
|
||||||
| **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants |
|
| **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants |
|
||||||
| **byte pin** | frozen-byte fixture on the **previous final variant** | same |
|
| **byte pin** | frozen-byte fixture on the **previous final variant** | same |
|
||||||
| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below |
|
| **gate** | daemon accepts from `>= 24`; producer withholds below | **`>= 26`**; producer withholds below |
|
||||||
| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a `< 25` frontend cannot drop-open; nothing it already had degrades |
|
| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a **`< 26`** frontend cannot drop-open; nothing it already had degrades |
|
||||||
| **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap |
|
| **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap |
|
||||||
| **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` |
|
| **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,28 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
|
||||||
kind: MouseKind::Drag(_),
|
kind: MouseKind::Drag(_),
|
||||||
..
|
..
|
||||||
} => Some(7),
|
} => Some(7),
|
||||||
|
// §5b G13a — the v25 mapped family needs its OWN tags. Falling
|
||||||
|
// through to `None` would make pixel-rate mapped motion lossless
|
||||||
|
// and fill the bounded queue, which is `OUTBOX_MAX` events of
|
||||||
|
// hover before the socket closes.
|
||||||
|
//
|
||||||
|
// Tail-replacement takes the whole newer event, so the surviving
|
||||||
|
// event carries the latest coordinate AND the latest
|
||||||
|
// `mapping_generation` together — a collapsed run can never pair
|
||||||
|
// a new coordinate with a stale generation.
|
||||||
|
//
|
||||||
|
// Distinct from the legacy tags 6/7 rather than shared with
|
||||||
|
// them. A session negotiates one family and never mixes the two,
|
||||||
|
// so sharing would buy nothing and would let a family confusion
|
||||||
|
// collapse silently instead of showing up as two queued events.
|
||||||
|
FrontendEvent::PanelPointerMapped {
|
||||||
|
kind: MouseKind::Move,
|
||||||
|
..
|
||||||
|
} => Some(8),
|
||||||
|
FrontendEvent::PanelPointerMapped {
|
||||||
|
kind: MouseKind::Drag(_),
|
||||||
|
..
|
||||||
|
} => Some(9),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1098,6 +1120,36 @@ impl AttachClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5b — the MAPPED gesture, echoing the generation of the frame
|
||||||
|
/// this frontend is displaying.
|
||||||
|
///
|
||||||
|
/// A separate entry point rather than an `Option<u64>` on the one
|
||||||
|
/// above: the two variants are exclusive per session, and a
|
||||||
|
/// nullable field would let a caller send the mapped family with no
|
||||||
|
/// generation, which is the shape the daemon refuses.
|
||||||
|
#[allow(clippy::too_many_arguments)] // mirrors `PanelPointerMapped`'s wire shape exactly.
|
||||||
|
pub fn send_panel_pointer_mapped(
|
||||||
|
&self,
|
||||||
|
geometry_epoch: u64,
|
||||||
|
panel_epoch: u64,
|
||||||
|
buffer_id: BufferId,
|
||||||
|
coord: CellCoord,
|
||||||
|
kind: MouseKind,
|
||||||
|
mods: Modifiers,
|
||||||
|
mapping_generation: u64,
|
||||||
|
) -> Result<(), TransportError> {
|
||||||
|
self.send_event(FrontendEvent::PanelPointerMapped {
|
||||||
|
frontend_id: self.frontend_id,
|
||||||
|
geometry_epoch,
|
||||||
|
panel_epoch,
|
||||||
|
buffer_id,
|
||||||
|
coord,
|
||||||
|
kind,
|
||||||
|
mods,
|
||||||
|
mapping_generation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
|
/// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
|
||||||
/// hit-tested locally to a panel CELL. Callers gate on
|
/// hit-tested locally to a panel CELL. Callers gate on
|
||||||
/// [`Self::session_protocol_version`] `>= 21`.
|
/// [`Self::session_protocol_version`] `>= 21`.
|
||||||
|
|
@ -1452,6 +1504,115 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fe_panel_mapped(kind: MouseKind, row: u32, col: u32, generation: u64) -> FrontendEvent {
|
||||||
|
FrontendEvent::PanelPointerMapped {
|
||||||
|
frontend_id: FrontendId(1),
|
||||||
|
buffer_id: BufferId::from_raw(1),
|
||||||
|
coord: CellCoord::new(row, col),
|
||||||
|
kind,
|
||||||
|
mods: Modifiers::NONE,
|
||||||
|
geometry_epoch: 1,
|
||||||
|
panel_epoch: 1,
|
||||||
|
mapping_generation: generation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G13a/G13b — the mapped family keeps the legacy family's
|
||||||
|
/// coalescing contract: `Move` and `Drag` collapse to the latest
|
||||||
|
/// coordinate AND generation together; press, release and every
|
||||||
|
/// wheel kind stay lossless and ordered.
|
||||||
|
#[test]
|
||||||
|
fn mapped_panel_motion_coalesces_carrying_its_latest_generation() {
|
||||||
|
let mut ob = Outbox::new();
|
||||||
|
ob.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 0, 0, 4));
|
||||||
|
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 1, 4));
|
||||||
|
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 2, 5));
|
||||||
|
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 3, 6));
|
||||||
|
ob.enqueue(fe_panel_mapped(MouseKind::Up(MouseButton::Left), 0, 3, 6));
|
||||||
|
assert_eq!(
|
||||||
|
ob.queue.len(),
|
||||||
|
3,
|
||||||
|
"the drag run collapsed to one; without a tag of its own the \
|
||||||
|
mapped family is lossless and this is five"
|
||||||
|
);
|
||||||
|
// The whole event is replaced, so coordinate and generation
|
||||||
|
// advance TOGETHER. A tag that replaced only the coordinate
|
||||||
|
// would leave generation 4 on a cell measured under 6, which the
|
||||||
|
// daemon then refuses as stale.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&ob.queue[1],
|
||||||
|
FrontendEvent::PanelPointerMapped {
|
||||||
|
kind: MouseKind::Drag(MouseButton::Left),
|
||||||
|
coord: CellCoord { row: 0, col: 3 },
|
||||||
|
mapping_generation: 6,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"surviving drag: {:?}",
|
||||||
|
&ob.queue[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
// G13b — wheel ticks carry scroll DISTANCE. Two ticks in one
|
||||||
|
// generation must both survive the queue, or the panel scrolls
|
||||||
|
// once and stops.
|
||||||
|
let mut wheel = Outbox::new();
|
||||||
|
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
|
||||||
|
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
|
||||||
|
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 1, 1, 7));
|
||||||
|
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollLeft, 1, 1, 7));
|
||||||
|
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollRight, 1, 1, 7));
|
||||||
|
assert_eq!(wheel.queue.len(), 5, "every wheel kind stays lossless");
|
||||||
|
|
||||||
|
// Repeated presses are what the daemon reads as a multi-click,
|
||||||
|
// and a right press is the context gesture.
|
||||||
|
let mut presses = Outbox::new();
|
||||||
|
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
|
||||||
|
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
|
||||||
|
presses.enqueue(fe_panel_mapped(
|
||||||
|
MouseKind::Down(MouseButton::Right),
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
8,
|
||||||
|
));
|
||||||
|
assert_eq!(presses.queue.len(), 3, "presses stay lossless");
|
||||||
|
|
||||||
|
// Motion does not collapse ACROSS an intervening lossless event.
|
||||||
|
let mut mixed = Outbox::new();
|
||||||
|
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 1, 9));
|
||||||
|
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 2, 9));
|
||||||
|
assert_eq!(mixed.queue.len(), 1);
|
||||||
|
mixed.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 3, 2, 9));
|
||||||
|
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 3, 9));
|
||||||
|
assert_eq!(
|
||||||
|
mixed.queue.len(),
|
||||||
|
3,
|
||||||
|
"the wheel tick between them must not be jumped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And Move does not fold into a Drag tail: they are separate
|
||||||
|
// gestures, and one tag for both would turn a hover into part of
|
||||||
|
// a selection.
|
||||||
|
let mut kinds = Outbox::new();
|
||||||
|
kinds.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 4, 1, 9));
|
||||||
|
kinds.enqueue(fe_panel_mapped(MouseKind::Move, 4, 2, 9));
|
||||||
|
assert_eq!(kinds.queue.len(), 2);
|
||||||
|
|
||||||
|
// The two FAMILIES do not coalesce into each other either.
|
||||||
|
let mut families = Outbox::new();
|
||||||
|
families.enqueue(FrontendEvent::PanelPointer {
|
||||||
|
frontend_id: FrontendId(1),
|
||||||
|
buffer_id: BufferId::from_raw(1),
|
||||||
|
coord: CellCoord::new(5, 1),
|
||||||
|
kind: MouseKind::Move,
|
||||||
|
mods: Modifiers::NONE,
|
||||||
|
geometry_epoch: 1,
|
||||||
|
panel_epoch: 1,
|
||||||
|
});
|
||||||
|
families.enqueue(fe_panel_mapped(MouseKind::Move, 5, 2, 9));
|
||||||
|
assert_eq!(families.queue.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
/// Acceptance 34: terminal move/drag runs coalesce to the latest
|
/// Acceptance 34: terminal move/drag runs coalesce to the latest
|
||||||
/// cell, while press, release, and wheel stay lossless and ordered.
|
/// cell, while press, release, and wheel stay lossless and ordered.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -849,11 +849,50 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
||||||
facts.last_frame_text = frame_probe_text(frame);
|
facts.last_frame_text = frame_probe_text(frame);
|
||||||
facts.last_title.clone_from(&frame.title);
|
facts.last_title.clone_from(&frame.title);
|
||||||
}
|
}
|
||||||
match msg.as_ref() {
|
// §5b — a panel message is only NOTED here; its facts
|
||||||
InstanceMessage::PanelFrame(
|
// are read from the retained state AFTER
|
||||||
pmacs_protocol::panel::PanelFramePayload::Present(frame),
|
// `apply_attach_message` has ruled on it.
|
||||||
) => {
|
//
|
||||||
|
// Reading the raw payload was a false-positive
|
||||||
|
// generator: once one valid frame had landed, a
|
||||||
|
// REJECTED frame — wrong family, invalid, stale
|
||||||
|
// generation — still supplied the expected text while
|
||||||
|
// the retained old frame supplied the rendering. The
|
||||||
|
// probe would report the band showing something it does
|
||||||
|
// not show.
|
||||||
|
// The payload KIND is kept only to tell a real `Absent`
|
||||||
|
// apart from a refusal; every fact below comes from the
|
||||||
|
// accepted state.
|
||||||
|
let panel_absent_payload = matches!(
|
||||||
|
msg.as_ref(),
|
||||||
|
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent)
|
||||||
|
);
|
||||||
|
let panel_message = matches!(msg.as_ref(), InstanceMessage::PanelFrame(_));
|
||||||
|
// The COMPLETE accepted authority, both halves. An
|
||||||
|
// epoch/size triple is not enough: ordinary content,
|
||||||
|
// focus, cursor and mapping-generation updates all leave
|
||||||
|
// it unchanged, so accepted frames would go uncounted —
|
||||||
|
// including the identical-frame/higher-generation case
|
||||||
|
// this slice requires — and a fixture waiting for two
|
||||||
|
// frames would wait forever.
|
||||||
|
let authority_before = state
|
||||||
|
.panel
|
||||||
|
.presented()
|
||||||
|
.cloned()
|
||||||
|
.map(|frame| (frame, state.panel.mapping_generation));
|
||||||
|
|
||||||
|
state.apply_attach_message(*msg);
|
||||||
|
|
||||||
|
if panel_message {
|
||||||
|
let authority_after = state
|
||||||
|
.panel
|
||||||
|
.presented()
|
||||||
|
.cloned()
|
||||||
|
.map(|frame| (frame, state.panel.mapping_generation));
|
||||||
|
if authority_before != authority_after {
|
||||||
facts.panel_frames += 1;
|
facts.panel_frames += 1;
|
||||||
|
}
|
||||||
|
if let Some((frame, _)) = authority_after.as_ref() {
|
||||||
facts.panel_rows = frame.size.rows;
|
facts.panel_rows = frame.size.rows;
|
||||||
facts.panel_cols = frame.size.cols;
|
facts.panel_cols = frame.size.cols;
|
||||||
facts.panel_focused = frame.focused;
|
facts.panel_focused = frame.focused;
|
||||||
|
|
@ -864,12 +903,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
||||||
facts.panel_text_observed = true;
|
facts.panel_text_observed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InstanceMessage::PanelFrame(
|
// Absence is only ever reported for an actual
|
||||||
pmacs_protocol::panel::PanelFramePayload::Absent,
|
// `Absent`. Inferring it from `presented() == None`
|
||||||
) => facts.panel_absent_observed = true,
|
// turns a REFUSAL with nothing retained into "the
|
||||||
_ => {}
|
// daemon says there is no band", which is a
|
||||||
|
// different fact entirely.
|
||||||
|
if panel_absent_payload {
|
||||||
|
facts.panel_absent_observed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.apply_attach_message(*msg);
|
|
||||||
if is_snapshot {
|
if is_snapshot {
|
||||||
// The dual declaration: a byte viewport for a
|
// The dual declaration: a byte viewport for a
|
||||||
// document, a cell size for a terminal. The daemon
|
// document, a cell size for a terminal. The daemon
|
||||||
|
|
@ -1972,7 +2014,7 @@ struct State {
|
||||||
/// Whether the negotiated session carries the panel wire at all.
|
/// Whether the negotiated session carries the panel wire at all.
|
||||||
///
|
///
|
||||||
/// Keyed on the NEGOTIATED version, never the `Hello` baseline.
|
/// Keyed on the NEGOTIATED version, never the `Hello` baseline.
|
||||||
panel_wire: bool,
|
panel_family: PanelFamily,
|
||||||
/// Set when a font/scale transaction has invalidated the panel's
|
/// Set when a font/scale transaction has invalidated the panel's
|
||||||
/// geometry declaration, so the caller that owns the client knows to
|
/// geometry declaration, so the caller that owns the client knows to
|
||||||
/// re-declare under a `Metrics` trigger.
|
/// re-declare under a `Metrics` trigger.
|
||||||
|
|
@ -2084,9 +2126,60 @@ struct PanelDrag {
|
||||||
sent_rows: u32,
|
sent_rows: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5b — which panel family this session negotiated.
|
||||||
|
///
|
||||||
|
/// **One value, derived once from `session_protocol_version`, used for
|
||||||
|
/// BOTH payload acceptance and pointer production.** Deriving the two
|
||||||
|
/// independently is how a frontend ends up accepting one family while
|
||||||
|
/// producing the other, which is a bypass with extra steps: the peer
|
||||||
|
/// would be speaking v25 inbound and v24 outbound and neither side
|
||||||
|
/// could tell.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
enum PanelFamily {
|
||||||
|
/// Below `PANEL_MIN_VERSION`: no panel at all.
|
||||||
|
#[default]
|
||||||
|
Unsupported,
|
||||||
|
/// v21–v24: `Present` in, `PanelPointer` out.
|
||||||
|
Legacy,
|
||||||
|
/// v25 and later: `PresentMapped` in, `PanelPointerMapped` out.
|
||||||
|
Mapped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelFamily {
|
||||||
|
/// Classify a negotiated session version.
|
||||||
|
///
|
||||||
|
/// Read from the NEGOTIATED version, never the `Hello` baseline:
|
||||||
|
/// that stays at the compatibility floor forever, so reading it
|
||||||
|
/// would leave the band permanently dark.
|
||||||
|
fn from_session_version(session_protocol_version: u32) -> Self {
|
||||||
|
if session_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION {
|
||||||
|
Self::Mapped
|
||||||
|
} else if session_protocol_version >= PANEL_MIN_VERSION {
|
||||||
|
Self::Legacy
|
||||||
|
} else {
|
||||||
|
Self::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a panel exists on this session at all.
|
||||||
|
fn carries_panel(self) -> bool {
|
||||||
|
!matches!(self, Self::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16).
|
/// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct PanelBand {
|
struct PanelBand {
|
||||||
|
/// §5b — the mapping generation the retained frame was published
|
||||||
|
/// at, echoed by every `PanelPointerMapped` this frontend sends.
|
||||||
|
///
|
||||||
|
/// Moves ATOMICALLY with `frame`: a generation naming a frame that
|
||||||
|
/// was never installed would be authority for something the user
|
||||||
|
/// cannot see.
|
||||||
|
///
|
||||||
|
/// **Nondecreasing, and NOT cleared by `Absent`** — a frame delayed
|
||||||
|
/// across a hide must not roll this frontend's authority backward.
|
||||||
|
mapping_generation: Option<u64>,
|
||||||
/// The last **valid** frame received, retained until an authoritative
|
/// The last **valid** frame received, retained until an authoritative
|
||||||
/// `Absent`.
|
/// `Absent`.
|
||||||
///
|
///
|
||||||
|
|
@ -2131,6 +2224,15 @@ 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>,
|
||||||
|
/// §5b G9b — the mapping generation `last_pointer_cell` was measured
|
||||||
|
/// under, so the dedupe re-arms across a mapping change.
|
||||||
|
///
|
||||||
|
/// Comparing the cell alone EATS the first motion after the mapping
|
||||||
|
/// moves: the pointer has not travelled, but the cell now denotes
|
||||||
|
/// different text, and that is exactly the motion the daemon needs.
|
||||||
|
/// `None` for a legacy session, where it is constant and the dedupe
|
||||||
|
/// behaves as it always did.
|
||||||
|
last_pointer_generation: Option<u64>,
|
||||||
/// Whether the pointer is currently over the divider strip, which
|
/// Whether the pointer is currently over the divider strip, which
|
||||||
/// decides the `RowResize` cursor icon.
|
/// decides the `RowResize` cursor icon.
|
||||||
hover_divider: bool,
|
hover_divider: bool,
|
||||||
|
|
@ -2494,15 +2596,56 @@ impl App {
|
||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let Some(client) = self.attach_client.as_ref() else {
|
// §5b — production reads the SAME negotiated family as
|
||||||
return false;
|
// acceptance. Deriving them separately is how a frontend ends
|
||||||
|
// up accepting one family and producing the other.
|
||||||
|
self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ship one panel gesture in whichever family this session
|
||||||
|
/// negotiated (§5b).
|
||||||
|
///
|
||||||
|
/// The single place that chooses, so acceptance and production
|
||||||
|
/// cannot disagree. A mapped session with no retained generation
|
||||||
|
/// sends NOTHING rather than falling back to the legacy variant:
|
||||||
|
/// falling back would be the frontend half of the bypass, and the
|
||||||
|
/// daemon would refuse it anyway.
|
||||||
|
fn send_panel_gesture(
|
||||||
|
&self,
|
||||||
|
geometry_epoch: u64,
|
||||||
|
panel_epoch: u64,
|
||||||
|
buffer_id: pmacs_protocol::BufferId,
|
||||||
|
coord: CellCoord,
|
||||||
|
kind: ProtocolMouseKind,
|
||||||
|
mods: Modifiers,
|
||||||
|
) {
|
||||||
|
let (Some(client), Some(state)) = (self.attach_client.as_ref(), self.state.as_ref()) else {
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) =
|
let sent = match state.panel_family {
|
||||||
client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods)
|
PanelFamily::Unsupported => return,
|
||||||
{
|
PanelFamily::Legacy => {
|
||||||
|
client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods)
|
||||||
|
}
|
||||||
|
PanelFamily::Mapped => {
|
||||||
|
let Some(mapping_generation) = state.panel.mapping_generation else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
client.send_panel_pointer_mapped(
|
||||||
|
geometry_epoch,
|
||||||
|
panel_epoch,
|
||||||
|
buffer_id,
|
||||||
|
coord,
|
||||||
|
kind,
|
||||||
|
mods,
|
||||||
|
mapping_generation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = sent {
|
||||||
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
|
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
|
||||||
}
|
}
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ship a panel gesture at an explicitly chosen cell.
|
/// Ship a panel gesture at an explicitly chosen cell.
|
||||||
|
|
@ -2533,16 +2676,11 @@ impl App {
|
||||||
let Some(coord) = coord else {
|
let Some(coord) = coord else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if let Err(e) = client.send_panel_pointer(
|
// §5b — through the one family-aware sender, so this site
|
||||||
frame.geometry_epoch,
|
// cannot drift from the other into producing the wrong variant.
|
||||||
frame.panel_epoch,
|
let (geometry_epoch, panel_epoch, buffer_id) =
|
||||||
frame.buffer_id,
|
(frame.geometry_epoch, frame.panel_epoch, frame.buffer_id);
|
||||||
coord,
|
self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods);
|
||||||
kind,
|
|
||||||
mods,
|
|
||||||
) {
|
|
||||||
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5389,7 +5527,7 @@ impl State {
|
||||||
terminal: None,
|
terminal: None,
|
||||||
panel: PanelBand::default(),
|
panel: PanelBand::default(),
|
||||||
panel_text_buffers: Vec::new(),
|
panel_text_buffers: Vec::new(),
|
||||||
panel_wire: false,
|
panel_family: PanelFamily::Unsupported,
|
||||||
panel_metrics_changed: false,
|
panel_metrics_changed: false,
|
||||||
last_terminal_size_sent: None,
|
last_terminal_size_sent: None,
|
||||||
terminal_frame_error_latched: false,
|
terminal_frame_error_latched: false,
|
||||||
|
|
@ -6727,7 +6865,7 @@ impl State {
|
||||||
/// `Hello` baseline: the baseline stays at the compatibility floor
|
/// `Hello` baseline: the baseline stays at the compatibility floor
|
||||||
/// forever, so reading it here would leave the band permanently dark.
|
/// forever, so reading it here would leave the band permanently dark.
|
||||||
fn set_panel_wire(&mut self, session_protocol_version: u32) {
|
fn set_panel_wire(&mut self, session_protocol_version: u32) {
|
||||||
self.panel_wire = session_protocol_version >= PANEL_MIN_VERSION;
|
self.panel_family = PanelFamily::from_session_version(session_protocol_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The band inset the document boundary is computed from.
|
/// The band inset the document boundary is computed from.
|
||||||
|
|
@ -6845,7 +6983,7 @@ impl State {
|
||||||
/// the attach client, so the whole state machine — dedup, exhaustion,
|
/// the attach client, so the whole state machine — dedup, exhaustion,
|
||||||
/// the latch — is reachable without a daemon.
|
/// the latch — is reachable without a daemon.
|
||||||
fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> {
|
fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> {
|
||||||
if !self.panel_wire || self.panel.exhausted {
|
if !self.panel_family.carries_panel() || self.panel.exhausted {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (total, advance) = self.declared_cell_total();
|
let (total, advance) = self.declared_cell_total();
|
||||||
|
|
@ -6895,6 +7033,13 @@ impl State {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match payload {
|
match payload {
|
||||||
|
// §5b G8d — a mapped frame is accepted ONLY by a session
|
||||||
|
// that negotiated the mapped family. Retention is ATOMIC on
|
||||||
|
// refusal: nothing about the previous frame, its generation
|
||||||
|
// or the pointer state is touched on the way out.
|
||||||
|
PanelFramePayload::PresentMapped { .. } if self.panel_family != PanelFamily::Mapped => {
|
||||||
|
false
|
||||||
|
}
|
||||||
PanelFramePayload::Absent => {
|
PanelFramePayload::Absent => {
|
||||||
// Authoritative removal, and always safe. Note this does
|
// Authoritative removal, and always safe. Note this does
|
||||||
// NOT clear the geometry declaration: the frontend's frame
|
// NOT clear the geometry declaration: the frontend's frame
|
||||||
|
|
@ -6908,8 +7053,66 @@ 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.last_pointer_generation = None;
|
||||||
had
|
had
|
||||||
}
|
}
|
||||||
|
// §5b G8b — a mapped session REJECTS the legacy family
|
||||||
|
// rather than painting a band it cannot safely hit-test. A
|
||||||
|
// frame with no mapping identity is one whose cells cannot
|
||||||
|
// be inverted safely, so accepting it would reintroduce the
|
||||||
|
// hole from the receiving side.
|
||||||
|
// Gated on the POSITIVE family, not on "not mapped".
|
||||||
|
// `Unsupported` is neither, and a `!= Mapped` test let it
|
||||||
|
// through — a session below `PANEL_MIN_VERSION` accepting a
|
||||||
|
// band it never negotiated. `carries_panel()` guards the
|
||||||
|
// geometry declaration, not this seam.
|
||||||
|
PanelFramePayload::Present(_) if self.panel_family != PanelFamily::Legacy => false,
|
||||||
|
PanelFramePayload::PresentMapped {
|
||||||
|
frame,
|
||||||
|
mapping_generation,
|
||||||
|
} => {
|
||||||
|
// §5b — zero is the wire's uninitialised value and is
|
||||||
|
// refused like any mismatch, and the generation is
|
||||||
|
// NONDECREASING: a delayed lower frame must not roll
|
||||||
|
// this frontend's authority backward.
|
||||||
|
if mapping_generation == 0
|
||||||
|
|| self
|
||||||
|
.panel
|
||||||
|
.mapping_generation
|
||||||
|
.is_some_and(|held| mapping_generation < held)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(error) = frame.validate() {
|
||||||
|
eprintln!("pmacs-gpu: rejecting invalid mapped panel frame: {error}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A duplicate frame at the SAME generation does no work.
|
||||||
|
// A duplicate at a HIGHER one still updates authority:
|
||||||
|
// the daemon has re-keyed the mapping, and echoing the
|
||||||
|
// old generation would have every gesture refused.
|
||||||
|
if self.panel.frame.as_ref() == Some(&frame)
|
||||||
|
&& self.panel.mapping_generation == Some(mapping_generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// NOTE: the gesture-latch reset on an identity change is
|
||||||
|
// R-d, owned by `panel-pointer-replay`. It is not
|
||||||
|
// duplicated here — two branches resetting the same
|
||||||
|
// latch would conflict at the rebase and neither would
|
||||||
|
// own the contract.
|
||||||
|
let plan = TerminalPaintPlan::build_grid(
|
||||||
|
frame.size,
|
||||||
|
&frame.cells,
|
||||||
|
frame.cursor,
|
||||||
|
Self::terminal_palette(),
|
||||||
|
);
|
||||||
|
self.panel.frame = Some(frame);
|
||||||
|
self.panel.mapping_generation = Some(mapping_generation);
|
||||||
|
self.panel.plan = Some(plan);
|
||||||
|
self.rebuild_panel_text_buffers();
|
||||||
|
true
|
||||||
|
}
|
||||||
PanelFramePayload::Present(frame) => {
|
PanelFramePayload::Present(frame) => {
|
||||||
if let Err(error) = frame.validate() {
|
if let Err(error) = frame.validate() {
|
||||||
eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}");
|
eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}");
|
||||||
|
|
@ -7236,10 +7439,18 @@ impl State {
|
||||||
/// traffic and every one of those is a daemon-side gesture — the same
|
/// traffic and every one of those is a daemon-side gesture — the same
|
||||||
/// reason the terminal path dedupes.
|
/// reason the terminal path dedupes.
|
||||||
fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool {
|
fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool {
|
||||||
if self.panel.last_pointer_cell == Some(coord) {
|
// §5b G9b — keyed by generation as well as cell. Deliberately
|
||||||
|
// read here rather than reset from the frame path: resetting on
|
||||||
|
// every accepted repaint would re-arm within one generation and
|
||||||
|
// bring pixel-rate traffic back, and the dedupe would stop
|
||||||
|
// being a dedupe.
|
||||||
|
if self.panel.last_pointer_cell == Some(coord)
|
||||||
|
&& self.panel.last_pointer_generation == self.panel.mapping_generation
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.panel.last_pointer_cell = Some(coord);
|
self.panel.last_pointer_cell = Some(coord);
|
||||||
|
self.panel.last_pointer_generation = self.panel.mapping_generation;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7248,6 +7459,7 @@ 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;
|
||||||
|
self.panel.last_pointer_generation = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begin a divider drag at surface pixel `y`, if the pointer is on the
|
/// Begin a divider drag at surface pixel `y`, if the pointer is on the
|
||||||
|
|
@ -19809,6 +20021,460 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A mapped session with a real installed frame, and the frame.
|
||||||
|
///
|
||||||
|
/// The baseline is installed in the CORRECT family, so every
|
||||||
|
/// assertion below has real authority to preserve rather than a
|
||||||
|
/// `None` that was never set.
|
||||||
|
fn mapped_panel_session(rows: u32, generation: u64) -> Option<(State, PanelFrame)> {
|
||||||
|
let mut state = headless_or_skip(800, 600, "alpha")?;
|
||||||
|
let frame = present_panel(&mut state, rows);
|
||||||
|
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||||
|
let mut mapped = frame;
|
||||||
|
mapped.panel_epoch += 1;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: mapped.clone(),
|
||||||
|
mapping_generation: generation,
|
||||||
|
}),
|
||||||
|
"fixture: the mapped baseline must install"
|
||||||
|
);
|
||||||
|
Some((state, mapped))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G9b — motion dedupe suppresses a repeat WITHIN one generation
|
||||||
|
/// and RE-ARMS across one.
|
||||||
|
///
|
||||||
|
/// The pointer has not moved in either case. What differs is whether
|
||||||
|
/// the cell still denotes the same text: comparing the cell alone
|
||||||
|
/// eats the first motion after the mapping moves, which is precisely
|
||||||
|
/// the motion the daemon needs to re-anchor the gesture.
|
||||||
|
#[test]
|
||||||
|
fn g9b_motion_dedupe_is_keyed_by_generation_not_by_cell_alone() {
|
||||||
|
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cell = pmacs_protocol::CellCoord::new(1, 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.panel_motion_is_new(cell),
|
||||||
|
"the first motion at a cell is always new"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.panel_motion_is_new(cell),
|
||||||
|
"a repeat within one generation is suppressed — without this \
|
||||||
|
sub-cell motion becomes pixel-rate wire traffic"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A same-generation repaint: a different frame, same key. The
|
||||||
|
// dedupe must NOT re-arm, or the suppression above is defeated
|
||||||
|
// by any style or selection repaint.
|
||||||
|
let mut repaint = frame.clone();
|
||||||
|
repaint.focused = !frame.focused;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: repaint,
|
||||||
|
mapping_generation: 5,
|
||||||
|
}),
|
||||||
|
"fixture: the repaint must be accepted, or it proves nothing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.panel_motion_is_new(cell),
|
||||||
|
"a same-generation repaint does not re-arm the dedupe"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mapping MOVES. The pointer still has not.
|
||||||
|
let mut rekeyed = frame.clone();
|
||||||
|
rekeyed.focused = frame.focused;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: rekeyed,
|
||||||
|
mapping_generation: 6,
|
||||||
|
}),
|
||||||
|
"fixture: the re-keyed frame must be accepted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.panel_motion_is_new(cell),
|
||||||
|
"and across a generation the same cell IS new: it denotes \
|
||||||
|
different text now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G9c — a byte-identical frame at a HIGHER generation still
|
||||||
|
/// updates authority.
|
||||||
|
///
|
||||||
|
/// The visual result is nothing, which is what makes this easy to
|
||||||
|
/// get wrong: returning early on frame equality leaves the frontend
|
||||||
|
/// echoing a generation the daemon has already retired, and every
|
||||||
|
/// gesture it sends is then refused.
|
||||||
|
#[test]
|
||||||
|
fn g9c_an_identical_frame_at_a_higher_generation_still_updates_authority() {
|
||||||
|
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Byte-identical, and the same generation: no work at all.
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: frame.clone(),
|
||||||
|
mapping_generation: 5,
|
||||||
|
}),
|
||||||
|
"a true duplicate does nothing"
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(5));
|
||||||
|
|
||||||
|
// Byte-identical, HIGHER generation: authority moves.
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: frame.clone(),
|
||||||
|
mapping_generation: 6,
|
||||||
|
}),
|
||||||
|
"the daemon has re-keyed the mapping under an unchanged \
|
||||||
|
picture — a fold or an edit off-screen does exactly that"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation,
|
||||||
|
Some(6),
|
||||||
|
"echoing 5 from here would have every gesture refused"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.frame.as_ref(),
|
||||||
|
Some(&frame),
|
||||||
|
"and the frame is unchanged, because it was identical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G10 — a structurally invalid mapped frame is refused
|
||||||
|
/// ATOMICALLY: previous frame and previous generation both retained.
|
||||||
|
///
|
||||||
|
/// Updating one without the other leaves a valid generation naming a
|
||||||
|
/// frame that was never shown, which is the same stale-mapping hole
|
||||||
|
/// from the receiving side.
|
||||||
|
#[test]
|
||||||
|
fn g10_an_invalid_mapped_frame_is_refused_atomically() {
|
||||||
|
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.set_panel_pointer_held(true);
|
||||||
|
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
|
||||||
|
|
||||||
|
// G10 — structurally invalid: the cell count contradicts `size`.
|
||||||
|
let mut invalid = frame.clone();
|
||||||
|
invalid.cells.pop();
|
||||||
|
assert!(
|
||||||
|
invalid.validate().is_err(),
|
||||||
|
"fixture: the frame must actually be invalid"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: invalid,
|
||||||
|
mapping_generation: 9,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.frame.as_ref(), Some(&frame), "frame retained");
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation,
|
||||||
|
Some(5),
|
||||||
|
"and its generation with it — a valid 9 here would name a \
|
||||||
|
frame this frontend never received"
|
||||||
|
);
|
||||||
|
|
||||||
|
// G10a — structurally VALID, generation zero. Zero is what a
|
||||||
|
// sender that never initialised the field produces; accepting it
|
||||||
|
// once disables the check for the session.
|
||||||
|
let mut zeroed = frame.clone();
|
||||||
|
zeroed.panel_epoch += 7;
|
||||||
|
assert!(
|
||||||
|
zeroed.validate().is_ok(),
|
||||||
|
"fixture: this one is structurally fine, so the refusal is \
|
||||||
|
about the zero"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: zeroed,
|
||||||
|
mapping_generation: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.frame.as_ref(), Some(&frame));
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(5));
|
||||||
|
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.last_pointer_cell,
|
||||||
|
Some(pmacs_protocol::CellCoord::new(1, 1)),
|
||||||
|
"nor discards its dedupe baseline"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G10a — a ZERO generation is refused when NO authority is
|
||||||
|
/// held yet.
|
||||||
|
///
|
||||||
|
/// This is the only setup that isolates the zero check. With a
|
||||||
|
/// generation already held, zero is also *lower* than it, so the
|
||||||
|
/// nondecreasing clause refuses the frame and a row built that way
|
||||||
|
/// stays green with the zero check deleted — measured, not assumed.
|
||||||
|
///
|
||||||
|
/// The case is the one the framing names: a sender that never
|
||||||
|
/// initialised the field. Accepting it once records `Some(0)`, and
|
||||||
|
/// from there no later frame is ever "lower", so the check is
|
||||||
|
/// disabled for the whole session.
|
||||||
|
#[test]
|
||||||
|
fn g10a_a_zero_generation_is_refused_before_any_authority_is_held() {
|
||||||
|
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// A legacy frame first, so there is a real retained band to
|
||||||
|
// preserve, then the family switch. `mapping_generation` is
|
||||||
|
// genuinely `None` here — that is the state under test, not an
|
||||||
|
// accident of the fixture.
|
||||||
|
let baseline = present_panel(&mut state, 4);
|
||||||
|
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation, None,
|
||||||
|
"fixture: no authority held yet"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut zeroed = baseline.clone();
|
||||||
|
zeroed.panel_epoch += 1;
|
||||||
|
assert!(
|
||||||
|
zeroed.validate().is_ok(),
|
||||||
|
"fixture: structurally fine, so the refusal is about the zero"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: zeroed,
|
||||||
|
mapping_generation: 0,
|
||||||
|
}),
|
||||||
|
"zero is the wire's uninitialised value and is never authority"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation, None,
|
||||||
|
"and nothing is recorded — `Some(0)` would make every later \
|
||||||
|
frame non-lower and disable the check for the session"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.frame.as_ref().map(|f| f.panel_epoch),
|
||||||
|
Some(baseline.panel_epoch),
|
||||||
|
"the retained band survives the refusal"
|
||||||
|
);
|
||||||
|
|
||||||
|
// POSITIVE CONTROL — the same frame with a real generation
|
||||||
|
// installs, so the refusal is about the zero and not about the
|
||||||
|
// family switch.
|
||||||
|
let mut real = baseline.clone();
|
||||||
|
real.panel_epoch += 2;
|
||||||
|
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: real,
|
||||||
|
mapping_generation: 1,
|
||||||
|
}));
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G10c — the generation is NONDECREASING, and `Absent` does not
|
||||||
|
/// erase the high-water mark.
|
||||||
|
///
|
||||||
|
/// Values may SKIP: the daemon advances the key on mapping changes,
|
||||||
|
/// not on frames, so a frontend that misses two repaints legitimately
|
||||||
|
/// jumps. What may never happen is going backward — a delayed frame
|
||||||
|
/// must not roll this frontend's authority back to a mapping that is
|
||||||
|
/// gone.
|
||||||
|
#[test]
|
||||||
|
fn g10c_generation_is_nondecreasing_and_survives_absent() {
|
||||||
|
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A SKIP forward is fine.
|
||||||
|
let mut skipped = frame.clone();
|
||||||
|
skipped.panel_epoch += 1;
|
||||||
|
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: skipped.clone(),
|
||||||
|
mapping_generation: 9,
|
||||||
|
}));
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(9));
|
||||||
|
|
||||||
|
// A LOWER one is refused, atomically.
|
||||||
|
let mut delayed = frame.clone();
|
||||||
|
delayed.panel_epoch += 2;
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: delayed.clone(),
|
||||||
|
mapping_generation: 8,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.frame.as_ref().map(|f| f.panel_epoch),
|
||||||
|
Some(skipped.panel_epoch),
|
||||||
|
"the retained frame survives"
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(9));
|
||||||
|
|
||||||
|
// An EQUAL generation with a different frame is accepted: a
|
||||||
|
// style or selection repaint does not move the mapping, and
|
||||||
|
// refusing it would freeze the band.
|
||||||
|
let mut repaint = skipped.clone();
|
||||||
|
repaint.focused = !skipped.focused;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: repaint,
|
||||||
|
mapping_generation: 9,
|
||||||
|
}),
|
||||||
|
"an equal-generation repaint is valid"
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.mapping_generation, Some(9));
|
||||||
|
|
||||||
|
// `Absent` hides the band but must NOT erase the high-water
|
||||||
|
// mark: a frame delayed across the hide would otherwise come
|
||||||
|
// back believed.
|
||||||
|
assert!(state.apply_panel_payload(PanelFramePayload::Absent));
|
||||||
|
assert!(state.panel.presented().is_none(), "the band is down");
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation,
|
||||||
|
Some(9),
|
||||||
|
"and the high-water mark stands"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: delayed,
|
||||||
|
mapping_generation: 8,
|
||||||
|
}),
|
||||||
|
"so the delayed frame is still refused after the hide"
|
||||||
|
);
|
||||||
|
assert!(state.panel.presented().is_none(), "and stays down");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G8b — a MAPPED frontend refuses the legacy family, and the
|
||||||
|
/// refusal is ATOMIC.
|
||||||
|
///
|
||||||
|
/// The baseline is accepted in the CORRECT family first, so there is
|
||||||
|
/// real authority to preserve: a row that installs a legacy frame
|
||||||
|
/// and then switches to mapped has `mapping_generation == None`
|
||||||
|
/// throughout, and asserting it stayed `None` proves nothing. The
|
||||||
|
/// pointer latches are primed for the same reason — untouched state
|
||||||
|
/// that was never set is not evidence of atomicity.
|
||||||
|
#[test]
|
||||||
|
fn g8b_a_mapped_frontend_refuses_legacy_atomically() {
|
||||||
|
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let frame = present_panel(&mut state, 4);
|
||||||
|
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||||
|
|
||||||
|
// A correct-family baseline, so the retained authority is real.
|
||||||
|
let mut baseline = frame.clone();
|
||||||
|
baseline.panel_epoch = frame.panel_epoch + 1;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: baseline.clone(),
|
||||||
|
mapping_generation: 5,
|
||||||
|
}),
|
||||||
|
"fixture: the mapped baseline must install"
|
||||||
|
);
|
||||||
|
state.set_panel_pointer_held(true);
|
||||||
|
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
|
||||||
|
|
||||||
|
let mut intruder = frame.clone();
|
||||||
|
intruder.panel_epoch = frame.panel_epoch + 2;
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::Present(intruder)),
|
||||||
|
"a v25 frontend must not paint a band it cannot safely \
|
||||||
|
hit-test — the frame carries no mapping identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.frame.as_ref().map(|f| f.panel_epoch),
|
||||||
|
Some(baseline.panel_epoch),
|
||||||
|
"the retained frame survives the refusal"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation,
|
||||||
|
Some(5),
|
||||||
|
"and so does its authority — this is the half a switched \
|
||||||
|
baseline could not have shown"
|
||||||
|
);
|
||||||
|
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.last_pointer_cell,
|
||||||
|
Some(pmacs_protocol::CellCoord::new(1, 1)),
|
||||||
|
"nor discards its dedupe baseline"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G8d — a LEGACY frontend refuses the mapped family, atomically.
|
||||||
|
///
|
||||||
|
/// An independent state, not a continuation of G8b: sharing one
|
||||||
|
/// would let the second direction inherit whatever the first left
|
||||||
|
/// behind.
|
||||||
|
#[test]
|
||||||
|
fn g8d_a_legacy_frontend_refuses_mapped_atomically() {
|
||||||
|
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let frame = present_panel(&mut state, 4);
|
||||||
|
state.set_panel_wire(PANEL_MIN_VERSION);
|
||||||
|
|
||||||
|
let mut baseline = frame.clone();
|
||||||
|
baseline.panel_epoch = frame.panel_epoch + 1;
|
||||||
|
assert!(
|
||||||
|
state.apply_panel_payload(PanelFramePayload::Present(baseline.clone())),
|
||||||
|
"fixture: the legacy baseline must install"
|
||||||
|
);
|
||||||
|
state.set_panel_pointer_held(true);
|
||||||
|
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(2, 2));
|
||||||
|
|
||||||
|
let mut intruder = frame.clone();
|
||||||
|
intruder.panel_epoch = frame.panel_epoch + 2;
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: intruder,
|
||||||
|
mapping_generation: 9,
|
||||||
|
}),
|
||||||
|
"a v24 frontend must reject a family it never negotiated"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.frame.as_ref().map(|f| f.panel_epoch),
|
||||||
|
Some(baseline.panel_epoch),
|
||||||
|
"the retained frame survives"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.mapping_generation, None,
|
||||||
|
"and no authority is acquired from a refused frame"
|
||||||
|
);
|
||||||
|
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
|
||||||
|
assert_eq!(
|
||||||
|
state.panel.last_pointer_cell,
|
||||||
|
Some(pmacs_protocol::CellCoord::new(2, 2))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — an UNSUPPORTED session accepts neither family.
|
||||||
|
///
|
||||||
|
/// The case a `!= Mapped` gate let through: `Unsupported` is neither
|
||||||
|
/// family, so a negative test admitted a band the session never
|
||||||
|
/// negotiated.
|
||||||
|
#[test]
|
||||||
|
fn an_unsupported_session_accepts_neither_panel_family() {
|
||||||
|
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let frame = present_panel(&mut state, 4);
|
||||||
|
state.set_panel_wire(PANEL_MIN_VERSION - 1);
|
||||||
|
|
||||||
|
let mut orphan = frame.clone();
|
||||||
|
orphan.panel_epoch = frame.panel_epoch + 1;
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::Present(orphan.clone())),
|
||||||
|
"a sub-panel session has no band at all"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
|
||||||
|
frame: orphan,
|
||||||
|
mapping_generation: 9,
|
||||||
|
}),
|
||||||
|
"in either family"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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]
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ pub use message::{
|
||||||
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
|
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
|
||||||
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
||||||
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
NegotiatedCapabilities, PANEL_MAPPING_MIN_VERSION, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
||||||
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace,
|
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace,
|
||||||
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
||||||
|
|
|
||||||
|
|
@ -550,8 +550,75 @@ pub enum FrontendEvent {
|
||||||
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
|
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
/// Bottom panel §5b (protocol v25): a panel-cell gesture that also
|
||||||
|
/// names the **inverse mapping** the frontend was looking at.
|
||||||
|
///
|
||||||
|
/// **APPENDED LAST, after [`Self::TextInput`].** "Beside
|
||||||
|
/// `PanelPointer`" would be adjacent insertion, which shifts every
|
||||||
|
/// discriminant below it; postcard encodes variants positionally, so
|
||||||
|
/// only the end of the enum is safe.
|
||||||
|
///
|
||||||
|
/// **Why the mapping and not the epochs.** A cell must be inverted
|
||||||
|
/// to a byte, and the existing ladder cannot tell whether the text
|
||||||
|
/// under that cell still means what it meant: `buffer_id` catches an
|
||||||
|
/// A→B replacement, `panel_epoch` catches close/reopen,
|
||||||
|
/// `geometry_epoch` catches a declaration race, and **nothing
|
||||||
|
/// catches a foreign edit** — which moves the mapping while every
|
||||||
|
/// epoch holds. `mapping_generation` closes exactly that.
|
||||||
|
///
|
||||||
|
/// **Not a per-frame token.** A token moving with each frame would
|
||||||
|
/// invalidate a gesture on every repaint and break drags outright,
|
||||||
|
/// which is why [`crate::panel::PanelFrame::panel_epoch`] is stable
|
||||||
|
/// across ordinary frames. This identifies the *mapping*: it moves
|
||||||
|
/// with the viewport, folds, wrap and gutter geometry, buffer
|
||||||
|
/// content and terminal topology, and holds across focus, styling,
|
||||||
|
/// selection-only repaints and absorbed cursor motion.
|
||||||
|
///
|
||||||
|
/// **Gated bilaterally on [`PANEL_MAPPING_MIN_VERSION`]**, and the
|
||||||
|
/// gate is a REFUSAL rather than a fallback: a `>= v25` session
|
||||||
|
/// sending the bare [`Self::PanelPointer`] is refused, and a
|
||||||
|
/// `<= v24` session sending this variant is refused too. Treating
|
||||||
|
/// either as "handled under the other family's semantics" would
|
||||||
|
/// leave the hole reachable by choosing a discriminant.
|
||||||
|
PanelPointerMapped {
|
||||||
|
/// Which frontend produced the gesture (untrusted, as above).
|
||||||
|
frontend_id: FrontendId,
|
||||||
|
/// Geometry declaration this gesture was hit-tested against.
|
||||||
|
geometry_epoch: u64,
|
||||||
|
/// Presentation identity this gesture addresses.
|
||||||
|
panel_epoch: u64,
|
||||||
|
/// Buffer the frontend believed the panel was displaying.
|
||||||
|
buffer_id: crate::BufferId,
|
||||||
|
/// Cell the pointer is over, within the declared panel grid.
|
||||||
|
coord: CellCoord,
|
||||||
|
/// Which gesture step this is.
|
||||||
|
kind: MouseKind,
|
||||||
|
/// Modifiers held during the gesture.
|
||||||
|
mods: Modifiers,
|
||||||
|
/// The inverse mapping the frontend was displaying.
|
||||||
|
///
|
||||||
|
/// **Zero is invalid** and is refused like a mismatch: it is
|
||||||
|
/// what a default-constructed or half-initialised sender
|
||||||
|
/// produces, so accepting it would let a peer opt out of the
|
||||||
|
/// check by sending nothing. A live key starts at 1.
|
||||||
|
///
|
||||||
|
/// Appended **last within the variant**, so this mirrors
|
||||||
|
/// [`Self::PanelPointer`]'s field order exactly and the two stay
|
||||||
|
/// diffable by eye.
|
||||||
|
mapping_generation: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First protocol version carrying the **mapped panel family** —
|
||||||
|
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||||
|
/// [`crate::panel::PanelFramePayload::PresentMapped`].
|
||||||
|
///
|
||||||
|
/// Read as a constant rather than compared against a literal, and
|
||||||
|
/// **not** derived from [`PROTOCOL_VERSION`]: expressing the boundary
|
||||||
|
/// against a moving ceiling would silently drag this feature forward
|
||||||
|
/// on the next bump.
|
||||||
|
pub const PANEL_MAPPING_MIN_VERSION: u32 = 25;
|
||||||
|
|
||||||
/// First protocol version carrying [`FrontendEvent::TextInput`].
|
/// First protocol version carrying [`FrontendEvent::TextInput`].
|
||||||
///
|
///
|
||||||
/// A frontend older than this **retains its existing `Key` behaviour and
|
/// A frontend older than this **retains its existing `Key` behaviour and
|
||||||
|
|
@ -617,7 +684,8 @@ impl FrontendEvent {
|
||||||
| Self::FrontendCellGeometry { frontend_id, .. }
|
| Self::FrontendCellGeometry { frontend_id, .. }
|
||||||
| Self::PanelResizeRows { frontend_id, .. }
|
| Self::PanelResizeRows { frontend_id, .. }
|
||||||
| Self::PanelPointer { frontend_id, .. }
|
| Self::PanelPointer { frontend_id, .. }
|
||||||
| Self::TextInput { frontend_id, .. } => *frontend_id,
|
| Self::TextInput { frontend_id, .. }
|
||||||
|
| Self::PanelPointerMapped { frontend_id, .. } => *frontend_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1920,7 +1988,18 @@ pub enum ResourceBody {
|
||||||
/// negotiated version. What is unusual here is only that the extension
|
/// negotiated version. What is unusual here is only that the extension
|
||||||
/// is **inbound-only** — there is no outbound counterpart to withhold,
|
/// is **inbound-only** — there is no outbound counterpart to withhold,
|
||||||
/// so the receiver check is the whole of the daemon's half.
|
/// so the receiver check is the whole of the daemon's half.
|
||||||
pub const PROTOCOL_VERSION: u32 = 24;
|
///
|
||||||
|
/// Bottom panel §5b: bumped 24 → 25 for the mapped panel family,
|
||||||
|
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||||
|
/// [`crate::panel::PanelFramePayload::PresentMapped`]. Both variants are
|
||||||
|
/// appended after their enum's v24 final variant. The family is gated in
|
||||||
|
/// both directions from the authenticated session's negotiated version:
|
||||||
|
/// v6–v24 sessions use only `PanelPointer`/`Present`, while v25 sessions
|
||||||
|
/// use only the mapped pair; choosing the other discriminant is refused,
|
||||||
|
/// not reinterpreted. [`crate::panel::PanelFramePayload::Absent`] remains
|
||||||
|
/// common because hiding a band carries no mapping. The advertised
|
||||||
|
/// baseline remains 20.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 25;
|
||||||
|
|
||||||
/// Protocol version placed in the daemon's server-first [`Hello`].
|
/// Protocol version placed in the daemon's server-first [`Hello`].
|
||||||
///
|
///
|
||||||
|
|
@ -2108,8 +2187,16 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
|
||||||
/// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23
|
/// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23
|
||||||
/// frontend negotiates v23, never sends the variant, and keeps today's
|
/// frontend negotiates v23, never sends the variant, and keeps today's
|
||||||
/// first-scalar behaviour.
|
/// first-scalar behaviour.
|
||||||
|
///
|
||||||
|
/// Bottom panel §5b: extended to `[6, ..., 25]` for
|
||||||
|
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||||
|
/// [`crate::panel::PanelFramePayload::PresentMapped`]. The authenticated
|
||||||
|
/// negotiated version selects exactly one legacy/mapped family in each
|
||||||
|
/// direction; a peer compiled from this crate can encode either variant,
|
||||||
|
/// so receiver-side refusal is load-bearing. `Absent` is common, and
|
||||||
|
/// [`ADVERTISED_PROTOCOL_VERSION`] remains 20.
|
||||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
||||||
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
|
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,29 @@ pub enum PanelFramePayload {
|
||||||
Present(PanelFrame),
|
Present(PanelFrame),
|
||||||
/// No panel is visible; clear any retained frame.
|
/// No panel is visible; clear any retained frame.
|
||||||
Absent,
|
Absent,
|
||||||
|
/// §5b (protocol v25): a visible panel **plus the identity of the
|
||||||
|
/// inverse mapping** it was painted from.
|
||||||
|
///
|
||||||
|
/// **APPENDED AFTER `Absent`, not beside `Present`.** postcard
|
||||||
|
/// encodes variants positionally, so inserting adjacent to
|
||||||
|
/// `Present` would shift `Absent`'s discriminant and silently
|
||||||
|
/// re-interpret every older peer's "hide the band" as something
|
||||||
|
/// else.
|
||||||
|
///
|
||||||
|
/// `Absent` is deliberately **shared by both families** — hiding a
|
||||||
|
/// band carries no mapping, so it needs no mapped twin.
|
||||||
|
PresentMapped {
|
||||||
|
/// The frame, reused whole and unchanged.
|
||||||
|
frame: PanelFrame,
|
||||||
|
/// Identity of the inverse mapping this frame was painted from.
|
||||||
|
///
|
||||||
|
/// A `PanelPointerMapped` echoes it, and the daemon refuses a
|
||||||
|
/// gesture whose echo no longer matches. **Zero is invalid**;
|
||||||
|
/// a live key starts at 1 and is **nondecreasing**, including
|
||||||
|
/// across `Absent` — a delayed lower frame must not roll the
|
||||||
|
/// producer's authority backward.
|
||||||
|
mapping_generation: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a [`PanelFrame`] is not structurally valid.
|
/// Why a [`PanelFrame`] is not structurally valid.
|
||||||
|
|
|
||||||
1431
src/daemon.rs
1431
src/daemon.rs
File diff suppressed because it is too large
Load Diff
135
src/editor.rs
135
src/editor.rs
|
|
@ -375,6 +375,76 @@ const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
|
||||||
/// column and clobbers no information.
|
/// column and clobbers no information.
|
||||||
const DIVIDER_HANDLE_GLYPH: char = '⇕';
|
const DIVIDER_HANDLE_GLYPH: char = '⇕';
|
||||||
|
|
||||||
|
/// The panel's **inverse mapping**, captured exactly (§5b,
|
||||||
|
/// Q#BP-R3).
|
||||||
|
///
|
||||||
|
/// A STRUCT compared structurally, not a hash. A hash would make
|
||||||
|
/// authoritative equality probabilistic: a collision silently
|
||||||
|
/// accepts a stale gesture, which is the precise failure this key
|
||||||
|
/// exists to prevent. The emitted `mapping_generation` is still a
|
||||||
|
/// `u64` on the wire — only the daemon's own comparison is exact.
|
||||||
|
///
|
||||||
|
/// **Deliberately EXCLUDED**, each an explicit contract: focus,
|
||||||
|
/// styling and theme, the cursor, and the selection. None changes
|
||||||
|
/// which byte a cell denotes, and a drag repaints the selection on
|
||||||
|
/// every motion — a key that moved with them would cancel the
|
||||||
|
/// gesture it protects after one step.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct PanelMappingSnapshot {
|
||||||
|
buffer_id: crate::buffer::BufferId,
|
||||||
|
/// Rows and columns held apart, never multiplied: a 2×6 panel
|
||||||
|
/// inverts nothing like a 6×2 one.
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
content: PanelMappingContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelMappingSnapshot {
|
||||||
|
/// Which domain decided this mapping. Exposed for the row that pins
|
||||||
|
/// the branch is taken by target kind.
|
||||||
|
#[must_use]
|
||||||
|
pub fn content(&self) -> &PanelMappingContent {
|
||||||
|
&self.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What decides the mapping BELOW the geometry, which differs by
|
||||||
|
/// target kind.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub enum PanelMappingContent {
|
||||||
|
/// A document panel: the buffer's content revision.
|
||||||
|
Document {
|
||||||
|
/// The buffer's content revision, or `None` if it is gone.
|
||||||
|
revision: Option<u64>,
|
||||||
|
/// Vertical viewport origin.
|
||||||
|
view_top: usize,
|
||||||
|
/// Horizontal viewport origin. GUI arc 1b makes this move.
|
||||||
|
view_left: u32,
|
||||||
|
/// Wrap mode: it decides how a source line becomes display rows.
|
||||||
|
wrap: crate::view::WrapMode,
|
||||||
|
/// Text width with the gutter reservation already subtracted.
|
||||||
|
content_cols: u32,
|
||||||
|
/// The owning frontend's fold projection policy.
|
||||||
|
fold_projection: bool,
|
||||||
|
/// Fold content, read at its source.
|
||||||
|
folds: Vec<pmacs_protocol::ByteRange>,
|
||||||
|
},
|
||||||
|
/// A terminal panel: the screen's **mapping revision** and the
|
||||||
|
/// view's scroll anchor.
|
||||||
|
///
|
||||||
|
/// **Not the buffer's revision**, which tracks something else
|
||||||
|
/// entirely, and **not `Screen::generation`**, which advances
|
||||||
|
/// for style, title, bell, tab stops and cursor motion — none
|
||||||
|
/// of which changes what a coordinate denotes.
|
||||||
|
Terminal {
|
||||||
|
/// Content and topology identity, excluding style, title, bell,
|
||||||
|
/// tab stops and cursor motion.
|
||||||
|
mapping_revision: u64,
|
||||||
|
/// The view's scroll anchor; `None` follows the live tail.
|
||||||
|
anchor: Option<crate::terminal::view::LogicalCellAnchor>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
impl EditorState {
|
impl EditorState {
|
||||||
/// Construct a fresh editor for an unnamed scratch buffer.
|
/// Construct a fresh editor for an unnamed scratch buffer.
|
||||||
///
|
///
|
||||||
|
|
@ -2452,6 +2522,66 @@ impl EditorState {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture the panel's inverse mapping for one frontend.
|
||||||
|
///
|
||||||
|
/// `None` when no panel is presentable — the absence of a mapping,
|
||||||
|
/// which is not the same as a mapping of zero.
|
||||||
|
pub fn panel_mapping_snapshot(&self, frontend_id: FrontendId) -> Option<PanelMappingSnapshot> {
|
||||||
|
let core = self.core.borrow();
|
||||||
|
let size = core.panel_grid_size(frontend_id)?;
|
||||||
|
let side = core.side_window_for(frontend_id)?;
|
||||||
|
let window = core.windows.get(&side)?;
|
||||||
|
let buffer_id = window.buffer_id;
|
||||||
|
|
||||||
|
let content = if self.terminal_manager.borrow().is_terminal(buffer_id) {
|
||||||
|
let key = TerminalViewKey::new(frontend_id, side, buffer_id);
|
||||||
|
let (mapping_revision, anchor) = self
|
||||||
|
.terminal_manager
|
||||||
|
.borrow()
|
||||||
|
.view_mapping_identity(key)
|
||||||
|
.unwrap_or((0, None));
|
||||||
|
PanelMappingContent::Terminal {
|
||||||
|
mapping_revision,
|
||||||
|
anchor,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let registry = core.registry.clone();
|
||||||
|
let revision = registry
|
||||||
|
.borrow()
|
||||||
|
.get(buffer_id)
|
||||||
|
.ok()
|
||||||
|
.map(crate::buffer::Buffer::revision);
|
||||||
|
PanelMappingContent::Document {
|
||||||
|
revision,
|
||||||
|
view_top: window.view_top,
|
||||||
|
view_left: window.view_left,
|
||||||
|
wrap: window.last_wrap,
|
||||||
|
content_cols: window.last_content_cols,
|
||||||
|
fold_projection: core
|
||||||
|
.views
|
||||||
|
.get(&frontend_id)
|
||||||
|
.is_some_and(|view| view.fold_projection),
|
||||||
|
// Read at their SOURCE — the registry's ranges — rather
|
||||||
|
// than through the derived `VisibleLineMap`, whose only
|
||||||
|
// public summary is `is_identity()`. Too coarse: a fold
|
||||||
|
// edit leaving the map non-identity still changes which
|
||||||
|
// source line a row shows.
|
||||||
|
folds: core.fold_registry.folds(buffer_id),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only COMMON geometry lives out here. Everything below is
|
||||||
|
// domain-specific: `view_top`, `view_left`, wrap, gutter width
|
||||||
|
// and folds describe a DOCUMENT projection and take no part in a
|
||||||
|
// terminal's, where the child's screen decides the mapping.
|
||||||
|
Some(PanelMappingSnapshot {
|
||||||
|
buffer_id,
|
||||||
|
rows: size.rows,
|
||||||
|
cols: size.cols,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Paint one semantic frontend's side window into a panel-sized grid
|
/// Paint one semantic frontend's side window into a panel-sized grid
|
||||||
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
|
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
|
||||||
///
|
///
|
||||||
|
|
@ -2671,6 +2801,11 @@ impl EditorState {
|
||||||
/// which needs the GPU band and lands in Stage 2B-3.
|
/// which needs the GPU band and lands in Stage 2B-3.
|
||||||
///
|
///
|
||||||
/// Returns whether the gesture was accepted.
|
/// Returns whether the gesture was accepted.
|
||||||
|
///
|
||||||
|
/// `#[must_use]` because the accepted-gesture latch is driven off
|
||||||
|
/// this answer, and discarding it silently arms on rejected presses
|
||||||
|
/// and consumes on rejected releases.
|
||||||
|
#[must_use]
|
||||||
pub fn dispatch_semantic_panel_pointer(
|
pub fn dispatch_semantic_panel_pointer(
|
||||||
&self,
|
&self,
|
||||||
frontend_id: FrontendId,
|
frontend_id: FrontendId,
|
||||||
|
|
|
||||||
181
src/protocol.rs
181
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
||||||
// --- M5.5a handshake & postcard round-trips ---
|
// --- M5.5a handshake & postcard round-trips ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_version_is_twenty_four_for_text_input() {
|
fn protocol_version_is_twenty_five_for_the_mapped_panel_family() {
|
||||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||||
|
|
@ -1741,10 +1741,11 @@ mod tests {
|
||||||
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||||
// pinned by literal bytes below.
|
// pinned by literal bytes below.
|
||||||
//
|
//
|
||||||
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an
|
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
|
||||||
// APPENDED variant, which is why the freeze above survives it
|
// is the mapped panel family (bottom-panel §5b) — both APPENDED
|
||||||
|
// variants, which is why the freeze above survives them
|
||||||
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
|
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
|
||||||
assert_eq!(PROTOCOL_VERSION, 24);
|
assert_eq!(PROTOCOL_VERSION, 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1822,18 +1823,19 @@ mod tests {
|
||||||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||||
// panel band), v22 (`LineWrapFacts`), v23
|
// panel band), v22 (`LineWrapFacts`), v23
|
||||||
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage
|
// (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
|
||||||
// 1a) all interoperate.
|
// and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
|
||||||
for accepted in 6..=24 {
|
// §5b) all interoperate.
|
||||||
|
for accepted in 6..=PROTOCOL_VERSION {
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(accepted),
|
is_supported_protocol_version(accepted),
|
||||||
"v{accepted} must be accepted"
|
"v{accepted} must be accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] {
|
for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] {
|
||||||
assert!(
|
assert!(
|
||||||
!is_supported_protocol_version(rejected),
|
!is_supported_protocol_version(rejected),
|
||||||
"v{rejected} must be rejected by a v24 binary"
|
"v{rejected} must be rejected by the current binary"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2008,6 +2010,167 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5b G0a — the **previous-final `FrontendEvent`** is now
|
||||||
|
/// `TextInput`, and it gets its own exact pin.
|
||||||
|
///
|
||||||
|
/// Pins ACCUMULATE rather than move. The `PanelPointer` pin above
|
||||||
|
/// still protects the v21–v23 population; this one protects v24's,
|
||||||
|
/// and appending `PanelPointerMapped` is exactly the edit that would
|
||||||
|
/// shift it if it were inserted rather than appended.
|
||||||
|
#[test]
|
||||||
|
fn text_input_encoding_is_unchanged_by_the_v25_build() {
|
||||||
|
let ev = FrontendEvent::TextInput {
|
||||||
|
frontend_id: FrontendId(2),
|
||||||
|
text: "hi".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&ev).expect("encode"),
|
||||||
|
[16, 2, 2, 104, 105],
|
||||||
|
"TextInput's v24 wire bytes changed — a variant was inserted \
|
||||||
|
before it; append new FrontendEvent variants at the end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`,
|
||||||
|
/// pinned through its real nesting inside `InstanceMessage`.
|
||||||
|
///
|
||||||
|
/// Nested deliberately: `PanelFramePayload` never travels alone, and
|
||||||
|
/// a pin on the bare enum would miss a shift in the message
|
||||||
|
/// discriminant that carries it.
|
||||||
|
#[test]
|
||||||
|
fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() {
|
||||||
|
let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent);
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&msg).expect("encode"),
|
||||||
|
[28, 1],
|
||||||
|
"Absent's wire bytes changed — a PanelFramePayload variant \
|
||||||
|
was inserted before it, or InstanceMessage's discriminant \
|
||||||
|
moved; append PresentMapped AFTER Absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G0b — exact **encode and decode** for the mapped pointer.
|
||||||
|
///
|
||||||
|
/// **Every adjacent same-typed field carries a different value**, so
|
||||||
|
/// a reordering is visible. `geometry_epoch`, `panel_epoch` and
|
||||||
|
/// `mapping_generation` are all `u64` and all distinct (3, 7, 11);
|
||||||
|
/// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would
|
||||||
|
/// stay green through any swap of those — it is self-consistent by
|
||||||
|
/// construction — which is why the exact bytes are asserted in both
|
||||||
|
/// directions.
|
||||||
|
#[test]
|
||||||
|
fn panel_pointer_mapped_encodes_and_decodes_exactly() {
|
||||||
|
const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11];
|
||||||
|
let ev = FrontendEvent::PanelPointerMapped {
|
||||||
|
frontend_id: FrontendId(2),
|
||||||
|
geometry_epoch: 3,
|
||||||
|
panel_epoch: 7,
|
||||||
|
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||||
|
coord: CellCoord { row: 5, col: 9 },
|
||||||
|
kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left),
|
||||||
|
mods: Modifiers::NONE,
|
||||||
|
mapping_generation: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&ev).expect("encode"),
|
||||||
|
WIRE,
|
||||||
|
"PanelPointerMapped's v25 wire bytes changed — it must be \
|
||||||
|
APPENDED after TextInput, with mapping_generation last"
|
||||||
|
);
|
||||||
|
let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode");
|
||||||
|
assert_eq!(
|
||||||
|
decoded, ev,
|
||||||
|
"the frozen historical bytes must decode back to the same \
|
||||||
|
value — round-trip alone would only witness the current \
|
||||||
|
encoder and decoder agreeing with each other"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G0b — exact **encode and decode** for the mapped frame,
|
||||||
|
/// through its real nesting.
|
||||||
|
///
|
||||||
|
/// Reordering named variant fields still compiles and changes
|
||||||
|
/// postcard's positional bytes, so the exact pin is load-bearing.
|
||||||
|
/// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are
|
||||||
|
/// adjacent `u64`s and are pinned distinct for the same reason as
|
||||||
|
/// above.
|
||||||
|
#[test]
|
||||||
|
fn present_mapped_encodes_and_decodes_exactly() {
|
||||||
|
const WIRE: [u8; 33] = [
|
||||||
|
28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 1, 13,
|
||||||
|
];
|
||||||
|
let frame = pmacs_protocol::panel::PanelFrame {
|
||||||
|
buffer_id: pmacs_protocol::BufferId::from_raw(6),
|
||||||
|
panel_epoch: 8,
|
||||||
|
geometry_epoch: 12,
|
||||||
|
size: CellSize::new(1, 2),
|
||||||
|
cells: vec![Cell::default(); 2],
|
||||||
|
cursor: None,
|
||||||
|
focused: true,
|
||||||
|
};
|
||||||
|
let msg =
|
||||||
|
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped {
|
||||||
|
frame,
|
||||||
|
mapping_generation: 13,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
postcard::to_allocvec(&msg).expect("encode"),
|
||||||
|
WIRE,
|
||||||
|
"PresentMapped's v25 wire bytes changed — it must be APPENDED \
|
||||||
|
after Absent, with mapping_generation last"
|
||||||
|
);
|
||||||
|
let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode");
|
||||||
|
assert_eq!(decoded, msg, "the frozen bytes must decode back");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G14a — the version constants move together, and the
|
||||||
|
/// advertised baseline does not move at all.
|
||||||
|
#[test]
|
||||||
|
fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() {
|
||||||
|
assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25);
|
||||||
|
assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25);
|
||||||
|
assert!(
|
||||||
|
pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION),
|
||||||
|
"a wire version the handshake does not support fails against \
|
||||||
|
this daemon's own peers"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
|
||||||
|
20,
|
||||||
|
"the advertised baseline is PERMANENT — it is not bumped to \
|
||||||
|
chase the wire version"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G14b — the supported set's boundary, and the family boundary,
|
||||||
|
/// are both literal.
|
||||||
|
#[test]
|
||||||
|
fn the_supported_set_ends_at_the_current_wire_version() {
|
||||||
|
assert!(pmacs_protocol::is_supported_protocol_version(25));
|
||||||
|
assert!(
|
||||||
|
!pmacs_protocol::is_supported_protocol_version(26),
|
||||||
|
"an unreleased version must not negotiate"
|
||||||
|
);
|
||||||
|
// The family boundary is stated against a LITERAL, not against
|
||||||
|
// `PROTOCOL_VERSION`: expressing it arithmetically would drag
|
||||||
|
// this feature forward on the next bump, so v24 sessions would
|
||||||
|
// silently start being offered a family they never negotiated.
|
||||||
|
//
|
||||||
|
// Asserted as equality rather than `24 < MIN`, which clippy
|
||||||
|
// correctly calls a compile-time tautology — it holds for every
|
||||||
|
// value above 24 and so pins nothing.
|
||||||
|
assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||||
|
assert_ne!(
|
||||||
|
pmacs_protocol::PANEL_MAPPING_MIN_VERSION,
|
||||||
|
pmacs_protocol::TEXT_INPUT_MIN_VERSION,
|
||||||
|
"the mapped family must not share v24's gate — that would \
|
||||||
|
admit it on sessions that negotiated only TextInput"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
||||||
let bid = pmacs_protocol::BufferId::from_raw(9);
|
let bid = pmacs_protocol::BufferId::from_raw(9);
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,29 @@ fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Ve
|
||||||
(window, selected_in_window)
|
(window, selected_in_window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5b G5 — what an accepted panel `Down` established, and what a
|
||||||
|
/// cancellation must therefore terminate.
|
||||||
|
///
|
||||||
|
/// A release has to MATCH the press it ends: same button, same
|
||||||
|
/// encoding, at a coordinate the child was actually told about. A
|
||||||
|
/// cancellation that guessed any of those would put bytes in a child's
|
||||||
|
/// input stream describing an event that never happened.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AcceptedPanelGesture {
|
||||||
|
/// The button the accepted `Down` carried.
|
||||||
|
pub button: pmacs_protocol::MouseButton,
|
||||||
|
/// The last coordinate known valid for this gesture — where a
|
||||||
|
/// release is delivered when the pointer has since left content.
|
||||||
|
pub coord: pmacs_protocol::CellCoord,
|
||||||
|
/// The panel buffer the gesture belongs to.
|
||||||
|
pub buffer_id: BufferId,
|
||||||
|
/// Whether the press actually REACHED the child, for a reporting
|
||||||
|
/// terminal. A release is owed only if a press was delivered;
|
||||||
|
/// synthesising one for a press the child never saw is the same
|
||||||
|
/// defect in the other direction.
|
||||||
|
pub reached_child: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns one `semantic_render` session's projection state: the last
|
/// Owns one `semantic_render` session's projection state: the last
|
||||||
/// viewport the frontend declared, and the diff baseline per buffer
|
/// viewport the frontend declared, and the diff baseline per buffer
|
||||||
/// for the `StyleSpans` and `Decorations` families.
|
/// for the `StyleSpans` and `Decorations` families.
|
||||||
|
|
@ -254,6 +277,15 @@ pub struct SemanticRenderState {
|
||||||
/// rows form. Also gates the per-row detail lookup: a peer that
|
/// rows form. Also gates the per-row detail lookup: a peer that
|
||||||
/// cannot carry a detail does not pay to resolve one.
|
/// cannot carry a detail does not pay to resolve one.
|
||||||
peer_knows_minibuffer_rows: bool,
|
peer_knows_minibuffer_rows: bool,
|
||||||
|
/// §5b — whether the peer negotiated `>= v25` and therefore takes
|
||||||
|
/// the **mapped** panel family.
|
||||||
|
///
|
||||||
|
/// The families are exclusive in both directions: a `>= v25` peer
|
||||||
|
/// receives `PresentMapped` and never legacy `Present`, and a
|
||||||
|
/// `<= v24` peer the reverse. "Send whichever and let the receiver
|
||||||
|
/// cope" would make negotiation a sender convention rather than a
|
||||||
|
/// gate.
|
||||||
|
peer_knows_mapped_panel: bool,
|
||||||
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
||||||
/// Q#C5), for cached-compare suppression (see
|
/// Q#C5), for cached-compare suppression (see
|
||||||
/// [`CompletionPopupFacts`]).
|
/// [`CompletionPopupFacts`]).
|
||||||
|
|
@ -377,6 +409,61 @@ pub struct SemanticRenderState {
|
||||||
/// retains its last valid frame and silence would leave a stale band
|
/// retains its last valid frame and silence would leave a stale band
|
||||||
/// on screen indefinitely (Q#BP15).
|
/// on screen indefinitely (Q#BP15).
|
||||||
last_panel_payload: Option<PanelFramePayload>,
|
last_panel_payload: Option<PanelFramePayload>,
|
||||||
|
/// §5b — the authoritative **cell-mapping key** for this frontend.
|
||||||
|
///
|
||||||
|
/// `(fingerprint, generation)`. The generation advances whenever the
|
||||||
|
/// fingerprint changes, and **both projection and inbound
|
||||||
|
/// validation read it through the same accessor**, so "what the
|
||||||
|
/// frontend was shown" and "what the daemon checks" cannot drift.
|
||||||
|
///
|
||||||
|
/// It is deliberately **not** recomputed from the last emitted
|
||||||
|
/// frame: a mapping mutation that has not yet been painted has still
|
||||||
|
/// changed the inverse, and a gesture arriving in that gap must be
|
||||||
|
/// refused. Advancing on demand at both seams is what makes
|
||||||
|
/// "advances before the next inbound pointer, whether or not
|
||||||
|
/// anything rendered" true rather than aspirational.
|
||||||
|
///
|
||||||
|
/// **Nondecreasing, and never cleared** — not even by `Absent`. A
|
||||||
|
/// delayed lower frame must not roll the producer's authority
|
||||||
|
/// backward, so this is a high-water mark for the session.
|
||||||
|
/// `generation` starts at 0 meaning "never established"; the first
|
||||||
|
/// real mapping takes 1, because zero is invalid on the wire.
|
||||||
|
panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>,
|
||||||
|
/// §5b G11a — set once the mapping generation cannot advance, and
|
||||||
|
/// never cleared for the session.
|
||||||
|
///
|
||||||
|
/// Exhaustion fails **closed**: no key means no authority, so the
|
||||||
|
/// band is published `Absent` and every inbound panel event is
|
||||||
|
/// refused. The latch is what makes that permanent. Without it the
|
||||||
|
/// next projection finds an UNCHANGED snapshot, takes the
|
||||||
|
/// "unchanged" arm, and hands back the frozen ceiling — resurrecting
|
||||||
|
/// the band with a key that can no longer distinguish anything.
|
||||||
|
panel_mapping_exhausted: bool,
|
||||||
|
/// §5b G5 — the panel gesture this frontend has an ACCEPTED `Down`
|
||||||
|
/// for, if any.
|
||||||
|
///
|
||||||
|
/// Per frontend, never global: two frontends can hold gestures on
|
||||||
|
/// distinct panels at once, and one global slot would make either
|
||||||
|
/// one's authority loss cancel or erase the other's (G5p).
|
||||||
|
///
|
||||||
|
/// Cancellation needs this to exist at all. Without a record of
|
||||||
|
/// what was accepted there is nothing to terminate: a release must
|
||||||
|
/// match the press it ends, and a stale `Up` with no accepted
|
||||||
|
/// `Down` must be inert rather than synthesising one.
|
||||||
|
accepted_gesture: Option<AcceptedPanelGesture>,
|
||||||
|
/// §5b — how many armed gestures an authority loss has ended this
|
||||||
|
/// session.
|
||||||
|
///
|
||||||
|
/// A COUNT, not a queue of records. The records are what replay
|
||||||
|
/// consumes to deliver each release, and replay is the branch that
|
||||||
|
/// introduces their reader — a queue landed here would grow one
|
||||||
|
/// entry per cancelled drag for the life of the daemon with nothing
|
||||||
|
/// ever draining it. A saturating count is bounded and still
|
||||||
|
/// distinguishes the two ways a latch empties: an ordinary `Up`
|
||||||
|
/// consumes it and leaves this alone, an authority loss ends it and
|
||||||
|
/// bumps it. It also does not collapse two losses in one dispatcher
|
||||||
|
/// burst the way a boolean flag would.
|
||||||
|
panel_gesture_cancellations: u64,
|
||||||
/// Highest presentation epoch allocated for this session; `0` means
|
/// Highest presentation epoch allocated for this session; `0` means
|
||||||
/// none has been. Advanced only when a frame is actually shipped, so
|
/// none has been. Advanced only when a frame is actually shipped, so
|
||||||
/// a frame that fails validation does not burn an identity the peer
|
/// a frame that fails validation does not burn an identity the peer
|
||||||
|
|
@ -516,11 +603,15 @@ impl SemanticRenderState {
|
||||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||||
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||||
|
s.peer_knows_mapped_panel =
|
||||||
|
negotiated_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION;
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fresh session state for frontend `frontend_id`: no viewport
|
/// Fresh session state for frontend `frontend_id`: no viewport
|
||||||
/// declared, nothing sent. Assumes a current-build peer (>= 18);
|
/// declared, nothing sent. Assumes a current-build peer (>= 18, and
|
||||||
|
/// current for every later capability too, including §5b's mapped
|
||||||
|
/// panel family);
|
||||||
/// daemon sessions with a real negotiated version use
|
/// daemon sessions with a real negotiated version use
|
||||||
/// [`Self::for_peer`].
|
/// [`Self::for_peer`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -536,6 +627,10 @@ impl SemanticRenderState {
|
||||||
last_menu_prompt: HashMap::new(),
|
last_menu_prompt: HashMap::new(),
|
||||||
last_minibuffer: None,
|
last_minibuffer: None,
|
||||||
peer_knows_minibuffer_rows: true,
|
peer_knows_minibuffer_rows: true,
|
||||||
|
// A current-build peer, like every other capability here.
|
||||||
|
// Leaving this `false` made `new()` contradict its own doc
|
||||||
|
// and emit the LEGACY family to an implicitly v25 peer.
|
||||||
|
peer_knows_mapped_panel: true,
|
||||||
last_completion_popup: HashMap::new(),
|
last_completion_popup: HashMap::new(),
|
||||||
last_summary: HashMap::new(),
|
last_summary: HashMap::new(),
|
||||||
last_status: HashMap::new(),
|
last_status: HashMap::new(),
|
||||||
|
|
@ -575,6 +670,10 @@ impl SemanticRenderState {
|
||||||
// peer already holds. Seeding the baseline keeps the first
|
// peer already holds. Seeding the baseline keeps the first
|
||||||
// frame from shipping a redundant authoritative `Absent`.
|
// frame from shipping a redundant authoritative `Absent`.
|
||||||
last_panel_payload: Some(PanelFramePayload::Absent),
|
last_panel_payload: Some(PanelFramePayload::Absent),
|
||||||
|
panel_mapping: None,
|
||||||
|
panel_mapping_exhausted: false,
|
||||||
|
accepted_gesture: None,
|
||||||
|
panel_gesture_cancellations: 0,
|
||||||
panel_epoch_used: 0,
|
panel_epoch_used: 0,
|
||||||
panel_presentation: None,
|
panel_presentation: None,
|
||||||
panel_error_latched: false,
|
panel_error_latched: false,
|
||||||
|
|
@ -592,11 +691,194 @@ impl SemanticRenderState {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
|
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
|
||||||
match self.last_panel_payload.as_ref()? {
|
match self.last_panel_payload.as_ref()? {
|
||||||
PanelFramePayload::Present(frame) => Some(frame),
|
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => {
|
||||||
|
Some(frame)
|
||||||
|
}
|
||||||
PanelFramePayload::Absent => None,
|
PanelFramePayload::Absent => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The panel payload most recently shipped, for the family rows.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_panel_payload_for_test(&self) -> Option<PanelFramePayload> {
|
||||||
|
self.last_panel_payload.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — how many armed gestures an authority loss has ended.
|
||||||
|
///
|
||||||
|
/// Counts rather than collapses: two losses in one dispatcher burst
|
||||||
|
/// read as two. Coincident causes on ONE transition are a different
|
||||||
|
/// matter — those take the latch once, so the count moves once.
|
||||||
|
#[must_use]
|
||||||
|
pub fn panel_gesture_cancellations(&self) -> u64 {
|
||||||
|
self.panel_gesture_cancellations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — arm the latch, and ONLY for an accepted left press.
|
||||||
|
///
|
||||||
|
/// A right press opens a menu and ends there; `Move`, wheel and the
|
||||||
|
/// other buttons begin nothing. Arming on any accepted pointer
|
||||||
|
/// event would let one of them manufacture a delayed release at the
|
||||||
|
/// next authority loss.
|
||||||
|
///
|
||||||
|
/// This is the SUBSTRATE for the framing's G5g. G5g itself — the
|
||||||
|
/// table-driven non-gesture events followed by an authority loss,
|
||||||
|
/// asserted to emit no synthetic release — is `panel-pointer-replay`'s
|
||||||
|
/// per §5b's split table, because the release it denies does not
|
||||||
|
/// exist on this branch.
|
||||||
|
pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) {
|
||||||
|
// A second accepted press while one is already armed means the
|
||||||
|
// first gesture's release never arrived — a dropped `Up`, or an
|
||||||
|
// outbox that closed under a stall. END it rather than
|
||||||
|
// overwrite it: overwriting discards the record silently, and
|
||||||
|
// once replay attaches effects to that record the child is left
|
||||||
|
// holding a button down with nothing left to release it.
|
||||||
|
self.cancel_accepted_gesture();
|
||||||
|
self.accepted_gesture = Some(gesture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — an ordinary accepted release CONSUMES the latch, WITHOUT
|
||||||
|
/// counting as a cancellation.
|
||||||
|
///
|
||||||
|
/// Otherwise a later invalidation finds a gesture it believes is
|
||||||
|
/// still live and synthesises a duplicate release for a button
|
||||||
|
/// already up. Substrate for the framing's G5c/G5d, whose duplicate-
|
||||||
|
/// release assertions are replay's.
|
||||||
|
pub fn consume_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> {
|
||||||
|
self.accepted_gesture.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G5a — cancellation: end the live gesture, if any, and return
|
||||||
|
/// its record so the caller can terminate it.
|
||||||
|
///
|
||||||
|
/// Returning the record rather than acting is deliberate. The
|
||||||
|
/// EFFECTS — clearing an empty selection, clearing the click chain,
|
||||||
|
/// delivering the child's release — live in panel replay, which is
|
||||||
|
/// `panel-pointer-replay`'s. This is the trigger and the state; the
|
||||||
|
/// two meet at that rebase.
|
||||||
|
///
|
||||||
|
/// Idempotent on an empty latch: no gesture, no count. That is what
|
||||||
|
/// lets a duplicate `Absent` and a coincident advance both call it
|
||||||
|
/// without inventing a second cancellation.
|
||||||
|
pub fn cancel_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> {
|
||||||
|
let cancelled = self.accepted_gesture.take();
|
||||||
|
if cancelled.is_some() {
|
||||||
|
self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1);
|
||||||
|
}
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a gesture is currently accepted, for assertions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_accepted_gesture(&self) -> bool {
|
||||||
|
self.accepted_gesture.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance-if-changed, then read: the authoritative mapping key.
|
||||||
|
///
|
||||||
|
/// **The single seam §5b requires.** Projection stamps the frame
|
||||||
|
/// with what this returns, and inbound validation compares against
|
||||||
|
/// what this returns; there is no second derivation to disagree
|
||||||
|
/// with.
|
||||||
|
///
|
||||||
|
/// `fingerprint` is `None` when no panel is presentable. That does
|
||||||
|
/// **not** reset the key — the high-water mark survives `Absent`,
|
||||||
|
/// so a frame delayed across a hide cannot come back with a lower
|
||||||
|
/// generation and be believed.
|
||||||
|
pub fn panel_mapping_generation(
|
||||||
|
&mut self,
|
||||||
|
snapshot: Option<crate::editor::PanelMappingSnapshot>,
|
||||||
|
) -> Option<u64> {
|
||||||
|
if self.panel_mapping_exhausted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let snapshot = snapshot?;
|
||||||
|
// Matched by value, not by reference: the changed arm CANCELS,
|
||||||
|
// and cancelling needs `&mut self`.
|
||||||
|
let seen = self
|
||||||
|
.panel_mapping
|
||||||
|
.as_ref()
|
||||||
|
.map(|(seen, generation)| (*seen == snapshot, *generation));
|
||||||
|
let next = match seen {
|
||||||
|
// Compared STRUCTURALLY above. A hash would make this
|
||||||
|
// probabilistic, and a collision here silently accepts a
|
||||||
|
// stale gesture — the exact failure the key exists for.
|
||||||
|
Some((true, generation)) => generation,
|
||||||
|
Some((false, generation)) => {
|
||||||
|
// §5b G5a — the key advancing IS the cancellation
|
||||||
|
// trigger, raised here rather than waiting for another
|
||||||
|
// pointer event. Reactive cancellation loses a race: if
|
||||||
|
// the successor frame reaches the frontend before the
|
||||||
|
// physical `Up`, the producer clears its latch and the
|
||||||
|
// cancelling event never arrives.
|
||||||
|
self.cancel_accepted_gesture();
|
||||||
|
let Some(next) = generation.checked_add(1) else {
|
||||||
|
// §5b G11a — fail CLOSED, and return BEFORE the
|
||||||
|
// store below. A saturating add would freeze the key
|
||||||
|
// at the ceiling while the mapping kept moving
|
||||||
|
// underneath it, which is precisely the stale-gesture
|
||||||
|
// hole the key exists to close, with the check still
|
||||||
|
// looking like it passes.
|
||||||
|
//
|
||||||
|
// Returning BEFORE the store below is a second,
|
||||||
|
// redundant guard against the same zombie band:
|
||||||
|
// recording `(snapshot, MAX)` here would make the
|
||||||
|
// next read take the unchanged arm and hand back the
|
||||||
|
// ceiling. Measured, the two are ALTERNATIVES —
|
||||||
|
// either alone keeps the band down, and only
|
||||||
|
// removing both resurrects it. The latch is kept as
|
||||||
|
// the primary because it has a job the ordering
|
||||||
|
// does not: it is what makes `peek` and the
|
||||||
|
// authoritative read agree that this session has no
|
||||||
|
// key, rather than reporting the ceiling it stopped
|
||||||
|
// at.
|
||||||
|
self.panel_mapping_exhausted = true;
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
next
|
||||||
|
}
|
||||||
|
// First establishment takes 1, never 0: zero is the wire's
|
||||||
|
// "uninitialised" value and is refused on sight.
|
||||||
|
None => 1,
|
||||||
|
};
|
||||||
|
self.panel_mapping = Some((snapshot, next));
|
||||||
|
Some(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G11a — whether this session's key is exhausted.
|
||||||
|
#[must_use]
|
||||||
|
pub fn panel_mapping_generation_exhausted(&self) -> bool {
|
||||||
|
self.panel_mapping_exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place the key one advance below the ceiling, so exhaustion is
|
||||||
|
/// reachable in a test without 2^64 mutations.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn seed_panel_mapping_generation_for_test(
|
||||||
|
&mut self,
|
||||||
|
snapshot: crate::editor::PanelMappingSnapshot,
|
||||||
|
generation: u64,
|
||||||
|
) {
|
||||||
|
self.panel_mapping = Some((snapshot, generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current key without advancing it, for assertions and for
|
||||||
|
/// callers that must not have a side effect.
|
||||||
|
///
|
||||||
|
/// `None` once exhausted, matching the authoritative read. The
|
||||||
|
/// stored pair still holds the ceiling it stopped at, and reporting
|
||||||
|
/// that would name a key no frame carries and no gesture may echo.
|
||||||
|
#[must_use]
|
||||||
|
pub fn panel_mapping_generation_peek(&self) -> Option<u64> {
|
||||||
|
if self.panel_mapping_exhausted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.panel_mapping
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, generation)| *generation)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the last shipped declaration is a `Present` whose epochs
|
/// Whether the last shipped declaration is a `Present` whose epochs
|
||||||
/// both match an inbound panel event **and** which still describes
|
/// both match an inbound panel event **and** which still describes
|
||||||
/// the side window that is live now (Q#BP16 steps 2–4).
|
/// the side window that is live now (Q#BP16 steps 2–4).
|
||||||
|
|
@ -1374,7 +1656,7 @@ impl SemanticRenderState {
|
||||||
self.publish_absent_panel(out);
|
self.publish_absent_panel(out);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let payload = PanelFramePayload::Present(PanelFrame {
|
let frame = PanelFrame {
|
||||||
buffer_id: projection.buffer_id,
|
buffer_id: projection.buffer_id,
|
||||||
panel_epoch,
|
panel_epoch,
|
||||||
geometry_epoch: geometry.geometry_epoch,
|
geometry_epoch: geometry.geometry_epoch,
|
||||||
|
|
@ -1382,7 +1664,28 @@ impl SemanticRenderState {
|
||||||
cells: projection.cells,
|
cells: projection.cells,
|
||||||
cursor: projection.cursor,
|
cursor: projection.cursor,
|
||||||
focused: projection.focused,
|
focused: projection.focused,
|
||||||
});
|
};
|
||||||
|
// §5b — ORDER MATTERS. The projection is prepared above, THEN
|
||||||
|
// the key is captured, THEN the payload is built. A terminal
|
||||||
|
// projection registers the view whose scroll anchor the key
|
||||||
|
// reads, so capturing earlier would stamp a frame with a key
|
||||||
|
// derived from an unregistered anchor.
|
||||||
|
let payload = if self.peer_knows_mapped_panel {
|
||||||
|
let snapshot = state.panel_mapping_snapshot(self.frontend_id);
|
||||||
|
let Some(mapping_generation) = self.panel_mapping_generation(snapshot) else {
|
||||||
|
// No presentable mapping means nothing to stamp. Falling
|
||||||
|
// back to the legacy variant here would hand a v25 peer
|
||||||
|
// the family it did not negotiate.
|
||||||
|
self.publish_absent_panel(out);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
PanelFramePayload::PresentMapped {
|
||||||
|
frame,
|
||||||
|
mapping_generation,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PanelFramePayload::Present(frame)
|
||||||
|
};
|
||||||
// Complete-payload comparison FIRST, like the terminal pass: only
|
// Complete-payload comparison FIRST, like the terminal pass: only
|
||||||
// validated payloads are ever stored, so a payload equal to the
|
// validated payloads are ever stored, so a payload equal to the
|
||||||
// baseline has already passed and re-running the per-cell width
|
// baseline has already passed and re-running the per-cell width
|
||||||
|
|
@ -1391,8 +1694,10 @@ impl SemanticRenderState {
|
||||||
self.panel_error_latched = false;
|
self.panel_error_latched = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let PanelFramePayload::Present(frame) = &payload else {
|
let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) =
|
||||||
unreachable!("the Present payload was constructed immediately above");
|
&payload
|
||||||
|
else {
|
||||||
|
unreachable!("a Present payload was constructed immediately above");
|
||||||
};
|
};
|
||||||
match frame.validate() {
|
match frame.validate() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|
@ -1433,6 +1738,22 @@ impl SemanticRenderState {
|
||||||
/// deliberately survives: it is answered by the frontend, not by the
|
/// deliberately survives: it is answered by the frontend, not by the
|
||||||
/// panel's presence.
|
/// panel's presence.
|
||||||
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
|
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
|
||||||
|
// §5b — `Absent` is a loss of gesture authority: it clears
|
||||||
|
// `panel_presentation` two lines below, so every later inbound
|
||||||
|
// event for that gesture is refused and its release can never
|
||||||
|
// arrive. Cancelled even on a DUPLICATE `Absent`, which does no
|
||||||
|
// wire work but must still leave no live gesture behind — the
|
||||||
|
// clears below are idempotent for the same reason.
|
||||||
|
//
|
||||||
|
// Only the producer half lands here. The framing's G5b matrix —
|
||||||
|
// the panel-epoch, buffer-replacement, same-size geometry and
|
||||||
|
// detach transitions, each with its v24 and v25 legs and its
|
||||||
|
// document and terminal effects — is `panel-pointer-replay`'s per
|
||||||
|
// §5b's split table. Those transitions leave the latch armed on
|
||||||
|
// this branch, which is inert here (nothing consumes it) and
|
||||||
|
// becomes a defect only once replay gives it effects, in the
|
||||||
|
// branch that owns the row.
|
||||||
|
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
|
||||||
// `Present` under `NoMessage` therefore has nothing it can
|
// `Present` under `NoMessage` therefore has nothing it can
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,15 @@ pub struct ScreenProjection {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
/// Screen generation represented by this projection.
|
/// Screen generation represented by this projection.
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
|
/// §5b — the **mapping revision** this projection was published at.
|
||||||
|
///
|
||||||
|
/// Carried here, not read live, because the two diverge while
|
||||||
|
/// synchronized output is held: `projection_ref` keeps returning the
|
||||||
|
/// last PUBLISHED cells while the live screen races ahead, so
|
||||||
|
/// stamping a frame with the live revision would give displayed
|
||||||
|
/// cells authority they were never painted under — a frontend would
|
||||||
|
/// then echo a generation that matches nothing it can see.
|
||||||
|
pub mapping_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Borrowed, publication-consistent row projection for in-process views.
|
/// Borrowed, publication-consistent row projection for in-process views.
|
||||||
|
|
@ -128,6 +137,8 @@ pub(crate) struct BorrowedScreenProjection<'a> {
|
||||||
pub cursor: Option<CellCoord>,
|
pub cursor: Option<CellCoord>,
|
||||||
pub title: Option<&'a str>,
|
pub title: Option<&'a str>,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
|
/// §5b — the mapping revision this projection was published at.
|
||||||
|
pub mapping_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BorrowedScreenProjection<'_> {
|
impl BorrowedScreenProjection<'_> {
|
||||||
|
|
@ -147,6 +158,7 @@ impl ScreenProjection {
|
||||||
cursor: self.cursor,
|
cursor: self.cursor,
|
||||||
title: self.title.as_deref(),
|
title: self.title.as_deref(),
|
||||||
generation: self.generation,
|
generation: self.generation,
|
||||||
|
mapping_revision: self.mapping_revision,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +204,9 @@ pub struct TerminalScreen {
|
||||||
tab_stops: BTreeSet<usize>,
|
tab_stops: BTreeSet<usize>,
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
/// §5b — see [`Screen::mapping_revision`]. Separate from
|
||||||
|
/// `generation`, which advances for style and title too.
|
||||||
|
mapping_revision: u64,
|
||||||
published: ScreenProjection,
|
published: ScreenProjection,
|
||||||
sync_started: Option<Instant>,
|
sync_started: Option<Instant>,
|
||||||
next_line_id: u64,
|
next_line_id: u64,
|
||||||
|
|
@ -221,6 +236,7 @@ impl TerminalScreen {
|
||||||
cursor: Some(CellCoord::new(0, 0)),
|
cursor: Some(CellCoord::new(0, 0)),
|
||||||
title: None,
|
title: None,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
mapping_revision: 0,
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
size,
|
size,
|
||||||
|
|
@ -240,6 +256,7 @@ impl TerminalScreen {
|
||||||
tab_stops: default_tab_stops(size.cols as usize),
|
tab_stops: default_tab_stops(size.cols as usize),
|
||||||
title: None,
|
title: None,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
mapping_revision: 0,
|
||||||
published,
|
published,
|
||||||
sync_started: None,
|
sync_started: None,
|
||||||
next_line_id,
|
next_line_id,
|
||||||
|
|
@ -269,24 +286,24 @@ impl TerminalScreen {
|
||||||
}
|
}
|
||||||
AnsiEvent::SetStyle(style) => {
|
AnsiEvent::SetStyle(style) => {
|
||||||
self.style = style;
|
self.style = style;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::CarriageReturn => {
|
AnsiEvent::CarriageReturn => {
|
||||||
self.cursor.col = 0;
|
self.cursor.col = 0;
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::Backspace => {
|
AnsiEvent::Backspace => {
|
||||||
self.cursor.col = self.cursor.col.saturating_sub(1);
|
self.cursor.col = self.cursor.col.saturating_sub(1);
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::Bell => {
|
AnsiEvent::Bell => {
|
||||||
self.bell_count = self.bell_count.saturating_add(1);
|
self.bell_count = self.bell_count.saturating_add(1);
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::LineFeed | AnsiEvent::Index => {
|
AnsiEvent::LineFeed | AnsiEvent::Index => {
|
||||||
|
|
@ -308,17 +325,17 @@ impl TerminalScreen {
|
||||||
}
|
}
|
||||||
AnsiEvent::SetTabStop => {
|
AnsiEvent::SetTabStop => {
|
||||||
self.tab_stops.insert(self.cursor.col);
|
self.tab_stops.insert(self.cursor.col);
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::ClearTabStop => {
|
AnsiEvent::ClearTabStop => {
|
||||||
self.tab_stops.remove(&self.cursor.col);
|
self.tab_stops.remove(&self.cursor.col);
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::ClearAllTabStops => {
|
AnsiEvent::ClearAllTabStops => {
|
||||||
self.tab_stops.clear();
|
self.tab_stops.clear();
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::CursorUp(n) => {
|
AnsiEvent::CursorUp(n) => {
|
||||||
|
|
@ -432,23 +449,23 @@ impl TerminalScreen {
|
||||||
CharacterSetSlot::G0 => self.g0 = charset,
|
CharacterSetSlot::G0 => self.g0 = charset,
|
||||||
CharacterSetSlot::G1 => self.g1 = charset,
|
CharacterSetSlot::G1 => self.g1 = charset,
|
||||||
}
|
}
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::ShiftOut => {
|
AnsiEvent::ShiftOut => {
|
||||||
self.use_g1 = true;
|
self.use_g1 = true;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::ShiftIn => {
|
AnsiEvent::ShiftIn => {
|
||||||
self.use_g1 = false;
|
self.use_g1 = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)),
|
AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)),
|
||||||
AnsiEvent::SetTitle(title) => {
|
AnsiEvent::SetTitle(title) => {
|
||||||
self.title = Some(sanitize_title(&title));
|
self.title = Some(sanitize_title(&title));
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AnsiEvent::EraseToEol => {
|
AnsiEvent::EraseToEol => {
|
||||||
|
|
@ -595,6 +612,7 @@ impl TerminalScreen {
|
||||||
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
||||||
title: self.title.as_deref(),
|
title: self.title.as_deref(),
|
||||||
generation: self.generation,
|
generation: self.generation,
|
||||||
|
mapping_revision: self.mapping_revision,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,7 +672,7 @@ impl TerminalScreen {
|
||||||
self.g0 = saved.g0;
|
self.g0 = saved.g0;
|
||||||
self.g1 = saved.g1;
|
self.g1 = saved.g1;
|
||||||
self.use_g1 = saved.use_g1;
|
self.use_g1 = saved.use_g1;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_text(&mut self, text: &str) {
|
fn write_text(&mut self, text: &str) {
|
||||||
|
|
@ -808,6 +826,24 @@ impl TerminalScreen {
|
||||||
if self.modes.insert {
|
if self.modes.insert {
|
||||||
self.insert_characters(width as u32);
|
self.insert_characters(width as u32);
|
||||||
}
|
}
|
||||||
|
// §5b — did the GLYPH change, or only the pen? Rewriting the
|
||||||
|
// same character in a new colour repaints the cell without
|
||||||
|
// changing what the coordinate denotes, and a drag must survive
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// Sampled BEFORE `clear_wide_at`, which blanks the cell when it
|
||||||
|
// is part of a wide pair — sampling after would compare the new
|
||||||
|
// glyph against a default and call every rewrite a change.
|
||||||
|
let glyph_changed = {
|
||||||
|
let row = self.cursor.row;
|
||||||
|
let col = self.cursor.col;
|
||||||
|
let cells = &self.active().rows[row].cells;
|
||||||
|
cells[col].glyph != Glyph::Char(ch)
|
||||||
|
|| (width == 2
|
||||||
|
&& cells
|
||||||
|
.get(col + 1)
|
||||||
|
.is_none_or(|next| next.glyph != Glyph::Continuation))
|
||||||
|
};
|
||||||
self.clear_wide_at(self.cursor.row, self.cursor.col);
|
self.clear_wide_at(self.cursor.row, self.cursor.col);
|
||||||
if width == 2 {
|
if width == 2 {
|
||||||
self.clear_wide_at(self.cursor.row, self.cursor.col + 1);
|
self.clear_wide_at(self.cursor.row, self.cursor.col + 1);
|
||||||
|
|
@ -828,7 +864,11 @@ impl TerminalScreen {
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
}
|
}
|
||||||
self.last_grapheme = Some((row, col));
|
self.last_grapheme = Some((row, col));
|
||||||
self.changed();
|
if glyph_changed {
|
||||||
|
self.changed();
|
||||||
|
} else {
|
||||||
|
self.display_only_changed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn soft_wrap(&mut self) {
|
fn soft_wrap(&mut self) {
|
||||||
|
|
@ -882,7 +922,7 @@ impl TerminalScreen {
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(cols - 1);
|
.unwrap_or(cols - 1);
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_vertical(&mut self, delta: i64) {
|
fn move_vertical(&mut self, delta: i64) {
|
||||||
|
|
@ -899,7 +939,7 @@ impl TerminalScreen {
|
||||||
};
|
};
|
||||||
self.cursor.row = moved.clamp(lo, hi);
|
self.cursor.row = moved.clamp(lo, hi);
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_horizontal(&mut self, delta: i64) {
|
fn move_horizontal(&mut self, delta: i64) {
|
||||||
|
|
@ -911,13 +951,13 @@ impl TerminalScreen {
|
||||||
};
|
};
|
||||||
self.cursor.col = moved.min(self.size.cols as usize - 1);
|
self.cursor.col = moved.min(self.size.cols as usize - 1);
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_col(&mut self, col: u32) {
|
fn set_col(&mut self, col: u32) {
|
||||||
self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize;
|
self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize;
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
fn set_row(&mut self, row: u32) {
|
fn set_row(&mut self, row: u32) {
|
||||||
let base = if self.modes.origin {
|
let base = if self.modes.origin {
|
||||||
|
|
@ -932,7 +972,7 @@ impl TerminalScreen {
|
||||||
};
|
};
|
||||||
self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi);
|
self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi);
|
||||||
self.cursor.pending_wrap = false;
|
self.cursor.pending_wrap = false;
|
||||||
self.changed();
|
self.display_only_changed();
|
||||||
}
|
}
|
||||||
fn set_position(&mut self, row: u32, col: u32) {
|
fn set_position(&mut self, row: u32, col: u32) {
|
||||||
self.set_row(row);
|
self.set_row(row);
|
||||||
|
|
@ -1466,6 +1506,32 @@ impl TerminalScreen {
|
||||||
|
|
||||||
fn changed(&mut self) {
|
fn changed(&mut self) {
|
||||||
self.generation = self.generation.saturating_add(1);
|
self.generation = self.generation.saturating_add(1);
|
||||||
|
// §5b: by DEFAULT a change also moves the mapping. Anything not
|
||||||
|
// explicitly classified as display-only is treated as content,
|
||||||
|
// which fails in the safe direction — over-cancelling a gesture
|
||||||
|
// is a nuisance, under-cancelling one lets a stale coordinate
|
||||||
|
// reach a child.
|
||||||
|
self.mapping_revision = self.mapping_revision.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change that repaints but **cannot move what a coordinate
|
||||||
|
/// denotes** (§5b's stable controls).
|
||||||
|
///
|
||||||
|
/// Style, title, bell, tab stops and pure cursor motion all land
|
||||||
|
/// here. The existing `generation` still advances — the screen does
|
||||||
|
/// look different — but `mapping_revision` does not, so a drag
|
||||||
|
/// survives them. Keying the panel's mapping on `generation` was
|
||||||
|
/// rejected for exactly this reason: it moves for all of these.
|
||||||
|
fn display_only_changed(&mut self) {
|
||||||
|
self.generation = self.generation.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — identity of what a terminal coordinate DENOTES.
|
||||||
|
///
|
||||||
|
/// Advances with content and topology, and holds across the display
|
||||||
|
/// changes above.
|
||||||
|
pub fn mapping_revision(&self) -> u64 {
|
||||||
|
self.mapping_revision
|
||||||
}
|
}
|
||||||
fn current_snapshot(&self) -> ScreenSnapshot {
|
fn current_snapshot(&self) -> ScreenSnapshot {
|
||||||
ScreenSnapshot {
|
ScreenSnapshot {
|
||||||
|
|
@ -1495,8 +1561,20 @@ impl TerminalScreen {
|
||||||
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
generation: self.generation,
|
generation: self.generation,
|
||||||
|
mapping_revision: self.mapping_revision,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Publish the current projection, for tests that drive events
|
||||||
|
/// directly instead of through the PTY reader.
|
||||||
|
///
|
||||||
|
/// `#[doc(hidden)]` rather than `#[cfg(test)]`: the rows that need
|
||||||
|
/// it are integration tests, which link the library WITHOUT
|
||||||
|
/// `cfg(test)` and so cannot see a gated item.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn publish_for_test(&mut self) {
|
||||||
|
self.publish();
|
||||||
|
}
|
||||||
|
|
||||||
fn publish(&mut self) {
|
fn publish(&mut self) {
|
||||||
self.published = self.current_projection();
|
self.published = self.current_projection();
|
||||||
}
|
}
|
||||||
|
|
@ -1910,6 +1988,67 @@ mod tests {
|
||||||
assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0)));
|
assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5b G3 — the terminal **stable controls**.
|
||||||
|
///
|
||||||
|
/// These are exactly the events that make `generation` unusable as a
|
||||||
|
/// mapping key: each one advances it. `mapping_revision` must hold
|
||||||
|
/// across all of them, or a drag over a panel terminal dies the
|
||||||
|
/// moment the child recolours a character or rings the bell.
|
||||||
|
#[test]
|
||||||
|
fn display_only_events_advance_the_generation_but_not_the_mapping() {
|
||||||
|
for (name, event) in [
|
||||||
|
("style", AnsiEvent::SetStyle(Style::default())),
|
||||||
|
("title", AnsiEvent::SetTitle("t".to_owned())),
|
||||||
|
("bell", AnsiEvent::Bell),
|
||||||
|
("tab stop", AnsiEvent::SetTabStop),
|
||||||
|
("clear tab stops", AnsiEvent::ClearAllTabStops),
|
||||||
|
("carriage return", AnsiEvent::CarriageReturn),
|
||||||
|
] {
|
||||||
|
let mut s = screen(2, 16);
|
||||||
|
let before_generation = s.snapshot().generation;
|
||||||
|
let before_mapping = s.mapping_revision();
|
||||||
|
|
||||||
|
s.apply_event(event);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
s.snapshot().generation > before_generation,
|
||||||
|
"{name} repaints, so the display generation must advance \
|
||||||
|
— otherwise this row proves nothing about the split"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.mapping_revision(),
|
||||||
|
before_mapping,
|
||||||
|
"{name} cannot change what a coordinate denotes, so the \
|
||||||
|
MAPPING revision must hold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G2 — content and topology **do** move the mapping revision.
|
||||||
|
///
|
||||||
|
/// The positive half. Without it, a `mapping_revision` that never
|
||||||
|
/// advanced at all would pass every stable control above.
|
||||||
|
#[test]
|
||||||
|
fn content_events_advance_the_mapping_revision() {
|
||||||
|
for (name, event) in [
|
||||||
|
("text", AnsiEvent::Text("hi".to_owned())),
|
||||||
|
("line feed", AnsiEvent::LineFeed),
|
||||||
|
(
|
||||||
|
"erase display",
|
||||||
|
AnsiEvent::EraseDisplay(crate::ansi::EraseMode::ToEnd),
|
||||||
|
),
|
||||||
|
("scroll up", AnsiEvent::ScrollUp(1)),
|
||||||
|
] {
|
||||||
|
let mut s = screen(2, 16);
|
||||||
|
let before = s.mapping_revision();
|
||||||
|
s.apply_event(event);
|
||||||
|
assert!(
|
||||||
|
s.mapping_revision() > before,
|
||||||
|
"{name} changes what a coordinate denotes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resize_only_adds_default_tab_stops_in_new_columns() {
|
fn resize_only_adds_default_tab_stops_in_new_columns() {
|
||||||
let mut s = screen(2, 16);
|
let mut s = screen(2, 16);
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,60 @@ impl TerminalManager {
|
||||||
self.screen_size(key.buffer_id)
|
self.screen_size(key.buffer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply one parsed event to a session's screen, for tests.
|
||||||
|
///
|
||||||
|
/// Terminal output normally arrives on the PTY reader thread, which
|
||||||
|
/// no daemon-level test can drive deterministically. §5b's terminal
|
||||||
|
/// rows must nevertheless be witnessed **across the seam** — the
|
||||||
|
/// screen counter and the daemon's key are separately provable, and
|
||||||
|
/// a `view_mapping_identity` returning a constant would leave both
|
||||||
|
/// green — so this exists to join them.
|
||||||
|
///
|
||||||
|
/// `#[doc(hidden)]` rather than `#[cfg(test)]`, because the rows
|
||||||
|
/// that need it are integration tests and those link the library
|
||||||
|
/// without `cfg(test)`.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn apply_event_for_test(
|
||||||
|
&mut self,
|
||||||
|
buffer_id: BufferId,
|
||||||
|
event: crate::ansi::AnsiEvent,
|
||||||
|
) -> bool {
|
||||||
|
match self.sessions.get_mut(&buffer_id) {
|
||||||
|
Some(session) => {
|
||||||
|
session.screen.apply_event(event);
|
||||||
|
session.screen.publish_for_test();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — the terminal's **mapping revision** plus its per-view
|
||||||
|
/// scroll anchor: together, the identity of what a coordinate in
|
||||||
|
/// this view denotes.
|
||||||
|
///
|
||||||
|
/// The anchor is part of it because the same coordinate names a
|
||||||
|
/// different retained row once the view scrolls, even with the
|
||||||
|
/// child's screen untouched.
|
||||||
|
#[must_use]
|
||||||
|
pub fn view_mapping_identity(
|
||||||
|
&self,
|
||||||
|
key: TerminalViewKey,
|
||||||
|
) -> Option<(u64, Option<LogicalCellAnchor>)> {
|
||||||
|
let session = self.sessions.get(&key.buffer_id)?;
|
||||||
|
// `top` IS the anchor: `None` means following the live tail,
|
||||||
|
// which is itself a distinct state from any pinned row.
|
||||||
|
let anchor = self.views.get(&key).and_then(|view| view.top);
|
||||||
|
// The PUBLISHED revision, not the live one. While synchronized
|
||||||
|
// output is held, `projection_ref` keeps returning the last
|
||||||
|
// published cells while the screen races ahead — reading
|
||||||
|
// `screen.mapping_revision()` there would stamp displayed cells
|
||||||
|
// with authority they were never painted under, and the frontend
|
||||||
|
// would echo a generation matching nothing it can see.
|
||||||
|
let published = session.screen.projection_ref().mapping_revision;
|
||||||
|
Some((published, anchor))
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared screen's current size, read from the borrowed
|
/// The shared screen's current size, read from the borrowed
|
||||||
/// projection.
|
/// projection.
|
||||||
///
|
///
|
||||||
|
|
@ -965,6 +1019,7 @@ mod tests {
|
||||||
cursor: None,
|
cursor: None,
|
||||||
title: Some("shell".into()),
|
title: Some("shell".into()),
|
||||||
generation: 7,
|
generation: 7,
|
||||||
|
mapping_revision: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1245,6 +1300,7 @@ mod tests {
|
||||||
cursor: None,
|
cursor: None,
|
||||||
title: None,
|
title: None,
|
||||||
generation: 2,
|
generation: 2,
|
||||||
|
mapping_revision: 0,
|
||||||
};
|
};
|
||||||
let mut state = TerminalViewState {
|
let mut state = TerminalViewState {
|
||||||
top: Some(LogicalCellAnchor {
|
top: Some(LogicalCellAnchor {
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,16 @@ impl Session {
|
||||||
first
|
first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The frame from whichever Present family this session negotiated.
|
||||||
|
///
|
||||||
|
/// Family-agnostic on purpose: these rows are about the PROJECTION,
|
||||||
|
/// and which wrapper carries it is §5b's own concern, pinned by the
|
||||||
|
/// G6/G7/G8 rows rather than incidentally by thirty others.
|
||||||
fn present(&mut self) -> PanelFrame {
|
fn present(&mut self) -> PanelFrame {
|
||||||
match self.frame() {
|
match self.frame() {
|
||||||
Some(PanelFramePayload::Present(frame)) => frame,
|
Some(
|
||||||
|
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||||
|
) => frame,
|
||||||
other => panic!("expected a Present panel payload, got {other:?}"),
|
other => panic!("expected a Present panel payload, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +529,10 @@ fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() {
|
||||||
session.declare(1, ROWS, COLS);
|
session.declare(1, ROWS, COLS);
|
||||||
open_panel(&session, "*panel*", 4);
|
open_panel(&session, "*panel*", 4);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(session.frame(), Some(PanelFramePayload::Present(_))),
|
matches!(
|
||||||
|
session.frame(),
|
||||||
|
Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. })
|
||||||
|
),
|
||||||
"{label}: fixture precondition — a band was visible first"
|
"{label}: fixture precondition — a band was visible first"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -898,7 +908,9 @@ fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() {
|
||||||
let panel_rows = messages
|
let panel_rows = messages
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|message| match message {
|
.find_map(|message| match message {
|
||||||
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)),
|
InstanceMessage::PanelFrame(
|
||||||
|
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||||
|
) => Some(rows_of(frame)),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("a panel frame");
|
.expect("a panel frame");
|
||||||
|
|
@ -1327,7 +1339,9 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
|
||||||
session.state.core.borrow_mut().focus_window(FID, panel);
|
session.state.core.borrow_mut().focus_window(FID, panel);
|
||||||
|
|
||||||
let frame = match session.frame() {
|
let frame = match session.frame() {
|
||||||
Some(PanelFramePayload::Present(frame)) => frame,
|
Some(
|
||||||
|
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||||
|
) => frame,
|
||||||
other => panic!(
|
other => panic!(
|
||||||
"a legally wide panel must still present its terminal; got {other:?} \
|
"a legally wide panel must still present its terminal; got {other:?} \
|
||||||
— and the durable state says hidden={}",
|
— and the durable state says hidden={}",
|
||||||
|
|
@ -1362,3 +1376,728 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
|
||||||
// write into their real data root.
|
// write into their real data root.
|
||||||
#[path = "common/iso.rs"]
|
#[path = "common/iso.rs"]
|
||||||
mod iso;
|
mod iso;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §5b G1–G4 — the authoritative cell-mapping key
|
||||||
|
//
|
||||||
|
// The key is derived from a FINGERPRINT of the inverse mapping's inputs,
|
||||||
|
// so the changing/stable split is structural: an input that is hashed
|
||||||
|
// moves the key by construction, and one that is not cannot. These rows
|
||||||
|
// pin each input individually, because a single "it changed" row cannot
|
||||||
|
// show WHICH input moved it — and a key that silently ignored, say,
|
||||||
|
// `view_left` would pass every row that only scrolls vertically.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign
|
||||||
|
/// edit" the ladder cannot see. Done in Rust rather than Lua because it
|
||||||
|
/// must be a plain content mutation with no view, cursor or command
|
||||||
|
/// state attached to it.
|
||||||
|
fn foreign_edit(session: &Session, text: &str) {
|
||||||
|
let core = session.state.core.borrow();
|
||||||
|
let side = core.side_window_for(FID).expect("a side window");
|
||||||
|
let buffer_id = core.windows[&side].buffer_id;
|
||||||
|
let registry = core.registry.clone();
|
||||||
|
let mut reg = registry.borrow_mut();
|
||||||
|
let buffer = reg.get_mut(buffer_id).expect("the panel's buffer");
|
||||||
|
buffer
|
||||||
|
.set_generated_contents(text.as_bytes())
|
||||||
|
.expect("a generated-contents write is a plain content change");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key as the daemon would compute it for `FID`, advancing on change.
|
||||||
|
fn mapping_generation(session: &mut Session) -> Option<u64> {
|
||||||
|
let snapshot = session.state.panel_mapping_snapshot(FID);
|
||||||
|
session.render.panel_mapping_generation(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G1 — a **foreign** edit before the next render moves the key.
|
||||||
|
///
|
||||||
|
/// This is the case the whole slice exists for: the epoch ladder cannot
|
||||||
|
/// see it. No buffer is replaced, no panel reopens, no geometry is
|
||||||
|
/// re-declared — every epoch holds — and yet the byte under a cell has
|
||||||
|
/// changed. It is also why the key must be derived on demand rather than
|
||||||
|
/// from the last emitted frame: nothing has rendered here.
|
||||||
|
#[test]
|
||||||
|
fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g1", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let before = mapping_generation(&mut session).expect("a presentable panel has a key");
|
||||||
|
assert!(
|
||||||
|
before >= 1,
|
||||||
|
"a live key is never zero — zero is the wire's invalid value"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An edit from somewhere other than the gesture's frontend, with no
|
||||||
|
// render in between.
|
||||||
|
foreign_edit(&session, "foreign edit\n");
|
||||||
|
|
||||||
|
let after = mapping_generation(&mut session).expect("still presentable");
|
||||||
|
assert!(
|
||||||
|
after > before,
|
||||||
|
"a foreign edit changes which byte a cell means, and no epoch \
|
||||||
|
moves with it — this is the hole the ladder cannot close"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G15 — **TUI structural control**: the local panel click, drag
|
||||||
|
/// and wheel paths keep their effects with NO mapping generation
|
||||||
|
/// anywhere.
|
||||||
|
///
|
||||||
|
/// The TUI hit-tests the daemon's own state directly. It receives no
|
||||||
|
/// `PanelFrame`, so it has no generation to echo and there is nothing
|
||||||
|
/// for a freshness check to compare. If the check ever migrates from
|
||||||
|
/// the authenticated semantic boundary into shared replay, local input
|
||||||
|
/// stops working entirely — and it would stop silently, because
|
||||||
|
/// refusing a gesture looks exactly like a gesture that did nothing.
|
||||||
|
///
|
||||||
|
/// The control is the FIXTURE: this session has no
|
||||||
|
/// `SemanticRenderState` at all. Every effect below is therefore
|
||||||
|
/// reached without a producer in existence, which is the strongest form
|
||||||
|
/// of "no generation was consulted" — not an assertion about a value,
|
||||||
|
/// but the absence of anything that could hold one.
|
||||||
|
#[test]
|
||||||
|
#[allow(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "one control over three input kinds: splitting it would \
|
||||||
|
give each leg its own fixture, and the shared fixture — a \
|
||||||
|
session with no SemanticRenderState at all — is the control"
|
||||||
|
)]
|
||||||
|
fn g15_local_tui_panel_input_keeps_its_effects_with_no_generation() {
|
||||||
|
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind as TuiMouseKind};
|
||||||
|
|
||||||
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
||||||
|
exec(&state, "pmacs.lsp.config = {}");
|
||||||
|
state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS));
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
"PANEL_BUF = pmacs.buffer.create(\"*g15*\")
|
||||||
|
pmacs.buffer.set_generated_contents(PANEL_BUF, \
|
||||||
|
\"alpha alpha\\nbravo bravo\\ncharlie\\ndelta\\necho\\nfox\\ngolf\\nhotel\\n\")
|
||||||
|
pmacs.window.display(PANEL_BUF, { side = \"bottom\", height = 4 })",
|
||||||
|
);
|
||||||
|
let mut state = state;
|
||||||
|
let panel = state
|
||||||
|
.core
|
||||||
|
.borrow()
|
||||||
|
.side_window_for(FrontendId::LOCAL)
|
||||||
|
.expect("a panel");
|
||||||
|
let document = state
|
||||||
|
.core
|
||||||
|
.borrow()
|
||||||
|
.non_side_target(FrontendId::LOCAL)
|
||||||
|
.expect("a document");
|
||||||
|
|
||||||
|
// Paint one frame first, exactly as the TUI does before a user can
|
||||||
|
// click anything. The window's text view is built from the buffer at
|
||||||
|
// paint time, so input dispatched against an unpainted window maps
|
||||||
|
// every row to offset zero and the row proves nothing.
|
||||||
|
{
|
||||||
|
let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
|
||||||
|
let mut grid = pmacs::cell::CellGrid {
|
||||||
|
cells: &mut cells,
|
||||||
|
stride: COLS,
|
||||||
|
size: CellSize::new(ROWS, COLS),
|
||||||
|
};
|
||||||
|
pmacs::editor::paint_frame(
|
||||||
|
&state,
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
&HashMap::new(),
|
||||||
|
&mut grid,
|
||||||
|
CellSize::new(ROWS, COLS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the panel actually is, from the same layout the TUI paints.
|
||||||
|
let panel_rect = {
|
||||||
|
let core = state.core.borrow();
|
||||||
|
let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view");
|
||||||
|
let area = pmacs::window::Rect::new(0, 0, ROWS - 1, COLS);
|
||||||
|
let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows);
|
||||||
|
view.layout.compute(area, &fixed)[&panel]
|
||||||
|
};
|
||||||
|
let row = u16::try_from(panel_rect.origin.row + 1).expect("row fits");
|
||||||
|
// Past the gutter, and inside a line that is long enough for the
|
||||||
|
// drag below to stay within it: clamping at the line end would make
|
||||||
|
// press and drag land on the same byte and prove nothing.
|
||||||
|
let gutter = state.core.borrow().windows[&panel].gutter_width();
|
||||||
|
let col = u16::try_from(panel_rect.origin.col + gutter + 1).expect("col fits");
|
||||||
|
let click = |kind| MouseEvent {
|
||||||
|
kind,
|
||||||
|
column: col,
|
||||||
|
row,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Precondition, scoped to THIS view: the panel is not already
|
||||||
|
// focused, or every assertion below would pass without input.
|
||||||
|
assert_eq!(
|
||||||
|
state.core.borrow().views[&FrontendId::LOCAL].active,
|
||||||
|
document,
|
||||||
|
"precondition: the document is focused"
|
||||||
|
);
|
||||||
|
|
||||||
|
// CLICK — focuses the panel and positions the cursor.
|
||||||
|
state.dispatch_mouse(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
click(TuiMouseKind::Down(MouseButton::Left)),
|
||||||
|
CellSize::new(ROWS, COLS),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.core.borrow().views[&FrontendId::LOCAL].active,
|
||||||
|
panel,
|
||||||
|
"a local press focuses the panel — no token, no refusal"
|
||||||
|
);
|
||||||
|
let pressed_cursor = state.core.borrow().windows[&panel].cursor;
|
||||||
|
assert_ne!(
|
||||||
|
pressed_cursor, 0,
|
||||||
|
"the press positioned the cursor inside the text, not at the \
|
||||||
|
buffer start — a row that pressed into an unpainted window \
|
||||||
|
would read zero here and never notice"
|
||||||
|
);
|
||||||
|
|
||||||
|
// DRAG — extends a selection inside the panel.
|
||||||
|
state.dispatch_mouse(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
MouseEvent {
|
||||||
|
kind: TuiMouseKind::Drag(MouseButton::Left),
|
||||||
|
column: col + 7,
|
||||||
|
row,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
},
|
||||||
|
CellSize::new(ROWS, COLS),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.core.borrow().windows[&panel].selection.is_some(),
|
||||||
|
"a local drag selects inside the panel"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
state.core.borrow().windows[&panel].cursor,
|
||||||
|
pressed_cursor,
|
||||||
|
"and the drag moved the cursor, so the selection is a real range"
|
||||||
|
);
|
||||||
|
|
||||||
|
state.dispatch_mouse(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
MouseEvent {
|
||||||
|
kind: TuiMouseKind::Up(MouseButton::Left),
|
||||||
|
column: col + 7,
|
||||||
|
row,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
},
|
||||||
|
CellSize::new(ROWS, COLS),
|
||||||
|
);
|
||||||
|
|
||||||
|
// WHEEL — two ticks, both of which must land. The mapped family's
|
||||||
|
// wheel exemption exists so this stays true over the wire; here
|
||||||
|
// there is no wire, and it must be true for the same reason.
|
||||||
|
let before = state.core.borrow().windows[&panel].view_top;
|
||||||
|
for _ in 0..2 {
|
||||||
|
state.dispatch_mouse(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
click(TuiMouseKind::ScrollDown),
|
||||||
|
CellSize::new(ROWS, COLS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
state.core.borrow().windows[&panel].view_top,
|
||||||
|
before,
|
||||||
|
"a local wheel scrolls the panel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G9a — a generation change is EMITTED even when the picture is
|
||||||
|
/// byte-identical.
|
||||||
|
///
|
||||||
|
/// The panel shows four rows; an edit further down the buffer moves the
|
||||||
|
/// key without changing a single visible cell. Suppressing that frame
|
||||||
|
/// because the cells match would leave the frontend echoing a
|
||||||
|
/// generation the daemon has already retired, and every gesture it then
|
||||||
|
/// sends is refused — the panel goes quietly dead while looking
|
||||||
|
/// perfectly correct.
|
||||||
|
#[test]
|
||||||
|
fn g9a_a_generation_change_ships_even_when_the_cells_are_identical() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g9a", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
|
||||||
|
// Enough lines that the visible four cannot see the edit below.
|
||||||
|
let mut lines = String::new();
|
||||||
|
for line in 0..20 {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
writeln!(lines, "line {line}").expect("writing to a String never fails");
|
||||||
|
}
|
||||||
|
foreign_edit(&session, &lines);
|
||||||
|
let baseline = session.present();
|
||||||
|
let before = mapping_generation(&mut session).expect("a live key");
|
||||||
|
|
||||||
|
// Change line 15 only. Rows 0..4 are untouched.
|
||||||
|
let edited = lines.replace("line 15\n", "LINE 15 CHANGED\n");
|
||||||
|
assert_ne!(edited, lines, "fixture: the edit must actually apply");
|
||||||
|
foreign_edit(&session, &edited);
|
||||||
|
|
||||||
|
let payload = session
|
||||||
|
.frame()
|
||||||
|
.expect("a generation change is not silence: the frame must ship");
|
||||||
|
let (frame, shipped) = match payload {
|
||||||
|
PanelFramePayload::PresentMapped {
|
||||||
|
frame,
|
||||||
|
mapping_generation,
|
||||||
|
} => (frame, mapping_generation),
|
||||||
|
other => panic!("a v25 producer ships the mapped family, got {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
frame.cells, baseline.cells,
|
||||||
|
"fixture: the visible cells really are identical — if they \
|
||||||
|
differ, this row is testing an ordinary repaint instead"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shipped > before,
|
||||||
|
"and the key moved with the edit below the fold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G3 — the **stable** inputs, one row each.
|
||||||
|
///
|
||||||
|
/// Every entry here is something that repaints a panel without changing
|
||||||
|
/// which byte a cell denotes. A drag provokes selection repaints on every
|
||||||
|
/// motion, so a key that moved with them would cancel the gesture it
|
||||||
|
/// exists to protect after a single step.
|
||||||
|
#[test]
|
||||||
|
fn g3_repaints_that_cannot_move_a_byte_leave_the_key_alone() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g3", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let baseline = mapping_generation(&mut session).expect("a key");
|
||||||
|
|
||||||
|
// Re-reading with nothing changed at all.
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(baseline),
|
||||||
|
"an idle re-read must not advance the key, or every frame would \
|
||||||
|
cancel every gesture"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cursor motion the follow rules absorb: the caret moves inside the
|
||||||
|
// viewport, so no origin moves with it. Set directly, so nothing but
|
||||||
|
// the cursor changes.
|
||||||
|
{
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("a side window");
|
||||||
|
let window = core.windows.get_mut(&side).expect("the side window");
|
||||||
|
window.cursor = 0;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(baseline),
|
||||||
|
"cursor motion that moves no origin is not a mapping change"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G4a — a **selection-only** repaint preserves the key.
|
||||||
|
///
|
||||||
|
/// Split from G3 because it is the one the lifecycle depends on: G4b —
|
||||||
|
/// that an in-flight drag then continues through real replay — is owed by
|
||||||
|
/// the rebased replay lane, which is the only branch where replay exists.
|
||||||
|
#[test]
|
||||||
|
fn g4a_a_selection_only_repaint_preserves_the_mapping_key() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g4a", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let baseline = mapping_generation(&mut session).expect("a key");
|
||||||
|
foreign_edit(&session, "alpha beta\n");
|
||||||
|
let after_edit = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(after_edit > baseline, "the edit itself is a mapping change");
|
||||||
|
|
||||||
|
// Now a selection, with no content or viewport change.
|
||||||
|
{
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("a side window");
|
||||||
|
let window = core.windows.get_mut(&side).expect("the side window");
|
||||||
|
window.selection = Some(pmacs::window::Selection { anchor: 0 });
|
||||||
|
window.cursor = 5;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(after_edit),
|
||||||
|
"a selection changes what is HIGHLIGHTED, never what a cell \
|
||||||
|
denotes — and a drag repaints the selection on every motion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — the key is a **high-water mark** and survives `Absent`.
|
||||||
|
///
|
||||||
|
/// Hiding the band clears input authority, but it must not reset the
|
||||||
|
/// generation: a frame delayed across the hide would otherwise return
|
||||||
|
/// with a lower value and be believed.
|
||||||
|
#[test]
|
||||||
|
fn the_mapping_key_never_moves_backward_across_a_hidden_panel() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "hw", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let before = mapping_generation(&mut session).expect("a key");
|
||||||
|
foreign_edit(&session, "one\n");
|
||||||
|
let peak = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(peak > before);
|
||||||
|
|
||||||
|
// Hide it: no fingerprint, so no advance — and no reset either.
|
||||||
|
{
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
core.views
|
||||||
|
.get_mut(&FID)
|
||||||
|
.expect("the frontend's view")
|
||||||
|
.panel_hidden = true;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
None,
|
||||||
|
"no presentable panel means no key to stamp, which is not the \
|
||||||
|
same as a key of zero"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.render.panel_mapping_generation_peek(),
|
||||||
|
Some(peak),
|
||||||
|
"the high-water mark SURVIVES the hide — clearing it would let a \
|
||||||
|
delayed frame roll the producer's authority backward"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G2 — **every changing input, one leg each.**
|
||||||
|
///
|
||||||
|
/// Enumerated rather than asserted in aggregate, and mutation testing is
|
||||||
|
/// what forced it: with only the content-edit row present, dropping
|
||||||
|
/// `view_left` from the key and collapsing the grid to `rows * cols`
|
||||||
|
/// both stayed GREEN. A single "the key moved" row cannot show *which*
|
||||||
|
/// input moved it, and a key that ignores horizontal scrolling passes
|
||||||
|
/// every row that only scrolls vertically.
|
||||||
|
#[test]
|
||||||
|
fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() {
|
||||||
|
// Each leg names one input and touches only that input.
|
||||||
|
type Leg = (&'static str, fn(&Session));
|
||||||
|
|
||||||
|
let legs: &[Leg] = &[
|
||||||
|
("view_top", |session| {
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
core.windows.get_mut(&side).expect("win").view_top += 1;
|
||||||
|
}),
|
||||||
|
("view_left — GUI arc 1b makes this real", |session| {
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
core.windows.get_mut(&side).expect("win").view_left += 1;
|
||||||
|
}),
|
||||||
|
("wrap mode", |session| {
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
let window = core.windows.get_mut(&side).expect("win");
|
||||||
|
window.last_wrap = match window.last_wrap {
|
||||||
|
pmacs::view::WrapMode::Wrap => pmacs::view::WrapMode::Truncate,
|
||||||
|
pmacs::view::WrapMode::Truncate => pmacs::view::WrapMode::Wrap,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
(
|
||||||
|
"content columns — the gutter is subtracted here",
|
||||||
|
|session| {
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
core.windows.get_mut(&side).expect("win").last_content_cols += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("fold PROJECTION POLICY, owned by the view", |session| {
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let view = core.views.get_mut(&FID).expect("view");
|
||||||
|
view.fold_projection = !view.fold_projection;
|
||||||
|
}),
|
||||||
|
("fold CONTENT, owned by the buffer", |session| {
|
||||||
|
let core = session.state.core.borrow();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
let buffer_id = core.windows[&side].buffer_id;
|
||||||
|
let registry = core.registry.clone();
|
||||||
|
let mut reg = registry.borrow_mut();
|
||||||
|
let buffer = reg.get_mut(buffer_id).expect("buffer");
|
||||||
|
let store = core.fold_registry.store_or_attach(buffer);
|
||||||
|
store
|
||||||
|
.lock()
|
||||||
|
.expect("fold store mutex")
|
||||||
|
.insert(pmacs_protocol::ByteRange { start: 0, end: 1 });
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, mutate) in legs {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g2", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let before = mapping_generation(&mut session).expect("a key");
|
||||||
|
mutate(&session);
|
||||||
|
let after = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(
|
||||||
|
after > before,
|
||||||
|
"changing {name} changes which byte a cell means, so the key \
|
||||||
|
must move; it did not ({before} → {after})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G2 — grid **rows** and **columns** are independent inputs,
|
||||||
|
/// proven by a **transposition**.
|
||||||
|
///
|
||||||
|
/// Revision-16's version changed one dimension at a time and could not
|
||||||
|
/// discriminate: `last_content_cols` co-varies with a column change, so
|
||||||
|
/// collapsing the key to `rows * cols` stayed green. I recorded that as
|
||||||
|
/// unwitnessable and claimed no production path reached a
|
||||||
|
/// same-area transition. **That was wrong** — resize plus redeclare
|
||||||
|
/// gets there: 4×80 → 8×40 holds the product at 320 while swapping the
|
||||||
|
/// dimensions, and `last_content_cols` is not refreshed until the next
|
||||||
|
/// render, so the two grid fields are isolated.
|
||||||
|
#[test]
|
||||||
|
fn g2_a_transposed_grid_moves_the_key_at_an_unchanged_area() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g2t", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let before = mapping_generation(&mut session).expect("a key");
|
||||||
|
|
||||||
|
// 4×80 → 8×40. Same area, different shape, and no render between.
|
||||||
|
assert!(
|
||||||
|
session.state.apply_panel_resize_rows(FID, 8),
|
||||||
|
"the resize must be accepted, or the transposition never happens"
|
||||||
|
);
|
||||||
|
session.declare(2, 24, 40);
|
||||||
|
|
||||||
|
let after = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(
|
||||||
|
after > before,
|
||||||
|
"a transposed grid inverts differently at the same area — a key \
|
||||||
|
hashing rows*cols would not notice ({before} → {after})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b G3 — **focus** is a stable input.
|
||||||
|
///
|
||||||
|
/// Missing from the first version of G3, which covered only idle and
|
||||||
|
/// cursor. Focus in particular is the one a naive
|
||||||
|
/// implementation gets wrong, because the panel frame carries a
|
||||||
|
/// `focused` flag and it is tempting to fold the whole frame into the
|
||||||
|
/// key.
|
||||||
|
#[test]
|
||||||
|
fn g3_focus_is_a_stable_input() {
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "g3b", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
|
||||||
|
let baseline = mapping_generation(&mut session).expect("a key");
|
||||||
|
|
||||||
|
// Focus. The band's `focused` flag flips; no byte moves.
|
||||||
|
{
|
||||||
|
let mut core = session.state.core.borrow_mut();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
core.focus_window(FID, side);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(baseline),
|
||||||
|
"focus decides CHROME, never which byte a cell denotes — and a \
|
||||||
|
click focuses the panel mid-gesture"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Styling is pinned STRUCTURALLY rather than by driving a theme
|
||||||
|
// change here: `PanelMappingSnapshot` has no style field at all, so
|
||||||
|
// there is nothing a recolour could touch. The terminal side — where
|
||||||
|
// a convenient style-bumping counter DOES exist and had to be
|
||||||
|
// rejected — is pinned in `screen.rs`'s own tests, at the level the
|
||||||
|
// classification lives.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — the snapshot **selects the right domain by target kind**.
|
||||||
|
///
|
||||||
|
/// Added because mutation testing found the branch unwitnessed: routing
|
||||||
|
/// terminal panels through the DOCUMENT arm — keying them on the
|
||||||
|
/// buffer's revision, which §5b explicitly rejects — left all thirty-five
|
||||||
|
/// other rows green. The daemon-level half of the terminal contract is
|
||||||
|
/// that the branch is taken at all; `screen.rs` owns the half that says
|
||||||
|
/// the revision it reads classifies events correctly.
|
||||||
|
#[test]
|
||||||
|
fn the_mapping_snapshot_picks_the_terminal_domain_for_a_terminal_panel() {
|
||||||
|
// Document panel → the document domain.
|
||||||
|
{
|
||||||
|
let mut session = Session::new();
|
||||||
|
open_panel(&session, "doc", 4);
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
let _ = session.present();
|
||||||
|
let snapshot = session
|
||||||
|
.state
|
||||||
|
.panel_mapping_snapshot(FID)
|
||||||
|
.expect("a presentable document panel");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
snapshot.content(),
|
||||||
|
pmacs::editor::PanelMappingContent::Document { .. }
|
||||||
|
),
|
||||||
|
"a document panel is keyed on its buffer's content revision"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal panel → the terminal domain.
|
||||||
|
{
|
||||||
|
let mut session = Session::new();
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
exec(
|
||||||
|
&session.state,
|
||||||
|
"TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \
|
||||||
|
args = { \"-c\", \"sleep 30\" }, display = \"panel\" }",
|
||||||
|
);
|
||||||
|
let _ = session.frame();
|
||||||
|
let snapshot = session
|
||||||
|
.state
|
||||||
|
.panel_mapping_snapshot(FID)
|
||||||
|
.expect("a presentable terminal panel");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
snapshot.content(),
|
||||||
|
pmacs::editor::PanelMappingContent::Terminal { .. }
|
||||||
|
),
|
||||||
|
"a terminal panel is keyed on the SCREEN's mapping revision \
|
||||||
|
and scroll anchor — its buffer revision tracks something \
|
||||||
|
else entirely and would both miss real changes and fire on \
|
||||||
|
non-changes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5b — the terminal key **across the seam**.
|
||||||
|
///
|
||||||
|
/// `screen.rs` proves the counter classifies events correctly, and the
|
||||||
|
/// row above proves the snapshot picks the terminal domain. Neither
|
||||||
|
/// notices a `view_mapping_identity` that returns a CONSTANT: the
|
||||||
|
/// classification is right, the branch is taken, and the daemon's key
|
||||||
|
/// still never moves. These rows join the two halves.
|
||||||
|
#[test]
|
||||||
|
fn g2_g3_a_terminal_panels_key_tracks_its_screen_and_anchor() {
|
||||||
|
use pmacs::ansi::AnsiEvent;
|
||||||
|
|
||||||
|
let mut session = Session::new();
|
||||||
|
session.declare(1, 24, 80);
|
||||||
|
exec(
|
||||||
|
&session.state,
|
||||||
|
"TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \
|
||||||
|
args = { \"-c\", \"sleep 30\" }, display = \"panel\" }",
|
||||||
|
);
|
||||||
|
let _ = session.frame();
|
||||||
|
|
||||||
|
let buffer_id = {
|
||||||
|
let core = session.state.core.borrow();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
core.windows[&side].buffer_id
|
||||||
|
};
|
||||||
|
let feed = |session: &Session, event: AnsiEvent| {
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.state
|
||||||
|
.terminal_manager
|
||||||
|
.borrow_mut()
|
||||||
|
.apply_event_for_test(buffer_id, event),
|
||||||
|
"the panel's terminal session must exist"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = mapping_generation(&mut session).expect("a terminal panel has a key");
|
||||||
|
|
||||||
|
// CHANGING: a glyph appears where none was.
|
||||||
|
feed(&session, AnsiEvent::Text("A".to_owned()));
|
||||||
|
let after_glyph = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(
|
||||||
|
after_glyph > start,
|
||||||
|
"new output changes what a coordinate denotes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// STABLE: the same glyph rewritten under a different pen. This is
|
||||||
|
// the row that forced `write_character` to compare glyphs — a
|
||||||
|
// blanket bump made a recolour cancel the drag.
|
||||||
|
feed(&session, AnsiEvent::CursorPosition { row: 1, col: 1 });
|
||||||
|
feed(
|
||||||
|
&session,
|
||||||
|
AnsiEvent::SetStyle(pmacs_protocol::Style::default()),
|
||||||
|
);
|
||||||
|
feed(&session, AnsiEvent::Text("A".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(after_glyph),
|
||||||
|
"rewriting the SAME glyph in another style repaints the cell \
|
||||||
|
without moving what it denotes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// STABLE: ordinary cursor motion.
|
||||||
|
feed(&session, AnsiEvent::CursorPosition { row: 2, col: 3 });
|
||||||
|
assert_eq!(
|
||||||
|
mapping_generation(&mut session),
|
||||||
|
Some(after_glyph),
|
||||||
|
"moving the caret denotes nothing new — and these paths were \
|
||||||
|
advancing the revision until §5b's terminal correction"
|
||||||
|
);
|
||||||
|
|
||||||
|
// CHANGING: the view's **scroll anchor** moves, with the child's
|
||||||
|
// screen untouched. The same coordinate then names a different
|
||||||
|
// retained row.
|
||||||
|
//
|
||||||
|
// This is the leg that separates the anchor from the screen
|
||||||
|
// revision: the baseline is taken AFTER the history exists, so a
|
||||||
|
// constant anchor leaves it unchanged and the assertion below fails
|
||||||
|
// even though `mapping_revision` is perfectly live.
|
||||||
|
let key = {
|
||||||
|
let core = session.state.core.borrow();
|
||||||
|
let side = core.side_window_for(FID).expect("side");
|
||||||
|
pmacs::terminal::view::TerminalViewKey::new(FID, side, buffer_id)
|
||||||
|
};
|
||||||
|
let viewport = {
|
||||||
|
let core = session.state.core.borrow();
|
||||||
|
let grid = core.panel_grid_size(FID).expect("a presentable panel");
|
||||||
|
pmacs_protocol::CellSize::new(grid.rows.saturating_sub(1), grid.cols)
|
||||||
|
};
|
||||||
|
for _ in 0..40 {
|
||||||
|
feed(&session, AnsiEvent::LineFeed);
|
||||||
|
}
|
||||||
|
let anchor_of = |session: &Session| {
|
||||||
|
session
|
||||||
|
.state
|
||||||
|
.terminal_manager
|
||||||
|
.borrow()
|
||||||
|
.view_mapping_identity(key)
|
||||||
|
.expect("the panel's terminal view")
|
||||||
|
.1
|
||||||
|
};
|
||||||
|
|
||||||
|
let before_scroll = mapping_generation(&mut session).expect("a key");
|
||||||
|
let anchor_before = anchor_of(&session);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.state
|
||||||
|
.terminal_manager
|
||||||
|
.borrow_mut()
|
||||||
|
.scroll_view(key, viewport, 3),
|
||||||
|
"the scroll must actually move, or this leg proves nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
anchor_of(&session),
|
||||||
|
anchor_before,
|
||||||
|
"scrolling pins the view to a retained row, so the anchor moves"
|
||||||
|
);
|
||||||
|
let after_scroll = mapping_generation(&mut session).expect("a key");
|
||||||
|
assert!(
|
||||||
|
after_scroll > before_scroll,
|
||||||
|
"the anchor is part of the mapping — the same coordinate now \
|
||||||
|
names a different retained row, with the screen untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,13 @@ fn press_and_await_panel(session: &mut Session) -> bool {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.expect("write panel-open key");
|
.expect("write panel-open key");
|
||||||
|
// §5b — whichever Present family this session negotiated. These
|
||||||
|
// rows are about the band ARRIVING; which wrapper carries it is
|
||||||
|
// pinned by the G6/G7/G8 rows, not incidentally here.
|
||||||
drain_until(&mut session.stream, "panel", |message| match message {
|
drain_until(&mut session.stream, "panel", |message| match message {
|
||||||
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size),
|
InstanceMessage::PanelFrame(
|
||||||
|
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||||
|
) => Some(frame.size),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.is_some()
|
.is_some()
|
||||||
|
|
@ -251,8 +256,8 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
press_and_await_panel(&mut current),
|
press_and_await_panel(&mut current),
|
||||||
"a v21-negotiated semantic session must be panel-capable and receive \
|
"a current-wire semantic session must be panel-capable and \
|
||||||
a Present panel frame"
|
receive a Present-family panel frame"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Half 3 — a semantic session that echoed the baseline is NOT
|
// Half 3 — a semantic session that echoed the baseline is NOT
|
||||||
|
|
@ -337,10 +342,11 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_baseline_stays_and_the_counter_offer_activates() {
|
fn the_baseline_stays_and_the_counter_offer_activates() {
|
||||||
// A deliberate tripwire: bumping the wire must be a conscious edit
|
// A deliberate tripwire: bumping the wire must be a conscious edit
|
||||||
// here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a);
|
// here, not a silent one. v25 is the mapped panel family
|
||||||
// v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was
|
// (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is
|
||||||
// `LineWrapFacts` (long-lines Stage 3).
|
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
|
||||||
assert_eq!(PROTOCOL_VERSION, 24);
|
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 25);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
"moving this is the incompatible act the mechanism exists to avoid"
|
"moving this is the incompatible act the mechanism exists to avoid"
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,11 @@ use common::daemon::{TestDaemon, build_default_caps};
|
||||||
/// server-first, so moving it locks out every already-shipped frontend
|
/// server-first, so moving it locks out every already-shipped frontend
|
||||||
/// before it can counter-offer. An additive family never needs it.
|
/// before it can counter-offer. An additive family never needs it.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_wire_is_v24_and_the_advertised_baseline_is_unmoved() {
|
fn the_wire_is_v25_and_the_advertised_baseline_is_unmoved() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
PROTOCOL_VERSION, 24,
|
PROTOCOL_VERSION, 25,
|
||||||
"v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
|
"v25 is the mapped panel family (bottom-panel §5b); v24 was \
|
||||||
|
TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
|
|
@ -618,7 +619,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() {
|
||||||
// rather than after the interesting half has already passed.
|
// rather than after the interesting half has already passed.
|
||||||
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
||||||
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||||
assert_eq!(PROTOCOL_VERSION, 24);
|
assert_eq!(PROTOCOL_VERSION, 25);
|
||||||
|
|
||||||
// Open the real `M-x` through the real key path, then narrow to the
|
// Open the real `M-x` through the real key path, then narrow to the
|
||||||
// probe command by typing it — the candidate window is ten rows out
|
// probe command by typing it — the candidate window is ten rows out
|
||||||
|
|
|
||||||
|
|
@ -804,8 +804,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
||||||
// three lines on purpose. The ceiling assertion is the load-bearing
|
// three lines on purpose. The ceiling assertion is the load-bearing
|
||||||
// one — it says the supported set ENDS here, which is what makes an
|
// one — it says the supported set ENDS here, which is what makes an
|
||||||
// accidentally-widened set a failure rather than a silent pass.
|
// accidentally-widened set a failure rather than a silent pass.
|
||||||
assert_eq!(PROTOCOL_VERSION, 24);
|
assert_eq!(PROTOCOL_VERSION, 25);
|
||||||
for version in 6..=24 {
|
for version in 6..=PROTOCOL_VERSION {
|
||||||
assert!(is_supported_protocol_version(version));
|
assert!(is_supported_protocol_version(version));
|
||||||
}
|
}
|
||||||
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
||||||
|
|
|
||||||
|
|
@ -888,10 +888,11 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
|
||||||
panic!("timed out waiting for {what}");
|
panic!("timed out waiting for {what}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tripwire: a wire bump must be a conscious edit here. v24 is
|
// Tripwire: a wire bump must be a conscious edit here. v25 is the
|
||||||
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
|
// mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI
|
||||||
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
// arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2);
|
||||||
assert_eq!(PROTOCOL_VERSION, 24);
|
// v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 25);
|
||||||
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
||||||
&[
|
&[
|
||||||
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue