From 4e6730acb9f8905ff2f082a27a4c01620c79cdd0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:10:32 +0200 Subject: [PATCH 01/15] docs: GUI Stage 1 framing (approved) and the 1-pre lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framing and the lane are this branch's FIRST commit, which is the arc's §5 rule for every PR in it and the reason this branch exists before any code: revision 9 was approved as an untracked file, and an approved framing that lives in one worktree is not portable. Revision 9 landed after EIGHT rejected revisions. The through-line of those rounds is worth carrying: each early draft turned a fresh source audit into SCOPE LABELS --- "DroppedFile", "translate_key completion", "no behaviour change" --- and review kept asking the same question, which is what the label means when someone has to test it. Revision 9 is a contract/witness/mutation matrix over every numbered clause, split into CHANGE clauses that fail today and PRESERVATION clauses that pass and must keep passing. Several findings only existed because the contracts were written down. "Send Detach before exit" was unexecutable against an outbox whose `enqueue` rejects once closed. A one-slot terminal reservation contradicted the FocusLost-before-Detach ordering two sections above it. "Terminal result after the commit resolves" was not total over a pipeline with three legitimate exits that never commit --- a claimed listener, a cleared handler slot, and the default handler's `open_async` returning immediately. None of those are visible from a scope label. This branch is 1-pre ALONE: the input seam, no behaviour change. Its evidence is a headless routing harness recording routing decisions, outbound events AND local effects, with production `window_event` as a thin call-through. P3 is recorded as an accepted structural exception: a headless test cannot construct `ActiveEventLoop`, so the delegation itself is a code-review invariant rather than a tested one, and saying so is better than a mutation that cannot exist. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 42 ++- docs/gui-stage1-input-framing.md | 436 +++++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 docs/gui-stage1-input-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index 5ae7f6a..18c8c31 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -250,7 +250,47 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. -## The GUI arc — Stage 0 branch OPEN, absorption COMPLETE, ready for PR +## GUI arc Stage 1 — 1-pre branch OPEN, framing APPROVED, no PR yet + +**Written at the branch's first commit**, with the framing, as the arc's +§5 requires of every PR in it. + +- **Branch `gui-stage1-pre`**, base `githubsucks/main` @ `f8ad3e7` (the + Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the + authoritative tip** — the ref, not a SHA. Recover with + `git fetch githubsucks && git checkout gui-stage1-pre`. +- **Framing `docs/gui-stage1-input-framing.md`, revision 9, APPROVED** + after **eight rejected revisions**. It is Stage 1's framing for **all** + slices and governs the later branches; only Stage 0 was framed by the + arc document itself. +- **Scope of THIS branch: 1-pre only — the input seam. No behaviour + change.** `App::window_event` is **655 lines** (`main.rs:2734`) and + nothing below it can be witnessed without a display, which is why the + seam precedes every other slice. +- **The evidence contract, because "no behaviour change" is not one:** a + **headless routing harness** records, per event family, the routing + decision, the **outbound protocol events, and the LOCAL effects** — + exit, redraw, resize, state mutation. Production `window_event` + becomes a **thin call-through**. Mutation evidence: bypassing an arm, + or misrouting one family to another, must fail. +- **P3 is an ACCEPTED STRUCTURAL EXCEPTION.** A headless test cannot + construct `ActiveEventLoop` or invoke the real callback, so the + `window_event → router` delegation is a **code-review invariant, not a + tested one**. Recorded rather than papered over: mutation evidence + covers every router arm and **not** the delegation. +- **Slice order (each its own branch and PR):** `1-pre` → `1a`\* → `1b` + → `1c` → `1d` → `1e`\*. **`1a` and `1e` are protocol-bearing (v24 + `TextInput`, v25 `OpenTarget`/`OpenTargetResult`) and are + SERIALIZED.** 1c is **not** protocol-bearing under Q#S1-8. +- **Q#S1-7 obligation carried by THIS PR:** Meta/Super policy moves to + Stage 2, so the arc framing's §2.5 and the standing backlog are + amended here. Stage 1 keeps the deliberate OS reservation and adds no + island. +- **Gates:** `./scripts/gate --acceptance gpu_invocation_acceptance` + plus touched input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p + pmacs-gpu`. **No `--protocol`** — 1-pre changes no wire. + +## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`) **Written at the branch's first commit**, with the framing, which is what this arc's own §5 requires of every PR in it. The standing diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md new file mode 100644 index 0000000..ddc3e38 --- /dev/null +++ b/docs/gui-stage1-input-framing.md @@ -0,0 +1,436 @@ +# GUI arc, Stage 1 — input foundation (framing) + +**Status: revision 9 — APPROVED.** Revisions 1–8 rejected. +**Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** Implementation may begin +from this document. + +**Verification base:** checked in the `gui-arc-stage0` worktree at +`a994f37`, whose tree for these files is what `f8ad3e7` merged. + +## 1. What this stage closes + +Journey **step 5**. **Not step 12** (Stage 4b, P2-gated). Five of nine +§3.1 blockers die here. + +## 2. Ground truth at `a994f37` + +`App::window_event` (`main.rs:2734`) is **655 lines**; **eight** +`WindowEvent` arms handled, the rest fall to `_`. + +**Two text producers.** `translate_key(logical: &Key, …)` +(`main.rs:10975`) reads the **logical key** and truncates via +`chars().next()`; **`KeyEvent.text` is never read.** +`WindowEvent::Ime::Commit(String)` is a **separate event, ignored +entirely** (`set_ime_allowed`/`WindowEvent::Ime`: zero occurrences). +**Today's two failures are therefore: multi-scalar keyboard input is +truncated to its first scalar, and an IME commit produces nothing.** + +**`FrontendEvent`: sixteen variants, none carrying an open path or +command invocation.** `PROTOCOL_VERSION = 23`. + +**The handshake precedes any window** — `EventLoop` built, client +constructed, handshake done **before `run_app`** (`main.rs:696`). + +**TUI wheel arms**: `EditorState::dispatch_mouse` (`src/editor.rs:3052`), +`ScrollUp`/`ScrollDown` at **`:3203`**. + +**1c is producer-side only for Focus/Detach** — those variants exist on +the wire. **Title, Bell and `Goodbye` are GPU consumer work.** + +**`Outbox::enqueue` returns `false` once closed** (`attach.rs:414`) and +**coalesces by kind**. + +## 3. PR topology + +`1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\* (\* `--protocol`) + +**1c is NOT protocol-bearing** under Q#S1-8's ruling. Protocol slices +are serialized. + +## 4. Q#S1-8 — RULED: (A), preserve pre-window readiness + +`AttachRequest.initial_size` for a semantic session is a **named, +provisional `SEMANTIC_BOOTSTRAP_GRID` of 24×80**. It is **not measured +geometry** and **must never become semantic frame or panel authority**. +**`FrontendCellGeometry`, sent after window creation, is the sole real +frame declaration.** + +This **codifies current daemon behaviour**, so **1c stays +non-protocol-bearing** — unless implementation changes that behaviour, +which would be a wire-contract change even with no bytes moved. + +## 5. Q#S1-9 — RULED: `TextInput` precedence + +**A `KeyboardInput` stays `Key` unless a rule below moves it.** + +1. **Named keys and control text remain `Key`**, regardless of + `KeyEvent.text` — so `Enter`'s `"\r"` never becomes text. +2. **Ctrl/Alt chords remain `Key`**, except **printable Ctrl+Alt + recognized by the existing AltGr rule**. +3. **Meta/Super-only text stays reserved to the OS.** +4. **Plain printable SINGLE-scalar remains `Key`** — preserving mode + keymaps and today's typed provenance. +5. **Printable MULTI-scalar becomes one `TextInput`.** +6. **Every non-empty `Ime::Commit` becomes one `TextInput`**, even + single-scalar. +7. **`Key::Dead` is 1d-owned; 1a buffers nothing.** +8. **`Shift` is already reflected in resolved text** and is **not** + carried on `TextInput`. + +**Provenance and chain.** A **single-scalar** `TextInput` rotates to +`buffer.self-insert` and creates **today's one-codepoint typed +provenance**. A **multi-scalar** `TextInput` **breaks the command chain +and creates no typed provenance**. Both are **one edit, one undo unit, +one hook, one eligible CRDT op**. + +**Modal precedence is preserved:** terminals take **raw UTF-8**; +search and minibuffer **consume** text; menu and query-replace **retain +their shadow behaviour**; **only the ordinary document path performs the +atomic edit**. + +**Payload cap: 64 KiB UTF-8, oversize REJECTED, never truncated.** + +## 6. Evidence + +**The promise, corrected.** Revision 4 claimed every mutation fails only +its own clause. That is **false and cannot be made true**: clauses have +real dependencies — D1 gates every 1d row, D3's failure surfaces at A6, +and **E0 and E2–E6 all presuppose E1** (no transport, no receiver). The promise is therefore +**dependency-aware, and split by clause kind** — revision 5 still said +"every clause fails today", which C6 falsifies by design: + +- **CHANGE clauses** have a witness that **fails today** and a mutation + that fails **at least** its own clause. +- **PRESERVATION clauses** (marked **[P]**) **pass today**; their + witness pins behaviour that must not regress, and their mutation is + the change that would break it. +- Where a mutation necessarily breaks dependents, **the dependency is + named**. + +P3 remains an accepted structural exception: not headlessly testable. + +### 1-pre + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| P1 | Every handled family routes through an extracted function | no extracted functions exist | misroute one family → that family's row | +| P2 | Harness records outbound events **and local effects** (exit, redraw, resize, state mutation) | no harness | record outbound only → exit/redraw rows | +| P3 | `window_event` is a thin call-through | — | **structural/code-review invariant; not testable headlessly** (no `ActiveEventLoop`) | + +### 1a — `TextInput` (v24) + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| A1 | `F1`–`F35` → `F(1..=35)` | `_ => return None` | map `F13+` → `None` → F13–F35 rows | +| A2 | Shift+Tab → `BackTab` with `Shift` set | produces `Tab` | drop `Shift` → A2 only | +| A3 | `ContextMenu` → `Menu` | produces nothing | map to `Char('\0')` → A3 only | +| A4 | Idle Escape reaches the daemon, never exits | exits (`main.rs:2771`) | restore the quit branch → A4 only | +| A5 | Precedence per §5 (1–8) | multi-scalar truncated; IME ignored | move rule 1 (control text → text) → the `Enter`-in-dired row | +| A6 | One commit = one edit, undo unit, hook, eligible CRDT op | commit truncated to one scalar | one edit per scalar → undo-unit row (**and D3 surfaces here**) | +| A7 | Prompts consume scalars **in order** | multi-scalar never arrives | reverse order → A7's prompt transcript | +| A8 | Terminals get **raw UTF-8, never bracketed paste** | multi-scalar never arrives | route via `Paste` → terminal row shows bracket markers | +| A9 | **64 KiB cap; oversize rejected, not truncated** | no cap exists | truncate instead → the oversize row observes silent loss | + +### 1b — pointer and scroll + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump | +| B2 | Wheel-right raises leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row | +| B3 | Clamps at content bounds; never a negative origin | no horizontal scroll to clamp | remove clamp → **at-bounds row: origin goes negative and the view blanks** | +| B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only | +| B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only | +| B6 | Wheel over the minimap scrolls the **document viewport** with **its own residual accumulator**; click/drag remains scrub | **a FULL tick already scrolls today** — minimap pixels are `Elsewhere` (`main.rs:2061`) and the wheel falls through to `scroll_by_lines` (`main.rs:3373`). What fails is **fractional accumulation**, and **residual ownership distinct from the document's**: sub-tick minimap deltas are discarded, and a **surface-switch fractional witness** (part-tick over the minimap, then over the document) must not carry residue across | share the document's accumulator → the surface-switch fractional row jumps | +| B7 | **TUI horizontal**: **three columns per wheel tick**, sign per B2; **left origin clamps at 0 and at (widest display-line width − text viewport width), SATURATING AT ZERO**; **wrap pins the origin to 0** | events arrive at `:3203` and are dropped | step of one → the three-column row; clamp at the widest line's **full width** → **the right-bound row blanks the viewport**; drop the wrap guard → the wrap row scrolls a wrapped buffer sideways | + +**B7's right bound corrected.** Clamping at the widest line's full width +lets the origin pass every glyph and leave the viewport **entirely +blank**. The bound is *width − viewport*, saturating at zero for buffers +narrower than the viewport, and **the right-bound witness asserts the +final display column is still visible**. + +**Why B6 changed — and revision 5's reason was wrong.** Scrubbing on +wheel is not *impossible*: the wheel handler already reads the cached +`state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute +target is available. It is the **wrong semantics**: a wheel is a +**relative** gesture, and mapping relative ticks onto an absolute +position would make one notch jump to wherever the pointer happens to +rest. Click and drag remain scrub because those *are* absolute. + +### 1c — session and window signals + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| C1 | Title `" — pmacs"`, `"pmacs"` when unnamed | title is static | drop the name → C1 only | +| C2 | **Visible bell: 120 ms, WHOLE CLIENT AREA visibly changes**; repeats **neither queue nor extend** the first deadline | `Bell` unconsumed | let repeats extend → the repeat row's flash outlasts 120 ms; flash a sub-region → the headless render witness cannot see it | +| C3 | `Goodbye` names the daemon's reason, else an explicitly **locally-classified** transport/EOF reason; never blank | live-loop reason discarded | blank the fallback → EOF row | +| C4 | `FocusLost` precedes `Detach`; `FocusGained` only after attach completes | `Focused` unhandled | swap → C4 only | +| C5a | **Local DPI correctness**: at scale 1→2 with **unchanged logical size**, logical wrapping, row count and hit testing are **stable** while **physical pixels double** — glyphs, clips, caret, hit tests and overlays all rescale | `scale: 1.0` hardcoded (`main.rs:8950`) | rescale glyphs only → **caret, clip and hit-test rows fail while text still looks right**, which is the bug this splits out | +| C5b | **Geometry declaration**: the epoch advances and `FrontendCellGeometry` is emitted | no `ScaleFactorChanged` arm | suppress the emit → C5b only, **C5a still passing** | +| C6 **[P]** | `initial_size` = `SEMANTIC_BOOTSTRAP_GRID` (24×80), **never** frame or panel authority | **passes today** — this codifies current daemon behaviour; the witness pins that a semantic frame/panel is sized from `FrontendCellGeometry` alone | derive a frame or panel extent from `initial_size` → the panel-authority row | +| C7 | Close contract — §7 | see §7 | see §7 | + +**C5 was one row and hid half the defect** — suppressing the wire emit +says nothing about glyphs or hit tests staying at scale 1. + +### 1d — IME + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| D1 | `set_ime_allowed(true)` | zero occurrences — **no composition arrives at all** | omit → **every 1d row (stated dependency, not a matrix defect)** | +| D2 | Preedit overlay with caret and selection; **indices are BYTE OFFSETS** into the preedit string | no overlay | treat as char indices → multibyte row | +| D3 | `Ime::Commit` emits A5's `TextInput` | commit ignored | emit per-scalar `Key`s → **A6's undo row (named dependency)** | +| D4 | **`set_ime_cursor_area` updated** after caret motion, scroll, resize, font/DPI change, and every preedit change | never called → candidates misplaced | update on caret only → scroll and resize rows | +| 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 | + +### 1e — `OpenTarget` (v25) + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| E0 | **SUCCESS**: a **successfully handled valid target** is **resolved, installed, hooked and dispatched through the existing file/directory pipeline**, and the originating frontend receives **exactly one terminal result** — `Opened { request_id, buffer_id }` when a commit lands, **or `Handled` when an extension legitimately claims it** | no receiver — a drop does nothing | dispatch without firing the open hooks → the hook row; settle before the deferred commit lands → the async-directory row (Q#S1-10) | +| E1 | Versioned `OpenTarget { request_id: u64, cwd, path }` carrying `InitialTarget`'s raw shape. **`request_id` is unique among a frontend's OUTSTANDING requests; a duplicate is REJECTED at the protocol boundary** and must never replace or settle the original completion | no variant exists | **omit `request_id`** → the two-concurrent-drops row misattributes; **accept a duplicate id** → the reuse row settles the first drop's completion with the second drop's outcome | +| E2 | Source is **authenticated** | no receiver | accept an unauthenticated sender → E2's forged-source row | +| E3 | Primary document `ViewDestination` captured **immediately on receipt**, before any await | no receiver | capture after the open resolves → **frontend-switch row: the file lands in whichever frontend is ambient** (the #231 defect) | +| E4 | **No window identity trusted from the wire** | no receiver | accept a window id → E4's forged-window row | +| E5 | Failures **before terminal disposition** — including completion-aware `Deferred` work — are **visible to the ORIGINATING frontend** as bounded `Failed { request_id, message }`. After `Handled`, responsibility and any later failure belong to the claiming extension, and no second result is sent — see §8 | no receiver | swallow the error → the permission and embedded-NUL rows | +| E6 | `InitialTarget` limits enforced: **32 KiB per raw path**, **non-empty path**, **absolute non-empty cwd**, **embedded NUL rejected** | no receiver | drop the NUL check → the embedded-NUL row | + +**The failure taxonomy was wrong in revision 5.** **A missing path is +NOT a failure**: the resolver deliberately creates an **empty +path-backed buffer** on `NotFound` (`src/editor_core.rs:1177`), which is +how "open a file that does not exist yet" works. **Directories are valid +targets too.** Genuine failures are permission denial, validation +rejection (E6), and open errors that are not `NotFound`. Revision 5 +listed "missing-path" and "not-a-directory" rows that would have pinned +the opposite of the intended behaviour. + +### Q#S1-10 — RULED: the result is TERMINAL + +Directory opening completes **asynchronously**, so `OpenTargetResult` +either waits for the captured-destination commit or merely acknowledges +dispatch. **Ruling: terminal.** The result is sent **when the request +reaches a terminal disposition** — for `Opened`, after the commit +resolves; for `Handled`, at the moment responsibility transfers; for +`Failed`, at the failure, **which for several paths has no commit at +all**. Asynchronous failure is reported through the same result — an acknowledgement-plus-later-channel design would need a +second source-scoped mechanism to carry exactly the failures that matter +most. + +``` +OpenTargetResult::Opened { request_id: u64, buffer_id: BufferId } +OpenTargetResult::Handled { request_id: u64 } // claimed; no buffer attributable +OpenTargetResult::Failed { request_id: u64, message: String } // 4 KiB cap +``` + +**Terminal completion must be TOTAL over the existing pipeline, and +"after the commit resolves" is not** — three legitimate paths reach +neither a commit nor a result: + +- **Claimed**: a `path.open-directory` listener returns `proceed = + false` and the dispatch **returns without committing** + (`src/editor.rs:1375`). +- **Disabled**: the `directory_handler` slot is clear, which is a + supported configuration — the dispatch emits a **status message and no + commit** (`src/editor.rs:1389`). +- **Asynchronous fallback**: the default handler calls `open_async` and + **returns immediately** (`builtin/runtime/dired.lua:750`), so the + commit lands long after the dispatch unwinds. + +**Mechanism: a request-scoped ONE-SHOT COMPLETION with an EXPLICIT +STATE MACHINE**, carried through the directory pipeline, settled +**exactly once**, and **discarded on source detach** — a frontend that +left cannot be told anything. + +``` +Pending ──(defer before scheduling)──▶ Deferred ──▶ Settled + │ │ + └──────────(commit / failure)────────┴──▶ Settled + │ + └──(source detach, from Pending or Deferred)──▶ Cancelled +``` + +**`open_async` transitions to `Deferred` BEFORE handing the completion +to scheduled work.** The ownership transfer is explicit and does not +depend on whether today's scheduler starts a coroutine synchronously or +a future scheduler defers its first step. Without the transition the +dispatch unwinds while the completion is still `Pending`, the +end-of-turn fallback fires, and the request is settled *before* the +commit it was waiting for. + +**The end-of-turn fallback acts ONLY on `Pending`.** A `Deferred` +completion is owned by the scheduled work. **Any later settlement is +exactly-once**: a second attempt against `Settled` or `Cancelled` is a +no-op, never a second message. + +*Mutation:* omit the `Deferred` transition, or delay it until after the +end-of-turn fallback → the async-directory row receives a premature +`Handled`/`Failed`; exactly-once settlement then suppresses the later +`Opened` attempt when the commit lands. + +`open_async` carrying the completion to the commit is what makes the +asynchronous path terminal rather than silent. + +**Total disposition, so no path can fall through:** + +| path | settles as | +|---|---| +| commit lands | `Opened { buffer_id }` | +| listener **claimed** and did not settle | `Handled` | +| **replacement handler** ran and did not settle | `Handled` | +| handler slot **disabled** | `Failed` naming that directory opening is disabled | +| **synchronous error** (listener raised, validation, permission) | `Failed` with the reason | +| pipeline unwinds **still `Pending`** at end of dispatch turn | `Handled` if a listener claimed or a replacement handler ran; **otherwise `Failed`** | +| **source detached** before settlement | discarded — nothing is sent, and the completion is dropped rather than leaked | + +**`Handled` exists because `Opened` cannot be honest there.** A claim +means a user listener took responsibility and **no buffer is +attributable**; reporting `Opened` would require inventing a +`buffer_id`, and reporting `Failed` would mislabel a supported +extension point as an error. `Handled` is the terminal responsibility +transfer: any later extension-owned failure uses the extension's own +reporting surface and cannot emit a second `OpenTargetResult`. + +*Witness:* one case per **live-source** row, each asserting **exactly +one** result. **The detach row asserts the opposite and must not be +read as "one result":** zero messages sent, **no completion retained**, +and a later settlement attempt **ignored** rather than delivered. +*Mutation:* drop the end-of-turn fallback → the claimed and disabled +rows hang with no result at all, which is the defect this ruling +closes. + +`message` uses the **existing 4 KiB error cap**. **Both affected enums +get an independent frozen-byte pin on their own preceding final +variant** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for +`OpenTargetResult` — because an appended variant's own round-trip cannot +detect a discriminant shift in either. + +## 7. The close contract + +`send_event` only enqueues (`attach.rs:1145`); the writer takes a batch +and releases the lock before blocking writes (`:671`); **`enqueue` +returns `false` once closed** (`:414`). + +**So revision 4's order was literally unexecutable** — closing first +would have rejected the `Detach` it then tried to enqueue. + +**Contract — state inspection plus any append/transition is ONE atomic +critical section under a single lock hold, dispatched on the outbox +STATE. The lock is released before notifying the writer, waiting on an +acknowledgement, returning, or shutting down.** Holding it while waiting +would prevent the writer from taking the suffix or transitioning to +`Drained`. The four states are tabulated below; this paragraph is their +normative statement, and revision 6's "append `Detach` / +already-closed → fallback" wording is superseded: + +- **Open** → under the lock, append the **complete suffix** (`FocusLost` + when currently focused, then `Detach`), transition to `Sealed`, and + capture its acknowledgement handle; release the lock, wake the writer, + then wait. +- **Sealed** → under the lock, capture the **existing acknowledgement**; + release the lock, then join it. Do not re-append, do not seal again, + do not shut down. +- **Drained** → release the lock, then return immediately. +- **Failed** → release the lock, then take the socket-shutdown fallback. + +**An ordinary post-seal `send_event` REJECTS and does nothing else** — +it must not invoke shutdown, because a healthy drain is in flight. +**`Detach` is exempt from coalescing.** **Wake the writer after +append-and-seal**, or +a sealed outbox with a pending `Detach` waits on a condvar nobody +signals and the 250 ms bound expires on a daemon that was reading fine. + +**The exactly-full case, which revision 5 left undefined.** +`OUTBOX_MAX` is **8192**, and a queue at exactly that length is a +**valid OPEN state**: the *next* ordinary `enqueue` both **sets +`closed`** and **rejects the event** (`attach.rs:427`). So terminal +close must not go through the ordinary path. **Ruling: the terminal operation appends the whole required SUFFIX +atomically** — **`FocusLost` when currently focused, then `Detach`** — +reserving **at most `OUTBOX_MAX + 2`**. + +**One slot was not enough, and C4 is why.** C4 requires `FocusLost` +before `Detach`; at exactly `OUTBOX_MAX` an *ordinary* `FocusLost` +enqueue is **rejected and sets `closed`**, so the terminal append would +then find a closed outbox and fall back — losing both events. Revision 6 +reserved one slot and so contradicted a contract two sections above it. + +**Exact-cap witness:** fill to exactly `OUTBOX_MAX` **while focused**, +then close. The transcript ends **`… FocusLost, Detach`**, the writer is +woken, and acknowledgement precedes exit. *Mutation:* reserve one slot → +`FocusLost` is dropped and C4's ordering row fails at exact cap only. + +**Outbox states — clean SEALED is not failed CLOSED.** Revision 6 had +one `closed` flag doing both jobs, so a duplicate close or a post-seal +send would see "closed" and **invoke the socket-shutdown fallback, +aborting the drain it should have joined**. Four states, with distinct +behaviour: + +| state | ordinary enqueue | close called again | +|---|---|---| +| **Open** | ordinary policy: accepted below the cap; a cap-crossing lossless append is rejected and transitions to `Failed` | performs the terminal append-and-seal | +| **Sealed** (suffix appended, drain in flight) | rejected | **waits on the existing acknowledgement** — never re-seals, never falls back | +| **Drained** (acknowledged) | rejected | returns immediately | +| **Failed** (overflow, or transport error) | rejected | **takes the socket-shutdown fallback** | + +*Mutation:* collapse `Sealed` into `Failed` → the duplicate-close witness +aborts a healthy drain and exits before `Detach` is written. + +**Acknowledgement point:** the batch containing `Detach` **fully written +and flushed to the socket**. **Bound: 250 ms.** + +**Two witnesses, mutually exclusive:** + +- **Responsive reader** — all preceding lossless events **and** `Detach` + are written, acknowledgement occurs, **then** exit. *Mutation:* drop + the drain → exit-before-`Detach` is observable. +- **Stalled reader** — the deadline fires, socket shutdown yields EOF + cleanup, the frontend **exits anyway**. *Mutation:* remove the bound + → this test hangs. + +*Seal mutation:* permit a late enqueue → an event appears after +`Detach` in the responsive transcript. + +## 8. Wire contracts + +| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** | +|---|---|---| +| **floor** | **v24** | **v25**, after v24, serialized | +| **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 | +| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; 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 | +| **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` | + +**E5's delivery mechanism.** `StatusFacts.message` is **global and can +be cleared before the originating frontend observes it**, so it cannot +carry this. **1e adds a source-scoped `OpenTargetResult`, correlated by +a frontend request ID**, so a failure reaches the frontend that dropped +the file and no other. + +## 9. Coherence impact (§20) + +- **Journey steps**: **5**; **3** (1e); **6(e)** on the GPU column. +- **Islands**: Escape ceasing to be a local quit **removes** one; + Q#S1-7 adds none → the census **falls by one**. +- **Config registry**: none. The bell's 120 ms is a constant. +- **Background work**: **1e adds no new worker**, but it **attributes + the existing asynchronous directory operation to an originating + frontend and request** until terminal settlement — the one-shot + completion is that attribution — **and drops it on source detach**. + That is §9's ownership question appearing in miniature, and it is + answered here for this one operation rather than in general. + +## 10. Rulings + +**Q#S1-1** native close detaches, `editor.quit` shuts down the daemon +and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e` +· **Q#S1-6** B/`TextInput` · **Q#S1-7** Meta/Super → Stage 2, arc §2.5 +and the backlog amended by 1-pre's first PR · **Q#S1-8** (A), +`SEMANTIC_BOOTSTRAP_GRID` · **Q#S1-9** precedence per §5 · **Q#S1-10** terminal `OpenTargetResult`. + +## 11. Gates + +`./scripts/gate --acceptance gpu_invocation_acceptance` plus touched +input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. +**`--protocol` for 1a and 1e only.** From 2d9f0550e0eefcc2ba970860fe8242af73a03edd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:12:04 +0200 Subject: [PATCH 02/15] docs: Q#S1-7 --- Meta/Super moves to Stage 2, in both records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The obligation this PR owes, discharged before any code lands so the scope Stage 1 is implementing is the scope both records describe. Meta/Super forwarding was mapped to Stage 1a. Forwarding it there would turn platform Command/Super shortcuts into daemon Meta chords BEFORE capability-aware and local-binding policy exists --- so the OS reservation currently in the tree is a deliberate, tested decision rather than an omission, and Stage 1 keeps it. Both records are amended, not one. The arc's §2.5 mapping table and the standing backlog's input bullet each claimed Stage 1a, and a mapping that lives in two files drifts unless both move together --- which is the failure class the Stage 0 lane spent seven review rounds retiring. The same row also recorded command/minibuffer chord forwarding as outstanding when `bc32332` shipped it. Marked shipped rather than left to imply work that does not exist. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-arc-framing.md | 3 ++- docs/side-quest-backlog.md | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/gui-arc-framing.md b/docs/gui-arc-framing.md index af0b8d9..8b0a274 100644 --- a/docs/gui-arc-framing.md +++ b/docs/gui-arc-framing.md @@ -275,7 +275,8 @@ reference** — each keeps its own framing. | Backlog item | Disposition | |---|---| -| Command/minibuffer chord forwarding; Meta/Super chords | Stage 1a | +| Command/minibuffer chord forwarding | **SHIPPED** (`bc32332`) — `Char`/`Enter`/`Tab` with Ctrl or Alt reach the daemon keymap, subsuming the old per-feature allowlists | +| **Meta/Super chords** | **Stage 2 (Q#S1-7 ruling, 2026-08-11)** — *moved from Stage 1a.* Blind forwarding would turn platform Command/Super shortcuts into daemon Meta chords **before** capability-aware and local-binding policy exists. Stage 1 keeps the deliberate, tested OS reservation (`pmacs-gpu/src/main.rs:11050`) and adds no island | | Rebindable local `Ctrl-V`/`Escape` | Escape half → Stage 1a; `Ctrl-V` half → Stage 2 (it is a keymap-vocabulary question, not an input-plumbing one) | | Middle-click paste | Stage 1b | | Right-click context menu | **Already shipped** (§2.1) — the backlog item is stale and Stage 0 retires the line | diff --git a/docs/side-quest-backlog.md b/docs/side-quest-backlog.md index c066fe1..e36c21f 100644 --- a/docs/side-quest-backlog.md +++ b/docs/side-quest-backlog.md @@ -193,9 +193,12 @@ external-change detection (verify-modtime-while-open, revert?). ## GPU frontend mechanics (non-theme) -- **Input:** full command/minibuffer chord forwarding to the GUI, - Meta/Super chords, rebindable local `Ctrl-V`/`Escape`, middle-click - paste, right-click context menu, frontend-local provisional selection. +- **Input:** ~~full command/minibuffer chord forwarding to the GUI~~ + (**shipped**, `bc32332`), **Meta/Super chords — now GUI arc Stage 2**, + not Stage 1a (Q#S1-7, 2026-08-11: forwarding them before + capability-aware policy exists would capture platform Command/Super + shortcuts), rebindable local `Ctrl-V`/`Escape`, middle-click paste, + right-click context menu, frontend-local provisional selection. - **Minibuffer:** `i/total` hint (already on the wire), Telescope-style preview pane, candidate kind/doc annotations, unify TUI inline vs GPU dropdown, multibyte-exact band caret, the nav highlight-wrap bug. From 014110fb900d527cca3dacd18ce3e16e0131bf7e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:22:58 +0200 Subject: [PATCH 03/15] feat(gpu): 1-pre --- the input seam, on the lifecycle family GUI Stage 1-pre. `App::window_event` decided and performed everything in one 655-line match, and nothing below it could be witnessed without a display: `ActiveEventLoop` is non-constructible outside a live event loop, and the arms that matter reach a GPU surface or a socket. This is the seam, given its shape on the three smallest arms before the 194-line `KeyboardInput` one. Deciding is `route_event(&WindowEvent) -> Route`, a free function over the event alone, composing one decision function per family --- `route_lifecycle` is the first. Performing stays on `App`: the `Resized` body moves verbatim to `App::apply_resize`. A route names its LOCAL EFFECT, not merely the family that claims it, and the harness records routes rather than outbound protocol traffic. That is deliberate and load-bearing: `CloseRequested` exits and sends the daemon nothing, so a transcript of daemon traffic alone cannot tell a handled arm from a dropped one. `RedrawRequested` is the second such arm and lands next. The zero-extent clamp moves into the router with the decision. wgpu rejects a zero-extent surface configuration and a minimize delivers 0x0, so `.max(1)` is a rule rather than defensive padding --- and deciding it in a pure function is what makes it witnessable with no surface at all. No behaviour change. The router is not yet reached for the families still inline in `window_event`; each subsequent commit moves one, and when the last goes the match collapses to the router call. Evidence --- 6 rows, 5 mutations, each failing its own row and no other beyond a stated dependency: M1 `CloseRequested` -> no family -> the exit row (+ transcript) M2 `Resized` -> `Exit` -> both resize rows (+ transcript) M3 clamp dropped -> the zero-extent row ALONE M4 harness records outbound only -> the transcript row ALONE M5 `ModifiersChanged` drops the state -> the modifiers row (+ transcript) The transcript row is the only one that fails under M4, because the per-variant rows assert `feed`'s return value; that row alone owns P2, which is what makes M4 discriminating rather than a blanket failure. P3 --- that `window_event` DELEGATES rather than deciding for itself --- is the framing's accepted structural exception, and it was demonstrated rather than assumed: deleting the whole delegation, which would leave the GUI unable to close, resize, or track a modifier, left all six rows green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 287 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 263 insertions(+), 24 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 8f5d817..b37f384 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2679,6 +2679,257 @@ impl App { } } } + + /// Perform [`LifecycleRoute::Resize`]. `width`/`height` arrive + /// already clamped away from zero by the router. + fn apply_resize(&mut self, width: u32, height: u32) { + let vp = self + .state + .as_mut() + .and_then(|state| state.resize(width, height)); + if let Some(vp) = vp + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: resize send_viewport failed: {e}"); + } + // Vterm Stage 3 — a resize is a real geometry change, so + // the cell grid is re-derived and declared here. The + // daemon resizes the shared PTY only if this frontend is + // the durable controller. + self.flush_terminal_declaration(); + // Q#BP15a — and so is the panel's whole-frame capacity. An + // identical cell total is not re-declared: nothing the + // daemon can act on changed. + self.flush_panel_geometry(GeometryTrigger::Surface); + } +} + +/// GUI Stage 1-pre — the input seam. +/// +/// `window_event` receives a `WindowEvent` and, until this seam existed, +/// decided *and performed* everything in one 655-line match. Nothing +/// below it could be witnessed without a display: `ActiveEventLoop` is +/// non-constructible outside a live event loop, and the arms that matter +/// reach a GPU surface or a socket. +/// +/// The seam splits the two halves. **Deciding** is [`route_event`] — a +/// free function over `&WindowEvent` alone, so a headless test can drive +/// every family. **Performing** stays on `App`, in the `apply_*` methods +/// the router's variants name. +/// +/// The decision is what the route *is*, not merely which family claims +/// it: `Exit` is the local exit effect, `Resize` carries the clamped +/// surface extent, `Modifiers` carries the state mutation. Two arms +/// (`CloseRequested`, and `RedrawRequested` once it moves here) send +/// nothing outbound at all, so a harness recording only protocol traffic +/// would leave them invisible — which is why a route names its local +/// effect and the harness records routes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Route { + /// The lifecycle family — see [`route_lifecycle`]. + Lifecycle(LifecycleRoute), + /// No extracted family claims this event. During 1-pre that covers + /// both the families still inline in `window_event` and the events + /// pmacs genuinely ignores; when the last family moves here it means + /// only the second. + Unrouted, +} + +/// The lifecycle family: the three arms that read no pointer state, hold +/// no `State` borrow, and reach the socket only through the resize +/// declaration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LifecycleRoute { + /// `CloseRequested` — leave the event loop. Q#S1-1: a native close + /// detaches this frontend, it does not shut the daemon down. + Exit, + /// `ModifiersChanged` — the new modifier state, already unwrapped + /// from winit's `Modifiers` wrapper. + Modifiers(winit::keyboard::ModifiersState), + /// `Resized` — the new surface extent, **already clamped away from + /// zero**. A minimize delivers `0×0`, and wgpu rejects a zero-extent + /// surface configuration, so the clamp is a rule rather than + /// defensive padding; deciding it here is what makes it witnessable + /// without a surface. + Resize { width: u32, height: u32 }, +} + +/// Decide what `window_event` should do with an event, from the event +/// alone. See [`Route`]. +fn route_event(event: &WindowEvent) -> Route { + if let Some(lifecycle) = route_lifecycle(event) { + return Route::Lifecycle(lifecycle); + } + Route::Unrouted +} + +/// The lifecycle family's decision. `None` means some other family owns +/// the event. +fn route_lifecycle(event: &WindowEvent) -> Option { + match event { + WindowEvent::CloseRequested => Some(LifecycleRoute::Exit), + WindowEvent::ModifiersChanged(mods) => Some(LifecycleRoute::Modifiers(mods.state())), + WindowEvent::Resized(size) => Some(LifecycleRoute::Resize { + width: size.width.max(1), + height: size.height.max(1), + }), + _ => None, + } +} + +/// GUI Stage 1-pre — the headless routing harness (P2). +/// +/// It feeds `WindowEvent`s through the production [`route_event`] and +/// records what each one routed to. The transcript is of **routes**, +/// deliberately, not of outbound protocol traffic: `CloseRequested` +/// exits and `RedrawRequested` repaints, and neither sends the daemon +/// anything, so a transcript of daemon traffic alone cannot tell a +/// handled arm from a dropped one. A route names its local effect — +/// exit, resize extent, modifier mutation — which is what makes those +/// arms observable here at all. +/// +/// P3 is the stated exception: that `window_event` *calls* the router +/// rather than deciding for itself is a code-review invariant, not a +/// tested one. `ActiveEventLoop` cannot be constructed outside a live +/// event loop, so the real callback is unreachable from a test. The +/// mutation evidence below covers every router arm and **not** the +/// delegation. +#[cfg(test)] +#[derive(Debug, Default)] +struct RoutingHarness { + transcript: Vec, +} + +#[cfg(test)] +impl RoutingHarness { + fn feed(&mut self, event: &WindowEvent) -> Route { + let route = route_event(event); + self.transcript.push(route); + route + } + + fn transcript(&self) -> &[Route] { + &self.transcript + } +} + +#[cfg(test)] +mod input_routing_tests { + use super::*; + use winit::dpi::PhysicalSize; + use winit::keyboard::ModifiersState; + + fn modifiers_changed(state: ModifiersState) -> WindowEvent { + WindowEvent::ModifiersChanged(state.into()) + } + + /// P1 — `CloseRequested`. It sends the daemon nothing and its whole + /// effect is local, so this row exists only because the route names + /// the effect. + #[test] + fn close_requested_routes_to_exit() { + let mut harness = RoutingHarness::default(); + assert_eq!( + harness.feed(&WindowEvent::CloseRequested), + Route::Lifecycle(LifecycleRoute::Exit) + ); + } + + /// P1 — `ModifiersChanged` carries the unwrapped state, which is the + /// mutation `window_event` performs. + #[test] + fn modifiers_changed_routes_the_new_state() { + let mut harness = RoutingHarness::default(); + let mods = ModifiersState::CONTROL | ModifiersState::ALT; + assert_eq!( + harness.feed(&modifiers_changed(mods)), + Route::Lifecycle(LifecycleRoute::Modifiers(mods)) + ); + // An empty state is a real transition (every modifier released), + // not an absent one. + assert_eq!( + harness.feed(&modifiers_changed(ModifiersState::empty())), + Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::empty())) + ); + } + + /// P1 — `Resized` carries the extent through unchanged when it is + /// already non-zero. + #[test] + fn resized_routes_the_new_extent() { + let mut harness = RoutingHarness::default(); + assert_eq!( + harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 720))), + Route::Lifecycle(LifecycleRoute::Resize { + width: 1280, + height: 720, + }) + ); + } + + /// A minimize delivers `0×0` and wgpu rejects a zero-extent surface + /// configuration. The clamp is per axis, so an extent that collapses + /// on one axis only still keeps the other. + #[test] + fn a_zero_extent_resize_clamps_per_axis() { + let mut harness = RoutingHarness::default(); + assert_eq!( + harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))), + Route::Lifecycle(LifecycleRoute::Resize { + width: 1, + height: 1, + }) + ); + assert_eq!( + harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 720))), + Route::Lifecycle(LifecycleRoute::Resize { + width: 1, + height: 720, + }) + ); + assert_eq!( + harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 0))), + Route::Lifecycle(LifecycleRoute::Resize { + width: 1280, + height: 1, + }) + ); + } + + /// An event no family claims. `Occluded` is chosen because pmacs has + /// never handled it and no later slice will — a still-inline family + /// would also read `Unrouted` today and stop doing so when it moves. + #[test] + fn an_unclaimed_event_is_unrouted() { + let mut harness = RoutingHarness::default(); + assert_eq!(harness.feed(&WindowEvent::Occluded(true)), Route::Unrouted); + } + + /// P2 — the harness records a transcript, and the transcript + /// distinguishes the three lifecycle effects from each other and + /// from an unclaimed event. Two of these four rows produce no + /// outbound traffic whatsoever. + #[test] + fn the_harness_records_each_local_effect_in_order() { + let mut harness = RoutingHarness::default(); + harness.feed(&WindowEvent::Resized(PhysicalSize::new(800, 600))); + harness.feed(&modifiers_changed(ModifiersState::SHIFT)); + harness.feed(&WindowEvent::Occluded(false)); + harness.feed(&WindowEvent::CloseRequested); + assert_eq!( + harness.transcript(), + &[ + Route::Lifecycle(LifecycleRoute::Resize { + width: 800, + height: 600, + }), + Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::SHIFT)), + Route::Unrouted, + Route::Lifecycle(LifecycleRoute::Exit), + ] + ); + } } impl ApplicationHandler for App { @@ -2733,8 +2984,6 @@ impl ApplicationHandler for App { #[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { match event { - WindowEvent::CloseRequested => event_loop.exit(), - WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(), WindowEvent::KeyboardInput { event: key, .. } => { if key.state != ElementState::Pressed { return; @@ -2929,27 +3178,6 @@ impl ApplicationHandler for App { eprintln!("pmacs-gpu: send_key failed: {e}"); } } - WindowEvent::Resized(size) => { - let vp = self - .state - .as_mut() - .and_then(|state| state.resize(size.width.max(1), size.height.max(1))); - if let Some(vp) = vp - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: resize send_viewport failed: {e}"); - } - // Vterm Stage 3 — a resize is a real geometry change, so - // the cell grid is re-derived and declared here. The - // daemon resizes the shared PTY only if this frontend is - // the durable controller. - self.flush_terminal_declaration(); - // Q#BP15a — and so is the panel's whole-frame capacity. An - // identical cell total is not re-declared: nothing the - // daemon can act on changed. - self.flush_panel_geometry(GeometryTrigger::Surface); - } // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). WindowEvent::CursorMoved { position, .. } => { let Some(state) = self.state.as_mut() else { @@ -3383,7 +3611,18 @@ impl ApplicationHandler for App { state.render(); } } - _ => {} + // Stage 1-pre — everything the router already claims. The + // arms above are the families not yet moved behind it; when + // the last one goes, this match collapses to the call below + // and `window_event` is the thin call-through P3 asks for. + ref routed => match route_event(routed) { + Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(), + Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, + Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { + self.apply_resize(width, height); + } + Route::Unrouted => {} + }, } } From 7f0f9db98cf80a31b9c006b4f061ab55512fd7ec Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:25:57 +0200 Subject: [PATCH 04/15] feat(gpu): 1-pre --- the redraw family, which sends the daemon nothing The second of the two arms with no outbound traffic. `RedrawRequested` joins the lifecycle family and its body moves to `App::apply_redraw`. This is the arm P2 was written for. With `CloseRequested` it makes the pair a harness built on protocol traffic could not see at all: neither one sends the daemon a byte, so "did this arm get handled?" has no answer in a transcript of daemon traffic. The transcript row now drives five events of which two are silent. The lifecycle family's criterion is stated properly here rather than left as the accident of which three arms happened to be smallest: events about the WINDOW ITSELF --- closing, resizing, repainting --- as against a gesture aimed into the document. `ModifiersChanged` is the one exception and is documented as one, since it is a bare state mutation with no gesture of its own and no body to extract. Evidence --- 7 rows, 2 further mutations: M7 `RedrawRequested` -> no family -> the redraw row (+ transcript) M8 harness records outbound only -> the transcript row ALONE M8 is M4 re-run now that a second silent arm exists: the mutation discards both `Exit` and `Redraw` and keeps only the resize, and still fails exactly one row, because the per-variant rows assert `feed`'s return value and the transcript row alone owns P2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 58 +++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b37f384..efdad5f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2703,6 +2703,14 @@ impl App { // daemon can act on changed. self.flush_panel_geometry(GeometryTrigger::Surface); } + + /// Perform [`LifecycleRoute::Redraw`]. A no-op before `resumed` has + /// built the surface. + fn apply_redraw(&mut self) { + if let Some(state) = self.state.as_mut() { + state.render(); + } + } } /// GUI Stage 1-pre — the input seam. @@ -2720,11 +2728,11 @@ impl App { /// /// The decision is what the route *is*, not merely which family claims /// it: `Exit` is the local exit effect, `Resize` carries the clamped -/// surface extent, `Modifiers` carries the state mutation. Two arms -/// (`CloseRequested`, and `RedrawRequested` once it moves here) send -/// nothing outbound at all, so a harness recording only protocol traffic -/// would leave them invisible — which is why a route names its local -/// effect and the harness records routes. +/// surface extent, `Modifiers` carries the state mutation. **Two arms — +/// `CloseRequested` and `RedrawRequested` — send nothing outbound at +/// all**, so a harness recording only protocol traffic would leave them +/// invisible; that is why a route names its local effect and the harness +/// records routes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Route { /// The lifecycle family — see [`route_lifecycle`]. @@ -2736,9 +2744,11 @@ enum Route { Unrouted, } -/// The lifecycle family: the three arms that read no pointer state, hold -/// no `State` borrow, and reach the socket only through the resize -/// declaration. +/// The lifecycle family: events about the **window itself** — closing, +/// resizing, repainting — rather than about a gesture aimed into the +/// document. `ModifiersChanged` is grouped here as the one exception, +/// and it is named as one: it is a bare state mutation with no gesture +/// of its own and no body to extract. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LifecycleRoute { /// `CloseRequested` — leave the event loop. Q#S1-1: a native close @@ -2753,6 +2763,10 @@ enum LifecycleRoute { /// defensive padding; deciding it here is what makes it witnessable /// without a surface. Resize { width: u32, height: u32 }, + /// `RedrawRequested` — paint a frame. Nothing goes to the daemon, + /// which is why the harness records local effects rather than + /// outbound traffic. + Redraw, } /// Decide what `window_event` should do with an event, from the event @@ -2774,6 +2788,7 @@ fn route_lifecycle(event: &WindowEvent) -> Option { width: size.width.max(1), height: size.height.max(1), }), + WindowEvent::RedrawRequested => Some(LifecycleRoute::Redraw), _ => None, } } @@ -2897,6 +2912,18 @@ mod input_routing_tests { ); } + /// P1 — `RedrawRequested`. The second arm that sends the daemon + /// nothing, and the reason the harness cannot be a transcript of + /// outbound traffic. + #[test] + fn redraw_requested_routes_to_redraw() { + let mut harness = RoutingHarness::default(); + assert_eq!( + harness.feed(&WindowEvent::RedrawRequested), + Route::Lifecycle(LifecycleRoute::Redraw) + ); + } + /// An event no family claims. `Occluded` is chosen because pmacs has /// never handled it and no later slice will — a still-inline family /// would also read `Unrouted` today and stop doing so when it moves. @@ -2907,14 +2934,16 @@ mod input_routing_tests { } /// P2 — the harness records a transcript, and the transcript - /// distinguishes the three lifecycle effects from each other and - /// from an unclaimed event. Two of these four rows produce no - /// outbound traffic whatsoever. + /// distinguishes each lifecycle effect from the others and from an + /// unclaimed event. **Two of these five rows produce no outbound + /// traffic whatsoever**, which is the property that rules out a + /// harness built on protocol traffic alone. #[test] fn the_harness_records_each_local_effect_in_order() { let mut harness = RoutingHarness::default(); harness.feed(&WindowEvent::Resized(PhysicalSize::new(800, 600))); harness.feed(&modifiers_changed(ModifiersState::SHIFT)); + harness.feed(&WindowEvent::RedrawRequested); harness.feed(&WindowEvent::Occluded(false)); harness.feed(&WindowEvent::CloseRequested); assert_eq!( @@ -2925,6 +2954,7 @@ mod input_routing_tests { height: 600, }), Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::SHIFT)), + Route::Lifecycle(LifecycleRoute::Redraw), Route::Unrouted, Route::Lifecycle(LifecycleRoute::Exit), ] @@ -3606,11 +3636,6 @@ impl ApplicationHandler for App { eprintln!("pmacs-gpu: wheel send_viewport failed: {e}"); } } - WindowEvent::RedrawRequested => { - if let Some(state) = self.state.as_mut() { - state.render(); - } - } // Stage 1-pre — everything the router already claims. The // arms above are the families not yet moved behind it; when // the last one goes, this match collapses to the call below @@ -3621,6 +3646,7 @@ impl ApplicationHandler for App { Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { self.apply_resize(width, height); } + Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), Route::Unrouted => {} }, } From 0705564ae4dba1cd339b89c17c72c5d5f06405be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:36:30 +0200 Subject: [PATCH 05/15] feat(gpu): 1-pre --- the keyboard family, and a second structural exception The 194-line arm moves to `App::apply_keyboard`, verified byte-identical against `HEAD~1` modulo exactly two named conversions: the press guard becomes the router's decision, and `event_loop.exit()` becomes a returned `EventOutcome::Exit`. THE OUTCOME RETURN IS THE LOAD-BEARING PART. The keyboard arm was the second caller of `event_loop.exit()` --- an idle Escape is a local quit --- so a body that owned the exit would have needed an `&ActiveEventLoop`, and `ActiveEventLoop` is exactly what cannot exist in a test. Returning the decision keeps `event_loop.exit()` in one place, `window_event`, and leaves every body reachable in principle. Both call sites are now inside `window_event` and nowhere else, which is checkable by grep. Stage 1a's A4 deletes the Escape branch, at which point `EventOutcome` has one variant and should go; the branch carries a comment saying so. A SECOND ACCEPTED STRUCTURAL EXCEPTION, ALONGSIDE P3, and it is winit's rather than this seam's: `KeyEvent` carries a `pub(crate) platform_specific` field, so NO `WindowEvent::KeyboardInput` CAN BE CONSTRUCTED OUTSIDE WINIT and no headless test can feed one. Checked in winit-0.30.13/src/event.rs, not assumed. The response is to shrink what the exception covers rather than to accept it whole. The family's only real decision --- press acted on, release claimed and discarded --- is factored into `route_key_action(ElementState) -> KeyAction`, which takes a constructible argument and is tested directly. What stays unwitnessed is one pattern arm containing a match and a call, with no logic in it. The exception does NOT extend to the pointer families: winit provides `DeviceId::dummy()` for exactly this purpose ("useful for unit testing") and `CursorMoved`/`MouseInput`/`MouseWheel` are constructible. Checked before writing the exception down, so its scope is measured. `Release` is a route and not a `None`. The family CLAIMS a key-up and drops it, which is a different fact from no family claiming the event; collapsing them would hide the drop the moment a slice wants key-up semantics. `window_event` merges the two arms because both are today nothing to do, and says so. Evidence --- 8 rows, 3 further mutations, each failing exactly one row: M9 a release treated as a press -> the key-action row M10 a press treated as a release -> the key-action row M11 harness records outbound only -> the transcript row `route_one` deliberately calls `route_event` and not the harness, so the transcript row stays P2's sole owner and M11 stays surgical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 571 +++++++++++++++++++++++++----------------- 1 file changed, 341 insertions(+), 230 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index efdad5f..b1a9a3a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -55,7 +55,7 @@ use pmacs_protocol::{ use unicode_width::UnicodeWidthChar; use wgpu::MultisampleState; use winit::application::ApplicationHandler; -use winit::event::{ElementState, WindowEvent}; +use winit::event::{ElementState, KeyEvent, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; @@ -2711,6 +2711,202 @@ impl App { state.render(); } } + + /// Perform [`KeyboardRoute::Press`]. The router has already + /// discarded key-ups, so `key` is always a press. + #[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order. + fn apply_keyboard(&mut self, key: &KeyEvent) -> EventOutcome { + // While the daemon is intercepting keystrokes — an active + // incremental search (Q#SR5), or a minibuffer / pending + // prefix — every key belongs to its handler, not the + // buffer. The GUI round-trips them all and never + // optimistic-applies (that would edit the document + // mid-search). + let intercept = self + .state + .as_ref() + .is_some_and(State::daemon_intercepts_keys); + + // Arc 1a Q#C6 — the completion popup is NON-modal, so it + // never flips the intercept gate (typing stays + // optimistic; the daemon's after-edit refresh re-ships + // the popup). Only the keys whose *default GPU handling + // is wrong under a popup* need this flag: Esc (below, + // else it's the local quit) and RET/TAB (the optimistic + // gate further down, else they'd insert instead of + // accept). C-n/C-p/C-g already round-trip as command + // chords, Up/Down as forwarded motion keys — the daemon's + // completion shadow handles all of them. + let completion_open = self + .state + .as_ref() + .is_some_and(State::completion_open_for_current_buffer); + + // Escape cancels an active intercept (e.g. a running + // search) or dismisses the completion popup; otherwise it + // stays the local quit. + if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { + if intercept || completion_open { + if let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) + { + eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}"); + } + } else { + // Q#S1-1 / A4 — the local quit, unchanged here and + // deleted by Stage 1a: an idle Escape must reach the + // daemon. `window_event` performs the exit; this is + // the only reason a body needs an outcome at all. + return EventOutcome::Exit; + } + return EventOutcome::Continue; + } + + let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else { + return EventOutcome::Continue; + }; + + // AltGr / international text (audit F-004). winit reports + // the text a keypress produces; when a keypress yields + // printable text *while both Ctrl and Alt* are held — the + // AltGr signature on Windows (LCtrl+RAlt) — it's text + // input, not a command chord. Strip those modifiers (keep + // Shift) so it inserts (through the plain-text path, or the + // daemon's SelfInsert while a prompt is open) instead of + // being routed to the keymap. Alt alone is left intact so + // macOS Option-as-Meta still reaches the keymap; on layouts + // where AltGr isn't Ctrl+Alt this is a no-op. + if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(key.text.as_deref(), pmods) { + pmods = if pmods.contains(Modifiers::SHIFT) { + Modifiers::SHIFT + } else { + Modifiers::NONE + }; + } + + // Ctrl-V — OS paste (Q#CM6). Read the system clipboard + // locally via arboard and ship it as a `Paste` event; the + // daemon inserts it. Handled before binding `client` so + // the `&mut self` clipboard read doesn't conflict with the + // client borrow. Skipped while intercepting (the daemon's + // active handler owns the key then). The daemon keymap's + // C-y yanks the in-app slot instead. + if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL { + let bytes = self.state.as_mut().and_then(State::read_os_clipboard); + if let Some(bytes) = bytes + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_paste(bytes) + { + eprintln!("pmacs-gpu: send_paste failed: {e}"); + } + return EventOutcome::Continue; + } + + let Some(client) = self.attach_client.as_ref() else { + return EventOutcome::Continue; + }; + + // Intercept path: round-trip every key into the daemon's + // active handler (search query / step / accept / cancel). + if intercept { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if debug_input() { + eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}"); + } + return EventOutcome::Continue; + } + + // Idle: forward any command chord (Char/Enter/Tab with + // Ctrl or Alt) to the daemon (Q#GC1). These drive the + // keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x, + // … — the same path the TUI forwards everything through. + // The GUI no longer withholds them (the minibuffer / prompt + // flows they open now render, Q#MB1). Once a forwarded + // chord opens a prompt or enters a prefix, `dispatch_idle` + // flips false and the intercept gate round-trips the rest — + // no optimistic local flip, so a chord that changes no + // daemon state can never wedge the gate. (Ctrl-V / OS paste + // is handled locally above and never reaches here.) + if is_command_chord(pkey, pmods) { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (command chord) failed: {e}"); + } + return EventOutcome::Continue; + } + + // Session B2 forwards cursor motion + plain text editing + // (Char / Backspace / Enter / Delete / Tab). Command chords + // are handled above; Meta/Super-only chords fall through + // here and are withheld, leaving OS/WM shortcuts (Cmd-Q, + // Cmd-C) to the platform. + if !should_forward_key(pkey, pmods) { + return EventOutcome::Continue; + } + + // Arc 1a Q#C6 — with the popup open, RET and TAB mean + // "accept", not "insert \n / \t": skip the optimistic + // path so they round-trip into the daemon's + // dispatch_completion_key. Everything else stays + // optimistic. + let completion_takes_key = + completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab); + + if !completion_takes_key + && let Some(op) = self.state.as_mut().and_then(|state| { + state + .optimistic_crdt_insert(pkey, pmods) + .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) + }) + { + if debug_input() { + eprintln!( + "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", + op.buffer_id, + op.op.bytes.len() + ); + } + if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { + eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); + } + // An optimistic edit near the viewport edge can + // scroll (a wrap-inducing insert, a Backspace + // above the top); re-declare the scoped viewport + // so the producer styles the newly visible lines. + if let Some(vp) = op.viewport + && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: send Viewport failed: {e}"); + } + return EventOutcome::Continue; + } + if let Some(state) = self.state.as_mut() { + if state.defer_round_trip_key_if_needed(pkey, pmods) { + if debug_input() { + eprintln!( + "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ + pending optimistic cursor" + ); + } + return EventOutcome::Continue; + } + state.mark_cursor_stale_after_round_trip(); + } + if debug_input() { + eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key failed: {e}"); + } + EventOutcome::Continue + } } /// GUI Stage 1-pre — the input seam. @@ -2733,10 +2929,15 @@ impl App { /// all**, so a harness recording only protocol traffic would leave them /// invisible; that is why a route names its local effect and the harness /// records routes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Route { +#[derive(Debug, Clone, Copy, PartialEq)] +enum Route<'a> { /// The lifecycle family — see [`route_lifecycle`]. Lifecycle(LifecycleRoute), + /// The keyboard family — see [`route_keyboard`]. + Keyboard { + action: KeyAction, + key: &'a KeyEvent, + }, /// No extracted family claims this event. During 1-pre that covers /// both the families still inline in `window_event` and the events /// pmacs genuinely ignores; when the last family moves here it means @@ -2744,6 +2945,21 @@ enum Route { Unrouted, } +/// What the event loop must do once a family's body has run. Only the +/// keyboard family produces anything but `Continue` today: an idle +/// Escape is a local quit. Returning the decision rather than taking an +/// `&ActiveEventLoop` is what keeps every body reachable from a test — +/// `event_loop.exit()` is called in exactly one place, `window_event`. +/// +/// Stage 1a's A4 deletes that branch (an idle Escape must reach the +/// daemon and never exit), at which point this type has one variant and +/// should go. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventOutcome { + Continue, + Exit, +} + /// The lifecycle family: events about the **window itself** — closing, /// resizing, repainting — rather than about a gesture aimed into the /// document. `ModifiersChanged` is grouped here as the one exception, @@ -2769,12 +2985,28 @@ enum LifecycleRoute { Redraw, } +/// The keyboard family's whole decision. `Release` is a route rather +/// than an absence: the family **claims** a key-up and drops it, which +/// is a different fact from no family claiming the event, and +/// conflating the two would hide the drop the moment a later slice +/// wants key-up semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyAction { + /// A key-down — the only keyboard state pmacs acts on. + Press, + /// A key-up. Claimed and deliberately discarded. + Release, +} + /// Decide what `window_event` should do with an event, from the event /// alone. See [`Route`]. -fn route_event(event: &WindowEvent) -> Route { +fn route_event(event: &WindowEvent) -> Route<'_> { if let Some(lifecycle) = route_lifecycle(event) { return Route::Lifecycle(lifecycle); } + if let Some((action, key)) = route_keyboard(event) { + return Route::Keyboard { action, key }; + } Route::Unrouted } @@ -2793,6 +3025,34 @@ fn route_lifecycle(event: &WindowEvent) -> Option { } } +/// The keyboard family's claim. `None` means some other family owns the +/// event. +/// +/// **A SECOND ACCEPTED STRUCTURAL EXCEPTION, alongside P3.** This +/// function's own pattern arm is unwitnessable: `KeyEvent` carries a +/// `pub(crate) platform_specific` field, so **no `WindowEvent::KeyboardInput` +/// can be constructed outside winit** and no headless test can feed one. +/// The limitation is winit's and not this seam's — it is why the arm is +/// kept to a pattern and a call, with the family's only real decision +/// factored into [`route_key_action`], which takes an `ElementState` and +/// is tested directly. The pointer families have no such problem: +/// `DeviceId::dummy()` is provided by winit for exactly this purpose, +/// and their events are constructible. +fn route_keyboard(event: &WindowEvent) -> Option<(KeyAction, &KeyEvent)> { + match event { + WindowEvent::KeyboardInput { event: key, .. } => Some((route_key_action(key.state), key)), + _ => None, + } +} + +/// Press or release — see [`KeyAction`]. +fn route_key_action(state: ElementState) -> KeyAction { + match state { + ElementState::Pressed => KeyAction::Press, + ElementState::Released => KeyAction::Release, + } +} + /// GUI Stage 1-pre — the headless routing harness (P2). /// /// It feeds `WindowEvent`s through the production [`route_event`] and @@ -2812,19 +3072,19 @@ fn route_lifecycle(event: &WindowEvent) -> Option { /// delegation. #[cfg(test)] #[derive(Debug, Default)] -struct RoutingHarness { - transcript: Vec, +struct RoutingHarness<'a> { + transcript: Vec>, } #[cfg(test)] -impl RoutingHarness { - fn feed(&mut self, event: &WindowEvent) -> Route { +impl<'a> RoutingHarness<'a> { + fn feed(&mut self, event: &'a WindowEvent) -> Route<'a> { let route = route_event(event); self.transcript.push(route); route } - fn transcript(&self) -> &[Route] { + fn transcript(&self) -> &[Route<'a>] { &self.transcript } } @@ -2839,32 +3099,43 @@ mod input_routing_tests { WindowEvent::ModifiersChanged(state.into()) } + /// The per-variant rows below drive [`route_event`] directly and the + /// transcript row drives the harness. That split is deliberate: it + /// leaves the transcript row as P2's sole owner, so a harness that + /// stopped recording an effect fails exactly one row instead of + /// every row. + /// + /// Events are bound to locals rather than passed as temporaries + /// because a `Route` borrows the event it came from — the keyboard + /// variant carries a `&KeyEvent`. + fn route_one(event: &WindowEvent) -> Route<'_> { + route_event(event) + } + /// P1 — `CloseRequested`. It sends the daemon nothing and its whole /// effect is local, so this row exists only because the route names /// the effect. #[test] fn close_requested_routes_to_exit() { - let mut harness = RoutingHarness::default(); - assert_eq!( - harness.feed(&WindowEvent::CloseRequested), - Route::Lifecycle(LifecycleRoute::Exit) - ); + let event = WindowEvent::CloseRequested; + assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Exit)); } /// P1 — `ModifiersChanged` carries the unwrapped state, which is the /// mutation `window_event` performs. #[test] fn modifiers_changed_routes_the_new_state() { - let mut harness = RoutingHarness::default(); let mods = ModifiersState::CONTROL | ModifiersState::ALT; + let held = modifiers_changed(mods); assert_eq!( - harness.feed(&modifiers_changed(mods)), + route_one(&held), Route::Lifecycle(LifecycleRoute::Modifiers(mods)) ); // An empty state is a real transition (every modifier released), // not an absent one. + let released = modifiers_changed(ModifiersState::empty()); assert_eq!( - harness.feed(&modifiers_changed(ModifiersState::empty())), + route_one(&released), Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::empty())) ); } @@ -2873,9 +3144,9 @@ mod input_routing_tests { /// already non-zero. #[test] fn resized_routes_the_new_extent() { - let mut harness = RoutingHarness::default(); + let event = WindowEvent::Resized(PhysicalSize::new(1280, 720)); assert_eq!( - harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 720))), + route_one(&event), Route::Lifecycle(LifecycleRoute::Resize { width: 1280, height: 720, @@ -2888,23 +3159,25 @@ mod input_routing_tests { /// on one axis only still keeps the other. #[test] fn a_zero_extent_resize_clamps_per_axis() { - let mut harness = RoutingHarness::default(); + let collapsed = WindowEvent::Resized(PhysicalSize::new(0, 0)); assert_eq!( - harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))), + route_one(&collapsed), Route::Lifecycle(LifecycleRoute::Resize { width: 1, height: 1, }) ); + let no_width = WindowEvent::Resized(PhysicalSize::new(0, 720)); assert_eq!( - harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 720))), + route_one(&no_width), Route::Lifecycle(LifecycleRoute::Resize { width: 1, height: 720, }) ); + let no_height = WindowEvent::Resized(PhysicalSize::new(1280, 0)); assert_eq!( - harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 0))), + route_one(&no_height), Route::Lifecycle(LifecycleRoute::Resize { width: 1280, height: 1, @@ -2917,11 +3190,22 @@ mod input_routing_tests { /// outbound traffic. #[test] fn redraw_requested_routes_to_redraw() { - let mut harness = RoutingHarness::default(); - assert_eq!( - harness.feed(&WindowEvent::RedrawRequested), - Route::Lifecycle(LifecycleRoute::Redraw) - ); + let event = WindowEvent::RedrawRequested; + assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Redraw)); + } + + /// P1, keyboard — the family's whole decision. + /// + /// It is driven through [`route_key_action`] rather than the + /// harness, and that is the accepted exception documented on + /// [`route_keyboard`]: `KeyEvent` has a `pub(crate)` field, so no + /// `WindowEvent::KeyboardInput` exists that a test can build. What + /// remains unwitnessed is one pattern arm with no logic in it; the + /// decision itself is here. + #[test] + fn a_press_is_acted_on_and_a_release_is_discarded() { + assert_eq!(route_key_action(ElementState::Pressed), KeyAction::Press); + assert_eq!(route_key_action(ElementState::Released), KeyAction::Release); } /// An event no family claims. `Occluded` is chosen because pmacs has @@ -2929,8 +3213,8 @@ mod input_routing_tests { /// would also read `Unrouted` today and stop doing so when it moves. #[test] fn an_unclaimed_event_is_unrouted() { - let mut harness = RoutingHarness::default(); - assert_eq!(harness.feed(&WindowEvent::Occluded(true)), Route::Unrouted); + let event = WindowEvent::Occluded(true); + assert_eq!(route_one(&event), Route::Unrouted); } /// P2 — the harness records a transcript, and the transcript @@ -2940,12 +3224,17 @@ mod input_routing_tests { /// harness built on protocol traffic alone. #[test] fn the_harness_records_each_local_effect_in_order() { + let events = [ + WindowEvent::Resized(PhysicalSize::new(800, 600)), + modifiers_changed(ModifiersState::SHIFT), + WindowEvent::RedrawRequested, + WindowEvent::Occluded(false), + WindowEvent::CloseRequested, + ]; let mut harness = RoutingHarness::default(); - harness.feed(&WindowEvent::Resized(PhysicalSize::new(800, 600))); - harness.feed(&modifiers_changed(ModifiersState::SHIFT)); - harness.feed(&WindowEvent::RedrawRequested); - harness.feed(&WindowEvent::Occluded(false)); - harness.feed(&WindowEvent::CloseRequested); + for event in &events { + harness.feed(event); + } assert_eq!( harness.transcript(), &[ @@ -3014,200 +3303,6 @@ impl ApplicationHandler for App { #[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { match event { - WindowEvent::KeyboardInput { event: key, .. } => { - if key.state != ElementState::Pressed { - return; - } - // While the daemon is intercepting keystrokes — an active - // incremental search (Q#SR5), or a minibuffer / pending - // prefix — every key belongs to its handler, not the - // buffer. The GUI round-trips them all and never - // optimistic-applies (that would edit the document - // mid-search). - let intercept = self - .state - .as_ref() - .is_some_and(State::daemon_intercepts_keys); - - // Arc 1a Q#C6 — the completion popup is NON-modal, so it - // never flips the intercept gate (typing stays - // optimistic; the daemon's after-edit refresh re-ships - // the popup). Only the keys whose *default GPU handling - // is wrong under a popup* need this flag: Esc (below, - // else it's the local quit) and RET/TAB (the optimistic - // gate further down, else they'd insert instead of - // accept). C-n/C-p/C-g already round-trip as command - // chords, Up/Down as forwarded motion keys — the daemon's - // completion shadow handles all of them. - let completion_open = self - .state - .as_ref() - .is_some_and(State::completion_open_for_current_buffer); - - // Escape cancels an active intercept (e.g. a running - // search) or dismisses the completion popup; otherwise it - // stays the local quit. - if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { - if intercept || completion_open { - if let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) - { - eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}"); - } - } else { - event_loop.exit(); - } - return; - } - - let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) - else { - return; - }; - - // AltGr / international text (audit F-004). winit reports - // the text a keypress produces; when a keypress yields - // printable text *while both Ctrl and Alt* are held — the - // AltGr signature on Windows (LCtrl+RAlt) — it's text - // input, not a command chord. Strip those modifiers (keep - // Shift) so it inserts (through the plain-text path, or the - // daemon's SelfInsert while a prompt is open) instead of - // being routed to the keymap. Alt alone is left intact so - // macOS Option-as-Meta still reaches the keymap; on layouts - // where AltGr isn't Ctrl+Alt this is a no-op. - if matches!(pkey, ProtocolKey::Char(_)) - && is_layout_text(key.text.as_deref(), pmods) - { - pmods = if pmods.contains(Modifiers::SHIFT) { - Modifiers::SHIFT - } else { - Modifiers::NONE - }; - } - - // Ctrl-V — OS paste (Q#CM6). Read the system clipboard - // locally via arboard and ship it as a `Paste` event; the - // daemon inserts it. Handled before binding `client` so - // the `&mut self` clipboard read doesn't conflict with the - // client borrow. Skipped while intercepting (the daemon's - // active handler owns the key then). The daemon keymap's - // C-y yanks the in-app slot instead. - if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL { - let bytes = self.state.as_mut().and_then(State::read_os_clipboard); - if let Some(bytes) = bytes - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_paste(bytes) - { - eprintln!("pmacs-gpu: send_paste failed: {e}"); - } - return; - } - - let Some(client) = self.attach_client.as_ref() else { - return; - }; - - // Intercept path: round-trip every key into the daemon's - // active handler (search query / step / accept / cancel). - if intercept { - if let Some(state) = self.state.as_mut() { - state.mark_cursor_stale_after_round_trip(); - } - if debug_input() { - eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}"); - } - if let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}"); - } - return; - } - - // Idle: forward any command chord (Char/Enter/Tab with - // Ctrl or Alt) to the daemon (Q#GC1). These drive the - // keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x, - // … — the same path the TUI forwards everything through. - // The GUI no longer withholds them (the minibuffer / prompt - // flows they open now render, Q#MB1). Once a forwarded - // chord opens a prompt or enters a prefix, `dispatch_idle` - // flips false and the intercept gate round-trips the rest — - // no optimistic local flip, so a chord that changes no - // daemon state can never wedge the gate. (Ctrl-V / OS paste - // is handled locally above and never reaches here.) - if is_command_chord(pkey, pmods) { - if let Some(state) = self.state.as_mut() { - state.mark_cursor_stale_after_round_trip(); - } - if let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key (command chord) failed: {e}"); - } - return; - } - - // Session B2 forwards cursor motion + plain text editing - // (Char / Backspace / Enter / Delete / Tab). Command chords - // are handled above; Meta/Super-only chords fall through - // here and are withheld, leaving OS/WM shortcuts (Cmd-Q, - // Cmd-C) to the platform. - if !should_forward_key(pkey, pmods) { - return; - } - - // Arc 1a Q#C6 — with the popup open, RET and TAB mean - // "accept", not "insert \n / \t": skip the optimistic - // path so they round-trip into the daemon's - // dispatch_completion_key. Everything else stays - // optimistic. - let completion_takes_key = - completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab); - - if !completion_takes_key - && let Some(op) = self.state.as_mut().and_then(|state| { - state - .optimistic_crdt_insert(pkey, pmods) - .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) - }) - { - if debug_input() { - eprintln!( - "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", - op.buffer_id, - op.op.bytes.len() - ); - } - if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { - eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); - } - // An optimistic edit near the viewport edge can - // scroll (a wrap-inducing insert, a Backspace - // above the top); re-declare the scoped viewport - // so the producer styles the newly visible lines. - if let Some(vp) = op.viewport - && let Err(e) = - client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: send Viewport failed: {e}"); - } - return; - } - if let Some(state) = self.state.as_mut() { - if state.defer_round_trip_key_if_needed(pkey, pmods) { - if debug_input() { - eprintln!( - "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ - pending optimistic cursor" - ); - } - return; - } - state.mark_cursor_stale_after_round_trip(); - } - if debug_input() { - eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); - } - if let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key failed: {e}"); - } - } // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). WindowEvent::CursorMoved { position, .. } => { let Some(state) = self.state.as_mut() else { @@ -3647,7 +3742,23 @@ impl ApplicationHandler for App { self.apply_resize(width, height); } Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), - Route::Unrouted => {} + Route::Keyboard { + action: KeyAction::Press, + key, + } => { + if self.apply_keyboard(key) == EventOutcome::Exit { + event_loop.exit(); + } + } + // A key-up is claimed by the keyboard family and + // discarded; an unrouted event was claimed by nobody. + // The route keeps those apart — they are merged here + // only because both are, today, nothing to do. + Route::Keyboard { + action: KeyAction::Release, + .. + } + | Route::Unrouted => {} }, } } From e95564567ef45021891125e682b017e2cc389053 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:45:04 +0200 Subject: [PATCH 06/15] feat(gpu): 1-pre --- the pointer families, and window_event collapses The last four arms --- `CursorMoved`, `MouseInput` twice, `MouseWheel` --- move to `apply_cursor_moved` / `apply_left_button` / `apply_right_press` / `apply_wheel`. `window_event` is now 33 lines against 655: one `route_event` call and one arm per route. The seam is complete. THE BUTTON DISCRIMINATION WAS THE FIND. It lived in the shape and order of two overlapping `MouseInput` arms --- left in either state, right in the pressed state only --- with everything else falling through a wildcard several hundred lines below. The asymmetry is real and deliberate (a context menu opens on the press; its release means nothing), but it was an artefact of arm order rather than a stated decision. `PointerRoute` names all four cases and both witnesses and mutations now bear on them. `UnusedButton` follows the keyboard family's `Release`: a middle / back / forward / other button, and a right-button release, are CLAIMED BY THE POINTER FAMILY AND DROPPED rather than left unrouted. Same behaviour as the wildcard they used to reach, and Stage 1b's B4 (middle-click PRIMARY paste on Linux) lands on exactly this route. The wheel delta is carried RAW. Converting it to lines needs the code line height, which is `State`'s to know, so the router must not try --- and the witness drives both `LineDelta` and `PixelDelta` to pin that. All four bodies verified as the original arm bodies rustfmt-normalised, by re-running rustfmt on the pre-move text at the new indent level and diffing. `apply_cursor_moved` additionally renames `position.x`/`.y` to `x`/`y`, 6 and 9 occurrences, counted. Evidence --- 13 rows, 6 further mutations: M12 right button claimed in both states -> the right-button row M13 left button claimed only on press -> the left-button row M14 CursorMoved axes swapped -> the cursor row (+ transcript) M15 unused button falls through -> unused + right rows (+ transcript) M16 harness records outbound only -> the transcript row ALONE M17 wheel delta zeroed -> the wheel row ALONE P3 RE-DEMONSTRATED AGAINST THE FINAL SHAPE, AND AGAINST THE WHOLE SUITE. Replacing `window_event`'s entire body with `let _ = (event_loop, event);` --- a GUI that responds to no input at all --- leaves ALL 256 `pmacs-gpu` tests green, not merely the 13 routing rows. That is the accepted structural exception measured rather than asserted: no headless test anywhere in this crate observes the delegation, because `ActiveEventLoop` cannot exist outside a live event loop. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 1095 ++++++++++++++++++++++++----------------- 1 file changed, 630 insertions(+), 465 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b1a9a3a..bf859ff 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -55,7 +55,7 @@ use pmacs_protocol::{ use unicode_width::UnicodeWidthChar; use wgpu::MultisampleState; use winit::application::ApplicationHandler; -use winit::event::{ElementState, KeyEvent, WindowEvent}; +use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; @@ -2704,6 +2704,429 @@ impl App { self.flush_panel_geometry(GeometryTrigger::Surface); } + /// Perform [`PointerRoute::Moved`]. `x`/`y` are the physical + /// pointer position winit reported. + #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. + fn apply_cursor_moved(&mut self, x: f64, y: f64) { + let Some(state) = self.state.as_mut() else { + return; + }; + state.pointer_pos = Some((x, y)); + // Q#CM1 — while the menu is open, motion only moves the + // highlight; send a hover when the item under the pointer + // changes from the daemon's current active row. + if state.menu.is_some() { + let hit = state.menu_hit(x, y); + let active = state.menu.as_ref().and_then(|m| m.active); + if let Some((row, true)) = hit + && active != Some(row) + { + self.send_menu_pointer(Some(row), false); + } + return; + } + // Bottom panel Stage 2B-3 — the band is consumed BEFORE + // the terminal and document paths. It sits below + // `document_text_bottom`, so a band pixel cannot hit the + // document grid, but ordering it first is what makes that a + // stated rule rather than a consequence of the arithmetic. + if state.panel.drag.is_some() { + self.advance_panel_drag(y); + return; + } + let surface = state.classify_pointer_surface(x as f32, y as f32); + if state.set_panel_divider_hover(surface == PointerSurface::PanelDivider) { + state.apply_panel_cursor_icon(); + } + match surface { + PointerSurface::PanelDivider | PointerSurface::PanelBackground => return, + PointerSurface::PanelCell(coord) => { + // A held left button makes this a `Drag(Left)`, not a + // `Move`. That distinction is the whole of panel + // selection: `Move` never focuses or claims, while + // every non-`Move` gesture activates the panel first, + // so reporting a drag as a hover makes a selection + // drag silently do nothing. + let kind = state.panel_motion_kind(); + if state.panel_motion_is_new(coord) { + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at(x, y, kind, mods); + } + return; + } + PointerSurface::Elsewhere => { + if state.panel.pointer_held { + // A drag that wandered out of the band keeps + // belonging to the band until the button comes up. + return; + } + } + } + // Vterm Stage 3 — inside the terminal clip, motion is a + // terminal gesture. Consumed before minimap scrubbing + // and document hit testing: terminal mode paints no + // minimap and has no source bytes to resolve. + let Some(state) = self.state.as_mut() else { + return; + }; + if state.terminal.is_some() { + let dragging = state.pointer_drag_active; + if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { + // Sub-cell motion resolves to the same cell and + // carries nothing new. Report only on a cell + // change, matching the document drag path's + // hit-byte dedupe — otherwise pixel-rate motion + // becomes pixel-rate wire traffic, and every one + // of those is a daemon-side gesture. + let state = self.state.as_mut().expect("checked above"); + if state.terminal_motion_is_new(coord) { + let mods = translate_mods(self.modifiers); + let kind = if dragging { + ProtocolMouseKind::Drag(ProtocolMouseButton::Left) + } else { + ProtocolMouseKind::Move + }; + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + } + return; + } + if state.minimap_scrub_active { + // Scrubbing (Q#M6): the press began on the + // minimap; motion keeps jumping, even if the + // pointer wanders out of the band. + let vp = state.minimap_jump_to(y); + if let Some(vp) = vp + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: minimap scrub send_viewport failed: {e}"); + } + return; + } + if !state.pointer_drag_active { + return; + } + // Q#M7 — arm/disarm edge auto-scroll from the drag's + // vertical position; `about_to_wait` runs the ticks. + state.edge_scroll_dir = + edge_scroll_direction(y as f32, state.config.height, state.fm, state.band_inset()); + // Drag coalescing (predicted finding #4): pixel-rate + // motion only ships when the hit byte changes. + let Some(byte) = state.hit_test_source_byte(x, y) else { + return; + }; + if state.last_pointer_sent_byte == Some(byte) { + return; + } + state.last_pointer_sent_byte = Some(byte); + state.note_pointer_round_trip(); + let buffer_id = state.current_buffer_id; + let mods = translate_mods(self.modifiers); + if let Some(buffer_id) = buffer_id { + self.send_pointer(buffer_id, byte, PointerKind::Drag, mods); + } + } + + /// Perform [`PointerRoute::Left`], press or release. + #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. + fn apply_left_button(&mut self, button_state: ElementState) { + let Some(state) = self.state.as_mut() else { + return; + }; + let Some((x, y)) = state.pointer_pos else { + return; + }; + // Q#CM1 — while the menu is open the left button drives + // it: a press invokes the row under the pointer (or + // dismisses on a click outside); a release is swallowed. + if state.menu.is_some() { + if button_state == ElementState::Pressed { + let action = match state.menu_hit(x, y) { + Some((row, true)) => Some((Some(row), true)), + Some((_, false)) => None, // separator — ignore + None => Some((None, true)), // outside — dismiss + }; + if let Some((index, invoke)) = action { + self.send_menu_pointer(index, invoke); + } + } + return; + } + let mods = translate_mods(self.modifiers); + // Bottom panel Stage 2B-3 — the divider strip and the band + // claim the gesture before either document path sees it. + let panel_surface = state.classify_pointer_surface(x as f32, y as f32); + match button_state { + ElementState::Pressed => { + if panel_surface == PointerSurface::PanelDivider + && state.begin_panel_drag(x as f32, y as f32) + { + return; + } + // Arm the gesture BEFORE sending, and only when the + // press actually landed on a cell: arming on a miss + // would make a later in-band motion send a `Drag` with + // no preceding `Down`, and not arming at all means + // `Drag(Left)` is never emitted and panel selection + // cannot work at all. + if let PointerSurface::PanelCell(_) = panel_surface { + let state = self.state.as_mut().expect("checked above"); + state.set_panel_pointer_held(true); + self.send_panel_pointer_at( + x, + y, + ProtocolMouseKind::Down(ProtocolMouseButton::Left), + mods, + ); + return; + } + if state.panel.pointer_held { + let state = self.state.as_mut().expect("checked above"); + state.set_panel_pointer_held(false); + } + } + ElementState::Released => { + if state.end_panel_drag() { + return; + } + if state.panel.pointer_held { + let cell = state.panel_release_cell(x as f32, y as f32); + self.send_panel_pointer_at_cell( + cell, + ProtocolMouseKind::Up(ProtocolMouseButton::Left), + mods, + ); + if let Some(state) = self.state.as_mut() { + state.set_panel_pointer_held(false); + } + return; + } + } + } + let Some(state) = self.state.as_mut() else { + return; + }; + if state.terminal.is_some() { + let hit = self.terminal_pointer_hit(x, y); + let state = self.state.as_mut().expect("checked above"); + let kind = match button_state { + ElementState::Pressed => { + // A press that MISSES the grid (the status + // band, the trailing padding) starts no + // drag: arming the flag there would make a + // later in-grid motion send a `Drag` with no + // preceding `Down`. + state.pointer_drag_active = hit.is_some(); + ProtocolMouseKind::Down(ProtocolMouseButton::Left) + } + ElementState::Released => { + // A release always ends the drag, including + // one that wandered outside the grid. + state.pointer_drag_active = false; + ProtocolMouseKind::Up(ProtocolMouseButton::Left) + } + }; + // A press or release always reports, and it re-arms + // the motion dedupe: the first drag after a press + // must reach the daemon even at the cell the press + // landed on. + state.last_terminal_pointer_cell = None; + if let Some((buffer_id, coord)) = hit { + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + return; + } + match button_state { + ElementState::Pressed => { + if state.in_minimap_band(x, y) { + // Q#M6 — consumed before text hit-testing; + // never a Pointer event. + state.minimap_scrub_active = true; + let vp = state.minimap_jump_to(y); + if let Some(vp) = vp + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = + client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: minimap jump send_viewport failed: {e}"); + } + return; + } + let Some(byte) = state.hit_test_source_byte(x, y) else { + return; + }; + let kind = state.classify_pointer_down(byte, mods.contains(Modifiers::SHIFT)); + state.pointer_drag_active = true; + state.last_pointer_sent_byte = Some(byte); + state.note_pointer_round_trip(); + if let Some(buffer_id) = state.current_buffer_id { + if debug_input() { + eprintln!("pmacs-gpu pointer: {kind:?} byte={byte}"); + } + self.send_pointer(buffer_id, byte, kind, mods); + } + } + ElementState::Released => { + if state.minimap_scrub_active { + state.minimap_scrub_active = false; + return; + } + if !state.pointer_drag_active { + return; + } + state.pointer_drag_active = false; + state.edge_scroll_dir = None; + state.edge_scroll_last = None; + let byte = state + .hit_test_source_byte(x, y) + .or(state.last_pointer_sent_byte); + let buffer_id = state.current_buffer_id; + if let (Some(byte), Some(buffer_id)) = (byte, buffer_id) { + self.send_pointer(buffer_id, byte, PointerKind::Up, mods); + } + } + } + } + + /// Perform [`PointerRoute::RightPress`]. + /// + /// Q#CM1 — right-click opens the context menu at the hit byte + /// (or dismisses an open one). The anchor pixel is remembered + /// so the popup the daemon sends back draws at the click. + #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. + fn apply_right_press(&mut self) { + let Some(state) = self.state.as_mut() else { + return; + }; + let Some((x, y)) = state.pointer_pos else { + return; + }; + if state.menu.is_some() { + self.send_menu_pointer(None, true); + return; + } + // Bottom panel Stage 2B-3 — a right-click in the band is a + // panel gesture, claimed before the terminal and document + // paths. The daemon decides between child mouse reporting and + // the editor context menu, so the anchor is remembered here + // exactly as for a document click; without this the band's + // context actions are unreachable and the click is applied to + // the document underneath instead. + if let PointerSurface::PanelCell(_) = state.classify_pointer_surface(x as f32, y as f32) { + if let Some(state) = self.state.as_mut() { + state.menu_anchor_px = (x, y); + } + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at( + x, + y, + ProtocolMouseKind::Down(ProtocolMouseButton::Right), + mods, + ); + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + // Vterm Stage 3 — a right-click in the terminal clip is + // a terminal gesture; the daemon decides between child + // reporting and the editor context menu, so the anchor + // is remembered here exactly as for a document click. + if state.terminal.is_some() { + state.menu_anchor_px = (x, y); + if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { + let mods = translate_mods(self.modifiers); + self.send_terminal_pointer( + buffer_id, + coord, + ProtocolMouseKind::Down(ProtocolMouseButton::Right), + mods, + ); + } + return; + } + let Some(byte) = state.hit_test_source_byte(x, y) else { + return; + }; + state.menu_anchor_px = (x, y); + let buffer_id = state.current_buffer_id; + let mods = translate_mods(self.modifiers); + if let Some(buffer_id) = buffer_id { + self.send_pointer(buffer_id, byte, PointerKind::Context, mods); + } + } + + /// Perform [`PointerRoute::Wheel`]. + #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. + fn apply_wheel(&mut self, delta: MouseScrollDelta) { + let Some(state) = self.state.as_mut() else { + return; + }; + // Wheel scroll is local-only: the GPU owns the + // viewport. Positive winit y = scroll up = smaller + // scroll_top. + let lines = match delta { + winit::event::MouseScrollDelta::LineDelta(_, y) => { + (-y * WHEEL_LINES_PER_TICK).round() as i64 + } + winit::event::MouseScrollDelta::PixelDelta(p) => { + (-(p.y as f32) / state.fm.code_line_height()).round() as i64 + } + }; + if lines == 0 { + return; + } + // Bottom panel Stage 2B-3 — a wheel tick over the band scrolls + // the PANEL's window, which is daemon-side state, so it + // crosses the wire instead of moving this frontend's local + // document `scroll_top`. Falling through would scroll the + // document while the pointer is inside the panel. + if let Some((x, y)) = state.pointer_pos + && matches!( + state.classify_pointer_surface(x as f32, y as f32), + PointerSurface::PanelCell(_) + ) + { + let mods = translate_mods(self.modifiers); + let kind = if lines < 0 { + ProtocolMouseKind::ScrollUp + } else { + ProtocolMouseKind::ScrollDown + }; + self.send_panel_pointer_at(x, y, kind, mods); + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + // Vterm Stage 3 — the terminal's scrollback belongs to + // the daemon-side view, not to this frontend's local + // scroll, so a wheel tick crosses the wire as a + // terminal gesture instead of moving `scroll_top`. + if state.terminal.is_some() { + if let Some((x, y)) = state.pointer_pos + && let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) + { + let mods = translate_mods(self.modifiers); + let kind = if lines < 0 { + ProtocolMouseKind::ScrollUp + } else { + ProtocolMouseKind::ScrollDown + }; + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + return; + } + let vp = state.scroll_by_lines(lines); + if let Some(vp) = vp + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: wheel send_viewport failed: {e}"); + } + } + /// Perform [`LifecycleRoute::Redraw`]. A no-op before `resumed` has /// built the surface. fn apply_redraw(&mut self) { @@ -2938,10 +3361,9 @@ enum Route<'a> { action: KeyAction, key: &'a KeyEvent, }, - /// No extracted family claims this event. During 1-pre that covers - /// both the families still inline in `window_event` and the events - /// pmacs genuinely ignores; when the last family moves here it means - /// only the second. + /// The pointer family — see [`route_pointer`]. + Pointer(PointerRoute), + /// No family claims this event: pmacs ignores it. Unrouted, } @@ -3007,6 +3429,9 @@ fn route_event(event: &WindowEvent) -> Route<'_> { if let Some((action, key)) = route_keyboard(event) { return Route::Keyboard { action, key }; } + if let Some(pointer) = route_pointer(event) { + return Route::Pointer(pointer); + } Route::Unrouted } @@ -3053,6 +3478,55 @@ fn route_key_action(state: ElementState) -> KeyAction { } } +/// The pointer family — Session M-2 pointer input, see +/// `docs/pmacs-gpu-mouse-framing.md`. +/// +/// The button discrimination used to live in the shape of two +/// overlapping `MouseInput` match arms — left in **either** state, right +/// in the **pressed** state only, everything else falling through the +/// wildcard several hundred lines away. Naming the cases makes the +/// asymmetry a decision instead of an artefact of arm order. +#[derive(Debug, Clone, Copy, PartialEq)] +enum PointerRoute { + /// `CursorMoved`, at the physical position winit reported. + Moved { x: f64, y: f64 }, + /// `MouseInput` on the left button, in **either** state: a press + /// starts a selection or a scrub, a release ends it. + Left(ElementState), + /// `MouseInput` **pressing** the right button. The context menu + /// opens on the press, so the matching release is deliberately + /// nothing — see `UnusedButton`. + RightPress, + /// A `MouseInput` this frontend has no semantics for: the right + /// button's release, and every middle / back / forward / other + /// button in either state. **Claimed by the pointer family and + /// dropped**, exactly as it behaved when it fell through to the + /// wildcard. Stage 1b's B4 gives the middle button a meaning + /// (PRIMARY-selection paste on Linux) and lands here. + UnusedButton, + /// `MouseWheel`. The delta is carried raw: converting it to lines + /// needs the code line height, which is `State`'s to know. + Wheel(MouseScrollDelta), +} + +/// The pointer family's decision. `None` means some other family owns +/// the event. +fn route_pointer(event: &WindowEvent) -> Option { + match event { + WindowEvent::CursorMoved { position, .. } => Some(PointerRoute::Moved { + x: position.x, + y: position.y, + }), + WindowEvent::MouseInput { state, button, .. } => Some(match (button, state) { + (MouseButton::Left, _) => PointerRoute::Left(*state), + (MouseButton::Right, ElementState::Pressed) => PointerRoute::RightPress, + _ => PointerRoute::UnusedButton, + }), + WindowEvent::MouseWheel { delta, .. } => Some(PointerRoute::Wheel(*delta)), + _ => None, + } +} + /// GUI Stage 1-pre — the headless routing harness (P2). /// /// It feeds `WindowEvent`s through the production [`route_event`] and @@ -3092,7 +3566,8 @@ impl<'a> RoutingHarness<'a> { #[cfg(test)] mod input_routing_tests { use super::*; - use winit::dpi::PhysicalSize; + use winit::dpi::{PhysicalPosition, PhysicalSize}; + use winit::event::{DeviceId, TouchPhase}; use winit::keyboard::ModifiersState; fn modifiers_changed(state: ModifiersState) -> WindowEvent { @@ -3208,9 +3683,114 @@ mod input_routing_tests { assert_eq!(route_key_action(ElementState::Released), KeyAction::Release); } + fn mouse_input(state: ElementState, button: MouseButton) -> WindowEvent { + WindowEvent::MouseInput { + device_id: DeviceId::dummy(), + state, + button, + } + } + + /// P1 — `CursorMoved` carries the position through. + #[test] + fn cursor_moved_routes_the_position() { + let event = WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(12.5, 34.25), + }; + assert_eq!( + route_one(&event), + Route::Pointer(PointerRoute::Moved { x: 12.5, y: 34.25 }) + ); + } + + /// P1 — the left button routes in **both** states, because a press + /// starts a gesture and a release ends it. The state is carried, not + /// discarded: routing a release as a press would leave a drag armed + /// forever. + #[test] + fn the_left_button_routes_in_either_state() { + for state in [ElementState::Pressed, ElementState::Released] { + let event = mouse_input(state, MouseButton::Left); + assert_eq!( + route_one(&event), + Route::Pointer(PointerRoute::Left(state)), + "left {state:?}" + ); + } + } + + /// P1 — the right button is **asymmetric with the left, on purpose**: + /// the context menu opens on the press and the release means nothing. + /// This asymmetry used to be implicit in the order and shape of two + /// overlapping match arms. + #[test] + fn the_right_button_routes_only_on_press() { + let pressed = mouse_input(ElementState::Pressed, MouseButton::Right); + assert_eq!( + route_one(&pressed), + Route::Pointer(PointerRoute::RightPress) + ); + let released = mouse_input(ElementState::Released, MouseButton::Right); + assert_eq!( + route_one(&released), + Route::Pointer(PointerRoute::UnusedButton) + ); + } + + /// A button the frontend has no semantics for is **claimed by the + /// pointer family and dropped**, not left unrouted — the same + /// distinction the keyboard family draws for a key-up. Stage 1b's B4 + /// gives the middle button a meaning and lands on this route. + #[test] + fn a_button_without_semantics_is_claimed_and_dropped() { + for button in [ + MouseButton::Middle, + MouseButton::Back, + MouseButton::Forward, + MouseButton::Other(9), + ] { + for state in [ElementState::Pressed, ElementState::Released] { + let event = mouse_input(state, button); + assert_eq!( + route_one(&event), + Route::Pointer(PointerRoute::UnusedButton), + "{button:?} {state:?}" + ); + } + } + } + + /// P1 — the wheel delta is carried **raw**. Converting it to lines + /// needs the code line height, which only `State` knows, so the + /// router must not try. + #[test] + fn the_wheel_carries_its_delta_unconverted() { + let lines = WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, -3.0), + phase: TouchPhase::Moved, + }; + assert_eq!( + route_one(&lines), + Route::Pointer(PointerRoute::Wheel(MouseScrollDelta::LineDelta(0.0, -3.0))) + ); + let pixels = WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 17.5)), + phase: TouchPhase::Moved, + }; + assert_eq!( + route_one(&pixels), + Route::Pointer(PointerRoute::Wheel(MouseScrollDelta::PixelDelta( + PhysicalPosition::new(0.0, 17.5) + ))) + ); + } + /// An event no family claims. `Occluded` is chosen because pmacs has - /// never handled it and no later slice will — a still-inline family - /// would also read `Unrouted` today and stop doing so when it moves. + /// never handled it and — unlike a mouse button it has no semantics + /// for — no family claims it at all. #[test] fn an_unclaimed_event_is_unrouted() { let event = WindowEvent::Occluded(true); @@ -3218,15 +3798,21 @@ mod input_routing_tests { } /// P2 — the harness records a transcript, and the transcript - /// distinguishes each lifecycle effect from the others and from an - /// unclaimed event. **Two of these five rows produce no outbound - /// traffic whatsoever**, which is the property that rules out a - /// harness built on protocol traffic alone. + /// distinguishes every routed effect from the others and from an + /// unclaimed event. **Two of these rows produce no outbound traffic + /// whatsoever** (`Redraw`, `Exit`), which is the property that rules + /// out a harness built on protocol traffic alone. #[test] fn the_harness_records_each_local_effect_in_order() { let events = [ WindowEvent::Resized(PhysicalSize::new(800, 600)), modifiers_changed(ModifiersState::SHIFT), + WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(4.0, 8.0), + }, + mouse_input(ElementState::Pressed, MouseButton::Left), + mouse_input(ElementState::Pressed, MouseButton::Middle), WindowEvent::RedrawRequested, WindowEvent::Occluded(false), WindowEvent::CloseRequested, @@ -3243,6 +3829,9 @@ mod input_routing_tests { height: 600, }), Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::SHIFT)), + Route::Pointer(PointerRoute::Moved { x: 4.0, y: 8.0 }), + Route::Pointer(PointerRoute::Left(ElementState::Pressed)), + Route::Pointer(PointerRoute::UnusedButton), Route::Lifecycle(LifecycleRoute::Redraw), Route::Unrouted, Route::Lifecycle(LifecycleRoute::Exit), @@ -3300,466 +3889,42 @@ impl ApplicationHandler for App { } } - #[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow. + /// P3 — the thin call-through. Every decision belongs to + /// [`route_event`] and every effect to an `apply_*` method; what is + /// left here is `event_loop.exit()`, which exists nowhere else in + /// the crate and is the one thing a headless test cannot reach. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - match event { - // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). - WindowEvent::CursorMoved { position, .. } => { - let Some(state) = self.state.as_mut() else { - return; - }; - state.pointer_pos = Some((position.x, position.y)); - // Q#CM1 — while the menu is open, motion only moves the - // highlight; send a hover when the item under the pointer - // changes from the daemon's current active row. - if state.menu.is_some() { - let hit = state.menu_hit(position.x, position.y); - let active = state.menu.as_ref().and_then(|m| m.active); - if let Some((row, true)) = hit - && active != Some(row) - { - self.send_menu_pointer(Some(row), false); - } - return; - } - // Bottom panel Stage 2B-3 — the band is consumed BEFORE - // the terminal and document paths. It sits below - // `document_text_bottom`, so a band pixel cannot hit the - // document grid, but ordering it first is what makes that a - // stated rule rather than a consequence of the arithmetic. - if state.panel.drag.is_some() { - self.advance_panel_drag(position.y); - return; - } - let surface = state.classify_pointer_surface(position.x as f32, position.y as f32); - if state.set_panel_divider_hover(surface == PointerSurface::PanelDivider) { - state.apply_panel_cursor_icon(); - } - match surface { - PointerSurface::PanelDivider | PointerSurface::PanelBackground => return, - PointerSurface::PanelCell(coord) => { - // A held left button makes this a `Drag(Left)`, not a - // `Move`. That distinction is the whole of panel - // selection: `Move` never focuses or claims, while - // every non-`Move` gesture activates the panel first, - // so reporting a drag as a hover makes a selection - // drag silently do nothing. - let kind = state.panel_motion_kind(); - if state.panel_motion_is_new(coord) { - let mods = translate_mods(self.modifiers); - self.send_panel_pointer_at(position.x, position.y, kind, mods); - } - return; - } - PointerSurface::Elsewhere => { - if state.panel.pointer_held { - // A drag that wandered out of the band keeps - // belonging to the band until the button comes up. - return; - } - } - } - // Vterm Stage 3 — inside the terminal clip, motion is a - // terminal gesture. Consumed before minimap scrubbing - // and document hit testing: terminal mode paints no - // minimap and has no source bytes to resolve. - let Some(state) = self.state.as_mut() else { - return; - }; - if state.terminal.is_some() { - let dragging = state.pointer_drag_active; - if let Some((buffer_id, coord)) = - self.terminal_pointer_hit(position.x, position.y) - { - // Sub-cell motion resolves to the same cell and - // carries nothing new. Report only on a cell - // change, matching the document drag path's - // hit-byte dedupe — otherwise pixel-rate motion - // becomes pixel-rate wire traffic, and every one - // of those is a daemon-side gesture. - let state = self.state.as_mut().expect("checked above"); - if state.terminal_motion_is_new(coord) { - let mods = translate_mods(self.modifiers); - let kind = if dragging { - ProtocolMouseKind::Drag(ProtocolMouseButton::Left) - } else { - ProtocolMouseKind::Move - }; - self.send_terminal_pointer(buffer_id, coord, kind, mods); - } - } - return; - } - if state.minimap_scrub_active { - // Scrubbing (Q#M6): the press began on the - // minimap; motion keeps jumping, even if the - // pointer wanders out of the band. - let vp = state.minimap_jump_to(position.y); - if let Some(vp) = vp - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = - client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: minimap scrub send_viewport failed: {e}"); - } - return; - } - if !state.pointer_drag_active { - return; - } - // Q#M7 — arm/disarm edge auto-scroll from the drag's - // vertical position; `about_to_wait` runs the ticks. - state.edge_scroll_dir = edge_scroll_direction( - position.y as f32, - state.config.height, - state.fm, - state.band_inset(), - ); - // Drag coalescing (predicted finding #4): pixel-rate - // motion only ships when the hit byte changes. - let Some(byte) = state.hit_test_source_byte(position.x, position.y) else { - return; - }; - if state.last_pointer_sent_byte == Some(byte) { - return; - } - state.last_pointer_sent_byte = Some(byte); - state.note_pointer_round_trip(); - let buffer_id = state.current_buffer_id; - let mods = translate_mods(self.modifiers); - if let Some(buffer_id) = buffer_id { - self.send_pointer(buffer_id, byte, PointerKind::Drag, mods); - } + match route_event(&event) { + Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(), + Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, + Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { + self.apply_resize(width, height); } - WindowEvent::MouseInput { - state: button_state, - button: winit::event::MouseButton::Left, - .. + Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), + Route::Keyboard { + action: KeyAction::Press, + key, } => { - let Some(state) = self.state.as_mut() else { - return; - }; - let Some((x, y)) = state.pointer_pos else { - return; - }; - // Q#CM1 — while the menu is open the left button drives - // it: a press invokes the row under the pointer (or - // dismisses on a click outside); a release is swallowed. - if state.menu.is_some() { - if button_state == ElementState::Pressed { - let action = match state.menu_hit(x, y) { - Some((row, true)) => Some((Some(row), true)), - Some((_, false)) => None, // separator — ignore - None => Some((None, true)), // outside — dismiss - }; - if let Some((index, invoke)) = action { - self.send_menu_pointer(index, invoke); - } - } - return; - } - let mods = translate_mods(self.modifiers); - // Bottom panel Stage 2B-3 — the divider strip and the band - // claim the gesture before either document path sees it. - let panel_surface = state.classify_pointer_surface(x as f32, y as f32); - match button_state { - ElementState::Pressed => { - if panel_surface == PointerSurface::PanelDivider - && state.begin_panel_drag(x as f32, y as f32) - { - return; - } - // Arm the gesture BEFORE sending, and only when the - // press actually landed on a cell: arming on a miss - // would make a later in-band motion send a `Drag` with - // no preceding `Down`, and not arming at all means - // `Drag(Left)` is never emitted and panel selection - // cannot work at all. - if let PointerSurface::PanelCell(_) = panel_surface { - let state = self.state.as_mut().expect("checked above"); - state.set_panel_pointer_held(true); - self.send_panel_pointer_at( - x, - y, - ProtocolMouseKind::Down(ProtocolMouseButton::Left), - mods, - ); - return; - } - if state.panel.pointer_held { - let state = self.state.as_mut().expect("checked above"); - state.set_panel_pointer_held(false); - } - } - ElementState::Released => { - if state.end_panel_drag() { - return; - } - if state.panel.pointer_held { - let cell = state.panel_release_cell(x as f32, y as f32); - self.send_panel_pointer_at_cell( - cell, - ProtocolMouseKind::Up(ProtocolMouseButton::Left), - mods, - ); - if let Some(state) = self.state.as_mut() { - state.set_panel_pointer_held(false); - } - return; - } - } - } - let Some(state) = self.state.as_mut() else { - return; - }; - if state.terminal.is_some() { - let hit = self.terminal_pointer_hit(x, y); - let state = self.state.as_mut().expect("checked above"); - let kind = match button_state { - ElementState::Pressed => { - // A press that MISSES the grid (the status - // band, the trailing padding) starts no - // drag: arming the flag there would make a - // later in-grid motion send a `Drag` with no - // preceding `Down`. - state.pointer_drag_active = hit.is_some(); - ProtocolMouseKind::Down(ProtocolMouseButton::Left) - } - ElementState::Released => { - // A release always ends the drag, including - // one that wandered outside the grid. - state.pointer_drag_active = false; - ProtocolMouseKind::Up(ProtocolMouseButton::Left) - } - }; - // A press or release always reports, and it re-arms - // the motion dedupe: the first drag after a press - // must reach the daemon even at the cell the press - // landed on. - state.last_terminal_pointer_cell = None; - if let Some((buffer_id, coord)) = hit { - self.send_terminal_pointer(buffer_id, coord, kind, mods); - } - return; - } - match button_state { - ElementState::Pressed => { - if state.in_minimap_band(x, y) { - // Q#M6 — consumed before text hit-testing; - // never a Pointer event. - state.minimap_scrub_active = true; - let vp = state.minimap_jump_to(y); - if let Some(vp) = vp - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = - client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: minimap jump send_viewport failed: {e}"); - } - return; - } - let Some(byte) = state.hit_test_source_byte(x, y) else { - return; - }; - let kind = - state.classify_pointer_down(byte, mods.contains(Modifiers::SHIFT)); - state.pointer_drag_active = true; - state.last_pointer_sent_byte = Some(byte); - state.note_pointer_round_trip(); - if let Some(buffer_id) = state.current_buffer_id { - if debug_input() { - eprintln!("pmacs-gpu pointer: {kind:?} byte={byte}"); - } - self.send_pointer(buffer_id, byte, kind, mods); - } - } - ElementState::Released => { - if state.minimap_scrub_active { - state.minimap_scrub_active = false; - return; - } - if !state.pointer_drag_active { - return; - } - state.pointer_drag_active = false; - state.edge_scroll_dir = None; - state.edge_scroll_last = None; - let byte = state - .hit_test_source_byte(x, y) - .or(state.last_pointer_sent_byte); - let buffer_id = state.current_buffer_id; - if let (Some(byte), Some(buffer_id)) = (byte, buffer_id) { - self.send_pointer(buffer_id, byte, PointerKind::Up, mods); - } - } + if self.apply_keyboard(key) == EventOutcome::Exit { + event_loop.exit(); } } - // Q#CM1 — right-click opens the context menu at the hit byte - // (or dismisses an open one). The anchor pixel is remembered - // so the popup the daemon sends back draws at the click. - WindowEvent::MouseInput { - state: ElementState::Pressed, - button: winit::event::MouseButton::Right, + Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y), + Route::Pointer(PointerRoute::Left(button_state)) => { + self.apply_left_button(button_state); + } + Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(), + Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta), + // Three different facts, merged only because all three are + // today nothing to do: a key-up the keyboard family claimed + // and dropped, a button the pointer family has no semantics + // for, and an event no family claims at all. + Route::Keyboard { + action: KeyAction::Release, .. - } => { - let Some(state) = self.state.as_mut() else { - return; - }; - let Some((x, y)) = state.pointer_pos else { - return; - }; - if state.menu.is_some() { - self.send_menu_pointer(None, true); - return; - } - // Bottom panel Stage 2B-3 — a right-click in the band is a - // panel gesture, claimed before the terminal and document - // paths. The daemon decides between child mouse reporting and - // the editor context menu, so the anchor is remembered here - // exactly as for a document click; without this the band's - // context actions are unreachable and the click is applied to - // the document underneath instead. - if let PointerSurface::PanelCell(_) = - state.classify_pointer_surface(x as f32, y as f32) - { - if let Some(state) = self.state.as_mut() { - state.menu_anchor_px = (x, y); - } - let mods = translate_mods(self.modifiers); - self.send_panel_pointer_at( - x, - y, - ProtocolMouseKind::Down(ProtocolMouseButton::Right), - mods, - ); - return; - } - let Some(state) = self.state.as_mut() else { - return; - }; - // Vterm Stage 3 — a right-click in the terminal clip is - // a terminal gesture; the daemon decides between child - // reporting and the editor context menu, so the anchor - // is remembered here exactly as for a document click. - if state.terminal.is_some() { - state.menu_anchor_px = (x, y); - if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { - let mods = translate_mods(self.modifiers); - self.send_terminal_pointer( - buffer_id, - coord, - ProtocolMouseKind::Down(ProtocolMouseButton::Right), - mods, - ); - } - return; - } - let Some(byte) = state.hit_test_source_byte(x, y) else { - return; - }; - state.menu_anchor_px = (x, y); - let buffer_id = state.current_buffer_id; - let mods = translate_mods(self.modifiers); - if let Some(buffer_id) = buffer_id { - self.send_pointer(buffer_id, byte, PointerKind::Context, mods); - } } - WindowEvent::MouseWheel { delta, .. } => { - let Some(state) = self.state.as_mut() else { - return; - }; - // Wheel scroll is local-only: the GPU owns the - // viewport. Positive winit y = scroll up = smaller - // scroll_top. - let lines = match delta { - winit::event::MouseScrollDelta::LineDelta(_, y) => { - (-y * WHEEL_LINES_PER_TICK).round() as i64 - } - winit::event::MouseScrollDelta::PixelDelta(p) => { - (-(p.y as f32) / state.fm.code_line_height()).round() as i64 - } - }; - if lines == 0 { - return; - } - // Bottom panel Stage 2B-3 — a wheel tick over the band scrolls - // the PANEL's window, which is daemon-side state, so it - // crosses the wire instead of moving this frontend's local - // document `scroll_top`. Falling through would scroll the - // document while the pointer is inside the panel. - if let Some((x, y)) = state.pointer_pos - && matches!( - state.classify_pointer_surface(x as f32, y as f32), - PointerSurface::PanelCell(_) - ) - { - let mods = translate_mods(self.modifiers); - let kind = if lines < 0 { - ProtocolMouseKind::ScrollUp - } else { - ProtocolMouseKind::ScrollDown - }; - self.send_panel_pointer_at(x, y, kind, mods); - return; - } - let Some(state) = self.state.as_mut() else { - return; - }; - // Vterm Stage 3 — the terminal's scrollback belongs to - // the daemon-side view, not to this frontend's local - // scroll, so a wheel tick crosses the wire as a - // terminal gesture instead of moving `scroll_top`. - if state.terminal.is_some() { - if let Some((x, y)) = state.pointer_pos - && let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) - { - let mods = translate_mods(self.modifiers); - let kind = if lines < 0 { - ProtocolMouseKind::ScrollUp - } else { - ProtocolMouseKind::ScrollDown - }; - self.send_terminal_pointer(buffer_id, coord, kind, mods); - } - return; - } - let vp = state.scroll_by_lines(lines); - if let Some(vp) = vp - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: wheel send_viewport failed: {e}"); - } - } - // Stage 1-pre — everything the router already claims. The - // arms above are the families not yet moved behind it; when - // the last one goes, this match collapses to the call below - // and `window_event` is the thin call-through P3 asks for. - ref routed => match route_event(routed) { - Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(), - Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, - Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { - self.apply_resize(width, height); - } - Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), - Route::Keyboard { - action: KeyAction::Press, - key, - } => { - if self.apply_keyboard(key) == EventOutcome::Exit { - event_loop.exit(); - } - } - // A key-up is claimed by the keyboard family and - // discarded; an unrouted event was claimed by nobody. - // The route keeps those apart — they are merged here - // only because both are, today, nothing to do. - Route::Keyboard { - action: KeyAction::Release, - .. - } - | Route::Unrouted => {} - }, + | Route::Pointer(PointerRoute::UnusedButton) + | Route::Unrouted => {} } } From 41a08b8237dc3ef41e33a01d6e9b97b40d29b71f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 23:55:57 +0200 Subject: [PATCH 07/15] docs: 1-pre implemented --- framing revision 10, the lane, and a hazard Three documents, one finding each. **`docs/gui-stage1-input-framing.md` -> revision 10.** Revision 9 is the approved design and revision 10 changes none of it. It records ONE scope correction that could not be seen from the design: P1 has a second structural exception, for the keyboard family alone, and it is winit's rather than this seam's. `KeyEvent` carries a `pub(crate) platform_specific` field, so no `WindowEvent::KeyboardInput` can be constructed outside winit. Bounded three ways rather than accepted whole --- it does not reach the pointer families (`DeviceId::dummy()` exists for exactly this, checked BEFORE writing the exception down), the family's only decision is factored into `route_key_action` and witnessed directly, and what stays uncovered is one pattern arm with no logic. Also records that P3 is now MEASURED: deleting the whole delegation leaves all 256 `pmacs-gpu` tests green, not merely the 13 new rows. **`docs/active-work.md`** --- the lane moves to IMPLEMENTED with the four commits, the shape, the verbatim-move method, and the gate result. **`docs/agent-handoff.md`** --- the stray-marker hazard gains what this run earned: `scripts/gate` DOES NOT ISOLATE `TMPDIR`. It isolates the target directory and five ambient roots, so `tempfile::tempdir()` still lands under whatever `/tmp` happens to contain, and the hazard therefore reproduces INSIDE a gate run --- which is how it surfaced here, on a lane that touches only `pmacs-gpu/src/main.rs`. The bullet now carries the discriminating command pair (`TMPDIR=/tmp` 0/2 versus a marker-free root 2/2) rather than only the narrative, because a rerun establishes nothing about this and the pair establishes everything. Isolating `TMPDIR` is assigned to the gate lane, not to whichever feature PR trips over it next. One code change rides along: `EventOutcome`'s doc comment said `event_loop.exit()` is called in "exactly one place", which is true of the function and false of the call sites --- there are two, both inside `window_event`. Stated precisely now, since the whole point of the sentence is that a reader can check it by grep. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 100 ++++++++++++++++++++++++------- docs/agent-handoff.md | 24 ++++++++ docs/gui-stage1-input-framing.md | 54 ++++++++++++++++- pmacs-gpu/src/main.rs | 3 +- 4 files changed, 155 insertions(+), 26 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 18c8c31..19709d6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -250,7 +250,7 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. -## GUI arc Stage 1 — 1-pre branch OPEN, framing APPROVED, no PR yet +## GUI arc Stage 1 — 1-pre IMPLEMENTED, no PR yet **Written at the branch's first commit**, with the framing, as the arc's §5 requires of every PR in it. @@ -259,25 +259,57 @@ waits for a signal that is not coming. Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout gui-stage1-pre`. -- **Framing `docs/gui-stage1-input-framing.md`, revision 9, APPROVED** - after **eight rejected revisions**. It is Stage 1's framing for **all** - slices and governs the later branches; only Stage 0 was framed by the - arc document itself. +- **Framing `docs/gui-stage1-input-framing.md`, revision 10, APPROVED** + after **eight rejected revisions**. Revision 9 is the approved design; + **revision 10 changes none of it** and records one scope correction + found against this implementation (below). It is Stage 1's framing for + **all** slices and governs the later branches; only Stage 0 was framed + by the arc document itself. - **Scope of THIS branch: 1-pre only — the input seam. No behaviour - change.** `App::window_event` is **655 lines** (`main.rs:2734`) and - nothing below it can be witnessed without a display, which is why the - seam precedes every other slice. -- **The evidence contract, because "no behaviour change" is not one:** a - **headless routing harness** records, per event family, the routing - decision, the **outbound protocol events, and the LOCAL effects** — - exit, redraw, resize, state mutation. Production `window_event` - becomes a **thin call-through**. Mutation evidence: bypassing an arm, - or misrouting one family to another, must fail. -- **P3 is an ACCEPTED STRUCTURAL EXCEPTION.** A headless test cannot - construct `ActiveEventLoop` or invoke the real callback, so the - `window_event → router` delegation is a **code-review invariant, not a - tested one**. Recorded rather than papered over: mutation evidence - covers every router arm and **not** the delegation. + change.** `App::window_event` was **655 lines** and is now **33**: one + `route_event` call and one arm per route. Nothing below it could be + witnessed without a display, which is why the seam precedes every + other slice. +- **IMPLEMENTED in four commits, one per event family**, so each lands + with its own witnesses and mutations rather than as one 600-line + diff: `014110f` lifecycle (close / modifiers / resize), `7f0f9db` + redraw, `0705564` keyboard, `e955645` the four pointer arms. +- **The shape.** Deciding is `route_event(&WindowEvent) -> Route`, a + free function composing one decision function per family + (`route_lifecycle`, `route_keyboard` + `route_key_action`, + `route_pointer`). Performing stays on `App` in seven `apply_*` + methods. **A route names its LOCAL EFFECT**, not merely the family + that claims it, and the harness records routes — because + `CloseRequested` and `RedrawRequested` send the daemon nothing, so a + transcript of protocol traffic could not tell a handled arm from a + dropped one. +- **Every moved body verified as the original, mechanically.** The + keyboard body is byte-identical modulo two named conversions; the four + pointer bodies were checked by re-running rustfmt on the pre-move text + at the new indent level and diffing, since de-indenting by 8 columns + lets rustfmt rejoin lines. **13 witnesses, 17 mutations.** +- **P3 IS NOW MEASURED, NOT ASSUMED.** Replacing `window_event`'s whole + body with `let _ = (event_loop, event);` — a GUI that responds to no + input at all — leaves **all 256 `pmacs-gpu` tests green**. That is the + exception's true extent: `ActiveEventLoop` cannot exist outside a live + event loop, so **no** headless test in the crate observes the + delegation, not merely none of the new ones. +- **A SECOND ACCEPTED STRUCTURAL EXCEPTION, found here and winit's + rather than ours.** `KeyEvent` carries a `pub(crate) + platform_specific` field, so **no `WindowEvent::KeyboardInput` can be + constructed outside winit** and the keyboard family's routing arm + cannot be fed by a test. Bounded three ways: it does **not** extend to + the pointer families (`DeviceId::dummy()` exists for exactly this, and + all three pointer events are constructible — checked before writing + the exception down); the family's only real decision is factored into + `route_key_action(ElementState)` and witnessed directly; and what + stays unwitnessed is one pattern arm containing a match and a call. +- **`event_loop.exit()` now appears in exactly two places, both inside + `window_event`.** The keyboard body was its second caller (the idle + Escape), so `apply_keyboard` returns an `EventOutcome` rather than + taking an `&ActiveEventLoop` — which is what keeps the bodies + reachable in principle. **Stage 1a's A4 deletes that branch**, at + which point `EventOutcome` has one variant and should go. - **Slice order (each its own branch and PR):** `1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\*. **`1a` and `1e` are protocol-bearing (v24 `TextInput`, v25 `OpenTarget`/`OpenTargetResult`) and are @@ -286,9 +318,33 @@ waits for a signal that is not coming. Stage 2, so the arc framing's §2.5 and the standing backlog are amended here. Stage 1 keeps the deliberate OS reservation and adds no island. -- **Gates:** `./scripts/gate --acceptance gpu_invocation_acceptance` - plus touched input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p - pmacs-gpu`. **No `--protocol`** — 1-pre changes no wire. +- **Gates:** `./scripts/gate --acceptance gpu_invocation_acceptance`. + That one invocation already runs `PMACS_REQUIRE_GPU=1 cargo test -p + pmacs-gpu` (step `gpu`) and the full `--workspace --no-fail-fast` + sweep in both feature configurations, so the framing §11 phrase "plus + touched input suites" is satisfied by the sweep rather than by a + hand-picked list. **No `--protocol`** — 1-pre changes no wire. +- **THE FIRST GATE RUN WENT RED ON A STRAY `/tmp/.git`, NOT ON THIS + BRANCH.** `m4` and the sweep failed + `m4_24_bare_string_glob_stays_relative` and + `m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — two LSP + file-watcher tests — with every other target green. Established as + environmental three ways, in increasing strength: + - **Structurally impossible for this branch to cause.** The diff + touches only `pmacs-gpu/src/main.rs`; `pmacs-gpu` is a workspace + **member but not a dependency** of the root package, so the + `m4_acceptance` binary never links it. + - **The marker is older than the session.** `/tmp/.git` is empty and + was created at 20:14 CEST, **3.5 h before** the gate run at 23:45; + `/tmp` held 8,920 entries. That is handoff §1's recorded hazard + exactly. + - **A discriminating pair, same binary and commit, one variable:** + `TMPDIR=/tmp` → **0/2**, `TMPDIR=` → **2/2**. A rerun + would have established nothing; this establishes the cause. + + **`scripts/gate` isolates the target dir and five ambient roots but + NOT `TMPDIR`** — recorded in handoff §1, where the standing fix is + assigned to the gate lane rather than to this PR. ## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 14b67e0..f03a881 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -210,6 +210,30 @@ commands, read `docs/active-work.md` immediately after this file. server-rooted watcher then faithfully watched. A markerless-fixture red that looks like a watcher bug may be an ancestor marker, on any machine. + + **SEEN AGAIN 2026-08-11, and `scripts/gate` DOES NOT PROTECT YOU + FROM IT.** The gate isolates the target directory and five ambient + roots but **not `TMPDIR`**, so `tempfile::tempdir()` still lands + under a `/tmp` that may carry a marker. It surfaced inside a gate + run on an unrelated lane (GUI 1-pre, which touches only + `pmacs-gpu/src/main.rs`) as **`m4_24_bare_string_glob_stays_relative` + and `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`**, in + both the `m4` step and the `--workspace` sweep, with every other + target in the corpus green. + + **Diagnose it with the discriminating pair, not a rerun** — same + binary, same commit, one variable: + + ```sh + TMPDIR=/tmp cargo test --test m4_acceptance -- m4_24_ # 0/2 + TMPDIR= cargo test --test m4_acceptance -- m4_24_ # 2/2 + ``` + + Check the ancestors of the temp root for `.git`, `Cargo.toml` and + friends before believing any markerless-fixture red. **Isolating + `TMPDIR` inside `scripts/gate` is the standing fix and belongs to + the gate lane**, not to whichever feature PR happens to trip over + it. - **Deliberately unbuilt**: kernel notification (framing option E) — a framed option, not residue; its trigger is the 4 s worst-case external-change latency mattering in practice. diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index ddc3e38..6791261 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,8 +1,10 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 9 — APPROVED.** Revisions 1–8 rejected. -**Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** Implementation may begin -from this document. +**Status: revision 10 — APPROVED.** Revisions 1–8 rejected; revision 9 +is the approved design and **revision 10 changes none of it** — it +records one scope correction found against the 1-pre implementation +(§6, "Revision 10"). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** +**1-pre is IMPLEMENTED**; 1a onward may begin from this document. **Verification base:** checked in the `gui-arc-stage0` worktree at `a994f37`, whose tree for these files is what `f8ad3e7` merged. @@ -117,6 +119,52 @@ P3 remains an accepted structural exception: not headlessly testable. | P2 | Harness records outbound events **and local effects** (exit, redraw, resize, state mutation) | no harness | record outbound only → exit/redraw rows | | P3 | `window_event` is a thin call-through | — | **structural/code-review invariant; not testable headlessly** (no `ActiveEventLoop`) | +**Revision 10 — one finding against the implementation, not the +design.** The 1-pre seam is built and the table above holds, with one +scope correction that could not be seen from the design. + +**P1 has a SECOND structural exception, and it is winit's rather than +this seam's.** `KeyEvent` carries a `pub(crate) platform_specific` +field (`winit-0.30.13/src/event.rs:655`), so **no +`WindowEvent::KeyboardInput` can be constructed outside winit** and no +headless test can feed one to the router. P1's mutation — misroute a +family, fail that family's row — is therefore unavailable for the +**keyboard** family alone. + +Three things bound it, so it is a measured exception rather than a +blanket one: + +- **The exception does not extend to the pointer families.** Winit + provides `DeviceId::dummy()` for exactly this purpose, and + `CursorMoved` / `MouseInput` / `MouseWheel` are constructible. Checked + before the exception was written down; all three are witnessed. +- **What stays unwitnessed is one pattern arm with no logic in it.** The + family's only decision — a press is acted on, a release is claimed and + discarded — is factored into `route_key_action(ElementState) -> + KeyAction`, which takes a constructible argument and is witnessed + directly, with both misroute mutations failing that row alone. +- **P3 is now measured, not assumed.** Replacing `window_event`'s entire + body with `let _ = (event_loop, event);` — a GUI that responds to no + input at all — leaves **all 256 `pmacs-gpu` tests green**, not merely + the 13 routing rows. That is the exception's true extent: no headless + test anywhere in the crate observes the delegation. + +**P2's harness records ROUTES, and that is the mechanism rather than a +narrowing.** A route names its local effect — `Exit`, `Redraw`, +`Resize { width, height }`, `Modifiers(state)` — so one transcript +covers both halves of P2's contract. The transcript row is deliberately +the **sole** owner of P2 (the per-variant rows call `route_event` +directly), which is what keeps "record outbound only" failing exactly +one row instead of every row. + +**One design consequence worth carrying into 1a.** The keyboard arm was +the second caller of `event_loop.exit()` — the idle-Escape local quit — +so its body returns an `EventOutcome` rather than taking an +`&ActiveEventLoop`. `event_loop.exit()` now appears in exactly two +places, both inside `window_event`, and nowhere else in the crate. +**A4 deletes the Escape branch, at which point `EventOutcome` has one +variant and should go with it.** + ### 1a — `TextInput` (v24) | # | Contract | Witness (fails today because) | Mutation | diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index bf859ff..9244539 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3371,7 +3371,8 @@ enum Route<'a> { /// keyboard family produces anything but `Continue` today: an idle /// Escape is a local quit. Returning the decision rather than taking an /// `&ActiveEventLoop` is what keeps every body reachable from a test — -/// `event_loop.exit()` is called in exactly one place, `window_event`. +/// the crate's two `event_loop.exit()` call sites, this one and +/// `LifecycleRoute::Exit`, both sit in `window_event` and nowhere else. /// /// Stage 1a's A4 deletes that branch (an idle Escape must reach the /// daemon and never exit), at which point this type has one variant and From b7fea705a0b0f74a5e8a7d59b95ef975c027dad7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 00:04:12 +0200 Subject: [PATCH 08/15] docs: the m4 red is environmental --- signature-level pair, and a green gate The `/tmp/.git` diagnosis, finished properly. **Compared on SIGNATURE, not test name**, which is what the ledger's own CI-triage rule requires. The contaminated leg panics at `m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""` --- byte-identical to the gate red's own signature --- and the clean leg has ZERO panics. Same binary, same commit, one variable. **All nine gates pass under an isolated `TMPDIR`**: m4 168/0/3 by the mandated command, and the 117-target `--workspace --no-fail-fast` sweep with zero failures anywhere, `m4_acceptance` running all 171 inside it. **The marker is left in place.** It is foreign, isolating is sufficient, and deleting it is not this lane's call. Recorded with the constraint that matters: the isolated `TMPDIR` must be outside `/tmp` AND outside every git worktree, because a child of `/tmp` is not isolated --- `/tmp/.git` is still its ancestor. **One hazard this cost a round, worth the bullet it gets.** A libtest filter that matches nothing prints `0 passed; 0 failed; N filtered out` and exits ZERO. This shell is zsh, which does not word-split unquoted parameter expansions, so `NAMES="a b"; cargo test -- $NAMES` passes one argument, matches no test, and reports green. The contaminated leg "passed" that way and briefly looked like the hypothesis collapsing. The fix is to read the `running N tests` line, which is the only place a filter's actual reach is stated --- the same family as the `feature-census` trap already recorded two bullets above, and the same lesson as "a probe must fail loudly". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 20 +++++++++++++++++--- docs/agent-handoff.md | 22 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 19709d6..71f1b02 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -338,14 +338,28 @@ waits for a signal that is not coming. was created at 20:14 CEST, **3.5 h before** the gate run at 23:45; `/tmp` held 8,920 entries. That is handoff §1's recorded hazard exactly. - - **A discriminating pair, same binary and commit, one variable:** - `TMPDIR=/tmp` → **0/2**, `TMPDIR=` → **2/2**. A rerun - would have established nothing; this establishes the cause. + - **A discriminating pair compared on SIGNATURE, not test name**, same + binary and commit, one variable. `TMPDIR=/tmp` → **0/2**, panicking + at `m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""` — + **byte-identical to the gate red's own signature**. + `TMPDIR=` → **2/2 with zero panics**. A rerun would + have established only intermittence; the pair establishes the cause. **`scripts/gate` isolates the target dir and five ambient roots but NOT `TMPDIR`** — recorded in handoff §1, where the standing fix is assigned to the gate lane rather than to this PR. + **The marker was left in place**: it is foreign, deleting it is + unnecessary, and the isolated `TMPDIR` is the correct remedy. It must + be **outside `/tmp` and outside every git worktree** — a child of + `/tmp` is not isolated, because `/tmp/.git` remains its ancestor. +- **GREEN under an isolated `TMPDIR`: all nine gates pass** (log + `20260811T215605Z-2664352`). fmt, clippy, lib, lib-crdt, + `gpu_invocation_acceptance`, **m4 168/0/3**, `PMACS_REQUIRE_GPU=1 -p + pmacs-gpu`, the **117-target `--workspace --no-fail-fast` sweep with + zero failures anywhere** (`m4_acceptance` running all 171 in it), and + `diff-check`. + ## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`) **Written at the branch's first commit**, with the framing, which is diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index f03a881..a435e86 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -230,10 +230,30 @@ commands, read `docs/active-work.md` immediately after this file. ``` Check the ancestors of the temp root for `.git`, `Cargo.toml` and - friends before believing any markerless-fixture red. **Isolating + friends before believing any markerless-fixture red, and **compare + SIGNATURES, not test names** — here both runs matched on panic site + (`:5668:5`, `:6615:5`) and on `.received = ""`, which is what makes + the pair evidence rather than coincidence. + + **The isolated `TMPDIR` must be outside `/tmp` AND outside every git + worktree.** A child of `/tmp` is not isolated: `/tmp/.git` is still + its ancestor. Verify with `git -C rev-parse --show-toplevel` + failing, not by eye. **Do not delete a foreign marker** — isolating + is sufficient and deletion is someone else's call. **Isolating `TMPDIR` inside `scripts/gate` is the standing fix and belongs to the gate lane**, not to whichever feature PR happens to trip over it. +- **A libtest filter that matches nothing reports `test result: ok`.** + `0 passed; 0 failed; N filtered out` and a zero exit code are what a + *typo'd or mis-quoted filter* looks like, and it is indistinguishable + from success at a glance. **This shell is zsh, which does NOT + word-split unquoted parameter expansions**, so `NAMES="a b"; cargo + test -- $NAMES` passes ONE argument `"a b"`, matches no test, and + prints a green line. Seen 2026-08-11 while running a + contaminated/clean pair: the contaminated leg "passed" and briefly + looked like the environmental hypothesis collapsing. **Read the + `running N tests` line, not just `test result`** — it is the only + place the filter's actual reach is stated. - **Deliberately unbuilt**: kernel notification (framing option E) — a framed option, not residue; its trigger is the 4 s worst-case external-change latency mattering in practice. From c256c630cf842cbc91ba4290d9ea0c1fed51bf3b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 00:05:21 +0200 Subject: [PATCH 09/15] docs: record PR #237 in the 1-pre lane An open PR gets its number in the ledger at the moment it opens, per the standing correction from #171 and #215 --- #171 drifted 153 commits while invisible here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 71f1b02..33c246e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -250,11 +250,12 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. -## GUI arc Stage 1 — 1-pre IMPLEMENTED, no PR yet +## GUI arc Stage 1 — 1-pre OPEN as PR #237 **Written at the branch's first commit**, with the framing, as the arc's §5 requires of every PR in it. +- **PR #237** — https://github.com/levineuwirth/pmacs/pull/237. - **Branch `gui-stage1-pre`**, base `githubsucks/main` @ `f8ad3e7` (the Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the authoritative tip** — the ref, not a SHA. Recover with From 1f8d1949080a1aa86626a2abcb6e99145aeb6233 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 10:05:41 +0200 Subject: [PATCH 10/15] docs: review round 1 --- four accuracy defects, one of them a bad command None changes a decision; all four were claims a reader would check and find false. **The durable diagnosis command did not run the pair it claimed.** `m4_24_` is a PREFIX matching **18** tests, so the recorded invocation would report roughly 16/2 contaminated and 18/0 clean --- not the 0/2 and 2/2 beside it. A reader following it would see a mostly-green run and conclude the hazard was mis-diagnosed. Replaced with **four literal `--exact` invocations, one test each**, every one of them executed before being written down: `running 1 test`, `171 filtered out`, contaminated `0 passed; 1 failed` panicking at `:5668:5` and `:6615:5`, clean `1 passed; 0 failed` with no panic. The block now also says to read the `running N tests` line, pointing at the libtest-filter bullet two entries below --- which is the trap that produced this defect in the first place. **"The diff touches only `pmacs-gpu/src/main.rs`" -> "the whole EXECUTABLE diff".** The branch changes six files, five under `docs/`. The structural argument was always about linkage, not file count, but as written it was simply false and the first `git diff --name-only` would say so. Fixed in both the ledger and the handoff. **"a headless test can drive every family"** contradicted the keyboard exception three paragraphs below it. Now says every family whose event winit lets a test construct --- all of them except keyboard --- and points at `route_keyboard` for how far that reaches. **`[KeyboardRoute::Press]` names a type that does not exist.** It was renamed to `KeyAction` when the payload moved onto `Route::Keyboard`, and this doc link was left behind pointing at nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 22 +++++++++++++--------- docs/agent-handoff.md | 34 ++++++++++++++++++++++++++-------- pmacs-gpu/src/main.rs | 8 +++++--- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 33c246e..0f00ab4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -331,20 +331,24 @@ waits for a signal that is not coming. `m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — two LSP file-watcher tests — with every other target green. Established as environmental three ways, in increasing strength: - - **Structurally impossible for this branch to cause.** The diff - touches only `pmacs-gpu/src/main.rs`; `pmacs-gpu` is a workspace - **member but not a dependency** of the root package, so the - `m4_acceptance` binary never links it. + - **Structurally impossible for this branch to cause.** The branch + changes six files, five of them under `docs/`; **the whole + executable diff is `pmacs-gpu/src/main.rs`**, and `pmacs-gpu` is a + workspace **member but not a dependency** of the root package, so + the `m4_acceptance` binary never links it. - **The marker is older than the session.** `/tmp/.git` is empty and was created at 20:14 CEST, **3.5 h before** the gate run at 23:45; `/tmp` held 8,920 entries. That is handoff §1's recorded hazard exactly. - **A discriminating pair compared on SIGNATURE, not test name**, same - binary and commit, one variable. `TMPDIR=/tmp` → **0/2**, panicking - at `m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""` — - **byte-identical to the gate red's own signature**. - `TMPDIR=` → **2/2 with zero panics**. A rerun would - have established only intermittence; the pair establishes the cause. + binary and commit, one variable — run as **four literal `--exact` + invocations, one test each**, since `m4_24_` is a prefix matching + **18** tests. Contaminated: `0 passed; 1 failed` each, panicking at + `m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""`, + **matching the gate red's own signature**. Clean: `1 passed; 0 + failed` each, **zero panics**. The exact commands are in handoff + §1's hazard bullet. A rerun would have established only + intermittence; the pair establishes the cause. **`scripts/gate` isolates the target dir and five ambient roots but NOT `TMPDIR`** — recorded in handoff §1, where the standing fix is diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a435e86..3c099f5 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -215,25 +215,43 @@ commands, read `docs/active-work.md` immediately after this file. FROM IT.** The gate isolates the target directory and five ambient roots but **not `TMPDIR`**, so `tempfile::tempdir()` still lands under a `/tmp` that may carry a marker. It surfaced inside a gate - run on an unrelated lane (GUI 1-pre, which touches only - `pmacs-gpu/src/main.rs`) as **`m4_24_bare_string_glob_stays_relative` + run on an unrelated lane (GUI 1-pre, whose whole **executable** diff + is `pmacs-gpu/src/main.rs`) as **`m4_24_bare_string_glob_stays_relative` and `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`**, in both the `m4` step and the `--workspace` sweep, with every other target in the corpus green. **Diagnose it with the discriminating pair, not a rerun** — same - binary, same commit, one variable: + binary, same commit, one variable. **Four literal `--exact` + invocations, one test each**, because `m4_24_` is a PREFIX matching + **18** tests and a prefix run reports ~16/2 and 18/0 rather than the + 0/1 and 1/0 that make the pair readable: ```sh - TMPDIR=/tmp cargo test --test m4_acceptance -- m4_24_ # 0/2 - TMPDIR= cargo test --test m4_acceptance -- m4_24_ # 2/2 + C= # outside /tmp AND outside every git worktree + + TMPDIR=/tmp cargo test --test m4_acceptance -- \ + --exact m4_24_bare_string_glob_stays_relative # 0 passed; 1 failed + TMPDIR=$C cargo test --test m4_acceptance -- \ + --exact m4_24_bare_string_glob_stays_relative # 1 passed; 0 failed + + TMPDIR=/tmp cargo test --test m4_acceptance -- \ + --exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 0 passed; 1 failed + TMPDIR=$C cargo test --test m4_acceptance -- \ + --exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 1 passed; 0 failed ``` + Each prints `running 1 test` and `171 filtered out`. **Read that + line** — see the libtest-filter bullet below for why a filter that + reaches nothing still prints green. + Check the ancestors of the temp root for `.git`, `Cargo.toml` and friends before believing any markerless-fixture red, and **compare - SIGNATURES, not test names** — here both runs matched on panic site - (`:5668:5`, `:6615:5`) and on `.received = ""`, which is what makes - the pair evidence rather than coincidence. + SIGNATURES, not test names** — the contaminated legs panic at + `m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""`, + matching the gate red's own signature, and the clean legs panic + nowhere. That is what makes the pair evidence rather than + coincidence. **The isolated `TMPDIR` must be outside `/tmp` AND outside every git worktree.** A child of `/tmp` is not isolated: `/tmp/.git` is still diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 9244539..0a7e8cc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3135,7 +3135,7 @@ impl App { } } - /// Perform [`KeyboardRoute::Press`]. The router has already + /// Perform [`KeyAction::Press`]. The router has already /// discarded key-ups, so `key` is always a press. #[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order. fn apply_keyboard(&mut self, key: &KeyEvent) -> EventOutcome { @@ -3342,8 +3342,10 @@ impl App { /// /// The seam splits the two halves. **Deciding** is [`route_event`] — a /// free function over `&WindowEvent` alone, so a headless test can drive -/// every family. **Performing** stays on `App`, in the `apply_*` methods -/// the router's variants name. +/// it for **every family whose event winit lets a test construct**, +/// which is all of them except keyboard; see [`route_keyboard`] for that +/// exception and how far it reaches. **Performing** stays on `App`, in +/// the `apply_*` methods the router's variants name. /// /// The decision is what the route *is*, not merely which family claims /// it: `Exit` is the local exit effect, `Resize` carries the clamped From f976dc1042f9607117cd4b854330962e7f1414ed Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 10:36:12 +0200 Subject: [PATCH 11/15] feat(gpu): 1-pre P2 --- the effect harness, outbound AND local Review round 1's blocker: P2 requires outbound events plus local effects, and the harness recorded `Route` classifications only. The wheel is the proof it was not enough --- a wheel route carries a delta, and whether that becomes a viewport update, a panel event, a terminal event or nothing at all is `State`'s to decide. A route names the family; only running the body names the effect. **`App::dispatch_window_event` is what makes P2 reachable, and it is the substantive change here.** Left inside `window_event`, the dispatch would force a harness to re-implement it --- and a harness that re-implements the thing it tests witnesses its own copy. `window_event` is now four lines: call dispatch, exit if it asks. **P3 narrows from a 33-line match to a single `if`.** `EffectHarness` drives production code end to end: * a REAL `AttachClient` over a `socketpair`, through the real handshake, outbox, writer thread and encoder, so what is recorded is the wire and not a mock's idea of it (`connect_stream_for_test` in attach.rs exists only because the constructor is private to that module; it adds no behaviour); * a REAL windowless `State`, so the bodies take their real branches; * `dispatch_window_event` itself. Local effects have no wire trace, so each is read where it lands: exit from the returned `EventOutcome`, redraw from a test-only `State::render_calls`, resize from the surface config, the modifier mutation from `App::modifiers`, and the scroll from `scroll_top`. **Steps are delimited by a sentinel key, not a sleep.** "This step sent nothing" is otherwise undecidable without waiting, and a fixed-duration wait against a writer thread is the core-count assumption PR #235's CI red was made of. The sentinel is not coalesceable (only viewport and drag kinds are), so it can neither replace nor be replaced by a recorded event. It does sit between steps, so cross-step coalescing that production would perform is absent here --- stated in the harness doc, since it makes the transcript per-step rather than as-coalesced. **Never skips.** Per the ruling, a missing wgpu adapter is an assertion failure and not a skip: this project has twice recorded a suite that returned `ok` without running. Mutation M21 makes `new_headless` return `None` and all NINE effect rows fail loudly while the thirteen pure routing rows, which need no GPU, stay green --- the two tiers behaving exactly as intended. TWO ROWS WERE WRONG AND THE MUTATIONS FOUND THEM, WHICH IS THE POINT: * the wheel row asserted `.all(|e| matches!(e, Viewport))` over the transcript --- VACUOUSLY TRUE ON AN EMPTY ONE, so an outbound-blind harness passed it. Now asserts non-empty first. * with that fixed it still failed, for a second reason: the fixture was two lines and could not scroll, and a headless `State` has no attached buffer, so `scroll_by_lines` returned `None` and withheld every send. Both are absences the harness manufactured itself --- the same shape as the panel wire, below. The panel wire is the third of those. `resumed` sets the frontend id and the session version on the state before any geometry flush; the harness did not, so `flush_panel_geometry` silently withheld the declaration and the resize row failed against an absence of its own making. The harness now mirrors that wiring and drains the attach-time declaration, so each row's transcript holds only what its own event produced. Evidence --- 22 rows (13 routing, 9 effect), 6 further mutations: M18 exit effect discarded -> the close row M19 redraw effect discarded -> the redraw row M20 apply_resize stops declaring -> the resize row M21 no wgpu adapter -> all NINE effect rows, loudly M22 harness blind to OUTBOUND -> resize + wheel M23 harness blind to LOCAL -> six rows M22 and M23 together are P2's contract made executable: blind the harness to either half and rows fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/attach.rs | 18 ++ pmacs-gpu/src/main.rs | 566 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 550 insertions(+), 34 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 06f91bd..5a75a0a 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -1169,6 +1169,24 @@ impl AttachClient { } } +/// Build a real [`AttachClient`] over an already-connected stream, for +/// the GUI 1-pre effect harness in `main.rs`. +/// +/// The harness needs to observe the protocol messages a dispatch +/// actually produces. Faking the client would only witness the fake, so +/// it drives the **real** handshake, outbox, writer thread and encoder +/// over a `socketpair`, and reads the encoded `FrontendEvent`s off the +/// other end. This wrapper exists solely because +/// [`connect_stream_with_sink`] is private to this module and the +/// harness is a sibling; it adds no behaviour of its own. +#[cfg(test)] +pub(crate) fn connect_stream_for_test( + stream: UnixStream, + sink: impl Fn(AttachEvent) -> bool + Send + 'static, +) -> Result { + connect_stream_with_sink(stream, None, sink) +} + #[cfg(test)] mod tests { use super::*; diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 0a7e8cc..0ca4608 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1579,6 +1579,11 @@ type LoroTextDeltaBatches = Arc>>>; reason = "independent render/input state flags, not a config bitset" )] struct State { + /// GUI 1-pre P2 — how many times [`State::render`] has been called. + /// The redraw arm's only local effect, and invisible without this + /// on a windowless `State`. Test-only. + #[cfg(test)] + render_calls: u64, // `None` in the headless render-test path (F-014): a windowless State // that renders to an offscreen texture instead of a surface. window: Option>, @@ -3135,6 +3140,49 @@ impl App { } } + /// Route one window event and perform it — **the whole of + /// `window_event` except the exit itself**, which is returned rather + /// than performed. + /// + /// This split is what makes P2 reachable at all. Left inside + /// `window_event`, the dispatch would force a harness to + /// re-implement it, and a harness that re-implements the thing it + /// tests witnesses its own copy. Here the harness drives production + /// code, and `window_event` keeps only the one statement no headless + /// test can reach — which narrows P3 from a 33-line match to a + /// single `if`. + fn dispatch_window_event(&mut self, event: &WindowEvent) -> EventOutcome { + match route_event(event) { + Route::Lifecycle(LifecycleRoute::Exit) => return EventOutcome::Exit, + Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, + Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { + self.apply_resize(width, height); + } + Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), + Route::Keyboard { + action: KeyAction::Press, + key, + } => return self.apply_keyboard(key), + Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y), + Route::Pointer(PointerRoute::Left(button_state)) => { + self.apply_left_button(button_state); + } + Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(), + Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta), + // Three different facts, merged only because all three are + // today nothing to do: a key-up the keyboard family claimed + // and dropped, a button the pointer family has no semantics + // for, and an event no family claims at all. + Route::Keyboard { + action: KeyAction::Release, + .. + } + | Route::Pointer(PointerRoute::UnusedButton) + | Route::Unrouted => {} + } + EventOutcome::Continue + } + /// Perform [`KeyAction::Press`]. The router has already /// discarded key-ups, so `key` is always a press. #[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order. @@ -3566,6 +3614,295 @@ impl<'a> RoutingHarness<'a> { } } +/// GUI Stage 1-pre — the headless EFFECT harness (P2). +/// +/// [`RoutingHarness`] above answers "where did this event go?". +/// This one answers **"what did it do?"** — the other half of P2, and +/// the half a route cannot supply on its own. A wheel route carries a +/// delta; whether that delta becomes a panel event, a terminal event, a +/// viewport update, or nothing at all depends on `State`, so only +/// running the body can say. +/// +/// It drives production code end to end: +/// +/// * **A real `AttachClient` over a `socketpair`**, through the real +/// handshake, outbox, writer thread and encoder. The harness reads +/// encoded `pmacs_protocol::FrontendEvent`s off the daemon end, so what it records is +/// the wire, not a mock's idea of it. +/// * **A real windowless `State`**, so the bodies take their real +/// branches. +/// * **`App::dispatch_window_event`**, the production dispatch — not a +/// re-implementation of it, which would witness only itself. +/// +/// **Local effects** have no wire trace, so each is read from the place +/// it actually lands: exit from the returned [`EventOutcome`], redraw +/// from `State::render_calls`, resize from the surface config, and the +/// modifier mutation from `App::modifiers`. +/// +/// **Two honest limits, both structural.** +/// +/// 1. `window_event` itself is still unreachable (P3) — the harness +/// calls `dispatch_window_event`, which is everything `window_event` +/// does except the `event_loop.exit()` it cannot construct. +/// 2. A step is delimited by a **sentinel key** pushed through the same +/// outbox, so "this step sent nothing" is decidable without a sleep. +/// The sentinel is not coalesceable (only viewport and drag kinds +/// are), so it can neither replace nor be replaced by a recorded +/// event — but it does sit between steps, so consecutive same-kind +/// events that production would coalesce are recorded separately +/// here. That makes the transcript per-step rather than as-coalesced, +/// which is what a per-step contract wants. +#[cfg(test)] +struct EffectHarness { + app: App, + daemon: std::os::unix::net::UnixStream, + /// Distinguishes one sentinel from the next, so a step cannot end on + /// a stale one left behind by an earlier read. + sentinel_seq: u32, +} + +/// A local effect — something a dispatch did that leaves no wire trace. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalEffect { + /// The dispatch asked the event loop to exit. + Exit, + /// `State::render` was called. + Redraw, + /// The surface was reconfigured to this extent. + Resize { width: u32, height: u32 }, + /// The document scrolled to this top line. + Scroll { top: usize }, + /// `App::modifiers` changed to this value. + Modifiers(winit::keyboard::ModifiersState), +} + +/// What one dispatched event did. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq)] +struct Step { + local: Vec, + outbound: Vec, +} + +#[cfg(test)] +impl EffectHarness { + /// The sentinel key. A private-use scalar, so it cannot collide with + /// anything a body would legitimately send. + const SENTINEL: char = '\u{e000}'; + + /// Build the harness, or fail loudly. + /// + /// **Never skips.** `State::new_headless` returns `None` when no + /// wgpu adapter exists, and a suite that quietly returns `ok` in + /// that case proves nothing — this project has recorded that failure + /// mode twice. Under `PMACS_REQUIRE_GPU` the absence is an assertion + /// failure; without it the harness still panics rather than skips, + /// because these rows are the whole of P2. + fn new() -> Self { + let (client_stream, mut daemon) = + std::os::unix::net::UnixStream::pair().expect("socketpair"); + + // The daemon half of the handshake, synchronous and inline: the + // client blocks reading `Hello` before it writes anything, so a + // thread would only add a join. + let handshake = std::thread::spawn(move || { + pmacs_protocol::write_message(&mut daemon, &test_hello()).expect("write Hello"); + let _: pmacs_protocol::AttachRequest = + pmacs_protocol::read_message(&mut daemon).expect("read AttachRequest"); + let _: pmacs_protocol::SessionBootstrapRequest = + pmacs_protocol::read_message(&mut daemon).expect("read bootstrap"); + daemon + }); + let client = crate::attach::connect_stream_for_test(client_stream, |_| true) + .expect("attach over socketpair"); + let daemon = handshake.join().expect("handshake thread"); + + // A document tall enough to scroll. A two-line fixture made the + // wheel row pass vacuously: `scroll_by_lines` returns `None` + // when there is nothing below the fold, so the row asserted + // "every outbound event is a Viewport" over an EMPTY transcript. + // Mutation M22 surfaced the first half of that and this fixture + // is the second. + let document = "line\n".repeat(200); + let state = State::new_headless(640, 480, &document); + assert!( + state.is_some(), + "no wgpu adapter: the 1-pre effect rows are P2's only witness and must not be skipped" + ); + + let mut harness = Self { + app: App { + mode: Mode::Attach { + socket: PathBuf::from("/nonexistent-harness-socket"), + }, + proxy: None, + state, + pending_events: Vec::new(), + attach_client: Some(client), + modifiers: winit::keyboard::ModifiersState::empty(), + }, + daemon, + sentinel_seq: 0, + }; + + // Mirror the post-connect wiring `resumed` performs. Without it + // the panel wire stays below `PANEL_MIN_VERSION` and every + // geometry declaration is silently withheld — the harness would + // then witness an absence it created itself. Getting this wrong + // is exactly what the first run of the resize row caught. + { + let client = harness.app.attach_client.as_ref().expect("harness client"); + let frontend_id = client.frontend_id(); + let session_version = client.session_protocol_version(); + let state = harness.app.state.as_mut().expect("harness state"); + state.set_frontend_id(frontend_id); + state.set_panel_wire(session_version); + // Stands in for the `BufferSnapshot` the daemon would send. + // Without a current buffer `scroll_by_lines` scrolls locally + // and returns `None`, so every viewport send is withheld and + // the harness would witness an absence it manufactured — + // the same trap the panel wire set above. This is the + // pattern the file's other headless-State tests already use. + state.current_buffer_id = Some(BufferId::from_raw(1)); + } + harness.app.flush_panel_geometry(GeometryTrigger::Surface); + + // Drain the attach-time declaration, so each row's transcript + // contains only what its own event produced. + harness.read_until_sentinel(); + harness + } + + /// Dispatch one event and report everything it did. + fn feed(&mut self, event: &WindowEvent) -> Step { + let before = self.snapshot(); + let outcome = self.app.dispatch_window_event(event); + let after = self.snapshot(); + + let mut local = Vec::new(); + if outcome == EventOutcome::Exit { + local.push(LocalEffect::Exit); + } + if after.render_calls > before.render_calls { + local.push(LocalEffect::Redraw); + } + if after.extent != before.extent { + local.push(LocalEffect::Resize { + width: after.extent.0, + height: after.extent.1, + }); + } + if after.modifiers != before.modifiers { + local.push(LocalEffect::Modifiers(after.modifiers)); + } + if after.scroll_top != before.scroll_top { + local.push(LocalEffect::Scroll { + top: after.scroll_top, + }); + } + + Step { + local, + outbound: self.read_until_sentinel(), + } + } + + /// Observable state the local effects are derived from. + fn snapshot(&self) -> EffectSnapshot { + let state = self.app.state.as_ref().expect("harness state"); + EffectSnapshot { + render_calls: state.render_calls, + extent: (state.config.width, state.config.height), + modifiers: self.app.modifiers, + scroll_top: state.scroll_top, + } + } + + /// Push a sentinel through the same outbox and read until it comes + /// back. Everything ahead of it belongs to the step just dispatched. + /// + /// This is a **condition**, not a sleep: it blocks on the socket + /// until the writer thread has drained past the sentinel, so it is + /// insensitive to how many cores are free — the mistake PR #235's CI + /// red was made of. + fn read_until_sentinel(&mut self) -> Vec { + self.sentinel_seq += 1; + let tag = self.sentinel_seq; + let client = self.app.attach_client.as_ref().expect("harness client"); + client + .send_key(ProtocolKey::Char(Self::SENTINEL), sentinel_mods(tag)) + .expect("enqueue sentinel"); + + let mut seen = Vec::new(); + loop { + let event: pmacs_protocol::FrontendEvent = + pmacs_protocol::read_message(&mut self.daemon).expect("read outbound"); + if is_sentinel(&event, tag) { + return seen; + } + seen.push(event); + } + } +} + +/// Observable state behind [`LocalEffect`]. +#[cfg(test)] +struct EffectSnapshot { + render_calls: u64, + extent: (u32, u32), + modifiers: winit::keyboard::ModifiersState, + scroll_top: usize, +} + +/// Modifier bits carrying the sentinel's sequence number, so a stale +/// sentinel cannot end the wrong step. Four bits is plenty: the harness +/// reads every sentinel it writes, so the counter only has to +/// distinguish neighbours. +#[cfg(test)] +fn sentinel_mods(tag: u32) -> Modifiers { + let mut mods = Modifiers::NONE; + if tag & 1 != 0 { + mods |= Modifiers::CTRL; + } + if tag & 2 != 0 { + mods |= Modifiers::ALT; + } + if tag & 4 != 0 { + mods |= Modifiers::SHIFT; + } + mods +} + +#[cfg(test)] +fn is_sentinel(event: &pmacs_protocol::FrontendEvent, tag: u32) -> bool { + matches!( + event, + pmacs_protocol::FrontendEvent::Key(k) + if k.key == ProtocolKey::Char(EffectHarness::SENTINEL) && k.mods == sentinel_mods(tag) + ) +} + +#[cfg(test)] +fn test_hello() -> pmacs_protocol::Hello { + pmacs_protocol::Hello { + protocol_version: pmacs_protocol::PROTOCOL_VERSION, + assigned_frontend_id: pmacs_protocol::FrontendId(7), + instance_identity: pmacs_protocol::InstanceIdentity { + pmacs_version: "1-pre-harness".to_owned(), + build_hash: None, + instance_name: None, + uptime_secs: 0, + working_directory: "/".to_owned(), + }, + instance_capabilities: pmacs_protocol::InstanceCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }, + } +} + #[cfg(test)] mod input_routing_tests { use super::*; @@ -3800,6 +4137,183 @@ mod input_routing_tests { assert_eq!(route_one(&event), Route::Unrouted); } + // --------------------------------------------------------------- + // P2 — the EFFECT rows. `EffectHarness` drives the production + // dispatch over a real client and a real windowless `State`, and + // records BOTH halves of P2: the outbound protocol messages and the + // local effects that leave no wire trace. + // --------------------------------------------------------------- + + /// P2 — a redraw's whole effect is local. It sends the daemon + /// nothing, so **the outbound half of the transcript is empty and + /// the local half is what proves the arm ran** — which is precisely + /// why P2 requires both. + #[test] + fn a_redraw_produces_a_local_effect_and_no_outbound_traffic() { + let mut h = EffectHarness::new(); + let step = h.feed(&WindowEvent::RedrawRequested); + assert_eq!(step.local, vec![LocalEffect::Redraw]); + assert!( + step.outbound.is_empty(), + "a redraw must send the daemon nothing, got {:?}", + step.outbound + ); + } + + /// P2 — `CloseRequested` is the other silent arm: one local effect, + /// no traffic. + #[test] + fn a_close_request_exits_locally_and_sends_nothing() { + let mut h = EffectHarness::new(); + let step = h.feed(&WindowEvent::CloseRequested); + assert_eq!(step.local, vec![LocalEffect::Exit]); + assert!(step.outbound.is_empty(), "{:?}", step.outbound); + } + + /// P2 — a modifier change mutates `App` and sends nothing. The + /// mutation is the effect. + #[test] + fn a_modifier_change_mutates_state_and_sends_nothing() { + let mut h = EffectHarness::new(); + let mods = ModifiersState::CONTROL; + let step = h.feed(&modifiers_changed(mods)); + assert_eq!(step.local, vec![LocalEffect::Modifiers(mods)]); + assert!(step.outbound.is_empty(), "{:?}", step.outbound); + assert_eq!(h.app.modifiers, mods); + } + + /// P2 — a resize is the one lifecycle arm with **both** halves: it + /// reconfigures the surface locally and declares the new cell + /// geometry to the daemon. + #[test] + fn a_resize_reconfigures_locally_and_declares_geometry() { + let mut h = EffectHarness::new(); + let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(900, 500))); + assert_eq!( + step.local, + vec![LocalEffect::Resize { + width: 900, + height: 500 + }] + ); + assert!( + step.outbound.iter().any(|e| matches!( + e, + pmacs_protocol::FrontendEvent::FrontendCellGeometry { .. } + )), + "a resize must declare the new cell geometry, got {:?}", + step.outbound + ); + } + + /// P2 — the zero-extent clamp, observed as a real surface + /// configuration rather than as a route value. wgpu rejects a + /// zero-extent surface, so this is the row that would fail if the + /// clamp were only cosmetic. + #[test] + fn a_minimize_configures_a_nonzero_surface() { + let mut h = EffectHarness::new(); + let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))); + assert_eq!( + step.local, + vec![LocalEffect::Resize { + width: 1, + height: 1 + }] + ); + } + + /// P2, pointer — **the row that shows a route cannot stand in for an + /// effect.** A wheel carries only a delta; whether it becomes a + /// viewport update, a panel event, a terminal event or nothing at + /// all is `State`'s to decide. Here, over a plain document, it + /// scrolls and re-declares the scoped viewport. + #[test] + fn a_wheel_over_a_document_declares_a_new_viewport() { + let mut h = EffectHarness::new(); + let event = WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, -3.0), + phase: TouchPhase::Moved, + }; + let step = h.feed(&event); + assert_eq!( + step.local, + vec![LocalEffect::Scroll { + top: WHEEL_LINES_PER_TICK as usize * 3 + }], + "a wheel scrolls locally" + ); + // NOT `.all()` alone — that is vacuously true on an empty + // transcript, so an outbound-blind harness would pass this row. + // Mutation M22 found exactly that. + assert!( + !step.outbound.is_empty(), + "a document wheel must send something" + ); + assert!( + step.outbound + .iter() + .all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })), + "a document wheel sends viewport updates and nothing else, got {:?}", + step.outbound + ); + } + + /// P2, pointer — a wheel that resolves to **zero lines** is dropped + /// by the body before any send. Same route, no effect: the + /// distinction only exists once effects are observed. + #[test] + fn a_subtick_wheel_sends_nothing_at_all() { + let mut h = EffectHarness::new(); + let event = WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, 0.0), + phase: TouchPhase::Moved, + }; + let step = h.feed(&event); + assert!(step.local.is_empty(), "{:?}", step.local); + assert!( + step.outbound.is_empty(), + "a zero-line wheel must send nothing, got {:?}", + step.outbound + ); + } + + /// P2, pointer — motion updates the cached pointer position, which + /// is the state mutation the drag path later reads. It is not a + /// `LocalEffect` variant because it is `State`-internal, so the row + /// asserts it directly. + #[test] + fn cursor_motion_caches_the_pointer_position() { + let mut h = EffectHarness::new(); + let event = WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(30.0, 40.0), + }; + h.feed(&event); + assert_eq!( + h.app.state.as_ref().expect("harness state").pointer_pos, + Some((30.0, 40.0)) + ); + } + + /// P2 — a button the frontend has no semantics for reaches no body: + /// nothing local, nothing outbound. The counterpart to the routing + /// row that calls it claimed-and-dropped. + #[test] + fn an_unused_button_produces_no_effect_of_any_kind() { + let mut h = EffectHarness::new(); + let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); + assert_eq!( + step, + Step { + local: Vec::new(), + outbound: Vec::new() + } + ); + } + /// P2 — the harness records a transcript, and the transcript /// distinguishes every routed effect from the others and from an /// unclaimed event. **Two of these rows produce no outbound traffic @@ -3893,41 +4407,13 @@ impl ApplicationHandler for App { } /// P3 — the thin call-through. Every decision belongs to - /// [`route_event`] and every effect to an `apply_*` method; what is - /// left here is `event_loop.exit()`, which exists nowhere else in - /// the crate and is the one thing a headless test cannot reach. + /// [`Self::dispatch_window_event`], which a headless test drives + /// directly. What is left here is `event_loop.exit()` — the one + /// statement no headless test can reach, because `ActiveEventLoop` + /// cannot exist outside a live event loop. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - match route_event(&event) { - Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(), - Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, - Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { - self.apply_resize(width, height); - } - Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), - Route::Keyboard { - action: KeyAction::Press, - key, - } => { - if self.apply_keyboard(key) == EventOutcome::Exit { - event_loop.exit(); - } - } - Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y), - Route::Pointer(PointerRoute::Left(button_state)) => { - self.apply_left_button(button_state); - } - Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(), - Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta), - // Three different facts, merged only because all three are - // today nothing to do: a key-up the keyboard family claimed - // and dropped, a button the pointer family has no semantics - // for, and an event no family claims at all. - Route::Keyboard { - action: KeyAction::Release, - .. - } - | Route::Pointer(PointerRoute::UnusedButton) - | Route::Unrouted => {} + if self.dispatch_window_event(&event) == EventOutcome::Exit { + event_loop.exit(); } } @@ -4576,6 +5062,8 @@ impl State { let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); let mut state = Self { + #[cfg(test)] + render_calls: 0, window, device, queue, @@ -9159,6 +9647,16 @@ impl State { /// windowed path. Composition lives in `render_to_view`, shared with /// the headless offscreen path (`render_offscreen`, F-014). fn render(&mut self) { + // GUI 1-pre P2 — a repaint is a LOCAL effect with no outbound + // trace, and headless there is no surface either: every path + // below returns without observable consequence. The counter is + // the only way a harness can witness that the redraw arm ran, + // and it is incremented before the surface check for exactly + // that reason. Test-only, so production carries nothing. + #[cfg(test)] + { + self.render_calls += 1; + } let frame = { let Some(surface) = self.surface.as_ref() else { return; From 4da3a3432bf504f247ee212185175e39aa8e61eb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 10:37:24 +0200 Subject: [PATCH 12/15] docs: P2 implemented --- framing revision 11 retracts revision 10's claim Revision 10 argued a route-classification transcript satisfied P2 because a route 'names its local effect'. The wheel falsifies it: a wheel route carries a delta, and whether that becomes a viewport update, a panel event, a terminal event or nothing at all depends on State. The route names the family; only running the body names the effect. Retracted rather than quietly amended, since it was an argument this document made and review overturned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 43 +++++++++++++++++--- docs/gui-stage1-input-framing.md | 69 +++++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0f00ab4..80962b3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -260,12 +260,13 @@ waits for a signal that is not coming. Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the authoritative tip** — the ref, not a SHA. Recover with `git fetch githubsucks && git checkout gui-stage1-pre`. -- **Framing `docs/gui-stage1-input-framing.md`, revision 10, APPROVED** +- **Framing `docs/gui-stage1-input-framing.md`, revision 11, APPROVED** after **eight rejected revisions**. Revision 9 is the approved design; - **revision 10 changes none of it** and records one scope correction - found against this implementation (below). It is Stage 1's framing for - **all** slices and governs the later branches; only Stage 0 was framed - by the arc document itself. + revision 10 recorded a scope correction found against this + implementation and **also argued P2 was satisfied by classification + alone, which review overturned — revision 11 retracts it**. It is + Stage 1's framing for **all** slices and governs the later branches; + only Stage 0 was framed by the arc document itself. - **Scope of THIS branch: 1-pre only — the input seam. No behaviour change.** `App::window_event` was **655 lines** and is now **33**: one `route_event` call and one arm per route. Nothing below it could be @@ -274,7 +275,37 @@ waits for a signal that is not coming. - **IMPLEMENTED in four commits, one per event family**, so each lands with its own witnesses and mutations rather than as one 600-line diff: `014110f` lifecycle (close / modifiers / resize), `7f0f9db` - redraw, `0705564` keyboard, `e955645` the four pointer arms. + redraw, `0705564` keyboard, `e955645` the four pointer arms. Review + round 1 added `f976dc1`, the P2 effect harness. +- **P2 HAS TWO HARNESSES, and the second was review round 1's blocker.** + The routing harness answers *where did this event go*; `EffectHarness` + answers *what did it do*. Classification alone could not satisfy P2 — + **a wheel route carries a delta, and whether that becomes a viewport + update, a panel event, a terminal event or nothing depends on + `State`.** The effect harness drives a real `AttachClient` over a + `socketpair` (real handshake, outbox, writer thread and encoder, so + the transcript is the wire), a real windowless `State`, and + `App::dispatch_window_event`. +- **`App::dispatch_window_event` is what made P2 reachable.** Left inside + `window_event`, the dispatch would force the harness to re-implement + it, and a harness that re-implements what it tests witnesses its own + copy. `window_event` is now **four lines**, so **P3 shrinks from a + 33-line match to a single `if`**. +- **Steps are delimited by a non-coalesceable sentinel key, not a + sleep** — otherwise "this step sent nothing" is undecidable without + waiting, and a fixed-duration wait against a writer thread is exactly + the core-count assumption behind PR #235's CI red. +- **The effect rows never skip**: a missing wgpu adapter is an assertion + failure. M21 confirms all **nine** effect rows fail loudly while the + **thirteen** GPU-free routing rows stay green. +- **Three manufactured absences, all found by running the rows**, and + each the same shape: the harness withheld something production + supplies, then witnessed its own omission. `resumed` sets the frontend + id and session version before any geometry flush (the resize row); + the fixture document was two lines and could not scroll; a headless + `State` has no attached buffer, so `scroll_by_lines` returned `None`. + A fourth was a vacuous assertion — `.all(|e| matches!(..))` over an + empty transcript is true — caught by the outbound-blind mutation. - **The shape.** Deciding is `route_event(&WindowEvent) -> Route`, a free function composing one decision function per family (`route_lifecycle`, `route_keyboard` + `route_key_action`, diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 6791261..45f48d7 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,10 +1,11 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 10 — APPROVED.** Revisions 1–8 rejected; revision 9 -is the approved design and **revision 10 changes none of it** — it -records one scope correction found against the 1-pre implementation -(§6, "Revision 10"). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** -**1-pre is IMPLEMENTED**; 1a onward may begin from this document. +**Status: revision 11 — APPROVED.** Revisions 1–8 rejected; revision 9 +is the approved design. Revision 10 recorded a scope correction found +against the 1-pre implementation and **also made a claim about P2 that +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 +IMPLEMENTED**; 1a onward may begin from this document. **Verification base:** checked in the `gui-arc-stage0` worktree at `a994f37`, whose tree for these files is what `f8ad3e7` merged. @@ -121,7 +122,9 @@ P3 remains an accepted structural exception: not headlessly testable. **Revision 10 — one finding against the implementation, not the design.** The 1-pre seam is built and the table above holds, with one -scope correction that could not be seen from the design. +scope correction that could not be seen from the design. *(Revision 10 +also argued P2 was satisfied by classification alone. It is not — see +revision 11 at the end of this section.)* **P1 has a SECOND structural exception, and it is winit's rather than this seam's.** `KeyEvent` carries a `pub(crate) platform_specific` @@ -147,15 +150,53 @@ blanket one: body with `let _ = (event_loop, event);` — a GUI that responds to no input at all — leaves **all 256 `pmacs-gpu` tests green**, not merely the 13 routing rows. That is the exception's true extent: no headless - test anywhere in the crate observes the delegation. + test anywhere in the crate observes the delegation. *(Revision 11 + shrinks what the exception covers: `window_event` is now four lines, + so the unwitnessed residue is one `if` rather than a 33-line match.)* -**P2's harness records ROUTES, and that is the mechanism rather than a -narrowing.** A route names its local effect — `Exit`, `Redraw`, -`Resize { width, height }`, `Modifiers(state)` — so one transcript -covers both halves of P2's contract. The transcript row is deliberately -the **sole** owner of P2 (the per-variant rows call `route_event` -directly), which is what keeps "record outbound only" failing exactly -one row instead of every row. +**Revision 11 — P2 IS IMPLEMENTED AS WRITTEN. Revision 10's argument +here was wrong and is retracted.** + +Revision 10 claimed a route-classification transcript covered both +halves of P2 because a route "names its local effect". **The wheel +falsifies that.** A wheel route carries a delta; whether that delta +becomes a viewport update, a panel event, a terminal event or nothing at +all depends on `State`. The route names the *family*, and only running +the body names the *effect* — so classification could not have +satisfied P2, and arguing that it did was a narrowing wearing the +costume of a mechanism. + +P2 now has a second harness beside the routing one: + +- **`EffectHarness` drives production end to end** — a real + `AttachClient` over a `socketpair` (real handshake, outbox, writer + thread, encoder, so the transcript is the wire), a real windowless + `State`, and `App::dispatch_window_event`. +- **`App::dispatch_window_event` is what made this reachable.** Left + inside `window_event`, the dispatch would force a harness to + re-implement it, and a harness that re-implements what it tests + witnesses its own copy. **P3 therefore narrows from a 33-line match to + a single `if`**: `window_event` is now `call dispatch, exit if it + asks`. +- **Local effects are read where they land**: exit from the returned + `EventOutcome`, redraw from a test-only `render_calls`, resize from + the surface config, modifiers from `App`, scroll from `scroll_top`. +- **Steps are delimited by a non-coalesceable sentinel key, not a + sleep** — "this step sent nothing" is otherwise undecidable without + waiting, and a fixed-duration wait against a writer thread is the + core-count assumption of PR #235's CI red. +- **The rows never skip.** A missing wgpu adapter is an assertion + failure; mutation M21 confirms all nine effect rows fail loudly while + the thirteen GPU-free routing rows stay green. + +`M22` (blind to outbound) and `M23` (blind to local) fail rows in both +directions, which is P2's contract executable rather than asserted. + +The routing rows stay, and the division of labour is deliberate: the +routing harness answers *where did this event go*, the effect harness +answers *what did it do*. The transcript row remains the routing +harness's sole P2-recording owner, which keeps its own mutation +surgical. **One design consequence worth carrying into 1a.** The keyboard arm was the second caller of `event_loop.exit()` — the idle-Escape local quit — From 68c99fb2e2828b3c39c9b9e55497a4adbdf06aa7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 10:45:44 +0200 Subject: [PATCH 13/15] docs: where the effect rows actually execute in CI Checked with `cargo metadata`, not assumed: `workspace_default_members` is the root `pmacs` package alone, so the `test` matrix and `crdt-test` --- both bare `cargo test --all-targets` --- never compile pmacs-gpu's unit tests. Only `gpu-render` runs them, and it installs lavapipe, proves the adapter with vulkaninfo, and sets PMACS_REQUIRE_GPU=1. That matters because the harness's adapter assert is UNCONDITIONAL rather than env-gated: it cannot be turned into a quiet ok anywhere, and a future job that runs -p pmacs-gpu without an adapter fails loudly instead of silently covering nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 80962b3..5d09e2a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -297,7 +297,18 @@ waits for a signal that is not coming. the core-count assumption behind PR #235's CI red. - **The effect rows never skip**: a missing wgpu adapter is an assertion failure. M21 confirms all **nine** effect rows fail loudly while the - **thirteen** GPU-free routing rows stay green. + **thirteen** GPU-free routing rows stay green. The assert is + **unconditional**, not `PMACS_REQUIRE_GPU`-gated, so no invocation + anywhere can turn a missing adapter into a quiet `ok`. +- **They execute in exactly ONE CI job, and that is checked rather than + assumed.** `cargo metadata` reports `workspace_default_members` as the + root `pmacs` package alone, so the `test` matrix and `crdt-test` — + both bare `cargo test --all-targets` — never compile `pmacs-gpu`'s + unit tests at all. Only **`gpu-render`** runs them, and it installs + lavapipe, proves the adapter with `vulkaninfo`, and sets + `PMACS_REQUIRE_GPU=1`. This is the handoff's "`gpu-render` runs a + DIFFERENT PACKAGE" fact showing up as a dependency: these rows live or + die with that one job. - **Three manufactured absences, all found by running the rows**, and each the same shape: the harness withheld something production supplies, then witnessed its own omission. `resumed` sets the frontend From 9f08f5278c508e96bf4d0841f27ca3568546864b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 11:07:41 +0200 Subject: [PATCH 14/15] fix(gpu): 1-pre round 2 --- a read ceiling, and the A4 consequence was wrong Two review findings, one of them a real defect. **THE SENTINEL READ COULD HANG FOREVER.** `read_until_sentinel` blocked with no bound, so a writer or encoder that regressed after `enqueue` would WEDGE THE GATE rather than redden it --- and a hang is the worst failure shape there is, because it looks like slowness until the job is killed. A 30 s `READ_CEILING` is armed on the daemon socket. The distinction is kept explicit in the code, because collapsing it is how this fix would undo the design it protects: **the sentinel remains the success condition and the ceiling is only an error ceiling.** Arrival is still decided by the sentinel, so the harness never infers "nothing was sent" from a duration --- the core-count assumption behind PR #235's CI red is not reintroduced. The ceiling sits far above any plausible drain, so reaching it means broken, never busy. M24 proves it fires rather than trusting it: drop the sentinel enqueue entirely and the row fails in under a second with a diagnostic naming both candidate causes and the partial transcript, instead of hanging. **THE STAGE 1a CONSEQUENCE WAS WRONG IN FOUR PLACES.** Every record claimed A4 would leave `EventOutcome` with one variant, so the type should go with the Escape branch. It will not, and it should not. `LifecycleRoute::Exit` --- a native window close --- returns `EventOutcome::Exit` too. A4 removes the KEYBOARD producer only, leaving one `Exit` producer. And **one producer is not one variant**: the type survives because `dispatch_window_event` must still distinguish `Continue` from `Exit` on every event it handles --- nearly all must not exit, and the close must. What A4 actually changes is `apply_keyboard`'s signature. Corrected in the `EventOutcome` doc, the Escape-branch comment, the framing and the ledger; the framing's superseded paragraph is deleted rather than patched, since it also carried the stale "two `event_loop.exit()` call sites" count. **There is exactly one executable `event_loop.exit()`**, in `window_event`. Also: the sentinel-tag comment claimed four modifier bits and used three. It now says three, wrapping every eight steps, and why that suffices --- each sentinel is read before the next is issued, so a tag only has to differ from its immediate predecessor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 86 +++++++++++++++++----------- docs/agent-handoff.md | 2 +- docs/gui-stage1-input-framing.md | 46 ++++++++++----- pmacs-gpu/src/main.rs | 98 ++++++++++++++++++++++---------- 4 files changed, 153 insertions(+), 79 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5d09e2a..3270b11 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -268,10 +268,11 @@ waits for a signal that is not coming. Stage 1's framing for **all** slices and governs the later branches; only Stage 0 was framed by the arc document itself. - **Scope of THIS branch: 1-pre only — the input seam. No behaviour - change.** `App::window_event` was **655 lines** and is now **33**: one - `route_event` call and one arm per route. Nothing below it could be - witnessed without a display, which is why the seam precedes every - other slice. + change.** `App::window_event` was **655 lines** and is now **four** — + call `dispatch_window_event`, exit if it asks. The dispatch it calls + is one `route_event` call and one arm per route. Nothing below it + could be witnessed without a display, which is why the seam precedes + every other slice. - **IMPLEMENTED in four commits, one per event family**, so each lands with its own witnesses and mutations rather than as one 600-line diff: `014110f` lifecycle (close / modifiers / resize), `7f0f9db` @@ -321,22 +322,26 @@ waits for a signal that is not coming. free function composing one decision function per family (`route_lifecycle`, `route_keyboard` + `route_key_action`, `route_pointer`). Performing stays on `App` in seven `apply_*` - methods. **A route names its LOCAL EFFECT**, not merely the family - that claims it, and the harness records routes — because - `CloseRequested` and `RedrawRequested` send the daemon nothing, so a - transcript of protocol traffic could not tell a handled arm from a - dropped one. + methods. A route carries the **decision**, not the effect: a wheel + route holds a delta, and whether that becomes a viewport update, a + panel event, a terminal event or nothing depends on `State`. Effects + are witnessed separately — see the P2 bullet below. - **Every moved body verified as the original, mechanically.** The keyboard body is byte-identical modulo two named conversions; the four pointer bodies were checked by re-running rustfmt on the pre-move text at the new indent level and diffing, since de-indenting by 8 columns - lets rustfmt rejoin lines. **13 witnesses, 17 mutations.** -- **P3 IS NOW MEASURED, NOT ASSUMED.** Replacing `window_event`'s whole - body with `let _ = (event_loop, event);` — a GUI that responds to no - input at all — leaves **all 256 `pmacs-gpu` tests green**. That is the - exception's true extent: `ActiveEventLoop` cannot exist outside a live - event loop, so **no** headless test in the crate observes the - delegation, not merely none of the new ones. + lets rustfmt rejoin lines. **22 witnesses — 13 routing, 9 effect — and + 23 mutations**, each failing its own rows, plus the P3 exception check + which must stay green. +- **P3 IS NOW MEASURED, NOT ASSUMED — and re-measured after the effect + harness landed.** Replacing `window_event`'s whole body with `let _ = + (event_loop, event);` — a GUI that responds to no input at all — + leaves **all 265 `pmacs-gpu` tests green** under `PMACS_REQUIRE_GPU=1` + at the current shape (it was 256 before the effect rows; the number is + re-run, not carried forward). That is the exception's true extent: + `ActiveEventLoop` cannot exist outside a live event loop, so **no** + headless test in the crate observes the delegation. What it covers is + now **one `if`**, not a 33-line match. - **A SECOND ACCEPTED STRUCTURAL EXCEPTION, found here and winit's rather than ours.** `KeyEvent` carries a `pub(crate) platform_specific` field, so **no `WindowEvent::KeyboardInput` can be @@ -347,12 +352,19 @@ waits for a signal that is not coming. the exception down); the family's only real decision is factored into `route_key_action(ElementState)` and witnessed directly; and what stays unwitnessed is one pattern arm containing a match and a call. -- **`event_loop.exit()` now appears in exactly two places, both inside - `window_event`.** The keyboard body was its second caller (the idle - Escape), so `apply_keyboard` returns an `EventOutcome` rather than - taking an `&ActiveEventLoop` — which is what keeps the bodies - reachable in principle. **Stage 1a's A4 deletes that branch**, at - which point `EventOutcome` has one variant and should go. +- **The crate now has exactly ONE executable `event_loop.exit()`**, in + `window_event`. Bodies return an `EventOutcome` rather than taking an + `&ActiveEventLoop`, which is what keeps them reachable in principle. + **`EventOutcome` has TWO producers and they differ in kind**: a native + close (`LifecycleRoute::Exit`), which must always exit, and + `apply_keyboard`'s idle Escape, which is a local quit. **Stage 1a's A4 + removes the keyboard producer only**, leaving **one** `Exit` + producer — the native close. **One producer is not one variant**: + `EventOutcome` survives because `dispatch_window_event` must still + distinguish `Continue` from `Exit` on every event, and only the close + exits. A4 changes `apply_keyboard`'s signature, not the type. An + earlier revision of this bullet claimed the type would collapse and + should go with the Escape branch; that was wrong. - **Slice order (each its own branch and PR):** `1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\*. **`1a` and `1e` are protocol-bearing (v24 `TextInput`, v25 `OpenTarget`/`OpenTargetResult`) and are @@ -374,10 +386,11 @@ waits for a signal that is not coming. file-watcher tests — with every other target green. Established as environmental three ways, in increasing strength: - **Structurally impossible for this branch to cause.** The branch - changes six files, five of them under `docs/`; **the whole - executable diff is `pmacs-gpu/src/main.rs`**, and `pmacs-gpu` is a - workspace **member but not a dependency** of the root package, so - the `m4_acceptance` binary never links it. + changes seven files, five of them under `docs/`; **the whole + executable diff is inside the `pmacs-gpu` crate** + (`src/main.rs` and `src/attach.rs`), and `pmacs-gpu` is a workspace + **member but not a dependency** of the root package, so the + `m4_acceptance` binary never links it. - **The marker is older than the session.** `/tmp/.git` is empty and was created at 20:14 CEST, **3.5 h before** the gate run at 23:45; `/tmp` held 8,920 entries. That is handoff §1's recorded hazard @@ -400,12 +413,21 @@ waits for a signal that is not coming. unnecessary, and the isolated `TMPDIR` is the correct remedy. It must be **outside `/tmp` and outside every git worktree** — a child of `/tmp` is not isolated, because `/tmp/.git` remains its ancestor. -- **GREEN under an isolated `TMPDIR`: all nine gates pass** (log - `20260811T215605Z-2664352`). fmt, clippy, lib, lib-crdt, - `gpu_invocation_acceptance`, **m4 168/0/3**, `PMACS_REQUIRE_GPU=1 -p - pmacs-gpu`, the **117-target `--workspace --no-fail-fast` sweep with - zero failures anywhere** (`m4_acceptance` running all 171 in it), and - `diff-check`. +- **GREEN under an isolated `TMPDIR`: all nine gates pass on the final + tree** (log `20260812T090034Z-2989598`, review round 2). fmt, clippy, + lib, lib-crdt, `gpu_invocation_acceptance`, **m4 168/0/3**, + `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` (**265 tests, none filtered and + none skipped**, all nine effect rows included), the **117-target + `--workspace --no-fail-fast` sweep with zero failures anywhere** + (`m4_acceptance` running all 171 in it), and `diff-check`. Earlier + full-green runs at `20260811T215605Z-2664352` (round 1) and + `20260812T083735Z-2869707` (the P2 harness) are superseded by this + one. **What changed after it is doc comments and prose only** — the + `EventOutcome` correction below and this paragraph; `cargo fmt + --check`, `clippy -D warnings` and the 22 routing/effect rows were + re-run on the result. Stated rather than glossed, because "the gate + was green" and "the gate was green on exactly this tree" are + different claims. ## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 3c099f5..281aac7 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -216,7 +216,7 @@ commands, read `docs/active-work.md` immediately after this file. roots but **not `TMPDIR`**, so `tempfile::tempdir()` still lands under a `/tmp` that may carry a marker. It surfaced inside a gate run on an unrelated lane (GUI 1-pre, whose whole **executable** diff - is `pmacs-gpu/src/main.rs`) as **`m4_24_bare_string_glob_stays_relative` + is inside the `pmacs-gpu` crate) as **`m4_24_bare_string_glob_stays_relative` and `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`**, in both the `m4` step and the `--workspace` sweep, with every other target in the corpus green. diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 45f48d7..c5553ff 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -148,11 +148,14 @@ blanket one: directly, with both misroute mutations failing that row alone. - **P3 is now measured, not assumed.** Replacing `window_event`'s entire body with `let _ = (event_loop, event);` — a GUI that responds to no - input at all — leaves **all 256 `pmacs-gpu` tests green**, not merely - the 13 routing rows. That is the exception's true extent: no headless - test anywhere in the crate observes the delegation. *(Revision 11 - shrinks what the exception covers: `window_event` is now four lines, - so the unwitnessed residue is one `if` rather than a 33-line match.)* + input at all — leaves **every `pmacs-gpu` test green**. That is the + exception's true extent: no headless test anywhere in the crate + observes the delegation. **Re-measured at the current shape after + revision 11: 265/265 under `PMACS_REQUIRE_GPU=1`** (it was 256 before + the effect rows existed, and the number is re-run rather than carried + forward). Revision 11 also shrinks what the exception *covers*: + `window_event` is four lines, so the unwitnessed residue is one `if` + rather than a 33-line match. **Revision 11 — P2 IS IMPLEMENTED AS WRITTEN. Revision 10's argument here was wrong and is retracted.** @@ -194,17 +197,30 @@ directions, which is P2's contract executable rather than asserted. The routing rows stay, and the division of labour is deliberate: the routing harness answers *where did this event go*, the effect harness -answers *what did it do*. The transcript row remains the routing -harness's sole P2-recording owner, which keeps its own mutation -surgical. +answers *what did it do*. **P2 is owned by the effect rows.** The +routing transcript row is only the sole owner of the *routing* +harness's own recording, which is what keeps that one mutation +surgical — revision 10 claimed it owned P2, and it never did. -**One design consequence worth carrying into 1a.** The keyboard arm was -the second caller of `event_loop.exit()` — the idle-Escape local quit — -so its body returns an `EventOutcome` rather than taking an -`&ActiveEventLoop`. `event_loop.exit()` now appears in exactly two -places, both inside `window_event`, and nowhere else in the crate. -**A4 deletes the Escape branch, at which point `EventOutcome` has one -variant and should go with it.** +**One further correction revision 11 carries, and it is the design +consequence 1a inherits.** Revision 10 stated that Stage 1a's A4 would +leave `EventOutcome` with a single variant and that the type should go +with the Escape branch. **Both are wrong.** + +`EventOutcome` has **two producers today**: `LifecycleRoute::Exit`, a +native window close that must always exit, and `apply_keyboard`'s idle +Escape, a local quit. **A4 removes the keyboard one**, leaving **exactly +one `Exit` producer** — the native close. + +**One producer is not one variant.** The type survives because +`dispatch_window_event` still has to distinguish `Continue` from +`Exit` on every event it handles: the overwhelming majority of +dispatches must *not* exit, and the native close must. What A4 actually +removes is `apply_keyboard`'s need to return an outcome at all, which is +a change to that one signature rather than to this type. + +The crate has **exactly one** executable `event_loop.exit()`, in +`window_event`. ### 1a — `TextInput` (v24) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 0ca4608..3b16815 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3226,8 +3226,9 @@ impl App { } else { // Q#S1-1 / A4 — the local quit, unchanged here and // deleted by Stage 1a: an idle Escape must reach the - // daemon. `window_event` performs the exit; this is - // the only reason a body needs an outcome at all. + // daemon. `window_event` performs the exit. This is the + // only reason a BODY needs an outcome; `EventOutcome` + // itself outlives A4, since a native close still exits. return EventOutcome::Exit; } return EventOutcome::Continue; @@ -3395,13 +3396,11 @@ impl App { /// exception and how far it reaches. **Performing** stays on `App`, in /// the `apply_*` methods the router's variants name. /// -/// The decision is what the route *is*, not merely which family claims -/// it: `Exit` is the local exit effect, `Resize` carries the clamped -/// surface extent, `Modifiers` carries the state mutation. **Two arms — -/// `CloseRequested` and `RedrawRequested` — send nothing outbound at -/// all**, so a harness recording only protocol traffic would leave them -/// invisible; that is why a route names its local effect and the harness -/// records routes. +/// A route carries the decision, not merely the family: `Resize` holds +/// the clamped extent, `Modifiers` the new state. **What a route does +/// NOT carry is the effect** — a `Wheel` may become a viewport update, a +/// panel event, a terminal event or nothing at all, depending on +/// `State`. Effects are witnessed separately, by `EffectHarness`. #[derive(Debug, Clone, Copy, PartialEq)] enum Route<'a> { /// The lifecycle family — see [`route_lifecycle`]. @@ -3417,16 +3416,25 @@ enum Route<'a> { Unrouted, } -/// What the event loop must do once a family's body has run. Only the -/// keyboard family produces anything but `Continue` today: an idle -/// Escape is a local quit. Returning the decision rather than taking an -/// `&ActiveEventLoop` is what keeps every body reachable from a test — -/// the crate's two `event_loop.exit()` call sites, this one and -/// `LifecycleRoute::Exit`, both sit in `window_event` and nowhere else. +/// What the event loop must do once a family's body has run. /// -/// Stage 1a's A4 deletes that branch (an idle Escape must reach the -/// daemon and never exit), at which point this type has one variant and -/// should go. +/// **Two producers, and they are not the same kind of thing.** +/// `LifecycleRoute::Exit` is a native window close, which must always +/// exit; `apply_keyboard` returns `Exit` for an idle Escape, which is a +/// local quit. Returning the decision rather than taking an +/// `&ActiveEventLoop` is what keeps every body reachable from a test: +/// the crate has **exactly one** executable `event_loop.exit()`, in +/// `window_event`. +/// +/// **Stage 1a's A4 removes the KEYBOARD producer only** — an idle +/// Escape must reach the daemon and never exit — leaving **one** `Exit` +/// producer, the native close. +/// +/// **One producer is not one variant.** This type survives A4 because +/// `dispatch_window_event` must still distinguish `Continue` from +/// `Exit` on every event it handles: nearly all of them must not exit, +/// and the close must. What A4 changes is `apply_keyboard`'s signature, +/// not this type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EventOutcome { Continue, @@ -3585,7 +3593,7 @@ fn route_pointer(event: &WindowEvent) -> Option { /// deliberately, not of outbound protocol traffic: `CloseRequested` /// exits and `RedrawRequested` repaints, and neither sends the daemon /// anything, so a transcript of daemon traffic alone cannot tell a -/// handled arm from a dropped one. A route names its local effect — +/// handled arm from a dropped one. A route records the decision — /// exit, resize extent, modifier mutation — which is what makes those /// arms observable here at all. /// @@ -3691,6 +3699,13 @@ impl EffectHarness { /// anything a body would legitimately send. const SENTINEL: char = '\u{e000}'; + /// Error ceiling on one outbound read. **Not a pacing device** — the + /// sentinel decides arrival — so this is set far above any plausible + /// drain: reaching it means the writer or encoder is broken, never + /// that the machine is busy. Without it a regression downstream of + /// `enqueue` wedges the gate instead of reddening it. + const READ_CEILING: std::time::Duration = std::time::Duration::from_secs(30); + /// Build the harness, or fail loudly. /// /// **Never skips.** `State::new_headless` returns `None` when no @@ -3717,6 +3732,9 @@ impl EffectHarness { let client = crate::attach::connect_stream_for_test(client_stream, |_| true) .expect("attach over socketpair"); let daemon = handshake.join().expect("handshake thread"); + daemon + .set_read_timeout(Some(Self::READ_CEILING)) + .expect("arm the outbound read ceiling"); // A document tall enough to scroll. A two-line fixture made the // wheel row pass vacuously: `scroll_by_lines` returns `None` @@ -3822,10 +3840,17 @@ impl EffectHarness { /// Push a sentinel through the same outbox and read until it comes /// back. Everything ahead of it belongs to the step just dispatched. /// - /// This is a **condition**, not a sleep: it blocks on the socket - /// until the writer thread has drained past the sentinel, so it is - /// insensitive to how many cores are free — the mistake PR #235's CI - /// red was made of. + /// **The sentinel is the success condition, and the timeout is only + /// an error ceiling** — the two are not the same thing and the + /// distinction is the whole design. Arrival is decided by the + /// sentinel, so the harness never infers "nothing was sent" from a + /// duration and is insensitive to how many cores are free; that is + /// the mistake PR #235's CI red was made of. But a blocking read + /// with no bound turns a regressed writer or encoder into a **wedged + /// gate** rather than a red one, and a hang is the worst failure + /// shape there is: it looks like slowness until the job is killed. + /// [`Self::READ_CEILING`] is therefore set far above any plausible + /// drain, so reaching it means broken, never busy. fn read_until_sentinel(&mut self) -> Vec { self.sentinel_seq += 1; let tag = self.sentinel_seq; @@ -3837,7 +3862,16 @@ impl EffectHarness { let mut seen = Vec::new(); loop { let event: pmacs_protocol::FrontendEvent = - pmacs_protocol::read_message(&mut self.daemon).expect("read outbound"); + match pmacs_protocol::read_message(&mut self.daemon) { + Ok(event) => event, + Err(e) => panic!( + "outbound read failed before the sentinel arrived \ + after {:?}: {e}. Either the writer or the encoder \ + regressed, or the step produced no sentinel at all. \ + Recorded so far: {seen:?}", + Self::READ_CEILING + ), + }; if is_sentinel(&event, tag) { return seen; } @@ -3856,9 +3890,11 @@ struct EffectSnapshot { } /// Modifier bits carrying the sentinel's sequence number, so a stale -/// sentinel cannot end the wrong step. Four bits is plenty: the harness -/// reads every sentinel it writes, so the counter only has to -/// distinguish neighbours. +/// sentinel cannot end the wrong step. **Three bits — Ctrl, Alt, Shift — +/// so the tag wraps every eight steps.** That is sufficient rather than +/// sloppy: the harness reads every sentinel it writes before issuing the +/// next, so a tag only ever has to distinguish itself from the one +/// immediately before it. #[cfg(test)] fn sentinel_mods(tag: u32) -> Modifiers { let mut mods = Modifiers::NONE; @@ -3915,10 +3951,10 @@ mod input_routing_tests { } /// The per-variant rows below drive [`route_event`] directly and the - /// transcript row drives the harness. That split is deliberate: it - /// leaves the transcript row as P2's sole owner, so a harness that - /// stopped recording an effect fails exactly one row instead of - /// every row. + /// transcript row drives [`RoutingHarness`]. That split keeps the + /// transcript row the only one that fails when the ROUTING harness + /// stops recording, so that mutation stays surgical. **P2 itself is + /// owned by the effect rows** — see [`EffectHarness`]. /// /// Events are bound to locals rather than passed as temporaries /// because a `Route` borrows the event it came from — the keyboard From 18ce60cdd77e48cd151e60ece5505ba2eb2a5f3f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 12 Aug 2026 11:31:13 +0200 Subject: [PATCH 15/15] docs: three counting and wording fixes from review round 3 M24 makes the total 24 mutations, not 23. Stated as M1-M24 with the split named --- twenty-three fail their own rows, and M6 is the P3 exception check, which must STAY GREEN. Writing it as a bare total was what let the count drift twice: an exception check and a failing mutation are both 'mutations' in the ledger's usual sense, so the sentence has to say which is which. 'Gate passes on the final tree' contradicted the very next sentence, which records that prose and doc comments changed after the run. It now says final EXECUTABLE tree and explains why the distinction is drawn --- 'the gate was green' and 'the gate was green on exactly this tree' are different claims, and only the weaker one is true here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3270b11..870b8be 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -331,8 +331,8 @@ waits for a signal that is not coming. pointer bodies were checked by re-running rustfmt on the pre-move text at the new indent level and diffing, since de-indenting by 8 columns lets rustfmt rejoin lines. **22 witnesses — 13 routing, 9 effect — and - 23 mutations**, each failing its own rows, plus the P3 exception check - which must stay green. + 24 mutations, M1–M24.** Twenty-three fail their own rows; **M6 is the + P3 exception check and must stay green**. - **P3 IS NOW MEASURED, NOT ASSUMED — and re-measured after the effect harness landed.** Replacing `window_event`'s whole body with `let _ = (event_loop, event);` — a GUI that responds to no input at all — @@ -414,7 +414,9 @@ waits for a signal that is not coming. be **outside `/tmp` and outside every git worktree** — a child of `/tmp` is not isolated, because `/tmp/.git` remains its ancestor. - **GREEN under an isolated `TMPDIR`: all nine gates pass on the final - tree** (log `20260812T090034Z-2989598`, review round 2). fmt, clippy, + EXECUTABLE tree** (log `20260812T090034Z-2989598`, review round 2) — + executable, not final, because prose and doc comments changed after + it, as the sentence below records. fmt, clippy, lib, lib-crdt, `gpu_invocation_acceptance`, **m4 168/0/3**, `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` (**265 tests, none filtered and none skipped**, all nine effect rows included), the **117-target