From ce982e6701cb389a193e6efdeb7fadc6ed0338a9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 13 Aug 2026 22:27:57 +0200 Subject: [PATCH 01/72] docs(framing): Stage 1 revision 13 --- 1b ground truth, and Q#S1-11 Adds SS2a, measured at `72da24a`. No B-row contract changes. Awaiting approval; nothing is implemented on this branch yet. Every 1b anchor was stale, which was the expected result --- 1-pre's router extraction moved the wheel path wholesale and `main.rs` is now 21,435 lines. `main.rs:2061` holds `PanelCell`, `:3337` an `attach_client` line, `:3373` a bare brace; `dispatch_mouse` is `editor.rs:3207`, not `:3052`. Those are renumbered in a table. Three findings are not renumbering. **THREE ROWS SAY "NOTHING EXISTS" WHERE SOMETHING DOES.** B3's "no horizontal scroll to clamp" --- the GPU has `code_scroll_left` and its own `horizontal_follow`; what is missing is a WHEEL-driven one, so B3 adds a second writer to an existing field. B5's "no I-beam" is true, but `apply_panel_cursor_icon` already sets `CursorIcon::Default` everywhere that is not the divider, so an I-beam written as its own site would be clobbered by that else branch. B4's "no middle-click path" --- 1-pre built the landing site and named this very row in its doc comment. Each contract is unaffected; each implementation shape is not. **AND B7 RE-OPENS A QUESTION ANOTHER FRAMING DEFERRED.** This is the one that needs a ruling. `horizontal_follow` documents the premise "there are no explicit scroll commands, so every viewport move originates here, and Q#HS4's snap-back hazard cannot arise". B7 IS an explicit horizontal viewport move, and the horizontal-scroll framing says in terms that such commands "are what re-opens Q#HS4". The hazard is not hypothetical --- it is why `scroll_window` carries the cursor with a VERTICAL wheel scroll: without it the auto-scroll pass snaps the origin back and the wheel "would feel stuck after one notch". A wheel-driven `view_left` that does not carry the cursor gets the identical bug one axis over, on BOTH frontends, since the GPU's `horizontal_follow` has the same shape. B7's contract does not mention the cursor, so its mutations cannot detect this: a clamp row and a wrap row both pass against a viewport that snaps back on the next caret event. Q#S1-11 is raised OPEN with two candidate answers, and either way B7 needs a witness the table lacks --- scroll sideways, trigger a caret-follow, assert the ruled behaviour. Also worth having: B7's wrap clause is already implemented for the caret-follow path, and B1/B2 share one witness, the four lines in `apply_wheel` that round to whole lines and discard both the remainder and the x delta. `code_scroll_residual` is NOT a wheel accumulator --- it is the caret-follow pixel residual --- and reusing it would be a defect. Gates: all nine green under `env -u TMPDIR`, log 20260813T202304Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 154 ++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 5 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 308bb75..ad9d012 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,5 +1,13 @@ # GUI arc, Stage 1 — input foundation (framing) +**Status: revision 13 — AWAITING APPROVAL.** Revision 13 is §2a's +ground-truth re-measurement **for Stage 1b**, taken at the post-#240 +tip. Every 1b line anchor was stale, which was expected. What was not +expected is that **three B-rows describe a field as empty when it is +occupied**, and that **B7 re-opens a question another framing +deliberately deferred** — so this revision asks for a ruling (Q#S1-11) +rather than only renumbering. **No B-row contract is changed here.** + **Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a revision 16.** Revision 13 changes no previously ruled outcome. It makes B1–B3's “per surface” scope explicit for the panel-document @@ -26,11 +34,13 @@ Protocol slices stay serialized; one was inserted in front. `ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**. **Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12), -the tip after 1-pre; it was originally taken at `a994f37`. Sections -other than §2 were written against `a994f37` and their *rulings* are -unaffected by 1-pre, which changed no behaviour — but **any line number -outside §2 predates 1-pre and should be re-checked before it is relied -on.** +the tip after 1-pre; it was originally taken at `a994f37`. **§2a is +measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it +is the base for **1b only**. Sections other than §2/§2a were written +against `a994f37` and their *rulings* are unaffected by 1-pre, which +changed no behaviour — but **any line number outside §2 and §2a +predates 1-pre and should be re-checked before it is relied on.** The +1b table's own anchors are superseded by §2a wholesale; read §2a first. ## 1. What this stage closes @@ -118,6 +128,128 @@ survives A4**: a native close still returns `Exit`, and keyboard input is truncated to its first scalar, and an IME commit produces nothing. +## 2a. Ground truth for 1b — MEASURED at `72da24a` (2026-08-13) + +§2 above was measured for **1a**, at `4f77491`. 1a (#239) has since +merged and #240 landed on top, so **every coordinate the 1b table cites +is stale** — `pmacs-gpu/src/main.rs` is now **21,435 lines** and 1-pre's +router extraction moved the wheel path wholesale. Renumbering alone +would have been routine. It is not what the measurement found. + +**`PROTOCOL_VERSION = 24`** now (1a's `TextInput`). 1b remains +non-protocol-bearing. + +### The anchors, re-measured + +| the table cites | holds what now | the real site | +|---|---|---| +| `main.rs:2061` "minimap is `Elsewhere`" | `PanelCell(CellCoord)` | `enum PointerSurface` `:2057`; **`Elsewhere` `:2067`** | +| `main.rs:3337` wheel reads `pointer_pos` | an `attach_client` line | `apply_wheel` `:3090` and `:3113` | +| `main.rs:3373` falls to `scroll_by_lines` | a bare `}` | `apply_wheel` `:3126`; `scroll_by_lines` `:8002` | +| `editor.rs:3052` `dispatch_mouse` | — | **`:3207`** | +| `editor.rs:3203` scroll arms | a doc-comment line | `ScrollUp` `:3358`, `ScrollDown` `:3362` | + +**B6's premise survives the move intact**, and is now stated by the code +itself: `Elsewhere`'s doc comment reads *"Not the band: the document, +the terminal, the minimap, or the chrome."* The wheel's panel branch +(`:3090`) tests `PanelCell` only, so minimap pixels still fall through +to `scroll_by_lines`. + +**B1's defect is visible in four lines.** `apply_wheel` rounds to whole +lines and returns on zero: + +```rust +let lines = match delta { LineDelta(_, y) => (-y * WHEEL_LINES_PER_TICK).round() as i64, … }; +if lines == 0 { return; } +``` + +`:3075`–`:3084`. The `_` is the **x** delta, discarded at the same site +— so **B1 and B2's witnesses are the same four lines**, and a residual +accumulator is what both need. **There is no wheel accumulator today.** +`code_scroll_residual` (`:1622`) is *not* it: that is the caret-follow +pixel residual, buffer-scoped, cleared at `:5928`. **Reusing it would +be a defect**, not a shortcut. + +### CORRECTION 3 — three rows say "nothing exists" where something does + +Each of these reads as an empty field in the table and is not one. The +contracts are unaffected; the **implementation shape** is. + +- **B3 — "no horizontal scroll to clamp".** The GPU has a horizontal + origin: **`code_scroll_left`** (`:1639`), a pixel offset *snapped to + the column grid*, moved by its own `horizontal_follow` (`:7702`). + What is missing is a **wheel-driven** horizontal scroll. B3 adds a + second writer to an existing field, which is a different job from + introducing one. +- **B5 — "no I-beam".** True as stated, but the cursor already has an + owner: **`apply_panel_cursor_icon`** (`:7328`) sets `RowResize` over + the divider and **`CursorIcon::Default` everywhere else**. An I-beam + written as a separate site would be **clobbered by that else branch**. + B5 must extend this function, not join it. +- **B4 — "no middle-click path".** 1-pre already built the landing + site and named this row in it: `PointerRoute::UnusedButton` (`:3623`) + is documented *"Stage 1b's B4 gives the middle button a meaning + (PRIMARY-selection paste on Linux) and lands here."* `route_pointer` + (`:3631`) sends every non-left, non-right-press button there. B4 + splits a variant that already exists. + +### CORRECTION 4 — B7 re-opens a deferred question, and the table does not say so + +This is the finding that needs a ruling rather than a renumber. + +The TUI horizontal origin is **`window.view_left`** (`src/window.rs:386`), +and `horizontal_follow` (`src/editor.rs:4495`) already **pins it to 0 +under wrap** — so *B7's wrap clause is implemented today*, for the +caret-follow path. Both frontends share the arithmetic +(`pmacs_protocol::scroll::follow_left`, `scroll.rs:134`), deliberately. + +But that function's doc states the premise B7 removes: + +> there are no explicit scroll commands, so **every viewport move +> originates here**, and Q#HS4's snap-back hazard cannot arise. + +**B7 is an explicit horizontal viewport move.** `docs/horizontal-scroll-framing.md` +is explicit that such commands *"are what re-opens Q#HS4"* (`:189`), and +Q#HS4 is recorded as **DEFERRED, not answered** (`:202`). The hazard is +concrete and already cost this project once — §1.5 there quotes +`scroll_window`, which carries the cursor with a **vertical** wheel +scroll for exactly this reason: + +> the renderer has an "auto-scroll to keep cursor visible" pass that +> would otherwise snap `view_top` straight back … so the user's +> mouse-wheel scroll would feel stuck after one notch. + +(That citation is itself stale: `scroll_window` is **`src/editor.rs:3845`**, +not `:3628`.) + +So a wheel-driven `view_left` that does **not** carry the cursor will be +snapped back by the next `horizontal_follow`, and horizontal wheel +scrolling will "feel stuck after one notch" — the identical bug, one +axis over, on **both** frontends, since the GPU's `horizontal_follow` +(`:7702`) has the same shape. + +**B7's stated contract does not mention the cursor at all**, and its +mutations therefore cannot detect this: a clamp row and a wrap row both +pass against a viewport that snaps back on the next caret event. + +#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **OPEN** + +Not answerable from the table as written; Q#HS4 was deferred rather +than closed precisely so this would be decided when explicit commands +arrived. Two candidate answers, both defensible: + +- **(A) Carry it, mirroring `scroll_window`.** Consistent with the + vertical axis, and the snap-back cannot arise. Costs a cursor move + the user did not ask for. +- **(B) Do not carry it; suppress the follow while a wheel origin is + live.** Preserves the cursor, but needs a rule for when the origin + stops being authoritative, and that rule is new state on both + frontends. + +**Whichever is ruled, B7 needs a witness the current table lacks:** +scroll sideways, then trigger a caret-follow, and assert the origin +behaves as ruled. Without it the row is green either way. + ## 3. PR topology `1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\* (\* `--protocol`) @@ -313,6 +445,12 @@ The crate has **exactly one** executable `event_loop.exit()`, in ### 1b — pointer and scroll +> **Read §2a first.** Every line number in this table was measured +> before 1-pre and is stale. §2a re-measures them at `72da24a` and +> records three rows whose "nothing exists yet" is wrong, plus **Q#S1-11 +> (open)**, which B7 cannot be implemented without. The contracts below +> are unchanged. + | # | Contract | Witness | Mutation | |---|---|---|---| | B1 | Residual per **axis and surface** — primary document, panel document and minimap are distinct owners | deltas discarded | share one accumulator → primary↔panel or document↔minimap surface-switch jump | @@ -621,6 +759,12 @@ and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e` 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`. +**OPEN: Q#S1-11** — does a horizontal wheel scroll carry the cursor? +Raised by §2a; **blocks B7 and B3 only**, and nothing else in 1b. It is +the question `docs/horizontal-scroll-framing.md` deferred as Q#HS4, +now live because B7 is the explicit horizontal scroll command whose +absence was that deferral's stated premise. + ## 11. Gates `./scripts/gate --acceptance gpu_invocation_acceptance` plus touched From e94ce85be1c6e4e968ee5701f95d70c22c83f2a6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 13 Aug 2026 23:21:28 +0200 Subject: [PATCH 02/72] docs(framing): Stage 1 revision 14 --- Q#S1-11 ruled B, and two holes closed Answers review of revision 13. Still framing only; no code. **Q#S1-11 IS RULED (B), VIEWPORT ONLY.** (A) is not viable in 1b and the vertical precedent does not reach it. `scroll_window` can carry point because it is TUI-side, where the editor owns the cursor. `OwnCursor` is "pmacs-gpu's own cursor position, MIRRORED from `CursorByte`" --- a read-only reflection --- and the only wire operation that positions it, `dispatch_pointer`, sets `active_frontend`, calls `break_command_chain`, and by its own comment moves point or changes selection on every kind. Carrying point from a wheel therefore needs a NEW wire operation, which contradicts 1b's non-protocol scope. (A) would not even be self-consistent: GPU vertical scrolling already does not carry point. **And "do not carry" alone is not a ruling** --- it leaves the origin's lifetime undefined, which is the part that decides whether the feature works. Five clauses, all of them the ruling: viewport only; an EFFECTIVE move arms authority; repaint, same-cursor follow, resize and vertical wheel preserve it while geometry and content changes CLAMP rather than release; a genuine cursor-position change releases it; wrap and buffer replacement clear it and pin zero. **THE HAZARD LANDS ON THE NEXT PAINT, NOT THE NEXT CARET EVENT.** Revision 13 said caret event and understated the exposure: `horizontal_follow` is the FIRST act of `prepare_window_cursor_visible`, which `paint_frame` runs every frame. The origin is overwritten by a redraw with no input at all. Witnesses must therefore drive real call sites on both frontends --- a helper unit test cannot see a follow that runs inside `paint_frame` --- and a TUI cross-axis row is required because vertical wheel DOES carry point there. Both mutations named: follow ignores authority, and authority never releases. **B1'S "SURFACE" IS NOW ENUMERATED.** Two facts made the gap real: quantization and the zero return happen at `:3074`, BEFORE the panel (`:3090`) and terminal (`:3112`) branches, so a sub-tick delta is discarded before anything knows where it was going; and `PointerSurface::Elsewhere` conflates document, terminal, minimap and chrome, so the existing classifier cannot name what B1 needs. Six targets are tabulated with a residual owner and a horizontal answer each. The horizontal gap is NOT a wire gap --- `MouseKind::ScrollLeft` and `ScrollRight` already exist and round-trip --- it is a missing handler, so each target must be ruled emit-and-handle or explicitly inert. Without the table, one global accumulator passes every per-surface row that tests one surface. Recorded while measuring: a wheel over the panel divider or the band's background scrolls the DOCUMENT today, though the enum says the band owns the pixel. **B3 TAKES B7'S EXACT BOUND.** "Content bounds" was vaguer than B7's saturated widest-line-minus-viewport rule, for the same bound on the same rule, and B7 is exact precisely because the loose version blanks the viewport. Stated in the GPU's column grid, since `horizontal_follow` already reckons there and re-multiplies to snap the offset --- a pixel clamp would break the snap the shared rule depends on. Narrow-buffer and final-column-visible rows added; a clamp at full content width must fail. Ledger: the minimal 1b recovery/checkpoint entry, plus three lane headers that were lying --- #240 and #239 were still marked OPEN, and the arc header still said 1a was next. The #239/#240 absorption stays deferred and is a separate lane. Gates: all nine green under `env -u TMPDIR`, log 20260813T211645Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 240 ++++++++++++++++++++++++++----- 1 file changed, 201 insertions(+), 39 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index ad9d012..f7424e4 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,12 +1,24 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 13 — AWAITING APPROVAL.** Revision 13 is §2a's -ground-truth re-measurement **for Stage 1b**, taken at the post-#240 -tip. Every 1b line anchor was stale, which was expected. What was not -expected is that **three B-rows describe a field as empty when it is -occupied**, and that **B7 re-opens a question another framing -deliberately deferred** — so this revision asks for a ruling (Q#S1-11) -rather than only renumbering. **No B-row contract is changed here.** +**Status: revision 14 — AWAITING APPROVAL.** Revision 14 answers review +of revision 13. **Q#S1-11 is RULED (B), viewport only**, with the full +five-clause lifetime contract and its witnesses; (A) was not viable +because the GPU cursor is a daemon-owned mirror and carrying point +would need a wire operation 1b is not allowed. Two further holes review +named are closed: **B1's "surface" is now enumerated** as a +wheel-target table with a residual owner and a horizontal answer per +target, and **B3 takes B7's exact saturated upper bound** in the GPU's +column grid instead of "content bounds". Revision 13's claim that the +snap-back lands on the next *caret event* is **corrected**: it lands on +the next **paint**. + +**Previously, revision 13 — SUPERSEDED.** §2a's ground-truth +re-measurement for Stage 1b at the post-#240 tip. Every 1b anchor was +stale, as expected; what was not was that **three B-rows describe a +field as empty when it is occupied**, and that **B7 re-opens a question +another framing deliberately deferred**. **No B-row contract is changed +by either revision** — 13 and 14 add ground truth, a ruling, and +witnesses. **Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a revision 16.** Revision 13 changes no previously ruled outcome. It @@ -36,7 +48,8 @@ Protocol slices stay serialized; one was inserted in front. **Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12), the tip after 1-pre; it was originally taken at `a994f37`. **§2a is measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it -is the base for **1b only**. Sections other than §2/§2a were written +is the base for **1b only**; it carries Q#S1-11's ruling. Sections +other than §2/§2a were written against `a994f37` and their *rulings* are unaffected by 1-pre, which changed no behaviour — but **any line number outside §2 and §2a predates 1-pre and should be re-checked before it is relied on.** The @@ -170,6 +183,57 @@ accumulator is what both need. **There is no wheel accumulator today.** pixel residual, buffer-scoped, cleared at `:5928`. **Reusing it would be a defect**, not a shortcut. +### CORRECTION 5 — B1's "surface" is not enumerated, and the classifier cannot enumerate it + +B1 says "residual per **axis and surface**" and the table leaves +"surface" undefined. Two facts make that a hole rather than a detail. + +**Quantization happens BEFORE routing.** The rounding and the +`lines == 0` return are at `:3074`–`:3084`; the panel branch is `:3090` +and the terminal branch `:3112`. So a sub-tick delta bound for the +panel or the terminal is **discarded before anything knows where it was +going**. An accumulator added after the routing decision would fix the +document and leave the wire targets exactly as broken as they are now. + +**And `PointerSurface` cannot name the surfaces B1 needs.** +`classify_pointer_surface` (`:7192`) resolves panel geometry only; +`Elsewhere` (`:2067`) is *"the document, the terminal, the minimap, or +the chrome"* — four wheel targets under one name, three of which B1 and +B6 must distinguish. **B1 needs a wheel-target enumeration; it does not +get one for free from the existing classifier.** + +Note also a live consequence of `:3090` matching `PanelCell(_)` alone: +**a wheel over the panel DIVIDER or the band's BACKGROUND scrolls the +document today**, though the enum's own doc says the band "still owns +the pixel." + +The enumeration B1 must carry, measured: + +| wheel target | classified today | vertical wheel today | residual owner | horizontal | +|---|---|---|---|---| +| Panel cell | `PanelCell` | wire `ScrollUp`/`Down` (`:3102`) | per panel | **must be ruled** — wire has the kinds, daemon has no arm | +| Panel divider / background | `PanelDivider` / `PanelBackground` | **falls through to the document** | — | inert | +| Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | wire terminal `ScrollUp`/`Down` (`:3122`) | per terminal | **must be ruled** — same gap | +| Minimap | `Elsewhere` | document `scroll_by_lines` | **its own** (B6) | inert | +| Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | document | `code_scroll_left` (B3) | +| Chrome | `Elsewhere` | document | none | inert | + +**The horizontal gap is not a wire gap.** `MouseKind::ScrollLeft` and +`ScrollRight` already exist (`pmacs-protocol/src/message.rs:245`, +`:247`) and round-trip (`src/protocol.rs:720`), so **emitting them +needs no protocol bump** — 1b stays non-protocol-bearing either way. +What is missing is a *handler*: the terminal path has arms for +`ScrollUp`/`ScrollDown` only (`src/editor.rs:3576`, `:3579`), and the +panel path only `ScrollUp` (`src/daemon.rs:6683`). So for each wire +target 1b must choose **emit-and-handle** or **explicitly inert**, and +say which. Silence here is what lets a horizontal tick vanish. + +**Without this table the rows are satisfiable by an implementation that +is wrong**: one global accumulator passes every per-surface row that +only ever tests one surface, and a document-and-minimap-only +implementation passes B1 and B6 while panel and terminal traffic still +carries residue across surfaces or drops sub-tick deltas silently. + ### CORRECTION 3 — three rows say "nothing exists" where something does Each of these reads as an empty field in the table and is not one. The @@ -222,33 +286,123 @@ scroll for exactly this reason: (That citation is itself stale: `scroll_window` is **`src/editor.rs:3845`**, not `:3628`.) -So a wheel-driven `view_left` that does **not** carry the cursor will be -snapped back by the next `horizontal_follow`, and horizontal wheel -scrolling will "feel stuck after one notch" — the identical bug, one -axis over, on **both** frontends, since the GPU's `horizontal_follow` -(`:7702`) has the same shape. +So a wheel-driven `view_left` that does **not** carry the cursor is +snapped back, and horizontal wheel scrolling "feels stuck after one +notch" — the identical bug, one axis over, on **both** frontends, since +the GPU's `horizontal_follow` (`:7702`) has the same shape. -**B7's stated contract does not mention the cursor at all**, and its -mutations therefore cannot detect this: a clamp row and a wrap row both -pass against a viewport that snaps back on the next caret event. +**And it happens on the next PAINT, not the next caret event.** +`prepare_window_cursor_visible` (`src/editor.rs:4518`) calls +`horizontal_follow` unconditionally as its **first** act (`:4539`), and +`paint_frame` (`:4747`) calls it every frame (`:4852`, and `:2572` for +the panel). Revision 13 said "the next caret event"; that was wrong and +understated the exposure — the origin is overwritten by a redraw with +no input at all. -#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **OPEN** +**B7's stated contract does not mention the cursor**, so its mutations +cannot detect this: a clamp row and a wrap row both pass against a +viewport that is overwritten on the next frame. -Not answerable from the table as written; Q#HS4 was deferred rather -than closed precisely so this would be decided when explicit commands -arrived. Two candidate answers, both defensible: +#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **RULED: (B), viewport only** -- **(A) Carry it, mirroring `scroll_window`.** Consistent with the - vertical axis, and the snap-back cannot arise. Costs a cursor move - the user did not ask for. -- **(B) Do not carry it; suppress the follow while a wheel origin is - live.** Preserves the cursor, but needs a rule for when the origin - stops being authoritative, and that rule is new state on both - frontends. +**(A) is not viable in 1b, and the vertical precedent does not reach +it.** `scroll_window` carries point because it is **TUI-side**, where +the editor owns the cursor directly. The GPU has no such power: +`OwnCursor` (`pmacs-gpu/src/main.rs:2337`) is *"pmacs-gpu's own cursor +position, **mirrored** from `CursorByte`"* — a read-only reflection of +daemon state. The only wire operation that positions it is `Pointer`, +and `dispatch_pointer` (`src/editor.rs:3638`) sets `active_frontend`, +calls `break_command_chain`, and — by its own comment — *"Every +`PointerKind` moves point or changes the selection."* Carrying point +from a wheel would mean **a new wire operation**, which contradicts +1b's non-protocol scope outright. -**Whichever is ruled, B7 needs a witness the current table lacks:** -scroll sideways, then trigger a caret-follow, and assert the origin -behaves as ruled. Without it the row is green either way. +Note also that **GPU vertical scrolling already does not carry point**: +`apply_wheel` ends at `send_viewport` (`:3129`). (A) would therefore not +even be internally consistent — it would make the horizontal axis carry +point on a frontend where the vertical axis does not. + +##### The lifetime contract (B) + +A ruling that only says "do not carry" leaves the origin's lifetime +undefined, which is the part that decides whether the feature works. +**All five clauses are the ruling**, not commentary on it: + +1. **Horizontal wheel changes the VIEWPORT only** — never point, never + selection, on either frontend. +2. **An effective wheel move makes that origin authoritative**, and it + stays authoritative while the cursor position is unchanged. "An + effective move" means one that actually changed the origin; a move + fully absorbed by the clamp arms nothing. +3. **Preserved by** repaint, a same-cursor follow, resize, and vertical + wheel. **Clamped by** geometry and content changes — clamped, not + released: a narrower window must not silently discard the gesture. +4. **A genuine cursor-position change releases it**, and normal follow + resumes on that same event. Release is driven by the cursor + *changing*, never by elapsed time or by the follow running. +5. **Wrap and buffer replacement clear it and pin the origin to zero.** + This is the existing rule (`horizontal_follow`'s wrap branch, and + the GPU's at `:7703`); authority must not survive either. + +##### What B7 and B3 must witness + +Rows against the **real call sites**, on **both** frontends — a unit +test of a helper cannot see a follow that runs inside `paint_frame`: + +- **Preservation:** wheel sideways, then drive a real paint with the + cursor unchanged; the origin holds. +- **Release:** wheel sideways, then a genuine cursor-position change; + the origin follows the cursor on that event. +- **Cross-axis, TUI only:** wheel sideways, then wheel **vertically**. + Vertical wheel carries point in the TUI (`scroll_window`), so this + path *does* move the cursor and would release a naive + authority-on-any-cursor-write implementation. Clause 3 says the + horizontal origin survives it. + +Two mutations, and both must fail their own rows: + +| mutation | must fail | +|---|---| +| follow ignores manual authority (always overwrites) | preservation | +| manual authority never releases | release | + +Clause 5 is already implemented for the caret path; the mutation for it +is the existing wrap-guard removal named in the B7 row. + +### CORRECTION 6 — B3's right bound is vaguer than B7's, for the same bound + +B7 states its upper bound exactly — *"(widest display-line width − text +viewport width), SATURATING AT ZERO"* — and pins that the final display +column stays visible. **B3 says only "content bounds"** and witnesses +just the negative-origin end. That asymmetry is not defensible: it is +the same bound on the same rule, and B7's exactness exists because the +loose version *blanks the viewport*. + +**B3 takes B7's rule verbatim, in the GPU's column-grid units.** The +GPU already reckons in that grid — `horizontal_follow` (`:7702`) +derives `cols = (width / advance).floor()` and `left_col = +(code_scroll_left / advance).round()`, then re-multiplies to snap the +offset back onto the grid, deliberately, so both frontends put the same +first character on screen. The clamp is therefore stated in **columns** +and applied to `code_scroll_left` through the same conversion — not in +pixels, which would break the snap the shared rule depends on. + +B3's rows, matching B7's: + +- **Lower bound:** the origin never goes negative (already named). +- **Upper bound:** clamped at *widest display-line width − text + viewport width*, **saturating at zero** — a buffer narrower than the + viewport clamps to 0, not to a negative. +- **Narrow-buffer row:** every line shorter than the viewport → the + origin stays 0 however far the wheel is pushed. +- **Final-column-visible row:** at the upper bound, the widest line's + **last display column is still on screen**. This is the row that + distinguishes the correct bound from the plausible one. + +**Mutation: clamp at the full content width.** It must fail the +final-column-visible row — that clamp lets the origin advance past +every glyph and leaves the viewport blank, which is exactly the defect +B7's revision found and B3 currently has no row to catch. ## 3. PR topology @@ -446,10 +600,13 @@ The crate has **exactly one** executable `event_loop.exit()`, in ### 1b — pointer and scroll > **Read §2a first.** Every line number in this table was measured -> before 1-pre and is stale. §2a re-measures them at `72da24a` and -> records three rows whose "nothing exists yet" is wrong, plus **Q#S1-11 -> (open)**, which B7 cannot be implemented without. The contracts below -> are unchanged. +> before 1-pre and is stale. §2a re-measures them at `72da24a`, records +> three rows whose "nothing exists yet" is wrong (B3, B4, B5), +> enumerates B1's wheel targets, gives B3 B7's exact upper bound, and +> rules **Q#S1-11 (B, viewport only)** with the lifetime contract and +> witnesses B7 and B3 both depend on. The contracts below are +> unchanged, but **none of B1, B3 or B7 is implementable from this +> table alone.** | # | Contract | Witness | Mutation | |---|---|---|---| @@ -759,11 +916,16 @@ and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e` 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`. -**OPEN: Q#S1-11** — does a horizontal wheel scroll carry the cursor? -Raised by §2a; **blocks B7 and B3 only**, and nothing else in 1b. It is -the question `docs/horizontal-scroll-framing.md` deferred as Q#HS4, -now live because B7 is the explicit horizontal scroll command whose -absence was that deferral's stated premise. +**Q#S1-11 — RULED: (B), viewport only** (§2a). A horizontal wheel +scroll never moves point or selection; the origin it sets is +authoritative under a **five-clause lifetime contract**, and B7 and B3 +carry preservation, release and cross-axis witnesses at the real call +sites. (A) — carrying point, as `scroll_window` does vertically — is +**not viable in 1b**: the GPU cursor is a mirror of daemon state, the +only wire operation that positions it also breaks the command chain and +changes selection, and a new one would break 1b's non-protocol scope. +This settles `docs/horizontal-scroll-framing.md`'s **Q#HS4** for the +wheel case, which was deferred against exactly this arrival. ## 11. Gates From a330658feb11b6f40a9b95ac9a29fd163da8e28d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 09:57:01 +0200 Subject: [PATCH 03/72] docs(framing): Stage 1 revision 15 --- B1 fully ruled, and three defects in 14 Answers review of revision 14. Still framing only; no code. Revision 15 DOES change two B-row contracts, B1 and B3; 14 claimed none changed and that was false. **I CITED A TEST FIXTURE AS A PRODUCTION HANDLER.** Revision 14 named `src/daemon.rs:6683` as the panel's `ScrollUp` arm. It is inside `#[cfg(test)] mod tests`, opened at `:3740`. The wrong citation hid a real defect: the production path, `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`), validates the coord, resolves the side window, focuses when the gesture activates, and returns WITHOUT REPLAYING ANYTHING. Its own doc says replay is parent acceptance 48 and lands in Stage 2B-3. So a panel wheel is dead today on BOTH axes --- a pre-existing violation of an already-ruled contract, which is why panel inertness was never an available answer. Ruled: 1b does NOT absorb the replay. 1b owns the frontend half --- per-panel residual, both axes emitted --- and replay is repaired in a prerequisite lane. It is already scoped to 2B-3, it is not input work (selection, listview rows, child SGR reporting, and it needs the GPU band), and the defect predates this slice. 1b's panel rows therefore witness EMISSION ONLY and must say so rather than implying a scroll the user cannot yet see. **B1 IS NOW RULED, NOT HALF-RULED.** Revision 14 left two cells reading "must be ruled", which is a question wearing a table's clothes. The terminal answer was derivable all along: the SGR encoder already carries `ScrollLeft` as 66 and `ScrollRight` as 67 (`src/terminal/input.rs:126`), and its guard returns `None` unless reporting is on --- so a non-reporting terminal is horizontally inert, matching the TUI, and no new arm is needed. Divider and background now CONSUME both axes, because falling through to the document contradicts `PanelBackground`'s own doc that the band owns the pixel. Chrome shares the document's scrolling and residual, stated so it is a choice rather than a leak. **THE GPU PRESERVATION WITNESS WAS VACUOUS.** "The next paint" is TUI-only. The GPU's `render()` (`:9881`) goes straight to `render_to_view` and never calls `horizontal_follow`; the follow reaches it only via `ensure_caret_painted`, whose callers are cursor paths and geometry paths (`resize`, `apply_font_facts`, `reflow_dynamic_code_geometry`). A GPU wheel-then-paint row would have stayed green with the overwrite mutation restored. Preservation is now specified per frontend: TUI drives a real paint, GPU drives a real same-cursor geometry re-follow. Release must move the cursor OUTSIDE the manual viewport. Inside it `follow_left` returns the same origin, so the row would pass whether or not release happened. **FIVE CLAUSES HAD THREE WITNESS SHAPES; THEY NOW HAVE EIGHT.** 14 left unconstrained: point and selection staying put, clamp-absorbed motion not arming authority, geometry/content changes re-clamping while PRESERVING authority, and wrap/buffer replacement clearing the LATCH rather than merely zeroing the origin. The existing wrap-origin rows cannot see a stale latch surviving wrap then truncate. Six mutations, each failing its own rows. Clause 3's direction was backwards: a WIDER viewport lowers the maximum origin, so widening re-clamps downward. 14 said "narrower". Ledger: three false claims removed. The 1b entry arrived in the SECOND commit, not the first --- recorded rather than quietly fixed, because a lane claiming compliance it did not achieve is what the #171/#215 correction exists to catch. The entry no longer says all six targets have horizontal answers. And the arc block said five slices remain, 1a is next, and v23; it is four, 1b, and v24. Gates: all nine green under `env -u TMPDIR`, log 20260814T075217Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 227 +++++++++++++++++++++++-------- 1 file changed, 170 insertions(+), 57 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index f7424e4..decded3 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,24 +1,46 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 14 — AWAITING APPROVAL.** Revision 14 answers review -of revision 13. **Q#S1-11 is RULED (B), viewport only**, with the full -five-clause lifetime contract and its witnesses; (A) was not viable -because the GPU cursor is a daemon-owned mirror and carrying point -would need a wire operation 1b is not allowed. Two further holes review -named are closed: **B1's "surface" is now enumerated** as a -wheel-target table with a residual owner and a horizontal answer per -target, and **B3 takes B7's exact saturated upper bound** in the GPU's -column grid instead of "content bounds". Revision 13's claim that the -snap-back lands on the next *caret event* is **corrected**: it lands on -the next **paint**. +**Status: revision 15 — AWAITING APPROVAL.** Revision 15 answers review +of 14 and **does change two B-row contracts**, which 14 wrongly denied. + +- **B1 is now fully ruled**, not half-ruled. Revision 14 left two + "must be ruled" cells in the wheel-target table, which is a question + wearing a table's clothes. Every cell is normative now, and the two + gaps are closed from measurement: the **terminal** emits both axes + (the SGR encoder already carries codes 66/67, and a non-reporting + terminal is inert, matching the TUI), and the **panel** emits both + axes while its missing **replay** is repaired in a **prerequisite + lane** rather than absorbed here. Divider, background and chrome are + ruled too. +- **Revision 14 cited a TEST FIXTURE as the panel's production + handler** (`src/daemon.rs:6683`, inside `#[cfg(test)]`). The real + path validates and focuses and **replays nothing** — a pre-existing + violation of an already-ruled contract, which the wrong citation hid. +- **The GPU preservation witness 14 specified was vacuous.** "The next + paint" is TUI-only; the GPU's `render()` never calls + `horizontal_follow`. The lifetime rows are respecified per frontend. +- **Five clauses now have eight witnesses**, not three. 14 left point + and selection, clamp-absorbed motion, re-clamping, and the authority + latch under wrap unconstrained. +- **Clause 3's direction was backwards** in 14: a *wider* viewport + lowers the maximum origin. + +**What is normative here, stated plainly:** §2a **changes B1 and B3**. +B1 gains a per-target enumeration it did not have, and B3 gains B7's +exact upper bound in place of "content bounds". B2, B4, B5, B6 and B7 +are unchanged. + +**Previously, revision 14 — SUPERSEDED.** Ruled Q#S1-11 (B) with the +five-clause lifetime contract, and gave B3 B7's exact saturated bound. +Both stand. **Previously, revision 13 — SUPERSEDED.** §2a's ground-truth re-measurement for Stage 1b at the post-#240 tip. Every 1b anchor was stale, as expected; what was not was that **three B-rows describe a field as empty when it is occupied**, and that **B7 re-opens a question -another framing deliberately deferred**. **No B-row contract is changed -by either revision** — 13 and 14 add ground truth, a ruling, and -witnesses. +another framing deliberately deferred**. Its claim that the snap-back +lands on the next *caret event* was corrected by 14 to the next +**paint**, and by 15 to *per-frontend* drivers. **Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a revision 16.** Revision 13 changes no previously ruled outcome. It @@ -49,9 +71,9 @@ Protocol slices stay serialized; one was inserted in front. the tip after 1-pre; it was originally taken at `a994f37`. **§2a is measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it is the base for **1b only**; it carries Q#S1-11's ruling. Sections -other than §2/§2a were written -against `a994f37` and their *rulings* are unaffected by 1-pre, which -changed no behaviour — but **any line number outside §2 and §2a +other than §2/§2a were written against `a994f37` and their *rulings* +are unaffected by 1-pre, which changed no behaviour — but **any line +number outside §2 and §2a predates 1-pre and should be re-checked before it is relied on.** The 1b table's own anchors are superseded by §2a wholesale; read §2a first. @@ -207,26 +229,84 @@ Note also a live consequence of `:3090` matching `PanelCell(_)` alone: document today**, though the enum's own doc says the band "still owns the pixel." -The enumeration B1 must carry, measured: +The enumeration B1 carries. **Every cell is normative** — "vertical +today" is the measurement, "vertical RULED" is the contract, and where +they differ 1b closes the gap: -| wheel target | classified today | vertical wheel today | residual owner | horizontal | -|---|---|---|---|---| -| Panel cell | `PanelCell` | wire `ScrollUp`/`Down` (`:3102`) | per panel | **must be ruled** — wire has the kinds, daemon has no arm | -| Panel divider / background | `PanelDivider` / `PanelBackground` | **falls through to the document** | — | inert | -| Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | wire terminal `ScrollUp`/`Down` (`:3122`) | per terminal | **must be ruled** — same gap | -| Minimap | `Elsewhere` | document `scroll_by_lines` | **its own** (B6) | inert | -| Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | document | `code_scroll_left` (B3) | -| Chrome | `Elsewhere` | document | none | inert | +| wheel target | classified | vertical today | vertical RULED | residual owner | horizontal RULED | +|---|---|---|---|---|---| +| Panel cell | `PanelCell` | emits (`:3102`), **replay missing** | emit **both axes**; replay is a **prerequisite lane** | per panel | emit `ScrollLeft`/`Right` | +| Panel divider / background | `PanelDivider` / `PanelBackground` | **scrolls the document** | **consume both axes** — the band owns the pixel | per panel | consumed, no emit | +| Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | emits (`:3122`) | emit **both axes** | per terminal | emit `ScrollLeft`/`Right`; **inert when not reporting** | +| Minimap | `Elsewhere` | document `scroll_by_lines` | document viewport, **own residual** (B6) | **its own** | inert | +| Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | unchanged | document | `code_scroll_left` (B3) | +| Chrome | `Elsewhere` | document | **shares the document's**, deliberately | **the document's** | shares the document's | + +**Divider and background consume both axes.** Falling through to the +document contradicts `PanelBackground`'s own doc — *"the band still owns +the pixel"* — and is a measurement, never a decision. Consuming is not +"inert": the residual is the panel's, so a gesture that crosses from +the band's background onto a cell does not jump. + +**Chrome shares the document's scrolling and the document's residual.** +That is today's behaviour, and making it normative is what keeps 1b +from having to change both frontends' hit testing for no user-visible +gain. It is written down so it is a choice rather than a leak. **The horizontal gap is not a wire gap.** `MouseKind::ScrollLeft` and `ScrollRight` already exist (`pmacs-protocol/src/message.rs:245`, `:247`) and round-trip (`src/protocol.rs:720`), so **emitting them -needs no protocol bump** — 1b stays non-protocol-bearing either way. -What is missing is a *handler*: the terminal path has arms for -`ScrollUp`/`ScrollDown` only (`src/editor.rs:3576`, `:3579`), and the -panel path only `ScrollUp` (`src/daemon.rs:6683`). So for each wire -target 1b must choose **emit-and-handle** or **explicitly inert**, and -say which. Silence here is what lets a horizontal tick vanish. +needs no protocol bump** — 1b stays non-protocol-bearing. + +**The terminal answer follows from the encoder.** `sgr_mouse_report` +(`src/terminal/input.rs`) already encodes `ScrollLeft` as **66** and +`ScrollRight` as **67** (`:126`–`:127`), so the terminal handles both +axes the moment they are emitted. Its guard (`:111`) returns `None` +unless `mouse_sgr` is on and tracking is not `Off`, so a +**non-reporting terminal is horizontally inert** — which matches the +TUI, where nothing consumes a horizontal tick either. No new arm is +needed. + +#### The panel replay gap — a pre-existing defect, repaired in a prerequisite lane + +**Revision 14 cited `src/daemon.rs:6683` as the panel's `ScrollUp` +handler. That was wrong: it is a test fixture**, inside `#[cfg(test)] +mod tests` (opened at `:3740`). Citing a fixture as production is +precisely the error B1's enumeration exists to prevent, and it hid a +real defect. + +The production path is `dispatch_semantic_panel_pointer` +(`src/editor.rs:2674`). It validates the coord against the panel grid, +resolves the side window, focuses when the gesture activates — and +**returns `true` without replaying anything.** Its own doc says so: +*"**Replay is out of scope in Stage 2B-2.** Driving selection, listview +rows, or child SGR reporting is **parent acceptance 48**, which needs +the GPU band and lands in **Stage 2B-3**."* + +So **a panel wheel does nothing today, on either axis** — the frontend +emits, the daemon validates and drops. That is a **pre-existing +violation of an already-ruled contract**, uncovered by this +measurement, not created by 1b. + +**Ruling: 1b does not absorb it.** 1b owns the frontend half — per-panel +residual, both axes emitted — and the replay is repaired in a +**prerequisite lane** carrying parent acceptance 48. Three reasons: + +1. **It is already scoped elsewhere.** Stage 2B-3 owns replay by an + existing ruling. Absorbing it would overrule that from an input + slice. +2. **It is not input work.** Replay drives selection, listview rows and + child SGR reporting, and needs the GPU band — none of which is + pointer-and-scroll. +3. **The defect predates 1b** and is not horizontal-specific: vertical + panel scrolling is equally dead today. A fix belongs where the + contract lives, not bolted to the slice that happened to find it. + +**Panel inertness is therefore NOT an option and is not claimed.** The +panel's contract is "emit both axes with its own residual"; what is +deferred is the daemon replaying them, and **1b's panel rows witness +emission only, which they must say explicitly** rather than implying a +scroll the user cannot yet see. **Without this table the rows are satisfiable by an implementation that is wrong**: one global accumulator passes every per-surface row that @@ -336,7 +416,12 @@ undefined, which is the part that decides whether the feature works. fully absorbed by the clamp arms nothing. 3. **Preserved by** repaint, a same-cursor follow, resize, and vertical wheel. **Clamped by** geometry and content changes — clamped, not - released: a narrower window must not silently discard the gesture. + released. **A WIDER viewport lowers the maximum origin** + (`widest − viewport`), so widening re-clamps downward; the gesture is + preserved at the new bound rather than discarded, and authority + survives the clamp. (Revision 14 said "a narrower window", which had + the direction backwards — narrowing *raises* the ceiling and needs no + clamp at all.) 4. **A genuine cursor-position change releases it**, and normal follow resumes on that same event. Release is driven by the cursor *changing*, never by elapsed time or by the follow running. @@ -346,28 +431,55 @@ undefined, which is the part that decides whether the feature works. ##### What B7 and B3 must witness -Rows against the **real call sites**, on **both** frontends — a unit -test of a helper cannot see a follow that runs inside `paint_frame`: +Rows against the **real call sites**. A unit test of a helper cannot see +a follow that runs inside a frame. -- **Preservation:** wheel sideways, then drive a real paint with the - cursor unchanged; the origin holds. -- **Release:** wheel sideways, then a genuine cursor-position change; - the origin follows the cursor on that event. -- **Cross-axis, TUI only:** wheel sideways, then wheel **vertically**. - Vertical wheel carries point in the TUI (`scroll_window`), so this - path *does* move the cursor and would release a naive - authority-on-any-cursor-write implementation. Clause 3 says the - horizontal origin survives it. +**The two frontends need DIFFERENT preservation drivers, and this is +where revision 14 was wrong.** It said "the next paint" without +qualification. **That is TUI-only.** The GPU's `render()` (`:9881`) goes +straight to `render_to_view` and **never calls `horizontal_follow`**; +the follow reaches it only through `ensure_caret_painted` (`:7674`), +whose callers are cursor paths (`:5607`, `:5659`, `:5776`, `:6320`) and +geometry paths — `resize` (`:9851`), `apply_font_facts` (`:9729`), +`reflow_dynamic_code_geometry` (`:9672`). **A GPU wheel-then-paint row +would stay green with the overwrite mutation restored**, which is a +vacuous witness of exactly the kind this framing keeps producing. -Two mutations, and both must fail their own rows: +| # | witness | driver | +|---|---|---| +| L1 | preservation, TUI | wheel sideways → **a real paint** (`paint_frame` → `prepare_window_cursor_visible`) | +| L2 | preservation, GPU | wheel sideways → **a real same-cursor geometry re-follow** (`resize` / `apply_font_facts`), cursor unchanged | +| L3 | release | wheel sideways → a genuine cursor-position change **to a column OUTSIDE the manual viewport** | +| L4 | cross-axis, **TUI only** | wheel sideways → wheel **vertically**; vertical wheel carries point in the TUI (`scroll_window`), so a naive authority-on-any-cursor-write releases here. Clause 3 says the origin survives | +| L5 | point and selection unmoved | wheel sideways on both frontends → point and selection byte-identical (clause 1) | +| L6 | clamp-absorbed motion does not arm | at the bound already, wheel further → origin unchanged **and authority NOT armed**, so the next follow moves normally (clause 2's "effective") | +| L7 | re-clamp preserves authority | wheel sideways → **widen** the viewport → origin re-clamped to the new maximum, authority still held, next same-cursor follow does not overwrite (clause 3) | +| L8 | wrap and buffer replacement clear the LATCH | wheel sideways → toggle to `Wrap` (and separately, replace the buffer) → origin zero **and authority cleared**, verified by a following `truncate` toggle where the caret rule governs again (clause 5) | + +**L3 must move the cursor OUTSIDE the manual viewport.** Inside it, +`follow_left` returns the same origin, so a release row would pass +whether or not release happened — the "authority never releases" +mutation would survive it. + +**L8 is not covered by the existing wrap-origin rows.** Those assert the +origin is zeroed; they cannot see a **stale latch** surviving the wrap, +which surfaces only on the return to `truncate` when the caret rule +should have resumed and does not. + +Mutations, each failing its own rows and no others: | mutation | must fail | |---|---| -| follow ignores manual authority (always overwrites) | preservation | -| manual authority never releases | release | +| follow ignores manual authority (always overwrites) | L1, L2 | +| manual authority never releases | L3 | +| authority armed by *any* wheel event, effective or not | L6 | +| re-clamp releases authority instead of preserving it | L7 | +| wrap/replacement zeroes the origin but leaves the latch set | L8 | +| the wheel path writes point or selection | L5 | -Clause 5 is already implemented for the caret path; the mutation for it -is the existing wrap-guard removal named in the B7 row. +Clause 5's *origin* half is already implemented for the caret path; the +existing wrap-guard removal named in the B7 row remains its mutation. +**L8 covers the half that is new — the latch.** ### CORRECTION 6 — B3's right bound is vaguer than B7's, for the same bound @@ -599,14 +711,15 @@ The crate has **exactly one** executable `event_loop.exit()`, in ### 1b — pointer and scroll -> **Read §2a first.** Every line number in this table was measured -> before 1-pre and is stale. §2a re-measures them at `72da24a`, records -> three rows whose "nothing exists yet" is wrong (B3, B4, B5), -> enumerates B1's wheel targets, gives B3 B7's exact upper bound, and -> rules **Q#S1-11 (B, viewport only)** with the lifetime contract and -> witnesses B7 and B3 both depend on. The contracts below are -> unchanged, but **none of B1, B3 or B7 is implementable from this -> table alone.** +> **Read §2a first, and note that it CHANGES two of these rows.** Every +> line number below was measured before 1-pre and is stale. §2a +> re-measures them at `72da24a`; records three rows whose "nothing +> exists yet" is wrong (B3, B4, B5); **replaces B1's undefined +> "surface" with a normative six-target enumeration**; **replaces B3's +> "content bounds" with B7's exact saturated upper bound**; and rules +> **Q#S1-11 (B, viewport only)** with a five-clause lifetime contract +> and eight witnesses. B2, B4, B5, B6 and B7 are unchanged. **None of +> B1, B3 or B7 is implementable from this table alone.** | # | Contract | Witness | Mutation | |---|---|---|---| From cf63f63a07dbaf92e6e419e093b5c8c717f5a9e2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 10:30:13 +0200 Subject: [PATCH 04/72] docs(framing): Stage 1 revision 16 --- the replay lane becomes a hard prerequisite Answers review of revision 15. Still framing only; no code. Three of the four changes correct 15's own reasoning. **THE PANEL PREREQUISITE IS NOW IN THE TOPOLOGY, NOT A FOOTNOTE.** The defect is exactly "frontend emits, receiver discards", so 15's emission-only panel witnesses would have reproduced the blind spot that let it sit undetected --- passing rows meaning nothing about whether a panel wheel scrolls. SS3 now reads `1a` -> panel-replay lane -> `1b`: the lane MERGES FIRST, 1b REBASES onto its merge commit (its base moves off `72da24a`), and 1b carries an END-TO-END panel-wheel EFFECT witness. 15's rationale "it is not input work" was wrong --- replay covers terminal mouse reporting and click-to-focus, which is input work. The accurate reason is breadth and ownership: acceptance 48 (`docs/bottom-panel-framing.md:1719`) has `PanelPointer` driving listview row selection, panel selection, terminal mouse reporting and click-to-focus. A wheel is one gesture in that set. **DIVIDER AND BACKGROUND MUST BANK NOTHING.** 15 gave them the panel cell's residual, which manufactures the very surface-switch jump B1 forbids: motion banked over an inert target, then spent the instant the pointer enters a cell, so a gesture that scrolled nothing completes a tick on arrival. They discard and reset both axes; any state kept there must be incapable of combining with cell input. A crossing witness pins it --- background partial plus cell partial must not reach a tick --- and its mutation is precisely 15's error. **THE DISCRIMINATING SETUP BELONGS TO EVERY LIFETIME ROW.** 15 stated it for L3 alone. With the cursor inside the manually scrolled viewport, `follow_left` returns that same origin, so held authority and released authority produce IDENTICAL state and the row passes either way. L1, L2, L4, L6, L7 and L8 now share L3's cursor-outside setup. L2 becomes a HEIGHT-ONLY GPU resize, so it invokes the real follow without moving the clamp. L7 splits: 7a widens the viewport, 7b SHRINKS THE CONTENT --- clause 3 promises re-clamping on geometry AND content, and widening witnesses only the geometry half. **THE MUTATION TABLE NOW OBEYS SS6.** 15's heading promised "own rows and no others"; SS6 of this same document says that promise is false and cannot be made true, and requires instead that a mutation bite its named rows with dependents NAMED. Rewritten that way, with the legitimate dependents listed per mutation. Ledger: checkpoint was still "revision 14", and described the snap-back as next-paint without the TUI/GPU split that 15 established. Both corrected, and the prerequisite recorded as a blocker with the rebase. Also records CI-red signature **U10**. The first gate run of this tree red on `composition_overhead_under_ten_percent` in `03-lib` at 1.343x, and the SAME RUN's sweep passed it, on a documentation-only diff. Worth a row beyond the usual: U9 asked whether sweep test-binary concurrency was the discriminator, and this occurrence runs that comparison in the opposite direction and gets the opposite result --- red at low concurrency, green at high. That retires the candidate without supplying a cause; two occurrences pointing opposite ways across one variable means the variable is not the discriminator, nothing more. U9's synthetic-load control is still the useful experiment and is still unrun. Gates: all nine green under `env -u TMPDIR`, log 20260814T082533Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 181 ++++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 40 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index decded3..fb3d293 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,7 +1,32 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 15 — AWAITING APPROVAL.** Revision 15 answers review -of 14 and **does change two B-row contracts**, which 14 wrongly denied. +**Status: revision 16 — AWAITING APPROVAL.** Revision 16 answers review +of 15. Four changes, three of them corrections to 15's own reasoning: + +- **The panel-replay lane becomes a HARD PREREQUISITE in §3's + topology**, not a note. The defect is "frontend emits, receiver + discards", so 15's emission-only witnesses would have reproduced the + blind spot that hid it. The lane merges, **1b rebases onto it**, and + 1b carries an **end-to-end panel-wheel effect** row. 15's rationale + *"it is not input work"* was wrong — replay includes terminal mouse + reporting and click-to-focus. The real reason is **breadth and + ownership**: acceptance 48 (`docs/bottom-panel-framing.md:1719`). +- **Divider and background must BANK NOTHING.** 15 gave them the panel + cell's residual, which manufactures the surface-switch jump B1 + forbids — motion banked over an inert target, spent on entering a + cell. They discard both axes, and a **crossing witness** pins it. +- **The discriminating setup applies to every lifetime row**, not just + L3. With the cursor inside the manual viewport, held and released + authority produce the **same origin**, so the rows pass either way. + **L2 becomes a height-only GPU resize**, and **L7 gains a + content-shrink leg** for the half viewport-widening cannot witness. +- **The mutation table follows §6's dependency-aware rule.** 15's + heading promised "own rows and no others", which this document + already says is false and unachievable; mutations now name the rows + they must **bite** and the **legitimate dependents**. + +**Previously, revision 15 — SUPERSEDED.** Answered review of 14 and +**changed two B-row contracts**, which 14 wrongly denied. - **B1 is now fully ruled**, not half-ruled. Revision 14 left two "must be ruled" cells in the wheel-target table, which is a question @@ -236,17 +261,31 @@ they differ 1b closes the gap: | wheel target | classified | vertical today | vertical RULED | residual owner | horizontal RULED | |---|---|---|---|---|---| | Panel cell | `PanelCell` | emits (`:3102`), **replay missing** | emit **both axes**; replay is a **prerequisite lane** | per panel | emit `ScrollLeft`/`Right` | -| Panel divider / background | `PanelDivider` / `PanelBackground` | **scrolls the document** | **consume both axes** — the band owns the pixel | per panel | consumed, no emit | +| Panel divider / background | `PanelDivider` / `PanelBackground` | **scrolls the document** | **consume both axes** — the band owns the pixel | **none — discards** | consumed, no emit | | Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | emits (`:3122`) | emit **both axes** | per terminal | emit `ScrollLeft`/`Right`; **inert when not reporting** | | Minimap | `Elsewhere` | document `scroll_by_lines` | document viewport, **own residual** (B6) | **its own** | inert | | Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | unchanged | document | `code_scroll_left` (B3) | | Chrome | `Elsewhere` | document | **shares the document's**, deliberately | **the document's** | shares the document's | -**Divider and background consume both axes.** Falling through to the -document contradicts `PanelBackground`'s own doc — *"the band still owns -the pixel"* — and is a measurement, never a decision. Consuming is not -"inert": the residual is the panel's, so a gesture that crosses from -the band's background onto a cell does not jump. +**Divider and background consume both axes, and BANK NOTHING.** Falling +through to the document contradicts `PanelBackground`'s own doc — +*"the band still owns the pixel"* — and is a measurement, never a +decision. + +**Revision 15 gave them the panel cell's residual. That was a defect, +and it is the very jump B1 forbids.** Motion over an inert target would +be banked and then spent the instant the pointer entered a cell, so a +gesture that scrolled nothing could complete a tick on arrival — a +surface-switch jump manufactured by the accumulator itself. Consumption +therefore **discards and resets both axes**; if an implementation keeps +state here at all, it must be state that **can never combine with cell +input**. + +**Crossing witness (required):** a partial motion over the background, +then a partial motion over a cell, **must not reach a tick** — neither +sub-tick alone is enough, and the first must not be available to +complete the second. The mutation is exactly revision 15's error: +share the cell residual with the background, and this row jumps. **Chrome shares the document's scrolling and the document's residual.** That is today's behaviour, and making it normative is what keeps 1b @@ -288,25 +327,47 @@ emits, the daemon validates and drops. That is a **pre-existing violation of an already-ruled contract**, uncovered by this measurement, not created by 1b. -**Ruling: 1b does not absorb it.** 1b owns the frontend half — per-panel -residual, both axes emitted — and the replay is repaired in a -**prerequisite lane** carrying parent acceptance 48. Three reasons: +**Ruling: 1b does not absorb it, and 1b does not ship before it.** The +replay is repaired in a **prerequisite lane** carrying parent +acceptance 48, and **1b depends on that lane** — see the ordering +below. Two reasons, and the one revision 15 gave was wrong: -1. **It is already scoped elsewhere.** Stage 2B-3 owns replay by an - existing ruling. Absorbing it would overrule that from an input - slice. -2. **It is not input work.** Replay drives selection, listview rows and - child SGR reporting, and needs the GPU band — none of which is - pointer-and-scroll. -3. **The defect predates 1b** and is not horizontal-specific: vertical +1. **Replay is broader than 1b, and already owned.** Acceptance 48 + (`docs/bottom-panel-framing.md:1719`) has `PanelPointer` driving + *"listview row selection, panel selection, terminal mouse reporting, + and click-to-focus without disturbing the document mirror"*, plus + activation ordering and coalescing rules. A wheel is one gesture in + that set. Implementing it alone from an input slice would deliver a + fragment of an acceptance criterion owned elsewhere. +2. **The defect predates 1b** and is not horizontal-specific: vertical panel scrolling is equally dead today. A fix belongs where the contract lives, not bolted to the slice that happened to find it. +**Revision 15 said "it is not input work". That was wrong** — replay +includes terminal mouse reporting and click-to-focus, which is exactly +input work. The reason is ownership and breadth, not category. + +##### Ordering — and why emission-only witnesses are not enough + +**The defect is precisely "the frontend emits and the receiver +discards".** An emission-only witness asserts the half that already +works and cannot see the half that does not — *it reproduces the blind +spot that let this sit undetected.* Passing panel rows would again mean +nothing about whether a panel wheel scrolls. + +So the dependency is **hard, and ordered**: + +1. **The replay lane merges first.** +2. **1b rebases onto it**, and its base moves from `72da24a` to that + merge commit. §2a's other measurements are unaffected — the replay + lane touches the daemon side. +3. **1b carries an END-TO-END panel-wheel EFFECT witness**: a wheel over + a panel cell moves that panel's viewport. Not "a `PanelPointer` was + emitted" — the observable effect. + **Panel inertness is therefore NOT an option and is not claimed.** The -panel's contract is "emit both axes with its own residual"; what is -deferred is the daemon replaying them, and **1b's panel rows witness -emission only, which they must say explicitly** rather than implying a -scroll the user cannot yet see. +panel's contract is "emit both axes with its own residual, and the +panel scrolls." **Without this table the rows are satisfiable by an implementation that is wrong**: one global accumulator passes every per-surface row that @@ -445,37 +506,62 @@ geometry paths — `resize` (`:9851`), `apply_font_facts` (`:9729`), would stay green with the overwrite mutation restored**, which is a vacuous witness of exactly the kind this framing keeps producing. -| # | witness | driver | +##### The discriminating setup, required by every row + +**Revision 15 stated this for L3 alone. It is required by all of +them.** Every row below asserts "the origin is X"; if the cursor sits +*inside* the manually scrolled viewport, `follow_left` returns that +same origin, so **held authority and released authority produce +identical state** and the row passes either way. Visible state +coincides; the assertion proves nothing. + +**Setup for L1, L2, L4, L6, L7 and L8, not only L3: the cursor is at a +column OUTSIDE the manual viewport**, so the two outcomes are +distinguishable — authority held keeps the wheel origin, authority +released snaps to the caret's. + +| # | witness | driver (cursor outside the manual viewport throughout) | |---|---|---| | L1 | preservation, TUI | wheel sideways → **a real paint** (`paint_frame` → `prepare_window_cursor_visible`) | -| L2 | preservation, GPU | wheel sideways → **a real same-cursor geometry re-follow** (`resize` / `apply_font_facts`), cursor unchanged | -| L3 | release | wheel sideways → a genuine cursor-position change **to a column OUTSIDE the manual viewport** | +| L2 | preservation, GPU | wheel sideways → **a HEIGHT-ONLY `resize`** — real follow, horizontal geometry unchanged | +| L3 | release | wheel sideways → a genuine cursor-position change, landing outside the manual viewport | | L4 | cross-axis, **TUI only** | wheel sideways → wheel **vertically**; vertical wheel carries point in the TUI (`scroll_window`), so a naive authority-on-any-cursor-write releases here. Clause 3 says the origin survives | | L5 | point and selection unmoved | wheel sideways on both frontends → point and selection byte-identical (clause 1) | | L6 | clamp-absorbed motion does not arm | at the bound already, wheel further → origin unchanged **and authority NOT armed**, so the next follow moves normally (clause 2's "effective") | -| L7 | re-clamp preserves authority | wheel sideways → **widen** the viewport → origin re-clamped to the new maximum, authority still held, next same-cursor follow does not overwrite (clause 3) | +| L7a | re-clamp on **viewport widening** | wheel sideways → **widen** the viewport → origin re-clamped to the new maximum, authority still held (clause 3) | +| L7b | re-clamp on **content shrink** | wheel sideways → **shorten the widest line** so the maximum falls → origin re-clamped, authority still held | | L8 | wrap and buffer replacement clear the LATCH | wheel sideways → toggle to `Wrap` (and separately, replace the buffer) → origin zero **and authority cleared**, verified by a following `truncate` toggle where the caret rule governs again (clause 5) | -**L3 must move the cursor OUTSIDE the manual viewport.** Inside it, -`follow_left` returns the same origin, so a release row would pass -whether or not release happened — the "authority never releases" -mutation would survive it. +**L2 is height-only for the same reason L3 leaves the viewport.** A +resize that changes width also changes the clamp, so the origin could +move for a reason unrelated to authority and the row would not +discriminate. Height-only invokes the real follow path +(`resize` → `ensure_caret_painted` → `horizontal_follow`) while leaving +horizontal geometry fixed. + +**L7 needs both legs.** Clause 3 promises re-clamping on *geometry and +content* changes; widening the viewport witnesses only the geometry +half. **L7b shrinks the content** — the maximum is +`widest − viewport`, so a shortened widest line lowers it with the +viewport untouched. **L8 is not covered by the existing wrap-origin rows.** Those assert the origin is zeroed; they cannot see a **stale latch** surviving the wrap, which surfaces only on the return to `truncate` when the caret rule should have resumed and does not. -Mutations, each failing its own rows and no others: +Mutations. Per §6's dependency-aware rule, each must **bite its named +rows**; where one necessarily breaks dependents, the dependency is +named rather than treated as a failure of the mutation. -| mutation | must fail | -|---|---| -| follow ignores manual authority (always overwrites) | L1, L2 | -| manual authority never releases | L3 | -| authority armed by *any* wheel event, effective or not | L6 | -| re-clamp releases authority instead of preserving it | L7 | -| wrap/replacement zeroes the origin but leaves the latch set | L8 | -| the wheel path writes point or selection | L5 | +| mutation | must bite | legitimate dependents | +|---|---|---| +| follow ignores manual authority (always overwrites) | L1, L2 | L4, L7a, L7b — all assert a preserved origin, which cannot survive an unconditional overwrite | +| manual authority never releases | L3 | L8's `truncate` leg, which checks the caret rule resumed | +| authority armed by *any* wheel event, effective or not | L6 | none | +| re-clamp releases authority instead of preserving it | L7a, L7b | none | +| wrap/replacement zeroes the origin but leaves the latch set | L8 | none | +| the wheel path writes point or selection | L5 | L4 in the TUI, where a spurious point write is what clause 3 must survive | Clause 5's *origin* half is already implemented for the caret path; the existing wrap-guard removal named in the B7 row remains its mutation. @@ -518,11 +604,26 @@ B7's revision found and B3 currently has no row to catch. ## 3. PR topology -`1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\* (\* `--protocol`) +`1-pre` → `1a`\* → **panel-replay lane** → `1b` → `1c` → `1d` → `1e`\* + (\* `--protocol`) **1c is NOT protocol-bearing** under Q#S1-8's ruling. Protocol slices are serialized. +**The panel-replay lane is a HARD PREREQUISITE of 1b**, not a parallel +track. §2a found that `dispatch_semantic_panel_pointer` validates and +focuses but **replays nothing**, so a panel wheel is dead on both axes +today — a pre-existing gap in **parent acceptance 48** +(`docs/bottom-panel-framing.md:1719`), which owns it. 1b's B1 makes the +panel a first-class wheel target with its own residual, and **the +defect is exactly "frontend emits, receiver discards"**, so a 1b that +witnessed emission alone would repeat the blind spot that hid it. + +**Order: the replay lane merges, 1b rebases onto that merge commit, +then 1b lands an end-to-end panel-wheel EFFECT witness.** 1b's base +moves off `72da24a` accordingly; the rest of §2a is measured on the GPU +and TUI sides and is unaffected. + ## 4. Q#S1-8 — RULED: (A), preserve pre-window readiness `AttachRequest.initial_size` for a semantic session is a **named, From 836f41731362bb47b7de583916370a481634e371 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 10:48:25 +0200 Subject: [PATCH 05/72] docs(framing): Stage 1 revision 17 --- B1's other two separations, and a false concurrency claim withdrawn Answers review of revision 16. Still framing only; no code. **"PER AXIS AND SURFACE" HAS TWO NOUNS AND 16 WITNESSED ONE.** The background-to-cell crossing row proves a CATEGORY change and nothing else, so an implementation could still share a residual between the two axes, or between two panels, or between two terminals, and pass every row written. Three discriminators added: R1 cross-axis --- a sub-tick horizontal then a sub-tick vertical over ONE surface must reach no tick on either axis. Mutation: one residual per surface rather than per (surface,axis). R2 panel A -> panel B replacement; motion over B starts from zero. Mutation: key the residual by "the panel", not panel identity. R3 terminal A -> terminal B, likewise. R2 and R3 also pin discard-on-disappearance: a residual banked against a surface that no longer exists must not be inherited by its successor. Acceptance 49's epoch machinery is the existing precedent. The end-to-end panel effect witness now runs BOTH AXES. "A wheel moves the viewport" leaves the horizontal half untested exactly where the emit-and-discard defect lived --- the axis with no handler at all. **TWO MUTATION DEPENDENCIES WERE PREDICTIONS, NOT DERIVATIONS.** Suppressing cursor-change release need not fail L8: clause 5 gives wrap and buffer replacement their own explicit clear, which does not run through the release path, so the latch is cleared and L8 passes. Making the horizontal wheel write point need not fail L4: L4 asserts the horizontal origin survives a VERTICAL wheel, and a spurious write on the horizontal path does not change what the vertical path does. Scoped to L3 and L5. Additional bites are now recorded only after a mutation run shows them, with the run named. **AND U10's CONCURRENCY CLAIM WAS FALSE.** I wrote that U10 ran U9's comparison "in the opposite direction" --- red at low concurrency, green at high. It did not. Cargo runs test binaries SEQUENTIALLY: the sweep log shows strict `Running` -> `test result` -> `Running` alternation across 119 binaries, the failing test is in the FIRST one, and it is the same 1,940-test libtest executable `03-lib` ran with the same default intra-binary threading. There was no concurrency difference between the two steps to compare, so no experiment reversed anything. U9's candidate is withdrawn on that ground --- its premise was false, which is a stronger and cheaper retirement than an experimental one, and it never needed a run. What U10 establishes is what it always established: another intermittent occurrence with an in-run control, on a documentation-only diff. Synthetic load against the `--lib` binary alone remains the useful experiment and remains unrun. Both rows corrected in place, because the wrong version is what a later reader would otherwise reconstruct from the two step names. Gates: all nine green under `env -u TMPDIR`, log 20260814T084344Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 107 +++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index fb3d293..02a88e6 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,7 +1,31 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 16 — AWAITING APPROVAL.** Revision 16 answers review -of 15. Four changes, three of them corrections to 15's own reasoning: +**Status: revision 17 — AWAITING APPROVAL.** Revision 17 answers review +of 16. Two additions and one correction: + +- **B1 gains the two separations its crossing witness could not + reach.** "Per axis and surface" has two nouns; 16 witnessed one + category crossing and left both of the others open. **R1** pins the + **axes** apart (a sub-tick horizontal then a sub-tick vertical over + one surface must reach no tick), and **R2/R3** pin **identity** + apart — panel A→panel B and terminal A→terminal B, so "per surface" + cannot degrade to "per surface *kind*". Each has a mutation that + shares the store in question. The end-to-end panel row now runs + **both axes**, the horizontal leg being the one with no handler at + all. +- **Two mutation dependencies were assumptions and are removed.** + "Never releases" need not bite L8 — clause 5's wrap/replacement clear + is a separate path — and "writes point" need not bite L4, which + concerns the vertical path. Scoped to L3 and L5. Dependents are + recorded **only after a mutation run shows them**. +- **U10's concurrency claim was false and is withdrawn**, along with + U9's candidate that prompted it. Detail in + `docs/ci-red-signatures.md`; the short form is that cargo runs test + binaries **sequentially**, so the two steps never differed in + concurrency and no experiment reversed anything. + +**Previously, revision 16 — SUPERSEDED.** Four changes, three of them +corrections to 15's own reasoning: - **The panel-replay lane becomes a HARD PREREQUISITE in §3's topology**, not a note. The defect is "frontend emits, receiver @@ -287,6 +311,43 @@ sub-tick alone is enough, and the first must not be available to complete the second. The mutation is exactly revision 15's error: share the cell residual with the background, and this row jumps. +##### "Per axis and surface" needs three separations, not one + +The crossing witness above proves **one** of them — a category change, +background to cell. **Two more are unwitnessed by it, and an +implementation can satisfy every row written so far while failing +both.** B1's phrase has two nouns in it, and each needs its own +discriminator: + +**Axis.** A single accumulator per surface, fed by both axes, passes +every row above: each one moves in one axis at a time. **R1 — +cross-axis:** a sub-tick **horizontal** motion followed by a sub-tick +**vertical** motion over the *same* surface must reach **no tick on +either axis**. *Mutation: one residual per surface instead of one per +(surface, axis)* — the two half-ticks combine and R1 sees a tick. + +**Identity, not just kind.** "Per surface" that means "per surface +*kind*" leaks between two panels or two terminals, which is the same +defect as leaking between kinds but invisible to a kind-level row. + +- **R2 — panel A→panel B:** bank a sub-tick over panel A, replace it + with panel B (the `buffer_id`/`panel_epoch` replacement acceptance 49 + already models), motion over B must **start from zero**. *Mutation: + key the residual by "the panel" rather than by panel identity.* +- **R3 — terminal A→terminal B:** the same, across two terminal + buffers. *Mutation: key the residual by "the terminal".* + +R2 and R3 also pin the **discard on disappearance**: a residual banked +against a surface that no longer exists must not be inherited by its +successor. That is the identity question and the staleness question at +once, and acceptance 49's epoch machinery is the existing precedent for +answering it. + +**The end-to-end panel row exercises BOTH axes.** "A wheel moves the +panel viewport" leaves the horizontal half untested precisely where +the emit-and-discard defect lived — the axis with no handler at all. +Vertical and horizontal are separate legs. + **Chrome shares the document's scrolling and the document's residual.** That is today's behaviour, and making it normative is what keeps 1b from having to change both frontends' hit testing for no user-visible @@ -361,9 +422,12 @@ So the dependency is **hard, and ordered**: 2. **1b rebases onto it**, and its base moves from `72da24a` to that merge commit. §2a's other measurements are unaffected — the replay lane touches the daemon side. -3. **1b carries an END-TO-END panel-wheel EFFECT witness**: a wheel over - a panel cell moves that panel's viewport. Not "a `PanelPointer` was - emitted" — the observable effect. +3. **1b carries an END-TO-END panel-wheel EFFECT witness, on BOTH + axes**: a wheel over a panel cell moves that panel's viewport + vertically, and a horizontal wheel moves it horizontally. Not "a + `PanelPointer` was emitted" — the observable effect. The horizontal + leg is not optional garnish: it is the axis with **no handler at + all**, which is exactly where the emit-and-discard defect lived. **Panel inertness is therefore NOT an option and is not claimed.** The panel's contract is "emit both axes with its own residual, and the @@ -551,17 +615,37 @@ which surfaces only on the return to `truncate` when the caret rule should have resumed and does not. Mutations. Per §6's dependency-aware rule, each must **bite its named -rows**; where one necessarily breaks dependents, the dependency is +rows**; where one *necessarily* breaks dependents, the dependency is named rather than treated as a failure of the mutation. -| mutation | must bite | legitimate dependents | +**A dependency belongs in the third column only when it is +UNAVOIDABLE**, and revision 16 put two there that are not. Predicting +collateral damage is not the same as deriving it, and a predicted bite +that does not occur reads afterwards as a witness that failed to fire. + +| mutation | must bite | necessary dependents | |---|---|---| -| follow ignores manual authority (always overwrites) | L1, L2 | L4, L7a, L7b — all assert a preserved origin, which cannot survive an unconditional overwrite | -| manual authority never releases | L3 | L8's `truncate` leg, which checks the caret rule resumed | +| follow ignores manual authority (always overwrites) | L1, L2 | L4, L7a, L7b — each asserts a preserved origin, which cannot survive an unconditional overwrite | +| manual authority never releases | **L3 only** | none | | authority armed by *any* wheel event, effective or not | L6 | none | | re-clamp releases authority instead of preserving it | L7a, L7b | none | | wrap/replacement zeroes the origin but leaves the latch set | L8 | none | -| the wheel path writes point or selection | L5 | L4 in the TUI, where a spurious point write is what clause 3 must survive | +| the wheel path writes point or selection | **L5 only** | none | + +**The two removed dependencies were assumptions, not derivations.** + +- *"Never releases" → L8.* Clause 5 gives wrap and buffer replacement + their **own explicit clear**, which does not run through the + cursor-change release path. An implementation with release suppressed + and clause 5 intact clears the latch on wrap and L8 passes. **Scoped + to L3.** +- *"Writes point" → L4.* L4 asserts the horizontal origin survives a + vertical wheel. A horizontal wheel that spuriously writes point does + not change what the *vertical* path does, so L4 can pass while L5 + fails. **Scoped to L5.** + +**Additional bites are recorded only after a mutation run shows +them** — observed, with the run named, never predicted in advance. Clause 5's *origin* half is already implemented for the caret path; the existing wrap-guard removal named in the B7 row remains its mutation. @@ -620,7 +704,8 @@ defect is exactly "frontend emits, receiver discards"**, so a 1b that witnessed emission alone would repeat the blind spot that hid it. **Order: the replay lane merges, 1b rebases onto that merge commit, -then 1b lands an end-to-end panel-wheel EFFECT witness.** 1b's base +then 1b lands an end-to-end panel-wheel EFFECT witness on both axes.** +1b's base moves off `72da24a` accordingly; the rest of §2a is measured on the GPU and TUI sides and is unaffected. From fff1779677692f5068665712a2a255031001dc9e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 13:11:56 +0200 Subject: [PATCH 06/72] docs(framing): Stage 1 revision 18 --- residuals that outlive the buffer, and two overclaims narrowed Answers review of revision 17. Still framing only; no code. **IDENTITY HAS A SECOND HALF, AND R2/R3 CANNOT REACH IT.** A panel or terminal residual is keyed to a surface that GOES AWAY, so replacement is naturally observable. The document's and the minimap's do not work that way: they live in the long-lived GPU `State` and OUTLIVE the buffer. Their reset is a manual list --- buffer replacement (`pmacs-gpu/src/main.rs:5922` onward) zeroes `scroll_top`, `code_scroll_residual` and `code_scroll_left` one explicit line at a time, each with its own comment. A new wheel residual will not reset itself, and nothing fails if it is forgotten except a user's next document scrolling from someone else's offset. R4 document A -> document B, covering the document residual AND the chrome residual it shares; successor starts from zero. Mutation: omit the document residual from the replacement reset. R5 minimap across A -> B; its residual is independent (B6) so it needs its own reset and its own leg. Mutation: omit the minimap residual from the replacement reset. The mutations are deliberately separate --- one reset omitted at a time --- because a single "forgot to reset" mutation would bite both legs and prove neither field individually covered. `code_scroll_left` had precisely this defect once, which is why it now carries a comment explaining that a switch would otherwise inherit the previous document's leftward viewport. **AND TWO OVERCLAIMS IN THE SIGNATURE ROWS.** I wrote that both steps ran "the same executable". They ran the same TARGET but different ARTIFACTS: `pmacs-9d4963151c6f3151` at 211,777,472 bytes and `pmacs-7624ed4bed0b6617` at 214,462,056, and `cmp` reports differing bytes. What is supported is the same libtest target, its 1,940-test population, and default intra-binary threading --- which is all the concurrency conclusion ever needed, so that conclusion is unchanged. "Cargo runs test binaries sequentially" was also stated as a universal. It is narrowed to what was observed: under cargo 1.95.0, this sweep ran its 119 binaries sequentially. The rows no longer generalize to cargo versions or invocations they did not measure. Gates: all nine green under `env -u TMPDIR`, log 20260814T110707Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 02a88e6..fa63dba 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,7 +1,27 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 17 — AWAITING APPROVAL.** Revision 17 answers review -of 16. Two additions and one correction: +**Status: revision 18 — AWAITING APPROVAL.** Revision 18 answers review +of 17: + +- **Identity has a second half that R2/R3 cannot reach.** A panel or + terminal residual is keyed to a surface that *goes away*; the + **document's and the minimap's live in the long-lived GPU `State` + and outlive the buffer**. Their reset is a **manual list** — + `pmacs-gpu/src/main.rs:5922` zeroes `scroll_top`, + `code_scroll_residual` and `code_scroll_left` one line at a time — so + a new wheel residual will not reset itself. **R4** (document plus the + chrome residual it shares) and **R5** (the minimap's independent + residual) add buffer A→B legs, each with its own mutation omitting + its own reset. `code_scroll_left` had this exact defect once already. +- **U9/U10 said "the same executable"; the two artifacts differ.** + `pmacs-9d4963…` and `pmacs-7624…`, different sizes, differing bytes. + The supported claim is the same *target*, 1,940-test population and + default intra-binary threading. **"Cargo runs test binaries + sequentially" is narrowed to the observed cargo 1.95.0 sweep** rather + than asserted universally. The concurrency conclusion is unaffected. + +**Previously, revision 17 — SUPERSEDED.** Two additions and one +correction: - **B1 gains the two separations its crossing witness could not reach.** "Per axis and surface" has two nouns; 16 witnessed one @@ -337,6 +357,35 @@ defect as leaking between kinds but invisible to a kind-level row. - **R3 — terminal A→terminal B:** the same, across two terminal buffers. *Mutation: key the residual by "the terminal".* +**R2 and R3 are not the whole of identity, because the document and the +minimap do not get replaced — their state OUTLIVES the buffer.** A +panel or terminal residual can be keyed to a surface that goes away; the +document's and the minimap's live in the long-lived GPU `State`, which +survives a buffer switch. **Their reset is manual, and there is a list +to be forgotten from.** Buffer replacement (`pmacs-gpu/src/main.rs:5922` +onward) zeroes `scroll_top`, `code_scroll_residual` and +`code_scroll_left` **one explicit line at a time**, each with its own +comment — including `code_scroll_left`, added later precisely because a +switch *"inherits the PREVIOUS document's leftward viewport and renders +the new buffer scrolled sideways until a cursor motion repairs it — a +symptom nothing about the new buffer explains."* **A new wheel residual +will not reset itself.** Two more legs: + +- **R4 — document A→document B**, covering the document residual **and + the chrome residual it shares** (chrome's owner is the document's, so + one reset must serve both): bank a sub-tick over the document, replace + the buffer, motion over B **starts from zero**. *Mutation: omit the + document residual from the replacement reset.* +- **R5 — minimap across A→B:** the minimap's residual is **independent** + (B6), so it needs its own reset and its own leg. *Mutation: omit the + minimap residual from the replacement reset.* + +Two mutations, deliberately separate: **one reset omitted at a time**, +because a single "forgot to reset" mutation would bite both legs and +prove neither field is individually covered. This is the same defect +`code_scroll_left` already had once, and the reason it now has a +comment explaining itself. + R2 and R3 also pin the **discard on disappearance**: a residual banked against a surface that no longer exists must not be inherited by its successor. That is the identity question and the staleness question at From 6882ec5e96bec3c39c6212206c734e6cd20bfcd6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 13:20:14 +0200 Subject: [PATCH 07/72] docs(framing): mark Stage 1 revision 18 approved Records the ruling. SS2a is the 1b ground truth and its contents are settled: Q#S1-11 (B), the five-clause lifetime contract with L1-L8, B1's six-target enumeration with R1-R5, and B3's exact saturated bound. Also records what the six rounds cost, because the pattern is the point: 13 through 18 each found a witness that would have PASSED against a broken implementation --- emission-only panel rows against a receiver that discards, a GPU preservation row driven by a paint that never calls the follow, lifetime rows whose setup left held and released authority indistinguishable, mutation dependents predicted rather than derived, and a residual-identity story that stopped at the surfaces which get destroyed and missed the two that outlive the buffer. None was a design disagreement; the rulings from 14 and 15 survived untouched. That is why the R- and L-rows specify their DRIVERS and SETUPS and not only their assertions. The framing no longer gates this lane. The panel-replay prerequisite does: it merges first, 1b rebases onto that merge commit, and 1b's base moves off `72da24a`. Gates: all nine green under `env -u TMPDIR`, log 20260814T111537Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gui-stage1-input-framing.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index fa63dba..d8e31c7 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,7 +1,10 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 18 — AWAITING APPROVAL.** Revision 18 answers review -of 17: +**Status: revision 18 — APPROVED (2026-08-14).** §2a is the 1b ground +truth and its rulings are settled: **Q#S1-11 (B)**, the five-clause +lifetime contract with witnesses L1–L8, B1's six-target enumeration +with discriminators **R1–R5**, and B3's exact saturated bound. Revision +18 answers review of 17: - **Identity has a second half that R2/R3 cannot reach.** A panel or terminal residual is keyed to a surface that *goes away*; the @@ -20,6 +23,18 @@ of 17: sequentially" is narrowed to the observed cargo 1.95.0 sweep** rather than asserted universally. The concurrency conclusion is unaffected. +**§2a took six review rounds (13–18), and each one found a witness that +would have passed against a broken implementation.** Emission-only +panel rows against a receiver that discards; a GPU preservation row +driven by a paint that never calls the follow; lifetime rows whose +setup left held and released authority indistinguishable; mutation +dependents predicted rather than derived; and a residual-identity story +that stopped at the surfaces which get destroyed, missing the two that +outlive the buffer. **None was a design disagreement** — the rulings +survived from 14 and 15 untouched. That is the failure mode §6 names, +arriving once per round, and it is why the R- and L-rows specify their +*drivers* and *setups* rather than only their assertions. + **Previously, revision 17 — SUPERSEDED.** Two additions and one correction: From bd595bd1481ca633c543b003212fdee909c12a07 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 31 Aug 2026 21:15:49 +0200 Subject: [PATCH 08/72] docs(framing): revision 19 --- reconcile two lineages that both said 13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/gui-stage1-input-framing.md` was advanced independently by two branches and both called their change "revision 13". gui-stage1b-pointer-scroll 13->18 adds §2a, 503 lines: six corrections, B1's wheel-target enumeration, Q#S1-11 ruled (B), the lifetime contract with L1-L8 --- APPROVED at 18 panel-replay lane (1fc3af6) its 13 the enriched B1/B2/B3 rows and the "Panel-replay consequence" paragraph --- approved by merge in #243, status line never updated Both are kept in full, verified rather than assumed: the enriched rows, the panel-replay paragraph, `wrap pins horizontal origin to zero`, §2a, the six-target enumeration and L1-L8 are all present after the rebase. Nothing is renumbered retroactively --- rewriting those numbers would falsify sentences like revision 15's "three defects in 14", and the duplicate is recorded instead. They turn out to be complementary. The panel-replay row names three residual owners; §2a's model is a superset that adds the TERMINAL and rules that chrome shares the DOCUMENT's residual. Where they do disagree, §2a wins, as §1b's own blockquote already said. Rebased onto 0ec13b3. The branch's stale copies of active-work.md and ci-red-signatures.md were resolved toward main, which is 167 commits newer; both are now byte-identical to main. --- docs/gui-stage1-input-framing.md | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index d8e31c7..355c722 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,6 +1,41 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 18 — APPROVED (2026-08-14).** §2a is the 1b ground +**Status: revision 19 — RECONCILES TWO LINEAGES THAT BOTH NUMBERED +THEMSELVES 13.** This document was advanced independently by two +branches, and both called their change "revision 13" — the second +block at `revision 13` below is the other lineage's, left exactly as it +was written. **Nothing is renumbered retroactively**, because rewriting +those numbers would falsify sentences like revision 15's "three defects +in 14". + +| lineage | revisions | contribution | approval | +|---|---|---|---| +| `gui-stage1b-pointer-scroll` | 13 → 18 | **§2a, 503 lines** — six corrections, B1's wheel-target enumeration, Q#S1-11 ruled (B, viewport only), the five-clause lifetime contract with L1–L8 | **APPROVED at 18** | +| the panel-replay lane (`1fc3af6`, #243) | its own 13 | the enriched **B1/B2/B3 rows** and the 12-line "Panel-replay consequence" paragraph in §1b | **approved by merge** — #243 landed it on `main`; its status line was never updated and still reads "AWAITING APPROVAL" | + +**Both are kept in full, and that was verified rather than assumed:** +the enriched rows, the panel-replay paragraph, `wrap pins horizontal +origin to zero`, §2a, the six-target enumeration and L1–L8 are all +present after the rebase. + +**They are complementary, and the one place they could have collided is +worth naming.** The panel-replay lane's B1 row names **three** residual +owners — primary document, panel document, minimap. §2a's model is a +**superset**: R2 panel, R3 terminal, R4 document *(sharing chrome's +residual)*, R5 minimap. So §2a adds the **terminal** as an owner and +**rules that chrome shares the document's**; it does not overturn the +three the table names. + +**Where the table and §2a disagree, §2a wins**, exactly as §1b's own +blockquote already says: it replaces B1's undefined "surface" with the +enumeration and B3's "content bounds" with B7's saturated upper bound. +The enriched rows stand for everything else they add — notably B2's +window-targeted `view_left` path and B3's wrap-pins-to-zero. + +**Nothing in revision 19 changes a ruling.** It is a reconciliation and +a record. + +**Previously, revision 18 — APPROVED (2026-08-14).** §2a is the 1b ground truth and its rulings are settled: **Q#S1-11 (B)**, the five-clause lifetime contract with witnesses L1–L8, B1's six-target enumeration with discriminators **R1–R5**, and B3's exact saturated bound. Revision @@ -126,6 +161,10 @@ another framing deliberately deferred**. Its claim that the snap-back lands on the next *caret event* was corrected by 14 to the next **paint**, and by 15 to *per-frontend* drivers. +**Previously, revision 13 (the PANEL-REPLAY lineage) — approved by +merge in #243; the status line it shipped with is preserved verbatim +below and was never updated.** + **Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a revision 16.** Revision 13 changes no previously ruled outcome. It makes B1–B3's “per surface” scope explicit for the panel-document From 12b37742a74d27f3558fa14b46c0810367a9a0fc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 31 Aug 2026 21:40:14 +0200 Subject: [PATCH 09/72] =?UTF-8?q?docs(framing):=20revision=2020=20---=20?= =?UTF-8?q?=C2=A72a=20re-measured=20at=200ec13b3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision 18's §2a was measured at `72da24a`, now 167 commits back, with #242 and #243 landing on exactly the pointer paths 1b builds on. Every 1b anchor is stale again, which §2a's own history predicted. All four load-bearing claims were re-verified and all four SURVIVE. Quantization still happens BEFORE routing (:3254 quantize, :3262 return on zero, :3270 panel routing) --- checked first, because an inversion would have changed B1's design. `Elsewhere` still collapses four wheel targets. The panel branch still tests `PanelCell` only, so B6's premise holds. `code_scroll_residual` is still the caret-follow residual, not a wheel accumulator. Three base facts were wrong: main.rs is 22,502 lines, not 21,435 PROTOCOL_VERSION is 25, not 24 --- #242 took v25 the panel-replay premise is stale That third one matters most. §2a said a panel wheel "does nothing today, on either axis". #243 landed the replay and it is in this base: the dispatcher is split into `classify_panel_pointer` and `apply_panel_pointer`, and the vertical axis replays for real. THE PREREQUISITE IS DISCHARGED. What does not lapse is the witness --- 1b still owes an end-to-end panel-wheel EFFECT witness on both axes, and #243 discharges only the vertical half. Citation policy: 45 live citations were re-measured and renumbered; a citation that records what an EARLIER revision cited is left exactly as written, and its block is labelled "citations are HISTORICAL (72da24a)". Renumbering those would falsify sentences whose whole point is that an earlier citation was wrong --- revision 15's test-fixture finding is the clearest case. Verified after the sweep: 79 §2a citations, zero out of range, and the seventeen load-bearing anchors each resolve to the construct they name. No ruling changes. Re-measurement, three corrections, one discharge. --- docs/gui-stage1-input-framing.md | 227 +++++++++++++++++++++---------- 1 file changed, 152 insertions(+), 75 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 355c722..b9f9985 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,6 +1,52 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 19 — RECONCILES TWO LINEAGES THAT BOTH NUMBERED +**Status: revision 20 — §2a RE-MEASURED at `0ec13b3`, and three base +facts corrected.** Revision 18's §2a was measured at `72da24a`. That +base is now **167 commits back**, and #242 and #243 landed on exactly +the pointer paths 1b builds on, so every 1b anchor is stale again — +which §2a's own history predicted would happen. + +**All four load-bearing claims were re-verified and all four SURVIVE:** + +1. **Quantization still happens BEFORE routing.** `:3254` quantizes, + `:3262` returns on zero, `:3270` routes to the panel. CORRECTION 5's + central point is intact — this was checked first, because an + inversion would have changed B1's design; +2. **`Elsewhere` still collapses four wheel targets** — its doc at + `:2108` still reads *"Not the band: the document, the terminal, the + minimap, or the chrome."*; +3. **the wheel's panel branch still tests `PanelCell` only** (`:3270`), + so minimap pixels still fall through to `scroll_by_lines`. **B6's + premise survives**; +4. **`code_scroll_residual` is still not a wheel accumulator** — it is + the caret-follow pixel residual (`:1664`), cleared at `:6218` and + `:8433`. Reusing it would still be a defect. + +**Three base facts were wrong, and are corrected in place:** + +- `pmacs-gpu/src/main.rs` is **22,502 lines**, not 21,435; +- **`PROTOCOL_VERSION = 25`**, not 24 — v25 was taken by #242's + cell-mapping generation; +- **the panel-replay premise is stale.** §2a said a panel wheel "does + nothing today, on either axis". **#243 landed the replay and it is in + this base**: the dispatcher is split into `classify_panel_pointer` + (`src/editor.rs:2985`) and `apply_panel_pointer` (`:3088`), and the + vertical axis replays for real at `:3277`–`:3278`. **The prerequisite + is DISCHARGED.** What does not lapse is the witness: 1b still owes an + end-to-end panel-wheel **effect** witness on **both** axes, of which + #243 discharges only the vertical half. + +**Citation policy, applied throughout:** a citation that makes a claim +about the tree **as it is** was re-measured and renumbered — 45 of +them. A citation that records **what an earlier revision cited** is +left exactly as written and its block is labelled *"citations are +HISTORICAL (`72da24a`)"*. Renumbering those would have falsified +sentences whose whole point is that an earlier citation was wrong. + +**No ruling changes in revision 20.** It is a re-measurement, three +corrections, and a discharge. + +**Previously, revision 19 — RECONCILES TWO LINEAGES THAT BOTH NUMBERED THEMSELVES 13.** This document was advanced independently by two branches, and both called their change "revision 13" — the second block at `revision 13` below is the other lineage's, left exactly as it @@ -35,7 +81,7 @@ window-targeted `view_left` path and B3's wrap-pins-to-zero. **Nothing in revision 19 changes a ruling.** It is a reconciliation and a record. -**Previously, revision 18 — APPROVED (2026-08-14).** §2a is the 1b ground +**Previously, revision 18 — APPROVED (2026-08-14); its citations are HISTORICAL (`72da24a`).** §2a is the 1b ground truth and its rulings are settled: **Q#S1-11 (B)**, the five-clause lifetime contract with witnesses L1–L8, B1's six-target enumeration with discriminators **R1–R5**, and B3's exact saturated bound. Revision @@ -70,7 +116,7 @@ survived from 14 and 15 untouched. That is the failure mode §6 names, arriving once per round, and it is why the R- and L-rows specify their *drivers* and *setups* rather than only their assertions. -**Previously, revision 17 — SUPERSEDED.** Two additions and one +**Previously, revision 17 — SUPERSEDED; its citations are HISTORICAL (`72da24a`).** Two additions and one correction: - **B1 gains the two separations its crossing witness could not @@ -94,7 +140,7 @@ correction: binaries **sequentially**, so the two steps never differed in concurrency and no experiment reversed anything. -**Previously, revision 16 — SUPERSEDED.** Four changes, three of them +**Previously, revision 16 — SUPERSEDED; its citations are HISTORICAL (`72da24a`).** Four changes, three of them corrections to 15's own reasoning: - **The panel-replay lane becomes a HARD PREREQUISITE in §3's @@ -119,7 +165,11 @@ corrections to 15's own reasoning: already says is false and unachievable; mutations now name the rows they must **bite** and the **legitimate dependents**. -**Previously, revision 15 — SUPERSEDED.** Answered review of 14 and +**Previously, revision 15 — SUPERSEDED. Every citation and every +present-tense claim in this block is HISTORICAL, measured at +`72da24a`**; see revision 20 for the live sites, and note in particular +that "replays nothing" was true then and is **not** true on `0ec13b3`. +Answered review of 14 and **changed two B-row contracts**, which 14 wrongly denied. - **B1 is now fully ruled**, not half-ruled. Revision 14 left two @@ -149,11 +199,11 @@ B1 gains a per-target enumeration it did not have, and B3 gains B7's exact upper bound in place of "content bounds". B2, B4, B5, B6 and B7 are unchanged. -**Previously, revision 14 — SUPERSEDED.** Ruled Q#S1-11 (B) with the +**Previously, revision 14 — SUPERSEDED; its citations are HISTORICAL (`72da24a`).** Ruled Q#S1-11 (B) with the five-clause lifetime contract, and gave B3 B7's exact saturated bound. Both stand. -**Previously, revision 13 — SUPERSEDED.** §2a's ground-truth +**Previously, revision 13 — SUPERSEDED; its citations are HISTORICAL (`72da24a`).** §2a's ground-truth re-measurement for Stage 1b at the post-#240 tip. Every 1b anchor was stale, as expected; what was not was that **three B-rows describe a field as empty when it is occupied**, and that **B7 re-opens a question @@ -290,27 +340,32 @@ produces nothing. §2 above was measured for **1a**, at `4f77491`. 1a (#239) has since merged and #240 landed on top, so **every coordinate the 1b table cites -is stale** — `pmacs-gpu/src/main.rs` is now **21,435 lines** and 1-pre's +is stale** — `pmacs-gpu/src/main.rs` is now **22,502 lines** and 1-pre's router extraction moved the wheel path wholesale. Renumbering alone would have been routine. It is not what the measurement found. -**`PROTOCOL_VERSION = 24`** now (1a's `TextInput`). 1b remains -non-protocol-bearing. +**`PROTOCOL_VERSION = 25`** now (`pmacs-protocol/src/message.rs:2002`) +— **not 24**, which is what this line said until revision 20: v25 was +taken by the panel cell-mapping generation (#242). 1b remains +non-protocol-bearing either way. ### The anchors, re-measured -| the table cites | holds what now | the real site | +**Left column: HISTORICAL — what §1b's table cited, as measured at +`72da24a`. Right column: LIVE, re-measured at `0ec13b3`.** + +| the table cited (historical, `72da24a`) | held what then | the real site NOW (`0ec13b3`) | |---|---|---| -| `main.rs:2061` "minimap is `Elsewhere`" | `PanelCell(CellCoord)` | `enum PointerSurface` `:2057`; **`Elsewhere` `:2067`** | -| `main.rs:3337` wheel reads `pointer_pos` | an `attach_client` line | `apply_wheel` `:3090` and `:3113` | -| `main.rs:3373` falls to `scroll_by_lines` | a bare `}` | `apply_wheel` `:3126`; `scroll_by_lines` `:8002` | -| `editor.rs:3052` `dispatch_mouse` | — | **`:3207`** | -| `editor.rs:3203` scroll arms | a doc-comment line | `ScrollUp` `:3358`, `ScrollDown` `:3362` | +| `main.rs:2061` "minimap is `Elsewhere`" | `PanelCell(CellCoord)` | `enum PointerSurface` `:2099`; **`Elsewhere` `:2109`** | +| `main.rs:3337` wheel reads `pointer_pos` | an `attach_client` line | `apply_wheel` `:3270` and `:3293` | +| `main.rs:3373` falls to `scroll_by_lines` | a bare `}` | `apply_wheel` `:3306`; `scroll_by_lines` `:8417` | +| `editor.rs:3052` `dispatch_mouse` | — | **`:3920`** | +| `editor.rs:3203` scroll arms | a doc-comment line | `ScrollUp` `:4296`, `ScrollDown` `:4299` | **B6's premise survives the move intact**, and is now stated by the code itself: `Elsewhere`'s doc comment reads *"Not the band: the document, the terminal, the minimap, or the chrome."* The wheel's panel branch -(`:3090`) tests `PanelCell` only, so minimap pixels still fall through +(`:3270`) tests `PanelCell` only, so minimap pixels still fall through to `scroll_by_lines`. **B1's defect is visible in four lines.** `apply_wheel` rounds to whole @@ -321,11 +376,11 @@ let lines = match delta { LineDelta(_, y) => (-y * WHEEL_LINES_PER_TICK).round() if lines == 0 { return; } ``` -`:3075`–`:3084`. The `_` is the **x** delta, discarded at the same site +`:3254`–`:3262`. The `_` is the **x** delta, discarded at the same site — so **B1 and B2's witnesses are the same four lines**, and a residual accumulator is what both need. **There is no wheel accumulator today.** -`code_scroll_residual` (`:1622`) is *not* it: that is the caret-follow -pixel residual, buffer-scoped, cleared at `:5928`. **Reusing it would +`code_scroll_residual` (`:1664`) is *not* it: that is the caret-follow +pixel residual, buffer-scoped, cleared at `:6218`. **Reusing it would be a defect**, not a shortcut. ### CORRECTION 5 — B1's "surface" is not enumerated, and the classifier cannot enumerate it @@ -334,20 +389,20 @@ B1 says "residual per **axis and surface**" and the table leaves "surface" undefined. Two facts make that a hole rather than a detail. **Quantization happens BEFORE routing.** The rounding and the -`lines == 0` return are at `:3074`–`:3084`; the panel branch is `:3090` -and the terminal branch `:3112`. So a sub-tick delta bound for the +`lines == 0` return are at `:3254`–`:3262`; the panel branch is `:3270` +and the terminal branch `:3292`. So a sub-tick delta bound for the panel or the terminal is **discarded before anything knows where it was going**. An accumulator added after the routing decision would fix the document and leave the wire targets exactly as broken as they are now. **And `PointerSurface` cannot name the surfaces B1 needs.** -`classify_pointer_surface` (`:7192`) resolves panel geometry only; -`Elsewhere` (`:2067`) is *"the document, the terminal, the minimap, or +`classify_pointer_surface` (`:7577`) resolves panel geometry only; +`Elsewhere` (`:2109`) is *"the document, the terminal, the minimap, or the chrome"* — four wheel targets under one name, three of which B1 and B6 must distinguish. **B1 needs a wheel-target enumeration; it does not get one for free from the existing classifier.** -Note also a live consequence of `:3090` matching `PanelCell(_)` alone: +Note also a live consequence of `:3270` matching `PanelCell(_)` alone: **a wheel over the panel DIVIDER or the band's BACKGROUND scrolls the document today**, though the enum's own doc says the band "still owns the pixel." @@ -358,11 +413,11 @@ they differ 1b closes the gap: | wheel target | classified | vertical today | vertical RULED | residual owner | horizontal RULED | |---|---|---|---|---|---| -| Panel cell | `PanelCell` | emits (`:3102`), **replay missing** | emit **both axes**; replay is a **prerequisite lane** | per panel | emit `ScrollLeft`/`Right` | +| Panel cell | `PanelCell` | emits (`:3282`); **vertical replay LANDED in #243** (`src/editor.rs:3277`) | emit **both axes**; the horizontal leg is B1–B3's | per panel | emit `ScrollLeft`/`Right` | | Panel divider / background | `PanelDivider` / `PanelBackground` | **scrolls the document** | **consume both axes** — the band owns the pixel | **none — discards** | consumed, no emit | -| Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | emits (`:3122`) | emit **both axes** | per terminal | emit `ScrollLeft`/`Right`; **inert when not reporting** | +| Terminal | `Elsewhere` + `terminal.is_some()` (`:3292`) | emits (`:3302`) | emit **both axes** | per terminal | emit `ScrollLeft`/`Right`; **inert when not reporting** | | Minimap | `Elsewhere` | document `scroll_by_lines` | document viewport, **own residual** (B6) | **its own** | inert | -| Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | unchanged | document | `code_scroll_left` (B3) | +| Document | `Elsewhere` | `scroll_by_lines` (`:3306`) | unchanged | document | `code_scroll_left` (B3) | | Chrome | `Elsewhere` | document | **shares the document's**, deliberately | **the document's** | shares the document's | **Divider and background consume both axes, and BANK NOTHING.** Falling @@ -416,7 +471,7 @@ minimap do not get replaced — their state OUTLIVES the buffer.** A panel or terminal residual can be keyed to a surface that goes away; the document's and the minimap's live in the long-lived GPU `State`, which survives a buffer switch. **Their reset is manual, and there is a list -to be forgotten from.** Buffer replacement (`pmacs-gpu/src/main.rs:5922` +to be forgotten from.** Buffer replacement (`pmacs-gpu/src/main.rs:6218` onward) zeroes `scroll_top`, `code_scroll_residual` and `code_scroll_left` **one explicit line at a time**, each with its own comment — including `code_scroll_left`, added later precisely because a @@ -463,8 +518,8 @@ needs no protocol bump** — 1b stays non-protocol-bearing. **The terminal answer follows from the encoder.** `sgr_mouse_report` (`src/terminal/input.rs`) already encodes `ScrollLeft` as **66** and -`ScrollRight` as **67** (`:126`–`:127`), so the terminal handles both -axes the moment they are emitted. Its guard (`:111`) returns `None` +`ScrollRight` as **67** (`:130`–`:131`), so the terminal handles both +axes the moment they are emitted. Its guard (`:120`) returns `None` unless `mouse_sgr` is on and tracking is not `Off`, so a **non-reporting terminal is horizontally inert** — which matches the TUI, where nothing consumes a horizontal tick either. No new arm is @@ -474,22 +529,33 @@ needed. **Revision 14 cited `src/daemon.rs:6683` as the panel's `ScrollUp` handler. That was wrong: it is a test fixture**, inside `#[cfg(test)] -mod tests` (opened at `:3740`). Citing a fixture as production is +mod tests` (opened at `:6813`). Citing a fixture as production is precisely the error B1's enumeration exists to prevent, and it hid a real defect. -The production path is `dispatch_semantic_panel_pointer` -(`src/editor.rs:2674`). It validates the coord against the panel grid, -resolves the side window, focuses when the gesture activates — and -**returns `true` without replaying anything.** Its own doc says so: -*"**Replay is out of scope in Stage 2B-2.** Driving selection, listview -rows, or child SGR reporting is **parent acceptance 48**, which needs -the GPU band and lands in **Stage 2B-3**."* +**As measured at `72da24a`**, the production path was +`dispatch_semantic_panel_pointer` (`src/editor.rs:2674` *at that base*). +It validated the coord against the panel grid, resolved the side +window, focused when the gesture activated — and **returned `true` +without replaying anything.** Its own doc said so: *"**Replay is out of +scope in Stage 2B-2.** Driving selection, listview rows, or child SGR +reporting is **parent acceptance 48**, which needs the GPU band and +lands in **Stage 2B-3**."* -So **a panel wheel does nothing today, on either axis** — the frontend -emits, the daemon validates and drops. That is a **pre-existing -violation of an already-ruled contract**, uncovered by this -measurement, not created by 1b. +**THAT IS NO LONGER THE BASE. #243 landed the replay, and revision 20 +re-measures it.** On `0ec13b3` the dispatcher is split into +`classify_panel_pointer` (`src/editor.rs:2985`) and +`apply_panel_pointer` (`:3088`), and the effect path replays a **panel +wheel's vertical axis** for real: `PKind::ScrollUp => +self.scroll_window(side, -SCROLL_LINES)` and its `ScrollDown` twin +(`:3277`–`:3278`). + +**So "a panel wheel does nothing today, on either axis" is FALSE on this +base, and the sentence is corrected rather than deleted** — it was true +when written and is the reason the prerequisite lane existed. What +remains is narrower and was already assigned here: **the horizontal +document-panel leg is B1–B3's**, per the "Panel-replay consequence" +paragraph in §1b. **Ruling: 1b does not absorb it, and 1b does not ship before it.** The replay is repaired in a **prerequisite lane** carrying parent @@ -548,13 +614,13 @@ Each of these reads as an empty field in the table and is not one. The contracts are unaffected; the **implementation shape** is. - **B3 — "no horizontal scroll to clamp".** The GPU has a horizontal - origin: **`code_scroll_left`** (`:1639`), a pixel offset *snapped to - the column grid*, moved by its own `horizontal_follow` (`:7702`). + origin: **`code_scroll_left`** (`:1681`), a pixel offset *snapped to + the column grid*, moved by its own `horizontal_follow` (`:8117`). What is missing is a **wheel-driven** horizontal scroll. B3 adds a second writer to an existing field, which is a different job from introducing one. - **B5 — "no I-beam".** True as stated, but the cursor already has an - owner: **`apply_panel_cursor_icon`** (`:7328`) sets `RowResize` over + owner: **`apply_panel_cursor_icon`** (`:7743`) sets `RowResize` over the divider and **`CursorIcon::Default` everywhere else**. An I-beam written as a separate site would be **clobbered by that else branch**. B5 must extend this function, not join it. @@ -570,7 +636,7 @@ contracts are unaffected; the **implementation shape** is. This is the finding that needs a ruling rather than a renumber. The TUI horizontal origin is **`window.view_left`** (`src/window.rs:386`), -and `horizontal_follow` (`src/editor.rs:4495`) already **pins it to 0 +and `horizontal_follow` (`src/editor.rs:5233`) already **pins it to 0 under wrap** — so *B7's wrap clause is implemented today*, for the caret-follow path. Both frontends share the arithmetic (`pmacs_protocol::scroll::follow_left`, `scroll.rs:134`), deliberately. @@ -591,18 +657,18 @@ scroll for exactly this reason: > would otherwise snap `view_top` straight back … so the user's > mouse-wheel scroll would feel stuck after one notch. -(That citation is itself stale: `scroll_window` is **`src/editor.rs:3845`**, +(That citation is itself stale: `scroll_window` is **`src/editor.rs:4583`**, not `:3628`.) So a wheel-driven `view_left` that does **not** carry the cursor is snapped back, and horizontal wheel scrolling "feels stuck after one notch" — the identical bug, one axis over, on **both** frontends, since -the GPU's `horizontal_follow` (`:7702`) has the same shape. +the GPU's `horizontal_follow` (`:8117`) has the same shape. **And it happens on the next PAINT, not the next caret event.** -`prepare_window_cursor_visible` (`src/editor.rs:4518`) calls -`horizontal_follow` unconditionally as its **first** act (`:4539`), and -`paint_frame` (`:4747`) calls it every frame (`:4852`, and `:2572` for +`prepare_window_cursor_visible` (`src/editor.rs:5256`) calls +`horizontal_follow` unconditionally as its **first** act (`:5277`), and +`paint_frame` (`:5485`) calls it every frame (`:5590`, and `:2572` for the panel). Revision 13 said "the next caret event"; that was wrong and understated the exposure — the origin is overwritten by a redraw with no input at all. @@ -616,17 +682,17 @@ viewport that is overwritten on the next frame. **(A) is not viable in 1b, and the vertical precedent does not reach it.** `scroll_window` carries point because it is **TUI-side**, where the editor owns the cursor directly. The GPU has no such power: -`OwnCursor` (`pmacs-gpu/src/main.rs:2337`) is *"pmacs-gpu's own cursor +`OwnCursor` (`pmacs-gpu/src/main.rs:1774`) is *"pmacs-gpu's own cursor position, **mirrored** from `CursorByte`"* — a read-only reflection of daemon state. The only wire operation that positions it is `Pointer`, -and `dispatch_pointer` (`src/editor.rs:3638`) sets `active_frontend`, +and `dispatch_pointer` (`src/editor.rs:4376`) sets `active_frontend`, calls `break_command_chain`, and — by its own comment — *"Every `PointerKind` moves point or changes the selection."* Carrying point from a wheel would mean **a new wire operation**, which contradicts 1b's non-protocol scope outright. Note also that **GPU vertical scrolling already does not carry point**: -`apply_wheel` ends at `send_viewport` (`:3129`). (A) would therefore not +`apply_wheel` ends at `send_viewport` (`:3309`). (A) would therefore not even be internally consistent — it would make the horizontal axis carry point on a frontend where the vertical axis does not. @@ -655,7 +721,7 @@ undefined, which is the part that decides whether the feature works. *changing*, never by elapsed time or by the follow running. 5. **Wrap and buffer replacement clear it and pin the origin to zero.** This is the existing rule (`horizontal_follow`'s wrap branch, and - the GPU's at `:7703`); authority must not survive either. + the GPU's at `:8089`); authority must not survive either. ##### What B7 and B3 must witness @@ -664,12 +730,12 @@ a follow that runs inside a frame. **The two frontends need DIFFERENT preservation drivers, and this is where revision 14 was wrong.** It said "the next paint" without -qualification. **That is TUI-only.** The GPU's `render()` (`:9881`) goes +qualification. **That is TUI-only.** The GPU's `render()` (`:10296`) goes straight to `render_to_view` and **never calls `horizontal_follow`**; -the follow reaches it only through `ensure_caret_painted` (`:7674`), -whose callers are cursor paths (`:5607`, `:5659`, `:5776`, `:6320`) and -geometry paths — `resize` (`:9851`), `apply_font_facts` (`:9729`), -`reflow_dynamic_code_geometry` (`:9672`). **A GPU wheel-then-paint row +the follow reaches it only through `ensure_caret_painted` (`:8071`), +whose callers are cursor paths (`:5897`, `:5949`, `:6066`, `:6610`) and +geometry paths — `resize` (`:10266`), `apply_font_facts` (`:10144`), +`reflow_dynamic_code_geometry` (`:10087`). **A GPU wheel-then-paint row would stay green with the overwrite mutation restored**, which is a vacuous witness of exactly the kind this framing keeps producing. @@ -764,7 +830,7 @@ the same bound on the same rule, and B7's exactness exists because the loose version *blanks the viewport*. **B3 takes B7's rule verbatim, in the GPU's column-grid units.** The -GPU already reckons in that grid — `horizontal_follow` (`:7702`) +GPU already reckons in that grid — `horizontal_follow` (`:8117`) derives `cols = (width / advance).floor()` and `left_col = (code_scroll_left / advance).round()`, then re-multiplies to snap the offset back onto the grid, deliberately, so both frontends put the same @@ -797,20 +863,31 @@ B7's revision found and B3 currently has no row to catch. **1c is NOT protocol-bearing** under Q#S1-8's ruling. Protocol slices are serialized. -**The panel-replay lane is a HARD PREREQUISITE of 1b**, not a parallel -track. §2a found that `dispatch_semantic_panel_pointer` validates and -focuses but **replays nothing**, so a panel wheel is dead on both axes -today — a pre-existing gap in **parent acceptance 48** -(`docs/bottom-panel-framing.md:1719`), which owns it. 1b's B1 makes the -panel a first-class wheel target with its own residual, and **the -defect is exactly "frontend emits, receiver discards"**, so a 1b that -witnessed emission alone would repeat the blind spot that hid it. +**The panel-replay lane WAS a hard prerequisite of 1b. It is +DISCHARGED.** §2a found, at `72da24a`, that +`dispatch_semantic_panel_pointer` validated and focused but **replayed +nothing**, so a panel wheel was dead on both axes — a pre-existing gap +in **parent acceptance 48** (`docs/bottom-panel-framing.md:1719`), which +owned it. -**Order: the replay lane merges, 1b rebases onto that merge commit, -then 1b lands an end-to-end panel-wheel EFFECT witness on both axes.** -1b's base -moves off `72da24a` accordingly; the rest of §2a is measured on the GPU -and TUI sides and is unaffected. +**#243 repaired it, and it is in this base.** `0ec13b3` contains the +split dispatcher (`src/editor.rs:2985`, `:3088`) and the vertical +panel-wheel replay (`:3277`–`:3278`). **1b has no outstanding +prerequisite**, and the rebase this revision was written on is the one +the order below called for. + +**What does NOT lapse is the witness.** The reason for demanding it +stands unchanged: 1b's B1 makes the panel a first-class wheel target +with its own residual, and **the original defect was exactly "frontend +emits, receiver discards"** — so a 1b that witnessed emission alone +would repeat the blind spot that hid it. **1b still owes an end-to-end +panel-wheel EFFECT witness on BOTH axes**, and #243 discharges only the +vertical half of what that witness must show. + +**Order, as it now stands: the prerequisite is met; 1b implements +B1–B7 on this base and lands the both-axis effect witness.** 1b's base +is **`0ec13b3`**, off `72da24a` as the earlier order required; the rest +of §2a is measured on the GPU and TUI sides and is unaffected. ## 4. Q#S1-8 — RULED: (A), preserve pre-window readiness From 8b8b6982ae420b7146f2f1626ac489543fe91730 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 31 Aug 2026 21:51:34 +0200 Subject: [PATCH 10/72] docs(framing): revision 20 --- three blockers from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification base still said `72da24a` in three live places: the §2 header's "Verification base" line, §2a's own heading, and §1b's blockquote. All three now name `0ec13b3` while preserving `72da24a` as the original measurement. The live panel-replay section still contradicted the discharge --- it said 1b "does not ship before" the prerequisite, "depends on that lane", that vertical panel scrolling is "equally dead today", and it repeated the three-step future ordering. The ruling and its two reasons are now marked as made-and-carried-out; steps 1 and 2 are struck as done; step 3 stands. And the conceptual point behind that, which I had wrong: #243 discharges the PREREQUISITE completely within its scope and supplies the vertical RECEIVER path. It discharges NONE of B1's witness, on either axis, because B1's producer --- a per-surface fractional accumulator --- is new and no #243 test exercises it. A vertical panel wheel routed through a new accumulator can drop sub-ticks or carry residue across a surface switch. Implementation already present is not evidence already owed, and saying "#243 discharges the vertical half of the witness" conflated the two. The sweep also verified RANGE, not IDENTITY, which is the weaker check. Six citations landed in the right file and the wrong construct: B4's `UnusedButton`/`route_pointer`, misfiled against editor.rs when they are main.rs; the panel paint call; `OwnCursor`'s type doc against its field; the GPU wrap branch against the `horizontal_follow` call; and `scroll_top`'s zeroing, one line before the residual's. Every live citation is now checked against the construct its prose names. --- docs/gui-stage1-input-framing.md | 135 ++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 40 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index b9f9985..907e709 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -32,19 +32,34 @@ which §2a's own history predicted would happen. this base**: the dispatcher is split into `classify_panel_pointer` (`src/editor.rs:2985`) and `apply_panel_pointer` (`:3088`), and the vertical axis replays for real at `:3277`–`:3278`. **The prerequisite - is DISCHARGED.** What does not lapse is the witness: 1b still owes an - end-to-end panel-wheel **effect** witness on **both** axes, of which - #243 discharges only the vertical half. + is DISCHARGED** — completely, within its scope, and it supplies the + vertical receiver path. **It discharges none of B1's witness**: 1b + still owes an end-to-end panel-wheel **effect** witness on **both** + axes, because B1's fractional accumulator is NEW and no #243 test + exercises it. Implementation already present is not evidence already + owed. **Citation policy, applied throughout:** a citation that makes a claim -about the tree **as it is** was re-measured and renumbered — 45 of -them. A citation that records **what an earlier revision cited** is +about the tree **as it is** was re-measured and renumbered — 45 in the +first pass, plus **six the first pass got wrong**. A citation that records **what an earlier revision cited** is left exactly as written and its block is labelled *"citations are HISTORICAL (`72da24a`)"*. Renumbering those would have falsified sentences whose whole point is that an earlier citation was wrong. +**The first pass verified RANGE, not IDENTITY, and that was the wrong +check.** Every citation landed inside its file, and six still pointed at +the wrong construct: B4's `UnusedButton`/`route_pointer` (`:3803`, +`:3811`, misfiled against `editor.rs` when they are `main.rs`), the +panel paint call (`src/editor.rs:2863`), `OwnCursor`'s **type doc** +(`:2448`) as against its field (`:1774`), the GPU **wrap branch** +(`:8120`) as against the `horizontal_follow` call (`:8089`), and +`scroll_top`'s zeroing (`:6217`), which sits one line before the +residual's. **Every live citation is now identity-checked against the +construct its prose names**, not merely bounds-checked. + **No ruling changes in revision 20.** It is a re-measurement, three -corrections, and a discharge. +corrections, a discharge, and one conceptual separation — implementation +already present is not evidence already owed. **Previously, revision 19 — RECONCILES TWO LINEAGES THAT BOTH NUMBERED THEMSELVES 13.** This document was advanced independently by two @@ -240,10 +255,15 @@ and it lands ahead of 1e because panel-pointer replay blocks 1b. Protocol slices stay serialized; one was inserted in front. `ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**. -**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12), +**Verification base: `0ec13b3`** (revision 20). §2 was re-measured at +`4f77491` (2026-08-12) and §2a **originally** at `72da24a` +(2026-08-13); both are historical anchors now, kept so the +re-measurements can be checked against what they replaced. +§2 is **re-measured at `4f77491`** (2026-08-12), the tip after 1-pre; it was originally taken at `a994f37`. **§2a is -measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it -is the base for **1b only**; it carries Q#S1-11's ruling. Sections +measured at `0ec13b3`** (revision 20) — originally at `72da24a` +(2026-08-13), the tip after 1a and #240 — and it is the base for **1b +only**; it carries Q#S1-11's ruling. Sections other than §2/§2a were written against `a994f37` and their *rulings* are unaffected by 1-pre, which changed no behaviour — but **any line number outside §2 and §2a @@ -336,7 +356,7 @@ survives A4**: a native close still returns `Exit`, and keyboard input is truncated to its first scalar, and an IME commit produces nothing. -## 2a. Ground truth for 1b — MEASURED at `72da24a` (2026-08-13) +## 2a. Ground truth for 1b — RE-MEASURED at `0ec13b3` (revision 20); originally at `72da24a` (2026-08-13) §2 above was measured for **1a**, at `4f77491`. 1a (#239) has since merged and #240 landed on top, so **every coordinate the 1b table cites @@ -471,7 +491,7 @@ minimap do not get replaced — their state OUTLIVES the buffer.** A panel or terminal residual can be keyed to a surface that goes away; the document's and the minimap's live in the long-lived GPU `State`, which survives a buffer switch. **Their reset is manual, and there is a list -to be forgotten from.** Buffer replacement (`pmacs-gpu/src/main.rs:6218` +to be forgotten from.** Buffer replacement (`pmacs-gpu/src/main.rs:6217` onward) zeroes `scroll_top`, `code_scroll_residual` and `code_scroll_left` **one explicit line at a time**, each with its own comment — including `code_scroll_left`, added later precisely because a @@ -557,10 +577,10 @@ remains is narrower and was already assigned here: **the horizontal document-panel leg is B1–B3's**, per the "Panel-replay consequence" paragraph in §1b. -**Ruling: 1b does not absorb it, and 1b does not ship before it.** The -replay is repaired in a **prerequisite lane** carrying parent -acceptance 48, and **1b depends on that lane** — see the ordering -below. Two reasons, and the one revision 15 gave was wrong: +**Ruling as made (historical): 1b does not absorb it, and 1b does not +ship before it.** The replay was to be repaired in a **prerequisite +lane** carrying parent acceptance 48, with **1b depending on that +lane**. Two reasons were given, and the one revision 15 gave was wrong: 1. **Replay is broader than 1b, and already owned.** Acceptance 48 (`docs/bottom-panel-framing.md:1719`) has `PanelPointer` driving @@ -569,9 +589,15 @@ below. Two reasons, and the one revision 15 gave was wrong: activation ordering and coalescing rules. A wheel is one gesture in that set. Implementing it alone from an input slice would deliver a fragment of an acceptance criterion owned elsewhere. -2. **The defect predates 1b** and is not horizontal-specific: vertical - panel scrolling is equally dead today. A fix belongs where the - contract lives, not bolted to the slice that happened to find it. +2. **The defect predates 1b** and was not horizontal-specific: + vertical panel scrolling was **equally dead at `72da24a`**. A fix + belongs where the contract lives, not bolted to the slice that + happened to find it. + +**STATUS AT `0ec13b3`: the ruling was carried out and the dependency is +DISCHARGED.** #243 was that prerequisite lane, it merged, and this base +contains it. The two reasons above are why the work went there rather +than here; they are not live obligations. **Revision 15 said "it is not input work". That was wrong** — replay includes terminal mouse reporting and click-to-focus, which is exactly @@ -585,18 +611,36 @@ works and cannot see the half that does not — *it reproduces the blind spot that let this sit undetected.* Passing panel rows would again mean nothing about whether a panel wheel scrolls. -So the dependency is **hard, and ordered**: +**Steps 1 and 2 are DONE. Step 3 is 1b's, undiminished.** -1. **The replay lane merges first.** -2. **1b rebases onto it**, and its base moves from `72da24a` to that - merge commit. §2a's other measurements are unaffected — the replay - lane touches the daemon side. +1. ~~The replay lane merges first.~~ **#243 merged.** +2. ~~1b rebases onto it.~~ **Done: 1b's base is `0ec13b3`**, which + contains #243. §2a's other measurements were unaffected by that + lane, as predicted — it touched the daemon side. 3. **1b carries an END-TO-END panel-wheel EFFECT witness, on BOTH axes**: a wheel over a panel cell moves that panel's viewport vertically, and a horizontal wheel moves it horizontally. Not "a - `PanelPointer` was emitted" — the observable effect. The horizontal - leg is not optional garnish: it is the axis with **no handler at - all**, which is exactly where the emit-and-discard defect lived. + `PanelPointer` was emitted" — the observable effect. + +**Why BOTH axes are still owed, when #243 already supplies the vertical +receiver.** These are different things and revision 20's first draft +conflated them: + +- **The receiver path** for a vertical panel wheel exists now + (`src/editor.rs:3277`–`:3278`). That is implementation, and #243 + discharges it completely within its scope; +- **B1's evidence** is not discharged by any of it, on either axis, + **because B1's producer is NEW**. 1b introduces a per-surface + fractional residual accumulator that did not exist when #243 was + written and that no #243 test exercises. A vertical panel wheel + routed through a new accumulator can fail in ways the old whole-tick + path could not — dropped sub-ticks, residue carried across a surface + switch, a residual keyed to a panel that is gone. + +So: **#243 discharges the PREREQUISITE. It discharges none of the +WITNESS.** The horizontal leg additionally has no handler at all, which +is where the emit-and-discard defect lived — but the vertical leg is +owed for its own reason, not as a formality. **Panel inertness is therefore NOT an option and is not claimed.** The panel's contract is "emit both axes with its own residual, and the @@ -625,10 +669,10 @@ contracts are unaffected; the **implementation shape** is. written as a separate site would be **clobbered by that else branch**. B5 must extend this function, not join it. - **B4 — "no middle-click path".** 1-pre already built the landing - site and named this row in it: `PointerRoute::UnusedButton` (`:3623`) + site and named this row in it: `PointerRoute::UnusedButton` (`:3803`) is documented *"Stage 1b's B4 gives the middle button a meaning (PRIMARY-selection paste on Linux) and lands here."* `route_pointer` - (`:3631`) sends every non-left, non-right-press button there. B4 + (`:3811`) sends every non-left, non-right-press button there. B4 splits a variant that already exists. ### CORRECTION 4 — B7 re-opens a deferred question, and the table does not say so @@ -668,7 +712,7 @@ the GPU's `horizontal_follow` (`:8117`) has the same shape. **And it happens on the next PAINT, not the next caret event.** `prepare_window_cursor_visible` (`src/editor.rs:5256`) calls `horizontal_follow` unconditionally as its **first** act (`:5277`), and -`paint_frame` (`:5485`) calls it every frame (`:5590`, and `:2572` for +`paint_frame` (`:5485`) calls it every frame (`:5590`, and `:2863` for the panel). Revision 13 said "the next caret event"; that was wrong and understated the exposure — the origin is overwritten by a redraw with no input at all. @@ -682,7 +726,8 @@ viewport that is overwritten on the next frame. **(A) is not viable in 1b, and the vertical precedent does not reach it.** `scroll_window` carries point because it is **TUI-side**, where the editor owns the cursor directly. The GPU has no such power: -`OwnCursor` (`pmacs-gpu/src/main.rs:1774`) is *"pmacs-gpu's own cursor +`OwnCursor` (type doc `pmacs-gpu/src/main.rs:2448`; the field is +`:1774`) is *"pmacs-gpu's own cursor position, **mirrored** from `CursorByte`"* — a read-only reflection of daemon state. The only wire operation that positions it is `Pointer`, and `dispatch_pointer` (`src/editor.rs:4376`) sets `active_frontend`, @@ -721,7 +766,8 @@ undefined, which is the part that decides whether the feature works. *changing*, never by elapsed time or by the follow running. 5. **Wrap and buffer replacement clear it and pin the origin to zero.** This is the existing rule (`horizontal_follow`'s wrap branch, and - the GPU's at `:8089`); authority must not survive either. + the GPU's wrap branch at `:8120`; `:8089` is only the +`horizontal_follow` call); authority must not survive either. ##### What B7 and B3 must witness @@ -876,13 +922,19 @@ panel-wheel replay (`:3277`–`:3278`). **1b has no outstanding prerequisite**, and the rebase this revision was written on is the one the order below called for. -**What does NOT lapse is the witness.** The reason for demanding it -stands unchanged: 1b's B1 makes the panel a first-class wheel target -with its own residual, and **the original defect was exactly "frontend -emits, receiver discards"** — so a 1b that witnessed emission alone -would repeat the blind spot that hid it. **1b still owes an end-to-end -panel-wheel EFFECT witness on BOTH axes**, and #243 discharges only the -vertical half of what that witness must show. +**What does NOT lapse is the witness, on EITHER axis.** The reason for +demanding it stands unchanged: 1b's B1 makes the panel a first-class +wheel target **with its own residual**, and **the original defect was +exactly "frontend emits, receiver discards"** — so a 1b that witnessed +emission alone would repeat the blind spot that hid it. + +**#243 supplies the vertical RECEIVER; it does not supply B1's +EVIDENCE.** B1's producer — a per-surface fractional accumulator — did +not exist when #243 was written, and no #243 test exercises it. So +**1b owes the end-to-end effect witness on BOTH axes**: the horizontal +leg because it has no handler at all, and the vertical leg because it +now runs through a new accumulator that can drop sub-ticks or carry +residue across a surface switch. **Order, as it now stands: the prerequisite is met; 1b implements B1–B7 on this base and lands the both-axis effect witness.** 1b's base @@ -1077,9 +1129,12 @@ The crate has **exactly one** executable `event_loop.exit()`, in ### 1b — pointer and scroll -> **Read §2a first, and note that it CHANGES two of these rows.** Every +> **Read §2a first, and note that it CHANGES two of these rows.** §2a +> is measured at **`0ec13b3`**; the line numbers it replaced were +> `72da24a`-era. Every > line number below was measured before 1-pre and is stale. §2a -> re-measures them at `72da24a`; records three rows whose "nothing +> re-measures them — at `72da24a` when this note was written, and at +> `0ec13b3` since revision 20; records three rows whose "nothing > exists yet" is wrong (B3, B4, B5); **replaces B1's undefined > "surface" with a normative six-target enumeration**; **replaces B3's > "content bounds" with B7's exact saturated upper bound**; and rules From 204998351b2753ea0110e3f55e43fbf95a8285b3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 31 Aug 2026 22:24:12 +0200 Subject: [PATCH 11/72] docs(framing): revision 20 --- the both-axis witness was vacuous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 asked only that "a wheel" move the panel. A whole tick passes straight through #243's existing vertical receiver even if B1's new fractional accumulator discards every sub-tick it is given --- so the witness was satisfiable with the exact mechanism it exists to protect completely broken. The justification for owing it at all is that B1's producer is NEW, and a witness that never exercises the producer proves nothing about it. It now requires fractional input end to end, per axis: a first individually sub-threshold delta produces NO viewport effect, and further same-panel sub-threshold deltas, once accumulated past a tick, produce EXACTLY ONE. Not zero, not two. §1b's panel-replay paragraph carries the same requirement so a reader who starts from the contract table sees it. Also: the v25 note still said the mapping slice "lands" ahead of 1e "because panel-pointer replay blocks 1b". Both landed --- #242 and #243. It is historical now: v25 landed ahead BECAUSE replay then blocked 1b, and the version numbering is what survives. --- docs/gui-stage1-input-framing.md | 56 +++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 907e709..bb7a7e0 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -57,9 +57,19 @@ panel paint call (`src/editor.rs:2863`), `OwnCursor`'s **type doc** residual's. **Every live citation is now identity-checked against the construct its prose names**, not merely bounds-checked. -**No ruling changes in revision 20.** It is a re-measurement, three -corrections, a discharge, and one conceptual separation — implementation -already present is not evidence already owed. +**One contract IS sharpened, and it had to be.** Step 3's both-axis +witness said only that "a wheel" moves the panel. **A whole tick passes +straight through #243's existing vertical receiver even if B1's new +accumulator discards every sub-tick it is given** — so the witness was +satisfiable with the very mechanism it exists to protect completely +broken. It now requires **fractional input end to end, per axis**: a +first sub-threshold delta produces no effect, and accumulated +same-panel deltas produce **exactly one**. That is a strengthening of a +witness, not a change to a ruling. + +**Otherwise no ruling changes in revision 20.** It is a re-measurement, +three corrections, a discharge, and one conceptual separation — +implementation already present is not evidence already owed. **Previously, revision 19 — RECONCILES TWO LINEAGES THAT BOTH NUMBERED THEMSELVES 13.** This document was advanced independently by two @@ -249,10 +259,12 @@ 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. **v26, not v25 — corrected by the panel mapping-generation slice.** -That slice (`docs/bottom-panel-framing.md` §5b) takes **v25** for +That slice (`docs/bottom-panel-framing.md` §5b) **took v25** for `PanelFramePayload::PresentMapped` / `FrontendEvent::PanelPointerMapped`, -and it lands ahead of 1e because panel-pointer replay blocks 1b. -Protocol slices stay serialized; one was inserted in front. +and **it landed ahead of 1e (as #242) because panel-pointer replay +blocked 1b at the time.** Both have since landed and neither blocks +anything now; the version numbering is what survives. Protocol slices +stay serialized; one was inserted in front. `ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**. **Verification base: `0ec13b3`** (revision 20). §2 was re-measured at @@ -618,9 +630,24 @@ nothing about whether a panel wheel scrolls. contains #243. §2a's other measurements were unaffected by that lane, as predicted — it touched the daemon side. 3. **1b carries an END-TO-END panel-wheel EFFECT witness, on BOTH - axes**: a wheel over a panel cell moves that panel's viewport - vertically, and a horizontal wheel moves it horizontally. Not "a - `PanelPointer` was emitted" — the observable effect. + axes, driven by FRACTIONAL input.** Per axis, over a panel cell: + - **a first individually sub-threshold delta produces NO viewport + effect** — nothing moves, and nothing is emitted that would move + it; + - **further same-panel sub-threshold deltas, once they accumulate + past a tick, produce EXACTLY ONE viewport effect** — the panel's + viewport moves once, not zero times and not twice. + + Not "a `PanelPointer` was emitted" — the observable effect on the + panel's viewport. + + **A whole-tick witness would NOT do, and that is the point.** A full + tick passes straight through #243's existing vertical receiver even + if B1's new accumulator discards every sub-tick delta it is given. + The justification for owing this witness at all is that **B1's + producer is new**; a witness that never exercises the producer is + satisfiable with the new mechanism completely broken, which is the + vacuous shape this document has rejected elsewhere. **Why BOTH axes are still owed, when #243 already supplies the vertical receiver.** These are different things and revision 20's first draft @@ -1158,8 +1185,15 @@ blank**. The bound is *width − viewport*, saturating at zero for buffers narrower than the viewport, and **the right-bound witness asserts the final display column is still visible**. -**Panel-replay consequence, ruled after §5a's 2026-08-20 -re-measurement.** “Horizontal panel wheel” has three receiver outcomes, +**Panel-replay consequence — the prerequisite is DISCHARGED (#243, in +base `0ec13b3`), the WITNESS is not.** B1's producer is new, so the +end-to-end panel-wheel effect witness is owed on **both** axes and must +be driven by **fractional** input: a first sub-threshold delta moves +nothing, and accumulated same-panel deltas move the viewport **exactly +once**. A whole-tick witness passes through #243's receiver with B1's +accumulator broken. See §3's step 3. + +**Ruled after §5a's 2026-08-20 re-measurement.** “Horizontal panel wheel” has three receiver outcomes, not one. When terminal precedence selects child reporting it already forwards `ScrollLeft` and `ScrollRight` as SGR codes 66/67. The local terminal branch — selected by Shift, reporting-off or a scrolled-back From 2d19713b0ec518a2966db1515d36f90ac28200fe Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 31 Aug 2026 22:54:44 +0200 Subject: [PATCH 12/72] feat(gui-1b): B1's per-target fractional wheel residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer 1b owes. `apply_wheel` used to round to whole lines and return on zero BEFORE consulting the pointer, so every sub-tick delta bound for the panel or the terminal was discarded by a decision taken upstream of routing. §2a CORRECTION 5 measured that ordering; this inverts it. The pipeline is now: classify the target, bank the fractional delta against THAT target's accumulator, route only the whole ticks that fall out. `WheelTarget` exists because `PointerSurface` cannot name what B1 needs --- it resolves panel geometry only and collapses the document, the terminal, the minimap and the chrome into one `Elsewhere`, three of which B1 and B6 must keep apart. Residual owners follow §2a's enumeration exactly: per panel, per terminal, the minimap's own, the document's --- and chrome shares the document's deliberately, so a gesture that strays onto the gutter does not lose its banked motion. Panel divider and background bank NOWHERE and clear the panel banks: a residual they could share with a cell would let motion over an inert strip complete a tick the moment the pointer entered a live one, which is the surface-switch jump B1 exists to forbid. Nine rows, including R1-R5's identity discriminators and §2a's required crossing witness. `trunc` rather than `round`, so a half-tick that was never delivered is not spent. Two things this commit does not do, both recorded rather than stubbed: - IDENTITY'S SECOND HALF --- disposal --- is owed. A residual keyed to a surface that goes away must go with it, and this frontend does not currently track "that buffer is gone". A helper nothing calls would read as a contract met, so the method is absent and the gap is documented on the type. - `scroll_by_columns` and the manual-authority latch land here as B3/B7 and Q#S1-11's foundation, but their witnesses (L1-L8) do not. They come with the horizontal wire path. One behaviour regression caught by an existing row and fixed: a wheel before the first cursor motion has no pointer position, and the first draft dropped it. It targets the document, as it did before 1b. --- pmacs-gpu/src/main.rs | 552 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 495 insertions(+), 57 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 9b2c113..e8bfeb0 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1869,6 +1869,15 @@ struct State { /// selection, until release. Never sends `Pointer` events — /// the viewport is frontend-owned. minimap_scrub_active: bool, + /// GUI Stage 1b, Q#S1-11 clause 2: a horizontal wheel that + /// EFFECTIVELY moved the origin makes it authoritative, and the + /// caret follow leaves it alone until the cursor position actually + /// changes. A move fully absorbed by the clamp arms nothing. + manual_left_authority: bool, + /// GUI Stage 1b B1: per-target, per-axis fractional wheel residual. + /// Sub-tick deltas are banked here instead of being rounded away + /// before routing knows where they were going. + wheel_residuals: WheelResiduals, /// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge /// band; `about_to_wait` ticks the viewport one line toward the /// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag @@ -2109,6 +2118,122 @@ enum PointerSurface { Elsewhere, } +/// The six wheel targets GUI Stage 1b's B1 owns a residual for. +/// +/// **`PointerSurface` cannot name these**, which is why this exists +/// (framing §2a CORRECTION 5): that classifier resolves panel geometry +/// only, and collapses the document, the terminal, the minimap and the +/// chrome into a single `Elsewhere` — three of which B1 and B6 must +/// keep apart. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WheelTarget { + /// A cell inside the band. Residual **per panel**, keyed by the + /// panel's buffer. + PanelCell { buffer: BufferId, coord: CellCoord }, + /// The divider or the band's background. **The band owns the + /// pixel**, so both axes are consumed — and nothing is banked. + PanelChrome, + /// The terminal clip. Residual **per terminal**, keyed by buffer. + Terminal { buffer: BufferId, coord: CellCoord }, + /// The minimap band. **Its own** residual (B6). + Minimap, + /// Document text. Shares its residual with [`WheelTarget::Chrome`]. + Document, + /// Anything else outside the band: gutter, margins, status chrome. + /// **Shares the document's residual, deliberately** — a wheel that + /// strays onto the gutter mid-gesture must not lose the motion. + Chrome, +} + +/// Which accumulator a [`WheelTarget`] banks into. +/// +/// Two targets map to `Document` on purpose, and one target maps to +/// nothing at all: panel chrome consumes without banking, because a +/// residual it could share with a cell would let motion over an inert +/// strip complete a tick the moment the pointer entered a live one — +/// **a surface-switch jump manufactured by the accumulator itself**, +/// which is exactly what B1 exists to forbid. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum ResidualOwner { + /// R2's identity: two panels are two owners. + Panel(BufferId), + /// R3's identity: two terminals are two owners. + Terminal(BufferId), + /// R5: independent of the document's, though it moves the document. + Minimap, + /// R4: the document's, shared with chrome. + Document, +} + +impl WheelTarget { + /// The accumulator this target banks into, or `None` when it banks + /// nowhere. + fn residual_owner(self) -> Option { + match self { + Self::PanelCell { buffer, .. } => Some(ResidualOwner::Panel(buffer)), + Self::Terminal { buffer, .. } => Some(ResidualOwner::Terminal(buffer)), + Self::Minimap => Some(ResidualOwner::Minimap), + Self::Document | Self::Chrome => Some(ResidualOwner::Document), + Self::PanelChrome => None, + } + } +} + +/// B1's per-owner, per-axis fractional wheel residual. +/// +/// **The producer 1b adds.** Before this, `apply_wheel` rounded to +/// whole lines and returned on zero *before* it knew where the delta +/// was going, so every sub-tick motion bound for the panel or the +/// terminal was discarded by a decision taken upstream of routing. +/// +/// Identity has two halves. The first is the key: a panel residual is +/// keyed to *that* panel, so a gesture that crosses from panel A to +/// panel B does not spend A's bank on B (R2, and R3 for terminals). +/// The second is **disposal** — a residual keyed to a surface that goes +/// away must go away with it, or it is spent on whatever later takes +/// that identity. +/// +/// **Disposal is NOT implemented yet, and is owed before this slice +/// ships.** It needs a signal for "this panel/terminal buffer is gone", +/// which this frontend does not currently track, and it needs its own +/// witness. It is recorded here rather than stubbed, because a helper +/// nothing calls reads as a contract met. +#[derive(Debug, Default)] +struct WheelResiduals { + /// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`. + banks: HashMap, +} + +impl WheelResiduals { + /// Bank `(dx, dy)` fractional ticks for `owner` and return the whole + /// ticks that fall out, keeping the remainder. + /// + /// `trunc`, not `round`: rounding would spend a half-tick that was + /// never delivered and leave a negative remainder behind. + fn accumulate(&mut self, owner: ResidualOwner, dx: f32, dy: f32) -> (i64, i64) { + let bank = self.banks.entry(owner).or_insert((0.0, 0.0)); + bank.0 += dx; + bank.1 += dy; + let ticks_x = bank.0.trunc(); + let ticks_y = bank.1.trunc(); + bank.0 -= ticks_x; + bank.1 -= ticks_y; + (ticks_x as i64, ticks_y as i64) + } + + /// Drop every panel bank. Panel chrome consumes both axes and must + /// leave nothing that could combine with cell input later. + fn clear_panels(&mut self) { + self.banks + .retain(|owner, _| !matches!(owner, ResidualOwner::Panel(_))); + } + + #[cfg(test)] + fn bank_of(&self, owner: ResidualOwner) -> Option<(f32, f32)> { + self.banks.get(&owner).copied() + } +} + /// A live divider drag (Q#BP15a, parent acceptance 47). #[derive(Clone, Copy, Debug)] struct PanelDrag { @@ -3242,73 +3367,148 @@ impl App { } } + /// Resolve a pointer position to the wheel target that owns it. + /// + /// **This runs BEFORE quantization**, which is the whole point: + /// §2a CORRECTION 5 measured that rounding and the `lines == 0` + /// return happened upstream of routing, so a sub-tick delta bound + /// for the panel or the terminal was discarded before anything knew + /// where it was going. + /// + /// `Chrome` is the fallthrough rather than `Document` so the + /// enumeration stays total: every pixel outside the band is one of + /// terminal, minimap, document or chrome, and the first three are + /// each tested explicitly. + fn classify_wheel_target(&mut self, x: f64, y: f64) -> WheelTarget { + match self + .state + .as_ref() + .map(|s| s.classify_pointer_surface(x as f32, y as f32)) + { + Some(PointerSurface::PanelCell(_)) => { + if let Some((_, _, buffer, coord)) = self.panel_pointer_hit(x, y) { + return WheelTarget::PanelCell { buffer, coord }; + } + // The band owns the pixel even when it maps to no cell. + return WheelTarget::PanelChrome; + } + Some(PointerSurface::PanelDivider | PointerSurface::PanelBackground) => { + return WheelTarget::PanelChrome; + } + Some(PointerSurface::Elsewhere) | None => {} + } + if let Some((buffer, coord)) = self.terminal_pointer_hit(x, y) { + return WheelTarget::Terminal { buffer, coord }; + } + let Some(state) = self.state.as_mut() else { + return WheelTarget::Chrome; + }; + if state.in_minimap_band(x, y) { + return WheelTarget::Minimap; + } + if state.hit_test_source_byte(x, y).is_some() { + return WheelTarget::Document; + } + WheelTarget::Chrome + } + /// Perform [`PointerRoute::Wheel`]. + /// + /// **GUI Stage 1b B1 reorders this pipeline.** It used to round to + /// whole lines and return on zero *before* consulting the pointer, + /// so a sub-tick delta bound for the panel or the terminal was + /// discarded by a decision taken upstream of routing. The order is + /// now: classify the target, bank the fractional delta against + /// **that target's** accumulator, and route only the whole ticks + /// that fall out. #[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 { + let Some(state) = self.state.as_ref() 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 + // Fractional deltas, in the units each axis scrolls by: lines + // for y, columns for x. Positive winit y = scroll up = smaller + // scroll_top, hence the negation. + let line_height = state.fm.code_line_height(); + let column_width = state.mono_advance(); + let (dx, dy) = match delta { + winit::event::MouseScrollDelta::LineDelta(x, y) => { + (x * WHEEL_LINES_PER_TICK, -y * WHEEL_LINES_PER_TICK) } - 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 + winit::event::MouseScrollDelta::PixelDelta(p) => ( + if column_width > 0.0 { + p.x as f32 / column_width } else { - ProtocolMouseKind::ScrollDown - }; - self.send_terminal_pointer(buffer_id, coord, kind, mods); + 0.0 + }, + if line_height > 0.0 { + -(p.y as f32) / line_height + } else { + 0.0 + }, + ), + }; + // No pointer position yet — a wheel before the first cursor + // motion. The document is the target, which is what this path + // did before 1b; dropping the input instead would be a + // regression B1 never asked for. + let (target, x, y) = match state.pointer_pos { + Some((x, y)) => (self.classify_wheel_target(x, y), x, y), + None => (WheelTarget::Document, 0.0, 0.0), + }; + + // The band owns the pixel: consume both axes and bank nothing. + // Panel banks are cleared so this motion can never combine with + // cell input later, which is the surface-switch jump B1 forbids. + if matches!(target, WheelTarget::PanelChrome) { + if let Some(state) = self.state.as_mut() { + state.wheel_residuals.clear_panels(); } 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}"); + let Some(owner) = target.residual_owner() else { + return; + }; + let Some(state) = self.state.as_mut() else { + return; + }; + let (ticks_x, ticks_y) = state.wheel_residuals.accumulate(owner, dx, dy); + if ticks_x == 0 && ticks_y == 0 { + return; + } + let mods = translate_mods(self.modifiers); + match target { + WheelTarget::PanelChrome => unreachable!("consumed above"), + WheelTarget::PanelCell { .. } => { + for kind in wheel_kinds(ticks_x, ticks_y) { + self.send_panel_pointer_at(x, y, kind, mods); + } + } + WheelTarget::Terminal { buffer, coord } => { + for kind in wheel_kinds(ticks_x, ticks_y) { + self.send_terminal_pointer(buffer, coord, kind, mods); + } + } + WheelTarget::Minimap | WheelTarget::Document | WheelTarget::Chrome => { + if ticks_y != 0 { + let vp = self + .state + .as_mut() + .and_then(|state| state.scroll_by_lines(ticks_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: wheel send_viewport failed: {e}"); + } + } + if ticks_x != 0 + && let Some(state) = self.state.as_mut() + { + state.scroll_by_columns(ticks_x); + } + } } } @@ -5059,6 +5259,32 @@ const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_milli /// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit. const WHEEL_LINES_PER_TICK: f32 = 3.0; +/// The wire scroll kinds for a banked `(x, y)` tick count, in order. +/// +/// One event per whole tick: a single wheel notch that banks two ticks +/// must move the receiver twice, and a receiver that coalesces is +/// making its own decision rather than being handed a rounded one. +fn wheel_kinds(ticks_x: i64, ticks_y: i64) -> Vec { + let mut kinds = Vec::new(); + let vertical = if ticks_y < 0 { + ProtocolMouseKind::ScrollUp + } else { + ProtocolMouseKind::ScrollDown + }; + for _ in 0..ticks_y.unsigned_abs() { + kinds.push(vertical); + } + let horizontal = if ticks_x < 0 { + ProtocolMouseKind::ScrollLeft + } else { + ProtocolMouseKind::ScrollRight + }; + for _ in 0..ticks_x.unsigned_abs() { + kinds.push(horizontal); + } + kinds +} + /// Byte range an optimistic Backspace/Delete removes at `cursor`, or /// `None` when it can't be predicted locally: buffer edge (the /// daemon's behavior is a no-op there anyway), a modifier variant @@ -5637,6 +5863,8 @@ impl State { last_pointer_sent_byte: None, last_pointer_down: None, minimap_scrub_active: false, + manual_left_authority: false, + wheel_residuals: WheelResiduals::default(), edge_scroll_dir: None, edge_scroll_last: None, styled_redraw_deadline: None, @@ -8090,6 +8318,66 @@ impl State { self.request_redraw(); } + /// Widest display line, in columns — B7's upper-bound input. + /// + /// Scans the laid-out lines; `line_layout` caches, so a repeat scan + /// on an unchanged buffer re-reads the cache rather than reshaping. + /// **Cost is proportional to the buffer's line count**, which is a + /// real consideration on a per-tick path and is called out for + /// review rather than optimised speculatively. + fn widest_display_columns(&mut self) -> u32 { + let advance = self.mono_advance(); + if advance <= 0.0 { + return 0; + } + let mut widest = 0.0f32; + for line_i in 0..self.buffer.lines.len() { + if let Some(layout) = self.buffer.line_layout(&mut self.font_system, line_i) { + for layout_line in layout { + widest = widest.max(layout_line.w); + } + } + } + (widest / advance).ceil().max(0.0) as u32 + } + + /// GUI Stage 1b B3/B7: move the horizontal origin by whole columns. + /// + /// The bound is B7's, stated exactly: `0 ..= widest − viewport`, + /// **saturating at zero** for buffers narrower than the viewport. + /// Clamping at the widest line's *full* width would let the origin + /// pass every glyph and leave the viewport blank. + /// + /// **Wrap pins the origin to zero** and clears manual authority + /// (lifetime clause 5): a wrapped buffer has nothing past the right + /// edge, so an origin — and a latch that would defend it — must not + /// survive. + /// + /// Returns whether the origin actually moved. Clause 2's "effective + /// move": one fully absorbed by the clamp arms nothing. + fn scroll_by_columns(&mut self, columns: i64) -> bool { + if self.buffer.wrap() != Wrap::None { + self.code_scroll_left = 0.0; + self.manual_left_authority = false; + return false; + } + let advance = self.mono_advance(); + let width = self.text_bounds_right() as f32 - self.text_left(); + if advance <= 0.0 || width <= 0.0 { + return false; + } + let viewport_cols = (width / advance).floor().max(0.0) as u32; + let max_left = self.widest_display_columns().saturating_sub(viewport_cols); + let current = (self.code_scroll_left / advance).round().max(0.0) as i64; + let next = (current + columns).clamp(0, i64::from(max_left)); + if next == current { + return false; + } + self.code_scroll_left = next as f32 * advance; + self.manual_left_authority = true; + true + } + /// Move `code_scroll_left` so the caret's column is on screen /// (Stage 5, framing Q#G2 — automatic only). /// @@ -14095,6 +14383,156 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> { #[cfg(test)] mod tests { + use super::{BufferId, ResidualOwner, WheelResiduals, WheelTarget, wheel_kinds}; + use pmacs_protocol::MouseKind as ProtocolMouseKind; + + /// Distinct ids. `BufferId::next` is the only constructor — the + /// inner field is private on purpose — so identity comes from + /// allocation order rather than a literal. + fn buf(_n: u64) -> BufferId { + BufferId::next() + } + + /// B1/R1 — the producer itself: sub-tick deltas are banked, not + /// rounded away, and they reach a tick together. + /// + /// This is the row #243 cannot satisfy. Its receiver only ever saw + /// whole ticks, so a producer that discards every fraction is + /// invisible to it. + #[test] + fn r1_sub_tick_deltas_bank_and_then_spend_exactly_one_tick() { + let mut r = WheelResiduals::default(); + let owner = ResidualOwner::Document; + assert_eq!( + r.accumulate(owner, 0.0, 0.4), + (0, 0), + "first sub-tick moves nothing" + ); + assert_eq!( + r.accumulate(owner, 0.0, 0.4), + (0, 0), + "still short of a tick" + ); + assert_eq!( + r.accumulate(owner, 0.0, 0.4), + (0, 1), + "1.2 banked spends exactly one tick, not two" + ); + let (_, y) = r.bank_of(owner).expect("bank survives"); + assert!( + (y - 0.2).abs() < 1e-5, + "the remainder is kept, not dropped: {y}" + ); + } + + /// B1 — the two axes are independent accumulators, not one. + #[test] + fn b1_axes_bank_independently() { + let mut r = WheelResiduals::default(); + let owner = ResidualOwner::Document; + assert_eq!(r.accumulate(owner, 0.6, 0.0), (0, 0)); + assert_eq!(r.accumulate(owner, 0.0, 0.6), (0, 0)); + assert_eq!( + r.accumulate(owner, 0.6, 0.6), + (1, 1), + "each axis reaches its own tick on its own schedule" + ); + } + + /// R2 — panel A's bank is not spent on panel B. + #[test] + fn r2_panel_identity_does_not_leak_across_panels() { + let mut r = WheelResiduals::default(); + let a = ResidualOwner::Panel(buf(1)); + let b = ResidualOwner::Panel(buf(2)); + assert_eq!(r.accumulate(a, 0.0, 0.9), (0, 0)); + assert_eq!( + r.accumulate(b, 0.0, 0.2), + (0, 0), + "panel B starts from zero; sharing would spend A's 0.9 here" + ); + } + + /// R3 — the same, across two terminals. + #[test] + fn r3_terminal_identity_does_not_leak_across_terminals() { + let mut r = WheelResiduals::default(); + let a = ResidualOwner::Terminal(buf(7)); + let b = ResidualOwner::Terminal(buf(8)); + assert_eq!(r.accumulate(a, 0.0, 0.9), (0, 0)); + assert_eq!(r.accumulate(b, 0.0, 0.2), (0, 0)); + } + + /// R4 — document and chrome SHARE, deliberately: a gesture that + /// strays onto the gutter must not lose its banked motion. + #[test] + fn r4_document_and_chrome_share_one_bank() { + assert_eq!( + WheelTarget::Document.residual_owner(), + WheelTarget::Chrome.residual_owner(), + "chrome banks into the document's accumulator" + ); + let mut r = WheelResiduals::default(); + let owner = WheelTarget::Document + .residual_owner() + .expect("document banks"); + assert_eq!(r.accumulate(owner, 0.0, 0.7), (0, 0)); + assert_eq!( + r.accumulate(WheelTarget::Chrome.residual_owner().unwrap(), 0.0, 0.7), + (0, 1), + "the strayed half completes the tick instead of being lost" + ); + } + + /// R5 — the minimap moves the document viewport but banks + /// independently of it. + #[test] + fn r5_minimap_bank_is_independent_of_the_documents() { + let mut r = WheelResiduals::default(); + assert_eq!(r.accumulate(ResidualOwner::Minimap, 0.0, 0.9), (0, 0)); + assert_eq!( + r.accumulate(ResidualOwner::Document, 0.0, 0.2), + (0, 0), + "the document starts from zero despite the minimap's 0.9" + ); + } + + /// The crossing witness §2a requires: partial motion over the band's + /// chrome, then partial motion over a cell, **must not reach a + /// tick**. Panel chrome banks nowhere and clears what a cell banked. + #[test] + fn panel_chrome_banks_nothing_and_cannot_combine_with_cell_input() { + assert_eq!( + WheelTarget::PanelChrome.residual_owner(), + None, + "the band owns the pixel and banks nothing" + ); + let mut r = WheelResiduals::default(); + let cell = ResidualOwner::Panel(buf(3)); + assert_eq!(r.accumulate(cell, 0.0, 0.9), (0, 0)); + r.clear_panels(); + assert_eq!( + r.accumulate(cell, 0.0, 0.2), + (0, 0), + "a gesture that scrolled nothing must not complete a tick on arrival" + ); + } + + /// One event per whole tick, in a stable order. + #[test] + fn wheel_kinds_emits_one_event_per_banked_tick() { + assert_eq!(wheel_kinds(0, 0), Vec::new()); + assert_eq!( + wheel_kinds(0, 2), + vec![ProtocolMouseKind::ScrollDown, ProtocolMouseKind::ScrollDown] + ); + assert_eq!(wheel_kinds(0, -1), vec![ProtocolMouseKind::ScrollUp]); + assert_eq!( + wheel_kinds(-1, 1), + vec![ProtocolMouseKind::ScrollDown, ProtocolMouseKind::ScrollLeft] + ); + } + use super::*; use pmacs_protocol::cell::Style; From 0f29530dd82b1bcf7df36edd81ee27f023a1d2f6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 00:00:59 +0200 Subject: [PATCH 13/72] fix(gui-1b): notch units, minimap inertness, document-wide bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three implementation blockers and one evidence-labelling defect, all from review. THE WIRE TARGETS MULTIPLIED ONE NOTCH TWICE. `apply_wheel` banked in LINES (notch x 3), then emitted one event per banked unit, and the receiver applied its own SCROLL_LINES = 3 to each. So LineDelta(0, 1) moved a panel or terminal NINE lines while the document moved three --- and it broke the "exactly one viewport effect" witness this slice owes before it was written. The accumulator now banks in NOTCHES, the unit that survives the wire, and the three-line/column step is applied exactly once at the point of effect. Pixel deltas divide by a notch's pixel height rather than a line's. THE MINIMAP SCROLLED THE DOCUMENT SIDEWAYS. §2a rules the minimap's horizontal axis inert; the shared local arm was passing its banked x to `scroll_by_columns`. It keeps its own vertical bank (B6) and no longer moves the document horizontally. B3's UPPER BOUND SAW ONLY THE VISIBLE SLICE. `widest_display_columns` scanned `self.buffer.lines`, which `rebuild_code_slice` populates from the visible window plus overscan, so every off-screen line was excluded: horizontal scrolling clamped prematurely and the bound moved as the view scrolled vertically. It now reads `current_text` --- the whole document --- through a display-column rule shared with the minimap rather than a third copy. Cost is O(document) on the wheel path, which is a real risk against this project's wall-clock budgets and is recorded on the function rather than pre-optimised: a cache needs an invalidation key, and the wrong key is a worse defect than a measurable scan. AND THE R-NAMES WERE WRONG. The rows I called R4 and R5 test document/chrome sharing and minimap independence; the framing's R4 and R5 are the two BUFFER-REPLACEMENT resets. The row I called R1 is basic accumulation; the real R1 is cross-axis. Renamed, and R1's body now asserts what R1 says --- a sub-tick horizontal followed by a sub-tick vertical over the same surface reaches no tick on either axis. The resets themselves are now implemented, on the buffer-replacement path beside `code_scroll_left`, as two separate clears so that omitting one is individually visible. Their witnesses --- an actual replacement through the harness --- are still owed and are labelled as such. --- pmacs-gpu/src/main.rs | 200 +++++++++++++++++++++++++++++++----------- 1 file changed, 149 insertions(+), 51 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e8bfeb0..06b7a27 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2221,6 +2221,18 @@ impl WheelResiduals { (ticks_x as i64, ticks_y as i64) } + /// Drop the document's bank (shared with chrome) — R4's reset. + fn clear_document(&mut self) { + self.banks.remove(&ResidualOwner::Document); + } + + /// Drop the minimap's bank — R5's reset, separate from R4's because + /// B6 gives the minimap its own accumulator and a single combined + /// clear would let one omission hide behind the other. + fn clear_minimap(&mut self) { + self.banks.remove(&ResidualOwner::Minimap); + } + /// Drop every panel bank. Panel chrome consumes both axes and must /// leave nothing that could combine with cell input later. fn clear_panels(&mut self) { @@ -3426,23 +3438,28 @@ impl App { let Some(state) = self.state.as_ref() else { return; }; - // Fractional deltas, in the units each axis scrolls by: lines - // for y, columns for x. Positive winit y = scroll up = smaller - // scroll_top, hence the negation. - let line_height = state.fm.code_line_height(); - let column_width = state.mono_advance(); + // **Fractional deltas in NOTCHES, not lines or columns.** The + // notch is the unit that survives the wire: a receiver applies + // its own per-notch step (`SCROLL_LINES`), so banking in lines + // here would apply the step twice — one notch would move a + // panel nine lines while the document moved three. The step is + // applied exactly once, at the point of effect; the wire carries + // notches. + // + // Positive winit y = scroll up = smaller scroll_top, hence the + // negation. + let notch_px_y = state.fm.code_line_height() * WHEEL_LINES_PER_TICK; + let notch_px_x = state.mono_advance() * WHEEL_COLUMNS_PER_TICK; let (dx, dy) = match delta { - winit::event::MouseScrollDelta::LineDelta(x, y) => { - (x * WHEEL_LINES_PER_TICK, -y * WHEEL_LINES_PER_TICK) - } + winit::event::MouseScrollDelta::LineDelta(x, y) => (x, -y), winit::event::MouseScrollDelta::PixelDelta(p) => ( - if column_width > 0.0 { - p.x as f32 / column_width + if notch_px_x > 0.0 { + p.x as f32 / notch_px_x } else { 0.0 }, - if line_height > 0.0 { - -(p.y as f32) / line_height + if notch_px_y > 0.0 { + -(p.y as f32) / notch_px_y } else { 0.0 }, @@ -3490,11 +3507,14 @@ impl App { } } WheelTarget::Minimap | WheelTarget::Document | WheelTarget::Chrome => { + // The local step, applied exactly once: notches become + // lines here and nowhere else. if ticks_y != 0 { + let lines = ticks_y * WHEEL_LINES_PER_TICK as i64; let vp = self .state .as_mut() - .and_then(|state| state.scroll_by_lines(ticks_y)); + .and_then(|state| state.scroll_by_lines(lines)); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() && let Err(e) = @@ -3503,10 +3523,16 @@ impl App { eprintln!("pmacs-gpu: wheel send_viewport failed: {e}"); } } + // **The minimap's horizontal axis is INERT** (§2a's + // enumeration rules it so). It banks vertically like any + // other target — B6 gives it its own accumulator — but a + // horizontal notch over the minimap must not scroll the + // document sideways. if ticks_x != 0 + && !matches!(target, WheelTarget::Minimap) && let Some(state) = self.state.as_mut() { - state.scroll_by_columns(ticks_x); + state.scroll_by_columns(ticks_x * WHEEL_COLUMNS_PER_TICK as i64); } } } @@ -5259,6 +5285,10 @@ const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_milli /// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit. const WHEEL_LINES_PER_TICK: f32 = 3.0; +/// Columns per horizontal wheel notch — B7's "three columns per wheel +/// tick", the horizontal twin of [`WHEEL_LINES_PER_TICK`]. +const WHEEL_COLUMNS_PER_TICK: f32 = 3.0; + /// The wire scroll kinds for a banked `(x, y)` tick count, in order. /// /// One event per whole tick: a single wheel notch that banks two ticks @@ -6452,6 +6482,20 @@ impl State { // cursor motion repairs it — a symptom nothing about // the new buffer explains. self.code_scroll_left = 0.0; + // GUI Stage 1b R4/R5 — the wheel residuals for the + // DOCUMENT and the MINIMAP live in this long-lived + // `State` and outlive the buffer, so they reset here + // for the same reason `code_scroll_left` does: a new + // buffer must not inherit banked motion from the old + // one. Two separate lines rather than one clear, so a + // mutation that omits either is individually visible — + // chrome shares the document's owner, so that one line + // serves both. + self.wheel_residuals.clear_document(); + self.wheel_residuals.clear_minimap(); + // Manual horizontal authority is viewport state tied to + // the document being shown (lifetime clause 5). + self.manual_left_authority = false; self.last_viewport_sent = None; // Vterm Stage 3 — a snapshot ALWAYS leaves terminal // mode, including a terminal→terminal switch. The prior @@ -8318,27 +8362,29 @@ impl State { self.request_redraw(); } - /// Widest display line, in columns — B7's upper-bound input. + /// Widest display line **in the whole document**, in columns — + /// B7's upper-bound input. /// - /// Scans the laid-out lines; `line_layout` caches, so a repeat scan - /// on an unchanged buffer re-reads the cache rather than reshaping. - /// **Cost is proportional to the buffer's line count**, which is a - /// real consideration on a per-tick path and is called out for - /// review rather than optimised speculatively. - fn widest_display_columns(&mut self) -> u32 { - let advance = self.mono_advance(); - if advance <= 0.0 { - return 0; - } - let mut widest = 0.0f32; - for line_i in 0..self.buffer.lines.len() { - if let Some(layout) = self.buffer.line_layout(&mut self.font_system, line_i) { - for layout_line in layout { - widest = widest.max(layout_line.w); - } - } - } - (widest / advance).ceil().max(0.0) as u32 + /// **It must not read `self.buffer.lines`.** That holds only the + /// visible byte slice plus overscan (`rebuild_code_slice`, session + /// S1), so a bound taken from it excludes every off-screen line: + /// horizontal scrolling would clamp prematurely and the bound would + /// change as the view moved vertically. B3 asks for the widest + /// display line of the document, so this reads `current_text`. + /// + /// **Cost is O(document) per call**, and this sits on the wheel + /// path. That is a real risk against this project's wall-clock + /// budget rows and is recorded rather than pre-optimised: a cache + /// needs an invalidation key, and the wrong key is a worse defect + /// than a measurable scan. + fn widest_display_columns(&self) -> u32 { + let widest = self + .current_text + .split('\n') + .map(|line| line.chars().fold(0usize, advance_display_col)) + .max() + .unwrap_or(0); + u32::try_from(widest).unwrap_or(u32::MAX) } /// GUI Stage 1b B3/B7: move the horizontal origin by whole columns. @@ -12803,7 +12849,7 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape { let mut indent_cols = 0usize; let mut in_indent = true; for ch in line.trim_end_matches('\r').chars() { - let next_col = advance_minimap_col(total_cols, ch); + let next_col = advance_display_col(total_cols, ch); if in_indent && (ch == ' ' || ch == '\t') { indent_cols = next_col; } else { @@ -12817,7 +12863,14 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape { } } -fn advance_minimap_col(col: usize, ch: char) -> usize { +/// Advance a display column past one character: tab stops, then +/// Unicode terminal width. +/// +/// Shared by the minimap and by B3's widest-line bound so the two +/// cannot disagree about what a column is. It is the same rule as the +/// TUI's `display_width::advance_char`; that copy lives in the other +/// crate. +fn advance_display_col(col: usize, ch: char) -> usize { if ch == '\t' { let tab_stop = TAB_STOP_COLUMNS as usize; col + tab_stop - col % tab_stop @@ -14393,14 +14446,17 @@ mod tests { BufferId::next() } - /// B1/R1 — the producer itself: sub-tick deltas are banked, not - /// rounded away, and they reach a tick together. + /// B1's producer itself: sub-tick deltas are banked, not rounded + /// away, and they reach a tick together. + /// + /// **Not a framed R-row.** The framing's R1 is the CROSS-AXIS row; + /// this is the basic accumulation this slice rests on. /// /// This is the row #243 cannot satisfy. Its receiver only ever saw /// whole ticks, so a producer that discards every fraction is /// invisible to it. #[test] - fn r1_sub_tick_deltas_bank_and_then_spend_exactly_one_tick() { + fn sub_tick_deltas_bank_and_then_spend_exactly_one_tick() { let mut r = WheelResiduals::default(); let owner = ResidualOwner::Document; assert_eq!( @@ -14425,18 +14481,30 @@ mod tests { ); } - /// B1 — the two axes are independent accumulators, not one. + /// **R1 — cross-axis.** A sub-tick horizontal motion followed by a + /// sub-tick vertical motion over the *same* surface reaches no tick + /// on either axis. + /// + /// *Mutation: one residual per surface instead of one per (surface, + /// axis)* — the two half-ticks combine and this row sees a tick. #[test] - fn b1_axes_bank_independently() { + fn r1_cross_axis_half_ticks_do_not_combine() { let mut r = WheelResiduals::default(); let owner = ResidualOwner::Document; - assert_eq!(r.accumulate(owner, 0.6, 0.0), (0, 0)); - assert_eq!(r.accumulate(owner, 0.0, 0.6), (0, 0)); assert_eq!( - r.accumulate(owner, 0.6, 0.6), - (1, 1), - "each axis reaches its own tick on its own schedule" + r.accumulate(owner, 0.6, 0.0), + (0, 0), + "a sub-tick horizontal motion moves nothing" ); + assert_eq!( + r.accumulate(owner, 0.0, 0.6), + (0, 0), + "and a sub-tick vertical motion over the SAME surface still \ + reaches no tick on either axis — one accumulator per \ + surface would have combined 0.6 + 0.6 into a tick here" + ); + // Each axis still reaches its own tick on its own schedule. + assert_eq!(r.accumulate(owner, 0.5, 0.5), (1, 1)); } /// R2 — panel A's bank is not spent on panel B. @@ -14463,10 +14531,13 @@ mod tests { assert_eq!(r.accumulate(b, 0.0, 0.2), (0, 0)); } - /// R4 — document and chrome SHARE, deliberately: a gesture that + /// Document and chrome SHARE one bank, deliberately: a gesture that /// strays onto the gutter must not lose its banked motion. + /// + /// **Not the framing's R4.** That row is the document/chrome + /// BUFFER-REPLACEMENT reset, which is still owed. #[test] - fn r4_document_and_chrome_share_one_bank() { + fn document_and_chrome_share_one_bank() { assert_eq!( WheelTarget::Document.residual_owner(), WheelTarget::Chrome.residual_owner(), @@ -14484,10 +14555,13 @@ mod tests { ); } - /// R5 — the minimap moves the document viewport but banks - /// independently of it. + /// The minimap moves the document viewport but banks independently + /// of it. + /// + /// **Not the framing's R5.** That row is the minimap's + /// BUFFER-REPLACEMENT reset, which is still owed. #[test] - fn r5_minimap_bank_is_independent_of_the_documents() { + fn minimap_bank_is_independent_of_the_documents() { let mut r = WheelResiduals::default(); assert_eq!(r.accumulate(ResidualOwner::Minimap, 0.0, 0.9), (0, 0)); assert_eq!( @@ -14518,6 +14592,30 @@ mod tests { ); } + /// R4 and R5's resets exist and are SEPARATE, so a mutation that + /// omits one is individually visible. + /// + /// **This is not R4/R5's witness.** Those rows require an actual + /// buffer replacement through the harness — bank a sub-tick over + /// document A, replace the buffer, and see motion over B start from + /// zero. That row is still owed; this only pins that the two clears + /// are distinct operations rather than one. + #[test] + fn document_and_minimap_resets_are_separate_operations() { + let mut r = WheelResiduals::default(); + assert_eq!(r.accumulate(ResidualOwner::Document, 0.0, 0.9), (0, 0)); + assert_eq!(r.accumulate(ResidualOwner::Minimap, 0.0, 0.9), (0, 0)); + r.clear_document(); + assert_eq!(r.bank_of(ResidualOwner::Document), None); + assert!( + r.bank_of(ResidualOwner::Minimap).is_some(), + "clearing the document must not clear the minimap, or one \ + omission hides behind the other" + ); + r.clear_minimap(); + assert_eq!(r.bank_of(ResidualOwner::Minimap), None); + } + /// One event per whole tick, in a stable order. #[test] fn wheel_kinds_emits_one_event_per_banked_tick() { From d3d720ba8a096c922c67b9c20d365edaf7ff42b4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 10:07:23 +0200 Subject: [PATCH 14/72] feat(gui-1b): B2's horizontal leg --- the panel origin actually moves `PKind::ScrollLeft | PKind::ScrollRight` were CLAIMED AND DROPPED in the panel replay, with a comment assigning the axis to Stage 1b. That is the "frontend emits, receiver discards" shape the panel-replay lane was opened to fix, inherited for the horizontal axis. This closes it. `scroll_window_columns` moves the side window's `view_left` by B7's bound, stated exactly: `0 ..= widest - viewport`, saturating at zero, so the final display column stays visible --- clamping at the widest line's full width would let the origin pass every glyph and blank the viewport. Wrap pins the origin to zero, matching `horizontal_follow`. It returns whether the origin actually moved, which is lifetime clause 2's "effective move". The widest-line rule is SHARED. `display_width::widest_line_columns` lives beside the module's other column helpers and both frontends use it, for the same reason `scroll::follow_left` is shared: two frontends that compute the right bound differently disagree about where the document ends. B2's row asserts the EFFECT --- `view_left` before and after --- not an emission, and it carries the discriminating setup the bound requires: a panel whose content fits has a maximum origin of zero, so the move is absorbed by the clamp and a dropped event reads identical to correct behaviour. The fixture gets a line wider than the viewport. Mutation: restore the claimed-and-dropped arm, and the row fires. Two mistakes of mine in this commit's history, both caught before it: - I reverted a mutation with `git checkout -- src/editor.rs` on a file holding UNCOMMITTED work, and destroyed the whole B2 implementation. Re-applied, and the mutation check redone against a file snapshot --- the discipline I had used earlier in the CRDT lane and dropped here. - Inserting the new test above an existing one STOLE ITS `#[test]` and its doc comment, so `r4_p1_a_chrome_press_neither_arms_nor_moves_point` silently stopped being a test. Clippy's "never used" caught it. Both are restored, and the suite count confirms 1994 tests rather than 1993. --- pmacs-gpu/src/main.rs | 11 ++++++ src/daemon.rs | 64 ++++++++++++++++++++++++++++++++ src/display_width.rs | 18 +++++++++ src/editor.rs | 85 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 06b7a27..d7a27c7 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8377,6 +8377,17 @@ impl State { /// budget rows and is recorded rather than pre-optimised: a cache /// needs an invalidation key, and the wrong key is a worse defect /// than a measurable scan. + /// + /// **Which "display line" this measures is a boundary B3's witness + /// must settle.** This counts SOURCE-TEXT display columns — tab + /// stops and Unicode width — and therefore excludes rendered + /// projections such as inline adornments and math substitutions, + /// which can occupy a different width on screen than the bytes they + /// stand for. That is consistent with the TUI-derived column rule + /// the two frontends share, and it is a choice, not an oversight: + /// "widest display line" is readable the other way. Recorded here + /// so the witness states which meaning governs rather than + /// discovering it. fn widest_display_columns(&self) -> u32 { let widest = self .current_text diff --git a/src/daemon.rs b/src/daemon.rs index 4394d99..7a1c0f6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7673,6 +7673,70 @@ mod tests { /// P1 — a press on the band's MODE LINE begins nothing. /// + /// GUI Stage 1b B2 — a horizontal panel notch reaches the daemon's + /// window-targeted `view_left` path and MOVES it. + /// + /// This arm used to be claimed and dropped, which is the + /// "frontend emits, receiver discards" shape §2a named. The row + /// asserts the **effect**, not the emission: `view_left` before and + /// after. + #[test] + fn b2_a_horizontal_panel_notch_moves_the_side_windows_view_left() { + let fid = FrontendId(791); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + // A CONTENT cell, not chrome: row 0 is inside the grid. + let (buffer_id, cell) = { + let core = editor.core.borrow(); + ( + core.windows[&panel].buffer_id, + pmacs_protocol::CellCoord::new(0, 0), + ) + }; + // **The discriminating setup.** B7's bound is + // `widest − viewport`, so a panel whose content fits has a + // maximum origin of zero and the move is absorbed by the + // clamp — correct behaviour that would read here as a dropped + // event. The row needs content wider than the viewport. + foreign_edit(&editor, buffer_id, "x".repeat(400).as_bytes()); + let before = editor + .core + .borrow() + .windows + .get(&panel) + .map_or(0, |w| w.view_left); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + cell, + pmacs_protocol::MouseKind::ScrollRight, + ), + ); + + let after = editor + .core + .borrow() + .windows + .get(&panel) + .map_or(0, |w| w.view_left); + assert_ne!( + after, before, + "a horizontal panel notch must move the side window's origin, \ + not be claimed and dropped" + ); + } + /// The merge made this arm the latch, because `Consumed` and /// `Accepted` were the same `true`. The row reads the cursor as well /// as the latch: a chrome press must not move point either. diff --git a/src/display_width.rs b/src/display_width.rs index c80670e..89386de 100644 --- a/src/display_width.rs +++ b/src/display_width.rs @@ -23,6 +23,24 @@ pub fn advance_char(column: u32, ch: char) -> u32 { column.saturating_add(width) } +/// Widest line in `text`, in display columns. +/// +/// GUI Stage 1b B7's upper-bound input, shared by both frontends so +/// they cannot disagree about where the right bound is — the same +/// reason `scroll::follow_left` is shared. +/// +/// **This measures SOURCE-TEXT display columns**: tab stops and Unicode +/// width. Rendered projections — inline adornments, math substitutions +/// — can occupy a different width on screen and are deliberately not +/// counted, matching the column rule the rest of this module states. +#[must_use] +pub fn widest_line_columns(text: &str) -> u32 { + text.split('\n') + .map(|line| line.chars().fold(0, advance_char)) + .max() + .unwrap_or(0) +} + /// Display width of the valid UTF-8 prefix of `bytes`. /// /// Invalid input is conservatively truncated at the first invalid byte. This diff --git a/src/editor.rs b/src/editor.rs index 7be75ce..53c744e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3356,23 +3356,81 @@ impl EditorState { } self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col)); } - // Claimed and dropped, for two different reasons kept in one - // arm because their bodies are identical: horizontal panel - // scrolling belongs to GUI arc Stage 1b's B-rows rather than - // parent 48, bare `Move` neither focuses nor claims, and the - // remaining buttons have no panel semantics at all. - PKind::ScrollLeft - | PKind::ScrollRight - | PKind::Move - | PKind::Down(_) - | PKind::Up(_) - | PKind::Drag(_) => {} + // GUI Stage 1b B2 — the horizontal leg, which this arm used + // to claim and drop. One notch is `SCROLL_COLUMNS`; the + // frontend banks fractions and sends whole notches, so the + // step is applied here exactly once. + PKind::ScrollLeft => { + self.scroll_window_columns(side, -SCROLL_COLUMNS); + } + PKind::ScrollRight => { + self.scroll_window_columns(side, SCROLL_COLUMNS); + } + // Bare `Move` neither focuses nor claims, and the remaining + // buttons have no panel semantics at all. + PKind::Move | PKind::Down(_) | PKind::Up(_) | PKind::Drag(_) => {} } // Only a left press can anchor, and it returns `true` above. // Every other kind reaching here handled something already live. false } + /// GUI Stage 1b B2/B3/B7 — move one window's horizontal origin by + /// `columns`, the daemon-side effect a panel-document horizontal + /// wheel reaches. + /// + /// This closes the leg §2a named: `ScrollLeft`/`ScrollRight` were + /// **claimed and dropped** here, which is the "frontend emits, + /// receiver discards" shape the panel-replay lane was opened to fix + /// and that B1–B3 inherited for the horizontal axis. + /// + /// The bound is B7's, stated exactly — `0 ..= widest − viewport`, + /// **saturating at zero** — so the final display column stays + /// visible; clamping at the widest line's full width would let the + /// origin pass every glyph and blank the viewport. **Wrap pins the + /// origin to zero**, matching `horizontal_follow`. + /// + /// Returns whether the origin actually moved, which is clause 2's + /// "effective move". + fn scroll_window_columns(&mut self, win_id: WindowId, columns: i32) -> bool { + let mut core = self.core.borrow_mut(); + let Some(window) = core.windows.get(&win_id) else { + return false; + }; + if window.last_wrap == crate::view::WrapMode::Wrap { + if let Some(window) = core.windows.get_mut(&win_id) { + window.view_left = 0; + } + return false; + } + let buffer_id = window.buffer_id; + let viewport_cols = window.last_content_cols; + let old_left = window.view_left; + let registry = core.registry.clone(); + let widest = { + let reg = registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return false; + }; + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut bytes); + crate::display_width::widest_line_columns(&String::from_utf8_lossy(&bytes)) + }; + let max_left = widest.saturating_sub(viewport_cols); + let next = i64::from(old_left) + .saturating_add(i64::from(columns)) + .clamp(0, i64::from(max_left)); + let next = u32::try_from(next).unwrap_or(0); + if next == old_left { + return false; + } + if let Some(window) = core.windows.get_mut(&win_id) { + window.view_left = next; + } + true + } + /// Byte under a panel cell, resolved against the SIDE window's own /// `view_top` and fold map. /// @@ -4654,6 +4712,11 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Columns one horizontal wheel notch moves a window's origin — B7's +/// "three columns per wheel tick", the horizontal twin of +/// [`SCROLL_LINES`]. +const SCROLL_COLUMNS: i32 = 3; + /// Gutter marker drawn on a collapsed region's head row (Arc 6 Stage 2, /// Q#FD20). Occupies the gutter's leading pad cell — the same cell the /// diagnostic sign uses — so it adds no column and changes no width; it From 9e54cd2c5b53d939d1d5ec351d56fbac16e168f0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 10:34:00 +0200 Subject: [PATCH 15/72] refactor: put the display-column rule where both frontends share it The previous commit CLAIMED the widest-line rule was shared. It was not. The daemon called `src/display_width.rs`; the GPU folded through its own private `advance_display_col`, a second copy of the same tab-stop and Unicode-width arithmetic. The two agreed for ordinary input, so nothing failed --- which is precisely why the claim was worth checking and why asserting structural protection that does not exist is the defect, not the duplication itself. `pmacs_protocol::columns` now owns the rule, for the same reason `scroll::follow_left` lives there: the protocol crate is the one place both frontends already depend on. `advance_char`, `line_columns` and `widest_line_columns` live there with their own rows; `display_width` and the GPU both delegate. The sharing is now demonstrated rather than described. Mutating the tab stop inside `pmacs_protocol::columns` breaks the GPU's `minimap_columns_match_code_tab_and_unicode_widths` --- a row that used to run entirely through the private copy and could not have noticed. Also restores `r4_p1_a_chrome_press_neither_arms_nor_moves_point`'s opening line, "P1 --- a press on the band's MODE LINE begins nothing", which my insertion had left attached to the B2 test. The attribute came back last round; the first paragraph did not. --- pmacs-gpu/src/main.rs | 28 ++++-------- pmacs-protocol/src/columns.rs | 82 +++++++++++++++++++++++++++++++++++ pmacs-protocol/src/lib.rs | 1 + src/daemon.rs | 4 +- src/display_width.rs | 31 ++++--------- 5 files changed, 102 insertions(+), 44 deletions(-) create mode 100644 pmacs-protocol/src/columns.rs diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index d7a27c7..69977b9 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8389,13 +8389,7 @@ impl State { /// so the witness states which meaning governs rather than /// discovering it. fn widest_display_columns(&self) -> u32 { - let widest = self - .current_text - .split('\n') - .map(|line| line.chars().fold(0usize, advance_display_col)) - .max() - .unwrap_or(0); - u32::try_from(widest).unwrap_or(u32::MAX) + pmacs_protocol::columns::widest_line_columns(&self.current_text) } /// GUI Stage 1b B3/B7: move the horizontal origin by whole columns. @@ -12874,20 +12868,16 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape { } } -/// Advance a display column past one character: tab stops, then -/// Unicode terminal width. +/// Advance a display column past one character. /// -/// Shared by the minimap and by B3's widest-line bound so the two -/// cannot disagree about what a column is. It is the same rule as the -/// TUI's `display_width::advance_char`; that copy lives in the other -/// crate. +/// **Delegates to [`pmacs_protocol::columns::advance_char`]**, which is +/// where the rule lives. It used to be a private copy of the same +/// arithmetic, which is exactly the drift this crate and the daemon +/// must not have: a bound computed one way and a follow computed the +/// other disagree about where the document ends, invisibly until a tab +/// or a wide character reaches the edge. fn advance_display_col(col: usize, ch: char) -> usize { - if ch == '\t' { - let tab_stop = TAB_STOP_COLUMNS as usize; - col + tab_stop - col % tab_stop - } else { - col + UnicodeWidthChar::width(ch).unwrap_or(0) - } + pmacs_protocol::columns::advance_char(u32::try_from(col).unwrap_or(u32::MAX), ch) as usize } fn minimap_style_color(style: CellStyle) -> [f32; 4] { diff --git a/pmacs-protocol/src/columns.rs b/pmacs-protocol/src/columns.rs new file mode 100644 index 0000000..81945e2 --- /dev/null +++ b/pmacs-protocol/src/columns.rs @@ -0,0 +1,82 @@ +// columns.rs --- The display-column rule both frontends reckon in. + +//! One definition of "a display column", shared across the wire. +//! +//! The TUI and the GPU both need to answer "how wide is this line?" — +//! for the caret follow, for the minimap, and for GUI Stage 1b's B7 +//! right bound. **Two copies of that answer is a defect waiting to +//! happen**: a bound computed one way and a follow computed the other +//! disagree about where the document ends, and the disagreement is +//! invisible until a tab or a wide character reaches the edge. +//! +//! It lives here for the same reason [`crate::scroll::follow_left`] +//! does — the protocol crate is the one place both frontends already +//! depend on. +//! +//! **Scope: SOURCE-TEXT columns.** Tab stops and Unicode terminal +//! width. Rendered projections — inline adornments, math substitutions +//! — can occupy a different width on screen and are deliberately not +//! counted here. + +use unicode_width::UnicodeWidthChar; + +/// Advance `column` past one character. +/// +/// A tab reaches the next [`crate::TAB_STOP_COLUMNS`] stop; every other +/// character contributes its Unicode terminal width, so control and +/// zero-width characters do not advance. +#[must_use] +pub fn advance_char(column: u32, ch: char) -> u32 { + let width = if ch == '\t' { + crate::TAB_STOP_COLUMNS - (column % crate::TAB_STOP_COLUMNS) + } else { + UnicodeWidthChar::width(ch).unwrap_or(0) as u32 + }; + column.saturating_add(width) +} + +/// Display width of one line, in columns. +#[must_use] +pub fn line_columns(line: &str) -> u32 { + line.chars().fold(0, advance_char) +} + +/// Widest line in `text`, in display columns — B7's right-bound input. +#[must_use] +pub fn widest_line_columns(text: &str) -> u32 { + text.split('\n').map(line_columns).max().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{advance_char, line_columns, widest_line_columns}; + + #[test] + fn a_tab_reaches_the_next_stop_rather_than_advancing_one() { + assert_eq!(advance_char(0, '\t'), crate::TAB_STOP_COLUMNS); + assert_eq!(advance_char(1, '\t'), crate::TAB_STOP_COLUMNS); + assert_eq!( + advance_char(crate::TAB_STOP_COLUMNS, '\t'), + crate::TAB_STOP_COLUMNS * 2 + ); + } + + #[test] + fn wide_and_zero_width_characters_are_measured_not_counted() { + assert_eq!(line_columns("ab"), 2); + assert_eq!(line_columns("漢字"), 4, "wide characters take two columns"); + assert_eq!(line_columns("a\u{200b}b"), 2, "zero-width adds nothing"); + } + + /// The widest line, not the last one and not the first. + #[test] + fn widest_line_is_the_maximum_over_all_lines() { + assert_eq!(widest_line_columns("a\nbbbb\ncc"), 4); + assert_eq!(widest_line_columns(""), 0); + assert_eq!( + widest_line_columns("\tx"), + crate::TAB_STOP_COLUMNS + 1, + "tabs count toward the bound" + ); + } +} diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 858359e..d1d4700 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -37,6 +37,7 @@ //! directly. pub mod cell; +pub mod columns; pub mod crdt; pub mod ids; pub mod message; diff --git a/src/daemon.rs b/src/daemon.rs index 7a1c0f6..549facb 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7671,8 +7671,6 @@ mod tests { editor.core.borrow().windows[&panel].cursor } - /// P1 — a press on the band's MODE LINE begins nothing. - /// /// GUI Stage 1b B2 — a horizontal panel notch reaches the daemon's /// window-targeted `view_left` path and MOVES it. /// @@ -7737,6 +7735,8 @@ mod tests { ); } + /// P1 — a press on the band's MODE LINE begins nothing. + /// /// The merge made this arm the latch, because `Consumed` and /// `Accepted` were the same `true`. The row reads the cursor as well /// as the latch: a chrome press must not move point either. diff --git a/src/display_width.rs b/src/display_width.rs index 89386de..6b8f7cf 100644 --- a/src/display_width.rs +++ b/src/display_width.rs @@ -7,38 +7,23 @@ //! stop. Offsets are clamped to the supplied slice and offsets inside a UTF-8 //! code point resolve to the preceding complete-code-point boundary. -use unicode_width::UnicodeWidthChar; - /// Advance `column` past one character. /// -/// A tab reaches the next protocol tab stop; all other characters use their -/// Unicode terminal width. Control and zero-width characters do not advance. +/// **Delegates to [`pmacs_protocol::columns::advance_char`]**, which is +/// where the rule lives so both frontends cannot drift apart on what a +/// column is. This re-export keeps the existing call sites. #[must_use] pub fn advance_char(column: u32, ch: char) -> u32 { - let width = if ch == '\t' { - pmacs_protocol::TAB_STOP_COLUMNS - (column % pmacs_protocol::TAB_STOP_COLUMNS) - } else { - UnicodeWidthChar::width(ch).unwrap_or(0) as u32 - }; - column.saturating_add(width) + pmacs_protocol::columns::advance_char(column, ch) } -/// Widest line in `text`, in display columns. +/// Widest line in `text`, in display columns — B7's right bound. /// -/// GUI Stage 1b B7's upper-bound input, shared by both frontends so -/// they cannot disagree about where the right bound is — the same -/// reason `scroll::follow_left` is shared. -/// -/// **This measures SOURCE-TEXT display columns**: tab stops and Unicode -/// width. Rendered projections — inline adornments, math substitutions -/// — can occupy a different width on screen and are deliberately not -/// counted, matching the column rule the rest of this module states. +/// **Delegates to [`pmacs_protocol::columns::widest_line_columns`]**, +/// which both frontends call. #[must_use] pub fn widest_line_columns(text: &str) -> u32 { - text.split('\n') - .map(|line| line.chars().fold(0, advance_char)) - .max() - .unwrap_or(0) + pmacs_protocol::columns::widest_line_columns(text) } /// Display width of the valid UTF-8 prefix of `bytes`. From 364eb7b09502b9448a73180262701a9a2b9113b7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 10:42:07 +0200 Subject: [PATCH 16/72] refactor(gpu): the rich-text projection delegates too A THIRD copy of the rule lived in the projection that decides where the GPU actually renders a later tab: manual `stop - column % stop` arithmetic, and a per-character advance calling `UnicodeWidthChar::width` directly. So the previous commit's mutation broke the minimap while leaving the rendering path untouched --- the shared bound could still drift from the columns the GPU draws at. Both now delegate to `pmacs_protocol::columns::advance_char`: the tab width is DERIVED from the shared advance rather than recomputed, and the per-character step is the shared one. The projection's stream semantics stay local, because they are real and distinct: the column runs ACROSS chunks, so adornment text shifts a later tab, and a newline restarts it. That is why the wrapper still exists rather than being replaced outright. Evidence, not assertion: mutating the tab stop in `pmacs_protocol::columns` now breaks BOTH `tab_projection_uses_shared_stops_and_unicode_columns` and `minimap_columns_match_code_tab_and_unicode_widths`. Six adornment rows, including `caret_projection_accounts_for_inline_adornments`, still pass, so the stream behaviour survived the delegation. `TAB_STOP_COLUMNS` and `UnicodeWidthChar` are now unused imports in pmacs-gpu and are dropped --- which is itself the check that no copy of the rule remains in this crate. --- pmacs-gpu/src/main.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 69977b9..7066966 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -46,13 +46,12 @@ use pmacs_protocol::{ MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers, MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind, - SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, - TEXT_INPUT_MIN_VERSION, TerminalFrame, UnderlineStyle, + SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TEXT_INPUT_MIN_VERSION, + TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, }; -use unicode_width::UnicodeWidthChar; use wgpu::MultisampleState; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; @@ -14099,8 +14098,15 @@ fn expand_chunk_tabs(chunks: Vec) -> Vec { source: offset_chunk_source(source, segment_start as u64), }); } - let tab_stop = TAB_STOP_COLUMNS as usize; - let tab_width = tab_stop - column % tab_stop; + // The tab's width comes from the SHARED rule, derived + // rather than recomputed: a second copy of `stop - column % + // stop` here is how the rendered column and B7's bound + // drift apart. + let tab_width = pmacs_protocol::columns::advance_char( + u32::try_from(column).unwrap_or(u32::MAX), + '\t', + ) as usize + - column; expanded.push(RichChunk { text: " ".repeat(tab_width), color, @@ -14144,12 +14150,23 @@ fn offset_chunk_source(source: ChunkSource, byte_offset: u64) -> ChunkSource { } } +/// Advance the projection's running display column across `text`. +/// +/// **Per-character advance delegates to +/// [`pmacs_protocol::columns::advance_char`]** so the column this +/// projection renders at is the same column B7's bound and the caret +/// follow reckon in. The stream semantics stay local and are the reason +/// this wrapper exists at all: the column runs across chunks, so +/// **adornment text shifts a later tab**, and a newline restarts it. fn advance_display_column(column: &mut usize, text: &str) { for ch in text.chars() { if ch == '\n' { *column = 0; } else { - *column += UnicodeWidthChar::width(ch).unwrap_or(0); + *column = pmacs_protocol::columns::advance_char( + u32::try_from(*column).unwrap_or(u32::MAX), + ch, + ) as usize; } } } From db18261a5dd25297b79d5f9ab8084479348176ce Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:00:20 +0200 Subject: [PATCH 17/72] feat(gui-1b): B7 --- the TUI's horizontal axis, which used to vanish `MouseEventKind::ScrollLeft` and `ScrollRight` reached `dispatch_mouse` and fell into its catch-all, so a horizontal wheel over a TUI document did nothing at all. B7's row said as much: "events arrive and are dropped". They now call `scroll_window_columns` --- the SAME helper B2's panel leg uses. One contract, one implementation, so the two document surfaces cannot clamp differently, which is what B3 means by "each document surface clamps at its own content bounds". Four rows, each mutation-checked against its own mutation: three columns per notch step of one -> 1 row right bound keeps the last clamp at full width -> 1 row display column visible origin saturates at zero (covered by the above) wrap pins the origin to 0 drop the wrap guard -> 1 row, only it The right-bound row is the one that needed care. "The origin stopped somewhere" is satisfiable by a bound that lets the origin pass every glyph and blanks the viewport, so the row asserts the FINAL DISPLAY COLUMN IS STILL REACHABLE --- origin + viewport >= widest --- and separately that the origin has not passed the content. Two fixture facts the rows need, both stated rather than assumed: - they PAINT first. B7's bound is `widest - viewport`, and a window that has never painted has `last_content_cols == 0`, so the bound degenerates to `widest` and the row measures nothing. My first draft had exactly that hole and passed for the wrong reason. - they set TRUNCATE after the paint. Under wrap the bound does not exist and clause 5 pins the origin to zero, so a bound row must be a truncate row. The paint writes `last_wrap` from the view config, which is why the order matters --- setting it first, as I first did, is overwritten and the rows silently exercised the wrap branch instead. --- src/editor.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/editor.rs b/src/editor.rs index 53c744e..4cdfa90 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4134,6 +4134,20 @@ impl EditorState { self.mouse_click = None; self.scroll_window(win_id, SCROLL_LINES); } + // GUI Stage 1b B7 — the TUI's horizontal axis, which these + // events reached and which dropped them into the catch-all + // below. `scroll_window_columns` carries B7's bound and its + // wrap pin, and is the same helper the panel's horizontal + // leg uses: one contract, one implementation, so the two + // surfaces cannot clamp differently. + MouseEventKind::ScrollLeft => { + self.mouse_click = None; + self.scroll_window_columns(win_id, -SCROLL_COLUMNS); + } + MouseEventKind::ScrollRight => { + self.mouse_click = None; + self.scroll_window_columns(win_id, SCROLL_COLUMNS); + } _ => { self.mouse_click = None; } @@ -7009,6 +7023,31 @@ mod tests { assert_eq!(glyph(11, 2), Glyph::Char('2')); } + /// Paint once, so `last_content_cols` is a real viewport width. + /// + /// B7's bound is `widest − viewport`, and a window that has never + /// painted has no viewport: a row that skips this measures against + /// zero and cannot see the blanking the bound exists to prevent. + fn paint_once(s: &EditorState, size: pmacs_protocol::CellSize) { + use crate::cell::{Cell, CellGrid}; + let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + let _ = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + // B7's bound only exists under truncate: a wrapped line has + // nothing past the right edge, and clause 5 pins the origin to + // zero. Set AFTER the paint, which writes `last_wrap` from the + // view config — a row about the bound must be a truncate row. + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + if let Some(window) = core.windows.get_mut(&id) { + window.last_wrap = crate::view::WrapMode::Truncate; + } + } + fn fresh_with(content: &[u8]) -> EditorState { let s = EditorState::new(); let new_id = s @@ -10372,6 +10411,158 @@ mod tests { assert!(s.core.borrow().active_window().selection.is_none()); } + /// GUI Stage 1b B7 — a horizontal wheel notch moves the TUI + /// document's origin by **three columns**, and these events used to + /// fall into `dispatch_mouse`'s catch-all and vanish. + /// + /// *Mutation: a step of one → this row.* + #[test] + fn b7_a_horizontal_notch_moves_the_origin_three_columns() { + use crossterm::event::MouseEventKind; + // Wide enough that the bound cannot absorb the move: B7's + // maximum is `widest − viewport`, so a document that fits has a + // maximum of zero and a dropped event reads as correct. + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + let before = s.core.borrow().active_window().view_left; + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + + let after = s.core.borrow().active_window().view_left; + assert_eq!( + after - before, + SCROLL_COLUMNS as u32, + "one notch moves three columns, and must move at all" + ); + } + + /// B7/B3 — the origin never goes negative, and a left notch at the + /// left edge is absorbed rather than wrapping around. + #[test] + fn b7_the_origin_saturates_at_zero_rather_than_going_negative() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollLeft, 5, 5), + term_size_24x80(), + ); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "already at the left bound: the notch is absorbed, not wrapped" + ); + } + + /// B7's right bound is `widest − viewport`, **saturating at zero**. + /// + /// The row that discriminates: scroll far past the end and assert + /// the **final display column is still visible**. Clamping at the + /// widest line's FULL width would let the origin pass every glyph + /// and leave the viewport blank, which a "the origin stopped + /// somewhere" assertion cannot see. + /// + /// *Mutation: clamp at the widest line's full width → this row.* + #[test] + fn b7_the_right_bound_keeps_the_final_display_column_visible() { + use crossterm::event::MouseEventKind; + let width = 400usize; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(width)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + + // Far more notches than the bound can absorb. + for _ in 0..500 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + } + + let window = s.core.borrow(); + let window = window.active_window(); + let viewport = window.last_content_cols; + assert!( + viewport > 0, + "fixture: the window must have content columns" + ); + assert!( + u64::from(window.view_left) + u64::from(viewport) >= width as u64, + "the final display column must still be reachable: origin {} + \ + viewport {viewport} against a widest line of {width}", + window.view_left + ); + assert!( + u64::from(window.view_left) < width as u64, + "and the origin must not pass every glyph: {} against {width}", + window.view_left + ); + } + + /// B7's wrap clause — **wrap pins the origin to zero**, and a + /// horizontal notch under wrap moves nothing. + /// + /// A wrapped line has nothing past the right edge, so an origin + /// there would scroll a buffer sideways that has no sideways. + /// + /// *Mutation: drop the wrap guard in `scroll_window_columns` → this + /// row, and only this row.* + #[test] + fn b7_wrap_pins_the_origin_to_zero_and_a_notch_moves_nothing() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + // Scroll sideways first, so the row can see the pin rather than + // an origin that merely never left zero. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + assert!( + s.core.borrow().active_window().view_left > 0, + "setup: the origin must be off zero before wrap is applied" + ); + { + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + if let Some(window) = core.windows.get_mut(&id) { + window.last_wrap = crate::view::WrapMode::Wrap; + } + } + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "wrap pins the origin to zero; a notch under wrap moves nothing" + ); + } + /// Mouse-wheel scrolls advance `view_top` and drag the cursor /// along by the same delta so it keeps its relative position in /// the viewport. Without the cursor-shift, the renderer's From 951c6447b3a0a903ce52e7841318d8d5d2d435be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:18:38 +0200 Subject: [PATCH 18/72] test(gui-1b): make B7's witnesses discriminate what they claim to Four evidence blockers. The implementation was right; the rows could not tell right from several wrongs. THE RIGHT BOUND WAS A RANGE, NOT THE CONTRACT. `origin + viewport >= widest` plus `origin < widest` admits every origin from `widest - viewport` through `widest - 1` --- almost the entire viewport blank, and passing. It now asserts the exact value, `widest.saturating_sub( viewport)`. The `widest - 1` mutant used to pass and now fires two rows. THE "SATURATES AT ZERO" ROW ONLY TESTED THE LOWER CLAMP. Its document is 400 columns wide, so it never exercised a buffer NARROWER than the viewport --- the case where `widest - viewport` underflows. The framed narrow-buffer row is added: short content, ten right notches, origin stays zero. THE FIXTURE FORGED THE WRAP CACHE. `paint_once` painted under the real default (wrap) and then wrote `last_wrap = Truncate` directly. Production fills that cache from `ui.line-wrap` during paint, so a row built on a forged value proves nothing about the path that fills it. `set_line_wrap` now sets the buffer-local setting, `paint_truncated` paints and ASSERTS the paint recorded truncate, and the wrap row paints a genuinely wrapped state at origin zero and shows a right notch is inert. Deleting the wheel guard still fires it, and only it. ALL FOUR ROWS USED ONE WINDOW. A mutant routing horizontal scroll to the ACTIVE window instead of `win_id` passed every one of them, and B2 requires the document surface under the pointer --- horizontal state is per-window. A split-window row wheels over the pane the pointer names, asserts only that pane's `view_left` moves, that the other is untouched, and that focus does not follow. The mutant fires it, and only it. `dispatch_mouse`'s documentation now covers the horizontal arms, their bound, and that the pane under the pointer is the one that moves. --- src/editor.rs | 221 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 183 insertions(+), 38 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 4cdfa90..71c441e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3965,6 +3965,13 @@ impl EditorState { /// * `ScrollUp` / `ScrollDown` scroll the window under the /// cursor by [`SCROLL_LINES`] lines, without changing the /// buffer cursor or active window. + /// * `ScrollLeft` / `ScrollRight` move that same window's + /// horizontal origin by [`SCROLL_COLUMNS`] columns (GUI Stage + /// 1b B7), likewise without changing the cursor or the active + /// window. **Horizontal state is per-window**, so the pane under + /// the pointer is the one that moves; the origin is bounded by + /// `widest − viewport`, saturating at zero, and pinned to zero + /// under wrap. /// /// Mouse moves with no buttons (`Moved`) and other buttons are /// ignored. Clicks on a window's mode line are also ignored @@ -7037,15 +7044,44 @@ mod tests { size, }; let _ = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); - // B7's bound only exists under truncate: a wrapped line has - // nothing past the right edge, and clause 5 pins the origin to - // zero. Set AFTER the paint, which writes `last_wrap` from the - // view config — a row about the bound must be a truncate row. - let mut core = s.core.borrow_mut(); - let id = core.active_window_id(); - if let Some(window) = core.windows.get_mut(&id) { - window.last_wrap = crate::view::WrapMode::Truncate; - } + } + + /// Set `ui.line-wrap` for a window's buffer, the way production does. + /// + /// **Not by writing `last_wrap` directly.** That field is a cache + /// the paint fills from this setting; forging it tests a state the + /// running editor never reaches, and a row built on a forged cache + /// proves nothing about the path that fills it. + fn set_line_wrap(s: &EditorState, win_id: WindowId, value: &str) { + let buffer_id = s.core.borrow().windows[&win_id].buffer_id; + let registry = s + .lua_host + .lua() + .app_data_ref::>>() + .expect("a config registry"); + registry + .borrow_mut() + .set_local( + buffer_id, + "ui.line-wrap", + crate::config_registry::ConfigValue::Str(value.to_owned()), + ) + .expect("ui.line-wrap is buffer-local and settable"); + } + + /// Paint with `ui.line-wrap` set to `truncate` FIRST, and assert the + /// paint recorded it — so a row about B7's bound rests on the cache + /// production actually writes. + fn paint_truncated(s: &EditorState, size: pmacs_protocol::CellSize) { + let id = s.core.borrow().active_window_id(); + set_line_wrap(s, id, "truncate"); + paint_once(s, size); + assert_eq!( + s.core.borrow().windows[&id].last_wrap, + crate::view::WrapMode::Truncate, + "the paint must have recorded truncate from ui.line-wrap; a \ + row about the right bound is meaningless under wrap" + ); } fn fresh_with(content: &[u8]) -> EditorState { @@ -10426,7 +10462,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); let before = s.core.borrow().active_window().view_left; s.dispatch_mouse( @@ -10452,7 +10488,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); s.dispatch_mouse( FrontendId::LOCAL, @@ -10484,7 +10520,7 @@ mod tests { content.extend_from_slice(&b"w".repeat(width)); content.push(b'\n'); let mut s = fresh_with(&content); - paint_once(&s, term_size_24x80()); + paint_truncated(&s, term_size_24x80()); // Far more notches than the bound can absorb. for _ in 0..500 { @@ -10502,16 +10538,42 @@ mod tests { viewport > 0, "fixture: the window must have content columns" ); - assert!( - u64::from(window.view_left) + u64::from(viewport) >= width as u64, - "the final display column must still be reachable: origin {} + \ - viewport {viewport} against a widest line of {width}", - window.view_left + // **The exact bound, not a range.** `origin + viewport >= widest` + // alone admits every origin up to `widest − 1`, which leaves + // almost the whole viewport blank and still passes. + assert_eq!( + window.view_left, + u32::try_from(width) + .unwrap_or(u32::MAX) + .saturating_sub(viewport), + "the origin rests exactly at widest − viewport" ); - assert!( - u64::from(window.view_left) < width as u64, - "and the origin must not pass every glyph: {} against {width}", - window.view_left + } + + /// B7's bound **saturates at zero for a buffer narrower than the + /// viewport**: `widest − viewport` underflows, and the origin must + /// stay at zero rather than wrapping to a huge maximum. + /// + /// The 400-column rows cannot see this — they never exercise a + /// document that fits. + #[test] + fn b7_a_document_narrower_than_the_viewport_never_scrolls() { + use crossterm::event::MouseEventKind; + let mut s = fresh_with(b"short\nalso short\n"); + paint_truncated(&s, term_size_24x80()); + + for _ in 0..10 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + } + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "a document that fits has a maximum origin of zero" ); } @@ -10530,25 +10592,22 @@ mod tests { content.extend_from_slice(&b"w".repeat(400)); content.push(b'\n'); let mut s = fresh_with(&content); + // A REAL wrapped state: `ui.line-wrap` is set to wrap and the + // paint records it. Nothing is forged, so the row exercises the + // cache production writes. + let id = s.core.borrow().active_window_id(); + set_line_wrap(&s, id, "wrap"); paint_once(&s, term_size_24x80()); - // Scroll sideways first, so the row can see the pin rather than - // an origin that merely never left zero. - s.dispatch_mouse( - FrontendId::LOCAL, - mouse(MouseEventKind::ScrollRight, 5, 5), - term_size_24x80(), + assert_eq!( + s.core.borrow().windows[&id].last_wrap, + crate::view::WrapMode::Wrap, + "setup: the paint must have recorded wrap from ui.line-wrap" ); - assert!( - s.core.borrow().active_window().view_left > 0, - "setup: the origin must be off zero before wrap is applied" + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "setup: a wrapped buffer starts pinned at zero" ); - { - let mut core = s.core.borrow_mut(); - let id = core.active_window_id(); - if let Some(window) = core.windows.get_mut(&id) { - window.last_wrap = crate::view::WrapMode::Wrap; - } - } s.dispatch_mouse( FrontendId::LOCAL, @@ -10559,7 +10618,93 @@ mod tests { assert_eq!( s.core.borrow().active_window().view_left, 0, - "wrap pins the origin to zero; a notch under wrap moves nothing" + "a right notch under wrap is inert; without the guard it \ + would move the origin off zero" + ); + } + + /// B2/B7 — a horizontal notch moves the origin of **the document + /// surface under the pointer**, not the active window's. + /// + /// Horizontal state is per-window, and the four single-window rows + /// above cannot tell `win_id` from "the active window": a mutant + /// that routes every horizontal scroll to the active pane passes + /// all of them. This row wheels over the INACTIVE pane. + /// + /// It also pins that a wheel does not focus — the pane under the + /// pointer is named, not activated. + /// + /// *Mutation: route to the active window instead of `win_id` → this + /// row, and only this row.* + #[test] + fn b7_a_notch_moves_the_pane_under_the_pointer_not_the_active_one() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + s.lua_host + .lua() + .load("pmacs.window.split_vertical()") + .exec() + .expect("a vertical split"); + + // Both panes truncate, and both painted, so each has a real + // viewport width for B7's bound. + let ids: Vec = s.core.borrow().windows.keys().copied().collect(); + assert_eq!(ids.len(), 2, "fixture: exactly two panes"); + for id in &ids { + set_line_wrap(&s, *id, "truncate"); + } + paint_once(&s, term_size_24x80()); + + let active = s.core.borrow().active_window_id(); + let other = *ids.iter().find(|id| **id != active).expect("a second pane"); + // A 50/50 vertical split: column 60 is inside the right pane. + // Whichever pane that is, it must be the one that moves. + let target_col: u16 = 60; + let under_pointer = { + let core = s.core.borrow(); + window_at_cell( + &core, + FrontendId::LOCAL, + term_size_24x80(), + 5, + u32::from(target_col), + ) + .map_or(other, |(id, _)| id) + }; + + let before_active = s.core.borrow().windows[&active].view_left; + let before_other = s.core.borrow().windows[&other].view_left; + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, target_col), + term_size_24x80(), + ); + + let after_active = s.core.borrow().windows[&active].view_left; + let after_other = s.core.borrow().windows[&other].view_left; + let (moved, still) = if under_pointer == active { + ((after_active, before_active), (after_other, before_other)) + } else { + ((after_other, before_other), (after_active, before_active)) + }; + assert_eq!( + moved.0 - moved.1, + SCROLL_COLUMNS as u32, + "the pane under the pointer moves by one notch" + ); + assert_eq!( + still.0, still.1, + "and the other pane's origin is untouched — horizontal state \ + is per-window" + ); + assert_eq!( + s.core.borrow().active_window_id(), + active, + "a wheel names a pane; it does not focus it" ); } From 5371229b9a646ad7572a5a5230b916f574086463 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:32:48 +0200 Subject: [PATCH 19/72] test(gui-1b): the split row now guarantees its own setup The row claimed to target the inactive pane and did not. It hit-tested a hardcoded column, took whichever pane that resolved to, and then branched its assertions to match. If the layout ever put the active pane under that column, an active-window-routing mutant would pass and the focus assertion would be a tautology --- the row would look like evidence while proving nothing. It now SCANS for a cell the hit-test actually resolves to the inactive pane, asserts `under_pointer == other` before dispatching, and asserts directly with no adaptive branch: the inactive pane moves by three the active pane's origin is unchanged the active window id is unchanged The setup guard is itself falsifiable: pointing the scan at the active pane makes the row fail on "setup: the pointer must be over the INACTIVE pane" rather than passing quietly. That check matters more than the mutation --- a setup assertion nothing can break is the same defect one level up. The routing mutant still fires the row, and only it. --- src/editor.rs | 63 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 71c441e..6ad502d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10660,46 +10660,57 @@ mod tests { let active = s.core.borrow().active_window_id(); let other = *ids.iter().find(|id| **id != active).expect("a second pane"); - // A 50/50 vertical split: column 60 is inside the right pane. - // Whichever pane that is, it must be the one that moves. - let target_col: u16 = 60; - let under_pointer = { + + // **Find a cell the hit-test actually resolves to the INACTIVE + // pane**, rather than assuming a column lands there. A row that + // adapts to whichever pane it hits cannot fail an + // active-window-routing mutant, and its focus assertion becomes + // a tautology. + let size = term_size_24x80(); + let target = { let core = s.core.borrow(); - window_at_cell( - &core, - FrontendId::LOCAL, - term_size_24x80(), - 5, - u32::from(target_col), - ) - .map_or(other, |(id, _)| id) + (0..size.rows.saturating_sub(1)) + .flat_map(|row| (0..size.cols).map(move |col| (row, col))) + .find(|(row, col)| { + window_at_cell(&core, FrontendId::LOCAL, size, *row, *col) + .is_some_and(|(id, _)| id == other) + }) }; + let (target_row, target_col) = target.expect("some cell must resolve to the inactive pane"); + { + let core = s.core.borrow(); + let (under_pointer, _) = + window_at_cell(&core, FrontendId::LOCAL, size, target_row, target_col) + .expect("the cell resolves to a pane"); + assert_eq!( + under_pointer, other, + "setup: the pointer must be over the INACTIVE pane" + ); + } let before_active = s.core.borrow().windows[&active].view_left; let before_other = s.core.borrow().windows[&other].view_left; s.dispatch_mouse( FrontendId::LOCAL, - mouse(MouseEventKind::ScrollRight, 5, target_col), - term_size_24x80(), + mouse( + MouseEventKind::ScrollRight, + u16::try_from(target_row).unwrap_or(0), + u16::try_from(target_col).unwrap_or(0), + ), + size, ); - let after_active = s.core.borrow().windows[&active].view_left; - let after_other = s.core.borrow().windows[&other].view_left; - let (moved, still) = if under_pointer == active { - ((after_active, before_active), (after_other, before_other)) - } else { - ((after_other, before_other), (after_active, before_active)) - }; assert_eq!( - moved.0 - moved.1, + s.core.borrow().windows[&other].view_left - before_other, SCROLL_COLUMNS as u32, - "the pane under the pointer moves by one notch" + "the INACTIVE pane, under the pointer, moves by one notch" ); assert_eq!( - still.0, still.1, - "and the other pane's origin is untouched — horizontal state \ - is per-window" + s.core.borrow().windows[&active].view_left, + before_active, + "the ACTIVE pane's origin is untouched — horizontal state is \ + per-window, and routing to the active pane would move this" ); assert_eq!( s.core.borrow().active_window_id(), From 493db8f96589ce110f66eeef9646b0cf0fcbd12b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:43:30 +0200 Subject: [PATCH 20/72] feat(gui-1b): B4 --- a middle click pastes the PRIMARY selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PointerRoute::UnusedButton`'s own doc named this row: "Stage 1b's B4 gives the middle button a meaning (PRIMARY-selection paste on Linux) and lands here." B4 splits that variant, as §2a said it would. A middle PRESS is now `PointerRoute::MiddlePress` and reads the PRIMARY selection, shipping it as the same `Paste` wire operation Ctrl-V uses. Its RELEASE stays unused, like the right button's --- the paste happens once, on the press. PRIMARY and CLIPBOARD are different selections with different contents: the clipboard holds what was last explicitly copied, PRIMARY holds what is currently selected. Reading the wrong one still produces a paste, just not the one the platform convention promises, so the row asserts the SOURCE rather than that a paste happened. `middle_click_paste_source` is the seam that makes that assertable without an OS clipboard; `read_os_selection` takes the source and uses arboard's `GetExtLinux` for PRIMARY. Two rows, three mutations, each firing: source = Clipboard -> the source row middle press unrouted -> the routing row release also pastes -> the routing row Three existing rows encoded the old behaviour --- that a middle press is semantics-free. They are updated to keep testing what they SAY rather than being weakened to accommodate B4: the routing row now covers Back/Forward/Other plus the middle RELEASE, and the two effect/order rows switch to `Back`, a button that still has no semantics. Widening them to accept the new meaning would have left no row asserting that semantics-free buttons stay inert. --- pmacs-gpu/src/main.rs | 173 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 7066966..3046e82 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3423,6 +3423,26 @@ impl App { WheelTarget::Chrome } + /// Perform [`PointerRoute::MiddlePress`] — GUI Stage 1b B4. + /// + /// Reads the selection [`middle_click_paste_source`] names and ships + /// it as a `Paste`, the same wire operation Ctrl-V uses. The daemon + /// inserts it; this frontend never edits the document itself. + fn apply_middle_press(&mut self) { + let source = middle_click_paste_source(); + let bytes = self + .state + .as_mut() + .and_then(|state| state.read_os_selection(source)); + if let Some(bytes) = bytes + && !bytes.is_empty() + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_paste(bytes) + { + eprintln!("pmacs-gpu: middle-click send_paste failed: {e}"); + } + } + /// Perform [`PointerRoute::Wheel`]. /// /// **GUI Stage 1b B1 reorders this pipeline.** It used to round to @@ -3578,6 +3598,7 @@ impl App { // 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::Pointer(PointerRoute::MiddlePress) => self.apply_middle_press(), Route::Keyboard { action: KeyAction::Release, .. @@ -4019,12 +4040,16 @@ enum PointerRoute { /// 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. + /// `MouseInput` **pressing** the middle button — GUI Stage 1b's + /// B4. On Linux this pastes the **PRIMARY selection**, which is the + /// platform convention and a different selection from the one + /// Ctrl-V reads. The release is deliberately nothing, like the + /// right button's. + MiddlePress, + /// A `MouseInput` this frontend has no semantics for: the right and + /// middle buttons' releases, and every 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. UnusedButton, /// `MouseWheel`. The delta is carried raw: converting it to lines /// needs the code line height, which is `State`'s to know. @@ -4042,6 +4067,7 @@ fn route_pointer(event: &WindowEvent) -> Option { WindowEvent::MouseInput { state, button, .. } => Some(match (button, state) { (MouseButton::Left, _) => PointerRoute::Left(*state), (MouseButton::Right, ElementState::Pressed) => PointerRoute::RightPress, + (MouseButton::Middle, ElementState::Pressed) => PointerRoute::MiddlePress, _ => PointerRoute::UnusedButton, }), WindowEvent::MouseWheel { delta, .. } => Some(PointerRoute::Wheel(*delta)), @@ -4610,7 +4636,6 @@ mod input_routing_tests { #[test] fn a_button_without_semantics_is_claimed_and_dropped() { for button in [ - MouseButton::Middle, MouseButton::Back, MouseButton::Forward, MouseButton::Other(9), @@ -4624,6 +4649,14 @@ mod input_routing_tests { ); } } + // **The middle button is no longer semantics-free**: GUI Stage + // 1b's B4 gives its PRESS a meaning. Its RELEASE still has none, + // so it stays in this row rather than leaving it. + assert_eq!( + route_one(&mouse_input(ElementState::Released, MouseButton::Middle)), + Route::Pointer(PointerRoute::UnusedButton), + "a middle release remains nothing, like the right button's" + ); } /// P1 — the wheel delta is carried **raw**. Converting it to lines @@ -4939,7 +4972,10 @@ mod input_routing_tests { #[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)); + // `Back`, not `Middle`: B4 gave the middle PRESS a meaning, so + // this row moved to a button that still has none rather than + // being weakened to accommodate the new one. + let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Back)); assert_eq!( step, Step { @@ -5076,7 +5112,10 @@ mod input_routing_tests { position: PhysicalPosition::new(4.0, 8.0), }, mouse_input(ElementState::Pressed, MouseButton::Left), - mouse_input(ElementState::Pressed, MouseButton::Middle), + // `Back`: this row is about ORDER, and it keeps a + // semantics-free button so B4's new middle-press meaning + // does not quietly become part of what it asserts. + mouse_input(ElementState::Pressed, MouseButton::Back), WindowEvent::RedrawRequested, WindowEvent::Occluded(false), WindowEvent::CloseRequested, @@ -5288,6 +5327,33 @@ const WHEEL_LINES_PER_TICK: f32 = 3.0; /// tick", the horizontal twin of [`WHEEL_LINES_PER_TICK`]. const WHEEL_COLUMNS_PER_TICK: f32 = 3.0; +/// Which OS selection a paste gesture reads. +/// +/// X11 and Wayland carry two: the CLIPBOARD, written by an explicit +/// copy, and the PRIMARY selection, written merely by selecting text. +/// **They are different selections with different contents**, and the +/// platform convention pairs them with different gestures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PasteSource { + /// What Ctrl-V reads. + Clipboard, + /// What a middle click reads on Linux (GUI Stage 1b B4). + Primary, +} + +/// The selection a middle click pastes from. +/// +/// **PRIMARY on Linux** — B4's whole content. Elsewhere there is no +/// PRIMARY selection, so the gesture falls back to the clipboard rather +/// than doing nothing, which is the closest available meaning. +const fn middle_click_paste_source() -> PasteSource { + if cfg!(target_os = "linux") { + PasteSource::Primary + } else { + PasteSource::Clipboard + } +} + /// The wire scroll kinds for a banked `(x, y)` tick count, in order. /// /// One event per whole tick: a single wheel notch that banks two ticks @@ -6375,10 +6441,36 @@ impl State { /// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on /// any failure (empty / non-text / unavailable). fn read_os_clipboard(&mut self) -> Option> { - match self.os_clipboard()?.get_text() { + self.read_os_selection(PasteSource::Clipboard) + } + + /// Read one named OS selection. + /// + /// GUI Stage 1b B4 needs the **PRIMARY** selection, which on Linux + /// is a different selection from the clipboard with different + /// contents. Reading the clipboard for a middle click would paste + /// whatever was last explicitly copied instead of what is currently + /// selected — a plausible-looking wrong answer, which is why B4's + /// row asserts the source rather than that "a paste happened". + fn read_os_selection(&mut self, source: PasteSource) -> Option> { + let clipboard = self.os_clipboard()?; + let read = match source { + PasteSource::Clipboard => clipboard.get_text(), + #[cfg(target_os = "linux")] + PasteSource::Primary => { + use arboard::{GetExtLinux, LinuxClipboardKind}; + clipboard + .get() + .clipboard(LinuxClipboardKind::Primary) + .text() + } + #[cfg(not(target_os = "linux"))] + PasteSource::Primary => clipboard.get_text(), + }; + match read { Ok(s) => Some(s.into_bytes()), Err(e) => { - eprintln!("pmacs-gpu: clipboard read failed: {e}"); + eprintln!("pmacs-gpu: {source:?} read failed: {e}"); None } } @@ -14610,6 +14702,65 @@ mod tests { ); } + /// B4 — a middle-click paste reads the **PRIMARY selection** on + /// Linux, not the clipboard. + /// + /// The two are different selections with different contents: the + /// clipboard holds what was last explicitly copied, PRIMARY holds + /// what is currently selected. Reading the wrong one produces a + /// paste — just not the one the platform convention promises — so + /// the row asserts the SOURCE rather than that a paste happened. + /// + /// *Mutation: return `PasteSource::Clipboard` → this row.* + #[test] + fn b4_a_middle_click_pastes_the_primary_selection_on_linux() { + let source = super::middle_click_paste_source(); + if cfg!(target_os = "linux") { + assert_eq!( + source, + super::PasteSource::Primary, + "B4: the middle button reads PRIMARY on Linux" + ); + } else { + assert_eq!( + source, + super::PasteSource::Clipboard, + "off Linux there is no PRIMARY selection; the clipboard is \ + the closest available meaning" + ); + } + } + + /// B4's routing half — a middle **press** is its own route now, and + /// no longer the claimed-and-dropped `UnusedButton`. + /// + /// Its RELEASE stays unused, like the right button's: the paste + /// happens once, on the press. + #[test] + fn b4_a_middle_press_routes_to_its_own_variant_and_its_release_does_not() { + use winit::event::{ElementState, MouseButton}; + let press = winit::event::WindowEvent::MouseInput { + device_id: winit::event::DeviceId::dummy(), + state: ElementState::Pressed, + button: MouseButton::Middle, + }; + let release = winit::event::WindowEvent::MouseInput { + device_id: winit::event::DeviceId::dummy(), + state: ElementState::Released, + button: MouseButton::Middle, + }; + assert_eq!( + super::route_pointer(&press), + Some(super::PointerRoute::MiddlePress), + "a middle press is B4's gesture" + ); + assert_eq!( + super::route_pointer(&release), + Some(super::PointerRoute::UnusedButton), + "and its release remains nothing, like the right button's" + ); + } + /// R4 and R5's resets exist and are SEPARATE, so a mutation that /// omits one is individually visible. /// From 3a4a7483160445d734667654cd1a4ded17d58bbe Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 12:14:16 +0200 Subject: [PATCH 21/72] fix(gui-1b): B4 --- witness the production effect, drop the unframed fallback TWO MUTATIONS LEFT BOTH B4 ROWS GREEN. Changing the source to `Clipboard`, or replacing the dispatch arm with a no-op, was invisible: one row asserted `middle_click_paste_source` in isolation and the other asserted `route_pointer` in isolation, and nothing asserted the effect the gesture produces. Two seams tested separately are not a path tested once. A third row drives a middle press through `dispatch_window_event` and asserts EXACTLY ONE outbound `Paste` carrying the PRIMARY payload, and that the release sends none. PRIMARY and CLIPBOARD are stubbed with DISTINGUISHABLE contents, which is the point --- identical stubs would pass with the wrong selection read. That needed a seam: `State::set_test_selection`, consulted by `read_os_selection` before the OS clipboard. A test-only field in production code is a cost, and it is the smallest one that makes B4's actual contract --- WHICH selection --- assertable without a real clipboard. Both mutations now fire: source-to-Clipboard fires two rows, the no-op dispatch fires the end-to-end row. AND THE OFF-LINUX FALLBACK WAS UNFRAMED BEHAVIOUR I INVENTED. B4 rules "PRIMARY on Linux" and rules nothing else. The gesture was inert on every other platform; my previous commit made it paste the CLIPBOARD there, and the row adopted that choice permanently. `middle_click_paste_source` now returns `Option`, `None` off Linux, and the gesture stays inert. A fallback needs framing and re-approval, not a default chosen while implementing. --- pmacs-gpu/src/main.rs | 112 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3046e82..04bfe80 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1873,6 +1873,9 @@ struct State { /// caret follow leaves it alone until the cursor position actually /// changes. A move fully absorbed by the clamp arms nothing. manual_left_authority: bool, + /// Stub selections for tests; see [`Self::set_test_selection`]. + #[cfg(test)] + test_selections: HashMap>, /// GUI Stage 1b B1: per-target, per-axis fractional wheel residual. /// Sub-tick deltas are banked here instead of being rounded away /// before routing knows where they were going. @@ -3429,7 +3432,9 @@ impl App { /// it as a `Paste`, the same wire operation Ctrl-V uses. The daemon /// inserts it; this frontend never edits the document itself. fn apply_middle_press(&mut self) { - let source = middle_click_paste_source(); + let Some(source) = middle_click_paste_source() else { + return; + }; let bytes = self .state .as_mut() @@ -4968,6 +4973,61 @@ mod input_routing_tests { /// P2 — a button the frontend has no semantics for reaches no body: /// nothing local, nothing outbound. The counterpart to the routing + /// B4 END TO END — a middle press driven through + /// `dispatch_window_event` sends **exactly one** `Paste`, carrying + /// the **PRIMARY** payload, and its release sends none. + /// + /// The two seam rows below cannot see this: mutating + /// `middle_click_paste_source` or replacing the dispatch arm with a + /// no-op leaves both of them green, because each asserts a function + /// in isolation rather than the effect the gesture produces. This + /// row drives the production path and asserts the payload. + /// + /// The two selections carry **distinguishable** contents, which is + /// the whole point — a row whose PRIMARY and CLIPBOARD stubs said + /// the same thing would pass with the wrong one read. + #[test] + #[cfg(target_os = "linux")] + fn b4_a_middle_press_sends_exactly_one_paste_carrying_primary() { + use winit::event::{ElementState, MouseButton}; + let mut h = EffectHarness::new(); + if let Some(state) = h.app.state.as_mut() { + state.set_test_selection(crate::PasteSource::Primary, b"PRIMARY-payload"); + state.set_test_selection(crate::PasteSource::Clipboard, b"CLIPBOARD-payload"); + } + + let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); + + let pastes: Vec<&pmacs_protocol::FrontendEvent> = step + .outbound + .iter() + .filter(|e| matches!(e, pmacs_protocol::FrontendEvent::Paste { .. })) + .collect(); + assert_eq!( + pastes.len(), + 1, + "exactly one paste, got {:?}", + step.outbound + ); + match pastes[0] { + pmacs_protocol::FrontendEvent::Paste { data, .. } => assert_eq!( + data.as_slice(), + b"PRIMARY-payload", + "B4 pastes the PRIMARY selection, not the clipboard" + ), + other => panic!("expected a Paste, got {other:?}"), + } + + let release = h.feed(&mouse_input(ElementState::Released, MouseButton::Middle)); + assert!( + !release + .outbound + .iter() + .any(|e| matches!(e, pmacs_protocol::FrontendEvent::Paste { .. })), + "the release pastes nothing; the gesture fires once, on the press" + ); + } + /// row that calls it claimed-and-dropped. #[test] fn an_unused_button_produces_no_effect_of_any_kind() { @@ -5333,7 +5393,7 @@ const WHEEL_COLUMNS_PER_TICK: f32 = 3.0; /// copy, and the PRIMARY selection, written merely by selecting text. /// **They are different selections with different contents**, and the /// platform convention pairs them with different gestures. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum PasteSource { /// What Ctrl-V reads. Clipboard, @@ -5341,16 +5401,20 @@ enum PasteSource { Primary, } -/// The selection a middle click pastes from. +/// The selection a middle click pastes from, or `None` where the +/// gesture has no ruled meaning. /// -/// **PRIMARY on Linux** — B4's whole content. Elsewhere there is no -/// PRIMARY selection, so the gesture falls back to the clipboard rather -/// than doing nothing, which is the closest available meaning. -const fn middle_click_paste_source() -> PasteSource { +/// **B4 rules PRIMARY on Linux, and rules nothing else.** Off Linux +/// there is no PRIMARY selection, and the gesture was inert before this +/// slice; making it paste the CLIPBOARD instead would be a new +/// behaviour on every other platform that no framing approved. It stays +/// inert, and a fallback needs framing and re-approval rather than a +/// default chosen here. +const fn middle_click_paste_source() -> Option { if cfg!(target_os = "linux") { - PasteSource::Primary + Some(PasteSource::Primary) } else { - PasteSource::Clipboard + None } } @@ -5959,6 +6023,8 @@ impl State { last_pointer_down: None, minimap_scrub_active: false, manual_left_authority: false, + #[cfg(test)] + test_selections: HashMap::new(), wheel_residuals: WheelResiduals::default(), edge_scroll_dir: None, edge_scroll_last: None, @@ -6444,6 +6510,20 @@ impl State { self.read_os_selection(PasteSource::Clipboard) } + /// Stub selection contents for tests, consulted by + /// [`Self::read_os_selection`] before the OS clipboard. + /// + /// **A test seam in production code, and deliberately so.** B4's + /// contract is *which selection* a middle click reads, and the two + /// selections cannot be told apart through a real clipboard in a + /// unit test — a row that asserts "a paste happened" passes with the + /// wrong selection read. This is the smallest seam that lets the row + /// assert the payload rather than the seam that chose it. + #[cfg(test)] + fn set_test_selection(&mut self, source: PasteSource, bytes: &[u8]) { + self.test_selections.insert(source, bytes.to_vec()); + } + /// Read one named OS selection. /// /// GUI Stage 1b B4 needs the **PRIMARY** selection, which on Linux @@ -6453,6 +6533,10 @@ impl State { /// selected — a plausible-looking wrong answer, which is why B4's /// row asserts the source rather than that "a paste happened". fn read_os_selection(&mut self, source: PasteSource) -> Option> { + #[cfg(test)] + if let Some(bytes) = self.test_selections.get(&source) { + return Some(bytes.clone()); + } let clipboard = self.os_clipboard()?; let read = match source { PasteSource::Clipboard => clipboard.get_text(), @@ -14718,15 +14802,15 @@ mod tests { if cfg!(target_os = "linux") { assert_eq!( source, - super::PasteSource::Primary, + Some(super::PasteSource::Primary), "B4: the middle button reads PRIMARY on Linux" ); } else { assert_eq!( - source, - super::PasteSource::Clipboard, - "off Linux there is no PRIMARY selection; the clipboard is \ - the closest available meaning" + source, None, + "B4 rules PRIMARY on Linux and nothing else; off Linux the \ + gesture stays inert rather than acquiring an unframed \ + clipboard meaning" ); } } From 200fc16ba29bb9277bd161a88f0edbc62129b23f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 12:21:32 +0200 Subject: [PATCH 22/72] fix(gui-1b): assert B4's off-Linux inertness where the effect would be The inertness stopped at a seam. `middle_click_paste_source() .unwrap_or(PasteSource::Clipboard)` at the call site restores the rejected fallback and passes every row: the helper still returns `None`, and Linux still receives PRIMARY. A contract asserted only in the function that decides it is not asserted on the path that acts on it. The end-to-end row drops its `cfg(target_os = "linux")` and asserts the complete transcript on both platforms: one PRIMARY paste on Linux, and off Linux NO paste of any selection and no local effect either. One honest limit is recorded on the row rather than left implied. On a Linux host that `unwrap_or` never engages --- the source is already `Some(Primary)` --- so no row on this machine can fire that mutant, and a green local run says nothing about it. The `else` branch is what catches it, and it runs on the non-Linux CI legs. Forcing the source to `None` everywhere fires two rows locally, which is the closest demonstration available here. Also repairs the neighbouring test's documentation, which my insertion had split: `an_unused_button_produces_no_effect_of_any_kind` was left with "row that calls it claimed-and-dropped" while its opening two lines had been absorbed into the B4 row's comment. Both are contiguous blocks again. Two process notes, because both recurred: - This is the THIRD insertion in this lane to damage an adjacent test's docs or attributes. The cause is anchoring a splice on a `fn` or doc line without checking what precedes it; from here I anchor above the doc block and read the neighbour back after inserting. - The previous commit's message claimed the `cfg` removal it did not contain: an edit script died partway, wrote nothing, and I committed on the strength of a later partial edit. Amended rather than left standing, and the file is now verified per claim rather than per script exit. --- pmacs-gpu/src/main.rs | 61 +++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 04bfe80..55514eb 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -4971,8 +4971,6 @@ mod input_routing_tests { ); } - /// P2 — a button the frontend has no semantics for reaches no body: - /// nothing local, nothing outbound. The counterpart to the routing /// B4 END TO END — a middle press driven through /// `dispatch_window_event` sends **exactly one** `Paste`, carrying /// the **PRIMARY** payload, and its release sends none. @@ -4986,8 +4984,22 @@ mod input_routing_tests { /// The two selections carry **distinguishable** contents, which is /// the whole point — a row whose PRIMARY and CLIPBOARD stubs said /// the same thing would pass with the wrong one read. + /// + /// **It asserts the whole transcript on BOTH platforms**, not just + /// Linux. A Linux-only row leaves the off-Linux contract to a helper + /// test, and + /// `middle_click_paste_source().unwrap_or(PasteSource::Clipboard)` + /// at the call site then restores the rejected clipboard fallback + /// while every row stays green: the helper still returns `None` and + /// Linux still gets PRIMARY. The inertness has to be asserted where + /// the effect would appear. + /// + /// **Where that mutant is actually caught, stated honestly:** on a + /// Linux host the `unwrap_or` never engages — the source is already + /// `Some(Primary)` — so no row on this machine can fire it, and a + /// green local run is not evidence about it. The `else` branch below + /// is what catches it, and it runs on the **non-Linux CI legs**. #[test] - #[cfg(target_os = "linux")] fn b4_a_middle_press_sends_exactly_one_paste_carrying_primary() { use winit::event::{ElementState, MouseButton}; let mut h = EffectHarness::new(); @@ -5003,19 +5015,34 @@ mod input_routing_tests { .iter() .filter(|e| matches!(e, pmacs_protocol::FrontendEvent::Paste { .. })) .collect(); - assert_eq!( - pastes.len(), - 1, - "exactly one paste, got {:?}", - step.outbound - ); - match pastes[0] { - pmacs_protocol::FrontendEvent::Paste { data, .. } => assert_eq!( - data.as_slice(), - b"PRIMARY-payload", - "B4 pastes the PRIMARY selection, not the clipboard" - ), - other => panic!("expected a Paste, got {other:?}"), + if cfg!(target_os = "linux") { + assert_eq!( + pastes.len(), + 1, + "exactly one paste, got {:?}", + step.outbound + ); + match pastes[0] { + pmacs_protocol::FrontendEvent::Paste { data, .. } => assert_eq!( + data.as_slice(), + b"PRIMARY-payload", + "B4 pastes the PRIMARY selection, not the clipboard" + ), + other => panic!("expected a Paste, got {other:?}"), + } + } else { + assert!( + pastes.is_empty(), + "off Linux the gesture is INERT: B4 rules PRIMARY on Linux \ + and nothing else, so no paste of any selection may appear \ + here. Got {:?}", + step.outbound + ); + assert!( + step.local.is_empty(), + "and no local effect either: {:?}", + step.local + ); } let release = h.feed(&mouse_input(ElementState::Released, MouseButton::Middle)); @@ -5028,6 +5055,8 @@ mod input_routing_tests { ); } + /// 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() { From 1799aa28c86fd9e9ff953b01573f0dc8bb48cda3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 12:38:29 +0200 Subject: [PATCH 23/72] fix(gui-1b): make B4's off-Linux leg actually run, and assert whole Steps MY "IT RUNS ON THE NON-LINUX CI LEGS" NOTE WAS FALSE. `cargo test -p pmacs-gpu` appears exactly once in ci.yml, in the Ubuntu-only `gpu-render` job; the macOS matrix tests the workspace default member only. So the `unwrap_or(PasteSource::Clipboard)` mutant was green in every environment that actually executes --- and I wrote that note one commit after writing about honesty, which is the part worth recording. The platform is now a PARAMETER rather than a `cfg!` read inside the decision: `paste_source_for(is_linux)`, with an injectable override on `App` for tests. The off-Linux branch runs on this host, and a new row asserts the gesture is completely inert there. The mutant fires it locally, not hypothetically. AND "WHOLE TRANSCRIPT" WAS STILL OVERSTATED. Both arms filtered for `Paste`, so any other outbound event passed, and the release assertion had the same hole. All three assertions are exact `Step` equality now: one PRIMARY paste and no local effect on Linux, completely empty off Linux, completely empty on release. The frontend id is read from the transcript rather than assumed, so the row pins payload and shape without pinning an id the handshake owns. Three mutations, each firing locally: unwrap_or(Clipboard) -> the off-Linux row dispatch arm no-op -> the end-to-end row source = Clipboard -> both I have not touched ci.yml. Adding a macOS `pmacs-gpu` leg is a change to shared infrastructure and belongs in its own lane; making the contract testable where the tests already run was the fix available here. One slip of mine, fixed in the same change: the first version of this commit left `middle_click_paste_source` dead --- `apply_middle_press` calls `paste_source_for` directly now --- and I committed with clippy failing because I ran the gates after `git commit` rather than before. The helper is gone, the seam row names both platforms explicitly, and the gates ran first this time. --- pmacs-gpu/src/main.rs | 200 ++++++++++++++++++++++++------------------ 1 file changed, 117 insertions(+), 83 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 55514eb..626dd29 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -723,6 +723,8 @@ fn main() { let mut app = App { mode, proxy: Some(proxy), + #[cfg(test)] + test_force_non_linux: false, state: None, attach_client, pending_events, @@ -1586,6 +1588,15 @@ struct App { /// a non-Option in a borrow. proxy: Option>, state: Option, + /// Test override for B4's platform decision: pretend this build is + /// not Linux, so a row can drive the inert branch on a Linux host. + /// + /// **Needed because no CI leg runs this crate's tests off Linux.** + /// Without it the off-Linux contract is asserted nowhere that + /// actually executes, and a call-site `unwrap_or(Clipboard)` passes + /// everything. + #[cfg(test)] + test_force_non_linux: bool, /// User events received before winit creates `state`. Managed attach /// starts its reader before `run_app`, so the initial snapshot may arrive /// before `resumed` on backends with a different callback order. @@ -3432,7 +3443,11 @@ impl App { /// it as a `Paste`, the same wire operation Ctrl-V uses. The daemon /// inserts it; this frontend never edits the document itself. fn apply_middle_press(&mut self) { - let Some(source) = middle_click_paste_source() else { + #[cfg(test)] + let is_linux = !self.test_force_non_linux; + #[cfg(not(test))] + let is_linux = cfg!(target_os = "linux"); + let Some(source) = paste_source_for(is_linux) else { return; }; let bytes = self @@ -4249,6 +4264,8 @@ impl EffectHarness { socket: PathBuf::from("/nonexistent-harness-socket"), }, proxy: None, + #[cfg(test)] + test_force_non_linux: false, state, pending_events: Vec::new(), attach_client: Some(client), @@ -4972,33 +4989,22 @@ mod input_routing_tests { } /// B4 END TO END — a middle press driven through - /// `dispatch_window_event` sends **exactly one** `Paste`, carrying - /// the **PRIMARY** payload, and its release sends none. + /// `dispatch_window_event` produces **exactly one `Paste` carrying + /// PRIMARY and nothing else**, and its release produces nothing at + /// all. /// - /// The two seam rows below cannot see this: mutating + /// The two seam rows cannot see this: mutating /// `middle_click_paste_source` or replacing the dispatch arm with a /// no-op leaves both of them green, because each asserts a function - /// in isolation rather than the effect the gesture produces. This - /// row drives the production path and asserts the payload. + /// in isolation rather than the effect the gesture produces. /// - /// The two selections carry **distinguishable** contents, which is - /// the whole point — a row whose PRIMARY and CLIPBOARD stubs said - /// the same thing would pass with the wrong one read. + /// The two selections carry **distinguishable** contents — a row + /// whose PRIMARY and CLIPBOARD stubs said the same thing would pass + /// with the wrong one read. /// - /// **It asserts the whole transcript on BOTH platforms**, not just - /// Linux. A Linux-only row leaves the off-Linux contract to a helper - /// test, and - /// `middle_click_paste_source().unwrap_or(PasteSource::Clipboard)` - /// at the call site then restores the rejected clipboard fallback - /// while every row stays green: the helper still returns `None` and - /// Linux still gets PRIMARY. The inertness has to be asserted where - /// the effect would appear. - /// - /// **Where that mutant is actually caught, stated honestly:** on a - /// Linux host the `unwrap_or` never engages — the source is already - /// `Some(Primary)` — so no row on this machine can fire it, and a - /// green local run is not evidence about it. The `else` branch below - /// is what catches it, and it runs on the **non-Linux CI legs**. + /// **It asserts the WHOLE `Step`, not "no `Paste`".** Filtering for + /// pastes lets any other outbound event through, so a gesture that + /// also emitted something spurious would pass. #[test] fn b4_a_middle_press_sends_exactly_one_paste_carrying_primary() { use winit::event::{ElementState, MouseButton}; @@ -5010,52 +5016,76 @@ mod input_routing_tests { let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); - let pastes: Vec<&pmacs_protocol::FrontendEvent> = step - .outbound - .iter() - .filter(|e| matches!(e, pmacs_protocol::FrontendEvent::Paste { .. })) - .collect(); - if cfg!(target_os = "linux") { - assert_eq!( - pastes.len(), - 1, - "exactly one paste, got {:?}", - step.outbound - ); - match pastes[0] { - pmacs_protocol::FrontendEvent::Paste { data, .. } => assert_eq!( - data.as_slice(), - b"PRIMARY-payload", - "B4 pastes the PRIMARY selection, not the clipboard" - ), - other => panic!("expected a Paste, got {other:?}"), - } - } else { - assert!( - pastes.is_empty(), - "off Linux the gesture is INERT: B4 rules PRIMARY on Linux \ - and nothing else, so no paste of any selection may appear \ - here. Got {:?}", - step.outbound - ); - assert!( - step.local.is_empty(), - "and no local effect either: {:?}", - step.local - ); - } + // The harness's frontend id is whatever the handshake assigned; + // the row is about the payload and the shape, not the id. + let frontend_id = match step.outbound.first() { + Some(pmacs_protocol::FrontendEvent::Paste { frontend_id, .. }) => *frontend_id, + other => panic!("expected a Paste first, got {other:?}"), + }; + assert_eq!( + step, + Step { + local: Vec::new(), + outbound: vec![pmacs_protocol::FrontendEvent::Paste { + frontend_id, + data: b"PRIMARY-payload".to_vec(), + }], + }, + "exactly one PRIMARY paste, no local effect, nothing else" + ); let release = h.feed(&mouse_input(ElementState::Released, MouseButton::Middle)); - assert!( - !release - .outbound - .iter() - .any(|e| matches!(e, pmacs_protocol::FrontendEvent::Paste { .. })), - "the release pastes nothing; the gesture fires once, on the press" + assert_eq!( + release, + Step { + local: Vec::new(), + outbound: Vec::new() + }, + "the release does nothing at all; the gesture fires once, on \ + the press" ); } - /// P2 — a button the frontend has no semantics for reaches no body: + /// B4's OFF-LINUX leg — the gesture is **completely inert**. + /// + /// B4 rules PRIMARY on Linux and rules nothing else, so off Linux a + /// middle press must produce no effect of any kind — not a clipboard + /// paste, not anything. + /// + /// **This row exists because no CI leg runs this crate's tests off + /// Linux.** `cargo test -p pmacs-gpu` appears once in `ci.yml`, in + /// the Ubuntu-only `gpu-render` job, so a `cfg`-gated row would + /// assert the off-Linux contract nowhere that actually executes, and + /// `paste_source_for(..).unwrap_or(PasteSource::Clipboard)` at the + /// call site would pass everything. The platform is injected instead + /// of read, so the branch runs here. + /// + /// *Mutation: `unwrap_or(PasteSource::Clipboard)` at the call site → + /// this row.* + #[test] + fn b4_off_linux_a_middle_press_is_completely_inert() { + use winit::event::{ElementState, MouseButton}; + let mut h = EffectHarness::new(); + h.app.test_force_non_linux = true; + if let Some(state) = h.app.state.as_mut() { + state.set_test_selection(crate::PasteSource::Primary, b"PRIMARY-payload"); + state.set_test_selection(crate::PasteSource::Clipboard, b"CLIPBOARD-payload"); + } + + let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); + + assert_eq!( + step, + Step { + local: Vec::new(), + outbound: Vec::new() + }, + "off Linux the gesture is inert: no paste of any selection, no \ + local effect, nothing" + ); + } + + /// P2 — a button the frontend has no semantics for reaches no body: /// 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] @@ -5430,8 +5460,8 @@ enum PasteSource { Primary, } -/// The selection a middle click pastes from, or `None` where the -/// gesture has no ruled meaning. +/// The selection a middle click pastes from on a given platform, or +/// `None` where the gesture has no ruled meaning. /// /// **B4 rules PRIMARY on Linux, and rules nothing else.** Off Linux /// there is no PRIMARY selection, and the gesture was inert before this @@ -5439,8 +5469,15 @@ enum PasteSource { /// behaviour on every other platform that no framing approved. It stays /// inert, and a fallback needs framing and re-approval rather than a /// default chosen here. -const fn middle_click_paste_source() -> Option { - if cfg!(target_os = "linux") { +/// +/// **The platform is a PARAMETER, not a `cfg!` read inside.** No CI leg +/// runs this crate's tests on a non-Linux host — `cargo test -p +/// pmacs-gpu` appears once, in the Ubuntu-only `gpu-render` job — so a +/// decision baked in by `cfg!` would leave the off-Linux contract +/// untested everywhere it actually runs. Taking it as an argument lets +/// a row drive both outcomes here. +const fn paste_source_for(is_linux: bool) -> Option { + if is_linux { Some(PasteSource::Primary) } else { None @@ -14827,21 +14864,18 @@ mod tests { /// *Mutation: return `PasteSource::Clipboard` → this row.* #[test] fn b4_a_middle_click_pastes_the_primary_selection_on_linux() { - let source = super::middle_click_paste_source(); - if cfg!(target_os = "linux") { - assert_eq!( - source, - Some(super::PasteSource::Primary), - "B4: the middle button reads PRIMARY on Linux" - ); - } else { - assert_eq!( - source, None, - "B4 rules PRIMARY on Linux and nothing else; off Linux the \ - gesture stays inert rather than acquiring an unframed \ - clipboard meaning" - ); - } + assert_eq!( + super::paste_source_for(true), + Some(super::PasteSource::Primary), + "B4: the middle button reads PRIMARY on Linux" + ); + assert_eq!( + super::paste_source_for(false), + None, + "B4 rules PRIMARY on Linux and nothing else; off Linux the \ + gesture stays inert rather than acquiring an unframed \ + clipboard meaning" + ); } /// B4's routing half — a middle **press** is its own route now, and From f4f3d63bc1946b191899b6f4ce725de81826e286 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 12:48:19 +0200 Subject: [PATCH 24/72] docs(gui-1b): repair B4's stale references and a duplicated sentence Deleting `middle_click_paste_source` left a broken intra-doc link on `apply_middle_press` and a stale name in the end-to-end row's comment. Both now name `paste_source_for`, which is what the code calls. And `an_unused_button_produces_no_effect_of_any_kind`'s opening sentence was duplicated on one line --- the tail of the same insertion that split it in the first place. Repaired. WHY NEITHER WAS CAUGHT, which is the part worth keeping: NOTHING RUNS `cargo doc`. It is absent from `scripts/gate` and from every ci.yml job, so broken intra-doc links are ungated across this repository. `git diff --check` cannot see them because they are syntactically valid, and clippy does not read doc links. Running it by hand here confirms my link now resolves --- and surfaces one PRE-EXISTING unresolved link, `MathNode` at pmacs-gpu/src/math_layout.rs:314, in a file this lane has never touched. I have not added a doc step to the gate: that is shared infrastructure and its own lane, alongside the clippy default-features gap this lane already recorded. The finding is carried to 1b's ledger block rather than left in a commit message. --- pmacs-gpu/src/main.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 626dd29..1a79750 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3439,9 +3439,10 @@ impl App { /// Perform [`PointerRoute::MiddlePress`] — GUI Stage 1b B4. /// - /// Reads the selection [`middle_click_paste_source`] names and ships - /// it as a `Paste`, the same wire operation Ctrl-V uses. The daemon - /// inserts it; this frontend never edits the document itself. + /// Reads the selection [`paste_source_for`] names for this platform + /// and ships it as a `Paste`, the same wire operation Ctrl-V uses. + /// The daemon inserts it; this frontend never edits the document + /// itself. fn apply_middle_press(&mut self) { #[cfg(test)] let is_linux = !self.test_force_non_linux; @@ -4994,7 +4995,7 @@ mod input_routing_tests { /// all. /// /// The two seam rows cannot see this: mutating - /// `middle_click_paste_source` or replacing the dispatch arm with a + /// `paste_source_for` or replacing the dispatch arm with a /// no-op leaves both of them green, because each asserts a function /// in isolation rather than the effect the gesture produces. /// @@ -5085,7 +5086,7 @@ mod input_routing_tests { ); } - /// P2 — a button the frontend has no semantics for reaches no body: /// P2 — a button the frontend has no semantics for reaches no body: + /// 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] From 5dd201485cd45d79b14b7b6267d5d60f5b266bc0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 12:48:44 +0200 Subject: [PATCH 25/72] docs(lane): open 1b's ledger block, with three infrastructure gaps The lane has been implementing without a ledger entry, which is the recovery state this file exists to carry. It records the base, the framing revision, what has landed, and what is still owed --- including B1's disposal half, which the code documents as absent rather than stubbing. Three findings are recorded because each cost review time and none is this lane's to fix: - `scripts/gate`'s clippy step runs default features only, so crdt-gated code is never linted locally; - NOTHING runs `cargo doc` anywhere, so broken intra-doc links are ungated repository-wide --- including one pre-existing, in a file this lane never touched; - no non-Linux CI leg runs `pmacs-gpu` tests, which is why B4's platform decision is an injected parameter rather than a `cfg!` read. Each names the lane that would own the fix rather than proposing it here. --- docs/active-work.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 1abcecf..ee3f029 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -301,6 +301,50 @@ waits for a signal that is not coming. - **THE FIRST DISPATCH IMMEDIATELY FOUND A RED ON `main`**, which is what this lane was built for. See the proptest entry below. +## GUI arc Stage 1b — pointer and scroll — IMPLEMENTING + +**Branch `gui-stage1b-pointer-scroll`, rebased onto `0ec13b3`.** Framing +`docs/gui-stage1-input-framing.md`, **revision 20** — §2a re-measured at +this base, the panel-replay prerequisite recorded as DISCHARGED by #243, +and the both-axis effect witness still owed. + +**Landed so far:** B1's per-target fractional wheel residual (the +producer), B2's daemon-side horizontal panel leg, B3/B7's shared +`scroll_window_columns` with its saturated bound and wrap pin, and B4's +middle-click PRIMARY paste. + +**Still owed:** B5, B6's routing row, the L1–L8 lifetime rows, step 3's +fractional both-axis panel witness, and **B1's disposal half** — a +residual keyed to a surface that goes away must go with it, and this +frontend does not yet track "that buffer is gone". + +### Two gate gaps this lane found, neither fixed here + +Both are shared infrastructure and belong in their own lane; recorded so +the next lane does not rediscover them at review. + +- **`scripts/gate`'s clippy step runs DEFAULT FEATURES only**, so no + `#[cfg(feature = "crdt")]` code is ever linted locally. A + `clippy::match_same_arms` on the CRDT lane's new enumeration passed + five consecutive all-green gate runs and then redded `Test (crdt)`. +- **NOTHING RUNS `cargo doc`** — not `scripts/gate`, not any `ci.yml` + job — so **broken intra-doc links are ungated across this + repository**. `git diff --check` cannot see them (they are + syntactically valid) and clippy does not read them. Found when + deleting a helper left a dangling `[`link`]` that review caught + instead. Running it by hand also surfaces a pre-existing unresolved + link, `MathNode` at `pmacs-gpu/src/math_layout.rs:314`. + +### And one coverage gap in CI, which shaped B4's design + +**No non-Linux leg runs this crate's tests.** `cargo test -p pmacs-gpu` +appears exactly once in `ci.yml`, in the Ubuntu-only `gpu-render` job; +the macOS matrix tests the workspace default member only. B4's +off-Linux contract would therefore have been asserted nowhere that +executes, so **the platform decision is a parameter rather than a +`cfg!` read** and both outcomes are driven on this host. Adding a macOS +`pmacs-gpu` leg is the real fix and is not this lane's. + ## CRDT identity-replace undo — MERGED as #246 (`78346de`) - **MERGED 2026-08-31T18:56:09Z** at approved head `093d677`, merge From cfd8f6ba28d5f5e3a25a7848a52c455d9fad2752 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 13:47:26 +0200 Subject: [PATCH 26/72] docs(lane): two precision fixes, and make 1b's block a real checkpoint The block said the platform decision "replaces a `cfg!` read". It does not: production still reads `cfg!(target_os = "linux")` at pmacs-gpu/src/main.rs:3450. What became injectable is the SELECTION decision taken after that target fact --- `is_linux` comes from `cfg!` in a non-test build and from an override otherwise, and `paste_source_for(is_linux)` is the part a row can drive both ways. And "L1-L8 still owed" hid implemented state that recovery needs. The manual authority latch, its wrap/replacement clearing, and R4/R5's two separate residual resets have all LANDED; what is owed is their witnesses. The block now splits "landed but not yet witnessed" from "owed outright" rather than letting one phrase imply neither exists. It also names the authoritative ref and the exact recovery command, which is what this file is for and what the block did not have. --- docs/active-work.md | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ee3f029..29b744a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -303,7 +303,18 @@ waits for a signal that is not coming. ## GUI arc Stage 1b — pointer and scroll — IMPLEMENTING -**Branch `gui-stage1b-pointer-scroll`, rebased onto `0ec13b3`.** Framing +**Branch `gui-stage1b-pointer-scroll`, rebased onto `0ec13b3`.** The +**remote ref `githubsucks/gui-stage1b-pointer-scroll` is +authoritative**; recover with +`git fetch githubsucks && git checkout -B gui-stage1b-pointer-scroll githubsucks/gui-stage1b-pointer-scroll`. + +**The rebase rewrote history**, so that ref was force-pushed. The seven +pre-rebase framing commits are preserved on +**`githubsucks/gui-stage1b-prerebase-backup`** (`5c83b15`) — pushed +before the rewrite, not after, so nothing depended on a local-only ref +surviving. + +Framing `docs/gui-stage1-input-framing.md`, **revision 20** — §2a re-measured at this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. @@ -313,10 +324,22 @@ producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, and B4's middle-click PRIMARY paste. -**Still owed:** B5, B6's routing row, the L1–L8 lifetime rows, step 3's -fractional both-axis panel witness, and **B1's disposal half** — a -residual keyed to a surface that goes away must go with it, and this -frontend does not yet track "that buffer is gone". +**Landed but NOT yet witnessed** — recovery needs this split, because +"L1–L8 owed" reads as though none of the mechanism exists: + +- the **manual horizontal authority latch** (`manual_left_authority`), + armed only by an *effective* move (clause 2); +- **wrap and buffer-replacement clearing** of that latch and the origin + (clause 5), on both the GPU's `scroll_by_columns` and the buffer + replacement path; +- **R4 and R5's residual resets**, as two separate clears beside + `code_scroll_left` so omitting either is individually visible. + +**Owed outright:** B5, B6's routing row, the **L1–L8 rows** for the +latch above, step 3's fractional both-axis panel witness, R4/R5's own +replacement witnesses, and **B1's disposal half** — a residual keyed to +a surface that goes away must go with it, and this frontend does not yet +track "that buffer is gone". ### Two gate gaps this lane found, neither fixed here @@ -341,9 +364,13 @@ the next lane does not rediscover them at review. appears exactly once in `ci.yml`, in the Ubuntu-only `gpu-render` job; the macOS matrix tests the workspace default member only. B4's off-Linux contract would therefore have been asserted nowhere that -executes, so **the platform decision is a parameter rather than a -`cfg!` read** and both outcomes are driven on this host. Adding a macOS -`pmacs-gpu` leg is the real fix and is not this lane's. +executes. **Production still reads `cfg!(target_os = "linux")`** +(`pmacs-gpu/src/main.rs:3450`); what became injectable is the +**selection decision taken after** that target fact — `is_linux` is +obtained from `cfg!` in a non-test build and from a test override +otherwise, and `paste_source_for(is_linux)` is the part a row can drive +both ways. Adding a macOS `pmacs-gpu` leg is the real fix and is not +this lane's. ## CRDT identity-replace undo — MERGED as #246 (`78346de`) From 9f24dceb6a96df43298dea7ed613e4776b8bc77b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:03:04 +0200 Subject: [PATCH 27/72] feat(gui-1b): B5 --- an I-beam over text content, and nowhere else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2a's CORRECTION 3 said where this had to land: `apply_panel_cursor_icon` already owns the cursor and writes `Default` in its else branch, so an I-beam at a separate site would be CLOBBERED by it on the next motion. B5 extends that owner rather than joining it --- `desired_cursor_icon` decides RowResize, Text and Default together or not at all, with the divider outranking the I-beam because a drag handle is never text. `pointer_over_text_content` is geometric, not a byte hit-test: an I-beam belongs over the text AREA including the blank past a short line's end, and a byte test would flicker along a ragged right margin. It excludes the gutter, the minimap, the panel band and everything outside the document's text rect, each for its own reason. The icon now applies on EVERY motion rather than only when divider hover flips. B5's transitions --- crossing the gutter, crossing the text's right edge --- do not touch `hover_divider`, so the old gate would have left the icon stale for exactly the cases B5 is about. The write is idempotent against `last_cursor_icon`, so per-motion calls cost a comparison rather than a platform round-trip. THE FIRST VERSION OF THE ROW COULD NOT SEE ITS OWN MUTATION. With line numbers off, `gutter_width_px` is 0 and `text_left == TEXT_LEFT`, so "extend the I-beam over the gutter" changed nothing and the row passed a broken build --- 0 rows fired. The fixture now turns line numbers on and ASSERTS a gutter exists before relying on one. Both mutations fire: I-beam over the gutter -> the coverage row I-beam outranks divider -> the coverage row no-pointer guesses a spot -> the no-pointer row --- pmacs-gpu/src/main.rs | 168 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 10 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 1a79750..616e3d5 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1884,6 +1884,9 @@ struct State { /// caret follow leaves it alone until the cursor position actually /// changes. A move fully absorbed by the clamp arms nothing. manual_left_authority: bool, + /// The icon last written to the window, so a per-motion call is a + /// comparison rather than a platform round-trip. + last_cursor_icon: Option, /// Stub selections for tests; see [`Self::set_test_selection`]. #[cfg(test)] test_selections: HashMap>, @@ -3038,9 +3041,15 @@ impl App { 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(); - } + state.set_panel_divider_hover(surface == PointerSurface::PanelDivider); + // **Applied on every motion, not only when divider hover flips.** + // B5's I-beam changes as the pointer crosses the gutter or the + // text's right edge, neither of which touches `hover_divider`; + // gating on that flag would leave the icon stale for exactly the + // transitions B5 is about. `apply_panel_cursor_icon` writes only + // when the icon actually changed, so this costs no extra + // `set_cursor` calls. + state.apply_panel_cursor_icon(); match surface { PointerSurface::PanelDivider | PointerSurface::PanelBackground => return, PointerSurface::PanelCell(coord) => { @@ -6090,6 +6099,7 @@ impl State { last_pointer_down: None, minimap_scrub_active: false, manual_left_authority: false, + last_cursor_icon: None, #[cfg(test)] test_selections: HashMap::new(), wheel_residuals: WheelResiduals::default(), @@ -8254,14 +8264,68 @@ impl State { /// otherwise. Driven from the same `hover_divider` bit the hit test /// sets, so the icon cannot advertise a drag target the press would /// miss. - fn apply_panel_cursor_icon(&self) { - if let Some(window) = &self.window { - window.set_cursor(if self.panel.hover_divider { - winit::window::CursorIcon::RowResize - } else { - winit::window::CursorIcon::Default - }); + fn apply_panel_cursor_icon(&mut self) { + let icon = self.desired_cursor_icon(); + if self.last_cursor_icon == Some(icon) { + return; } + self.last_cursor_icon = Some(icon); + if let Some(window) = &self.window { + window.set_cursor(icon); + } + } + + /// The cursor icon for the current pointer position. + /// + /// **One owner, deliberately.** GUI Stage 1b's B5 adds an I-beam + /// over text content, and §2a's CORRECTION 3 is why it lands here + /// rather than at a site of its own: this function's `else` branch + /// writes `Default` unconditionally, so a separate I-beam writer + /// would be **clobbered by it** on the next motion. The divider's + /// `RowResize`, B5's `Text` and the `Default` fallback are decided + /// together or not at all. + /// + /// Order matters: the divider outranks the I-beam, because the + /// divider strip is a drag handle and is never text. + fn desired_cursor_icon(&self) -> winit::window::CursorIcon { + if self.panel.hover_divider { + winit::window::CursorIcon::RowResize + } else if self.pointer_over_text_content() { + winit::window::CursorIcon::Text + } else { + winit::window::CursorIcon::Default + } + } + + /// Whether the pointer is over **document text content** — B5's + /// "text content only". + /// + /// Excluded, each for its own reason: the **gutter**, which is left + /// of `text_left` and is chrome rather than text; the **minimap**, + /// which is a scrub surface; the **panel band**, which owns its own + /// pixels; the **status band** and everything below the document's + /// text bottom; and anything right of the text bounds. + /// + /// Geometric rather than a byte hit-test: an I-beam belongs over the + /// text *area*, including the blank space past a short line's end, + /// and a byte test would flicker the cursor along a ragged right + /// margin. + fn pointer_over_text_content(&self) -> bool { + let Some((x, y)) = self.pointer_pos else { + return false; + }; + let (x, y) = (x as f32, y as f32); + if self.in_minimap_band(f64::from(x), f64::from(y)) { + return false; + } + if !matches!( + self.classify_pointer_surface(x, y), + PointerSurface::Elsewhere + ) { + return false; + } + let bottom = document_text_bottom(self.config.height, self.fm, self.band_inset()); + x >= self.text_left() && x < self.text_bounds_right() as f32 && y >= TEXT_TOP && y < bottom } /// Consume the "a font/scale change invalidated the declaration" flag. @@ -14853,6 +14917,90 @@ mod tests { ); } + /// B5 — the I-beam appears over **text content only**, and the + /// divider outranks it. + /// + /// The row drives `desired_cursor_icon` across the surfaces the + /// contract distinguishes rather than asserting one position: an + /// I-beam that appeared over the gutter, the minimap or the band + /// would each be a different defect, and a single-point row would + /// see none of them. + /// + /// *Mutation: extend the I-beam over the gutter (drop the + /// `x >= text_left()` bound) → this row.* + #[test] + fn b5_the_i_beam_covers_text_content_and_nothing_else() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + // No adapter here; the row needs a real surface for its + // geometry and is skipped rather than asserting on a stub. + return; + }; + // Geometry the row can reason about: the text area starts at + // `text_left` and ends at `text_bounds_right`. + // **A REAL gutter, or the row cannot see its own mutation.** + // With line numbers off, `gutter_width_px` is 0 and + // `text_left == TEXT_LEFT`, so "extend the I-beam over the + // gutter" changes nothing and the row passes a broken build. + state.line_numbers = LineNumberMode::Absolute; + let text_left = state.text_left(); + assert!( + text_left > TEXT_LEFT, + "fixture: a gutter must exist for this row to discriminate" + ); + let inside = (text_left + 4.0, TEXT_TOP + 4.0); + let in_gutter = (TEXT_LEFT.midpoint(text_left), TEXT_TOP + 4.0); + + state.pointer_pos = Some((f64::from(inside.0), f64::from(inside.1))); + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Text, + "over text content the cursor is an I-beam" + ); + + state.pointer_pos = Some((f64::from(in_gutter.0), f64::from(in_gutter.1))); + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Default, + "the gutter is chrome, not text: no I-beam" + ); + + state.pointer_pos = Some((f64::from(inside.0), 1.0)); + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Default, + "above the text top is chrome too" + ); + + // The divider outranks the I-beam even at a text-content x. + state.pointer_pos = Some((f64::from(inside.0), f64::from(inside.1))); + state.panel.hover_divider = true; + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::RowResize, + "the divider is a drag handle and is never text" + ); + } + + /// B5 — with no pointer position there is no I-beam. + /// + /// Before the first motion the frontend does not know where the + /// pointer is, and guessing `Text` would show an I-beam over + /// whatever the window happens to be showing. + #[test] + fn b5_no_pointer_position_means_no_i_beam() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + // No adapter here; the row needs a real surface for its + // geometry and is skipped rather than asserting on a stub. + return; + }; + state.pointer_pos = None; + assert_eq!(state.desired_cursor_icon(), CursorIcon::Default); + } + /// B4 — a middle-click paste reads the **PRIMARY selection** on /// Linux, not the clipboard. /// From 6ad0c448b1c8d09b1a8397f22a4b1105d80ed528 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:16:27 +0200 Subject: [PATCH 28/72] fix(gui-1b): B5 --- witness the motion path, the blank, and the menu Three of B5's claims were asserted nowhere that could fail. THE EVERY-MOTION FIX WAS UNWITNESSED. Both rows called `desired_cursor_icon` directly, so reinstating the divider-change gate left them green --- the gate is on the caller. A new row drives `apply_cursor_moved` from text into the gutter with `hover_divider` false throughout and asserts `last_cursor_icon` changes. Reinstating the gate fires it. THE GEOMETRIC-VERSUS-BYTE RULING WAS UNWITNESSED. The only positive point sat over an actual glyph, so a byte hit-test passed. A row now puts the pointer well past a short line's end, inside the text rectangle, and requires `Text`. Bounding x by the glyphs' extent --- byte-hit-test semantics expressed geometrically --- fires it. THE MENU PATH LEAKED AN I-BEAM. `apply_cursor_moved` returns early while a menu is open, so an I-beam showing when the menu opened stayed on screen over the menu indefinitely. The menu now applies the icon on that path and counts as chrome in `pointer_over_text_content`, with a row; dropping the guard fires it. `apply_panel_cursor_icon`'s doc still said it chooses between RowResize and Default. It chooses among three, and says so, including why calling it per-motion is cheap. --- pmacs-gpu/src/main.rs | 151 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 616e3d5..c2dbb30 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3022,6 +3022,11 @@ impl App { // highlight; send a hover when the item under the pointer // changes from the daemon's current active row. if state.menu.is_some() { + // B5 — the menu owns this pointer path, and it is not text. + // Without this the I-beam that was showing when the menu + // opened stays on screen over the menu indefinitely: the + // early return below skips the icon application entirely. + state.apply_panel_cursor_icon(); let hit = state.menu_hit(x, y); let active = state.menu.as_ref().and_then(|m| m.active); if let Some((row, true)) = hit @@ -4998,6 +5003,56 @@ mod input_routing_tests { ); } + /// B5 — the icon is re-applied on **every** motion, not only when + /// divider hover flips. + /// + /// Crossing from text into the gutter changes the icon and does not + /// touch `hover_divider`, so the old divider-change gate left the + /// I-beam on screen for exactly the transitions B5 is about. The + /// unit rows cannot see this: they call `desired_cursor_icon` + /// directly and never exercise the gate. + /// + /// *Mutation: gate the call on `set_panel_divider_hover(..)` again → + /// this row.* + #[test] + fn b5_the_icon_follows_motion_between_text_and_chrome() { + let mut h = EffectHarness::new(); + let (text_x, gutter_x, y) = { + let state = h.app.state.as_mut().expect("harness state"); + state.line_numbers = crate::LineNumberMode::Absolute; + let text_left = state.text_left(); + assert!( + text_left > crate::TEXT_LEFT, + "fixture: a gutter must exist for this row to discriminate" + ); + ( + f64::from(text_left + 8.0), + f64::from(crate::TEXT_LEFT.midpoint(text_left)), + f64::from(crate::TEXT_TOP + 4.0), + ) + }; + + h.app.apply_cursor_moved(text_x, y); + assert_eq!( + h.app.state.as_ref().expect("state").last_cursor_icon, + Some(winit::window::CursorIcon::Text), + "motion into text sets the I-beam" + ); + assert!( + !h.app.state.as_ref().expect("state").panel.hover_divider, + "setup: divider hover stays false throughout, so a \ + divider-gated apply would never run" + ); + + h.app.apply_cursor_moved(gutter_x, y); + assert_eq!( + h.app.state.as_ref().expect("state").last_cursor_icon, + Some(winit::window::CursorIcon::Default), + "motion into the gutter clears it — the gate would have left \ + the I-beam showing" + ); + } + /// B4 END TO END — a middle press driven through /// `dispatch_window_event` produces **exactly one `Paste` carrying /// PRIMARY and nothing else**, and its release produces nothing at @@ -8258,12 +8313,17 @@ impl State { } } - /// Apply the divider hover cursor icon to the real window. + /// Apply the cursor icon [`Self::desired_cursor_icon`] chose to the + /// real window. /// - /// `RowResize` while the pointer is on the strip, the default arrow - /// otherwise. Driven from the same `hover_divider` bit the hit test - /// sets, so the icon cannot advertise a drag target the press would - /// miss. + /// **Three outcomes, not two**, since GUI Stage 1b's B5: + /// `RowResize` on the divider strip, `Text` over document text + /// content, and the default arrow otherwise. The divider half is + /// driven from the same `hover_divider` bit the hit test sets, so + /// the icon cannot advertise a drag target the press would miss. + /// + /// Idempotent: it writes only when the icon actually changed, which + /// is what makes calling it on every pointer motion cheap. fn apply_panel_cursor_icon(&mut self) { let icon = self.desired_cursor_icon(); if self.last_cursor_icon == Some(icon) { @@ -8311,6 +8371,12 @@ impl State { /// and a byte test would flicker the cursor along a ragged right /// margin. fn pointer_over_text_content(&self) -> bool { + // An open context menu covers the document and owns the + // pointer; its pixels are chrome however text-like whatever is + // painted beneath them may be. + if self.menu.is_some() { + return false; + } let Some((x, y)) = self.pointer_pos else { return false; }; @@ -14983,6 +15049,81 @@ mod tests { ); } + /// B5 — the I-beam covers text-area BLANK too, not only glyphs. + /// + /// The ruling is that `pointer_over_text_content` is **geometric** + /// rather than a byte hit-test: an I-beam belongs over the text + /// area including the space past a short line's end, and a byte test + /// would flicker the cursor along a ragged right margin. + /// + /// A row whose only positive point sits over an actual glyph cannot + /// see that: replacing the geometry with a byte hit-test passes it. + /// + /// *Mutation: decide by `hit_test_source_byte` → this row.* + #[test] + fn b5_the_i_beam_covers_the_blank_past_a_short_lines_end() { + use winit::window::CursorIcon; + // One very short line, so most of the text rectangle's first row + // is blank — and the row asks for the I-beam there. + let Some(mut state) = State::new_headless(640, 480, "ab\n\n\n") else { + return; + }; + state.line_numbers = LineNumberMode::Absolute; + let far_right = state.text_bounds_right() as f32 - 8.0; + assert!( + far_right > state.text_left() + 40.0, + "fixture: the row needs blank space well past the line's end" + ); + state.pointer_pos = Some((f64::from(far_right), f64::from(TEXT_TOP + 4.0))); + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Text, + "the text AREA carries the I-beam, not just the glyphs in it" + ); + } + + /// B5 — an open context menu owns its pixels, and they are not text. + /// + /// The menu's motion path returns before the icon is applied, so an + /// I-beam showing when the menu opened would stay on screen over the + /// menu indefinitely. + /// + /// *Mutation: drop the `menu.is_some()` guard → this row.* + #[test] + fn b5_an_open_menu_is_not_text() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + return; + }; + state.line_numbers = LineNumberMode::Absolute; + let over_text = ( + f64::from(state.text_left() + 8.0), + f64::from(TEXT_TOP + 4.0), + ); + state.pointer_pos = Some(over_text); + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Text, + "setup: an I-beam is showing before the menu opens" + ); + + state.menu = Some(MenuLocal { + rows: vec![MenuPromptRow { + label: "an item".to_owned(), + separator: false, + }], + active: Some(0), + anchor_px: (10.0, 10.0), + }); + + assert_eq!( + state.desired_cursor_icon(), + CursorIcon::Default, + "the menu owns the pointer; its pixels are chrome" + ); + } + /// B5 — with no pointer position there is no I-beam. /// /// Before the first motion the frontend does not know where the From e8bc6750ae08d7e3d0d8a085e021bc7d7513d98f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:23:33 +0200 Subject: [PATCH 29/72] fix(gui-1b): B5 --- the icon follows the MENU'S LIFECYCLE, not motion The menu case was wrong in a way the motion-path patch only hid. MENU OWNERSHIP CHANGES WITH NO POINTER MOTION. `MenuPrompt` opens and closes the menu, and re-deriving the icon only on motion meant opening while an I-beam showed left it on screen over the menu, and closing left the arrow over text --- in both cases until the pointer happened to move. The icon is a function of the state, so it is now re-derived where the state changes. That makes the motion-path call REDUNDANT, and it is removed rather than kept: motion inside an open menu changes no ownership, and a second writer there is one no row could distinguish from the first. This is the option review offered, and it is the better half --- the transition is where the fact lives. The new row drives `apply_attach_message` --- the production path --- and asserts `last_cursor_icon`, the value actually written, across both transitions with the pointer never moving. Dropping the `MenuPrompt` call fires it. The decision-half row stays separate so a failure says whether the DECISION or the APPLICATION broke; dropping the `menu.is_some()` guard fires both. And the blank-area row documented a mutation that was not the one run. `hit_test_source_byte` takes `&mut self` and the helper is `&self`, so the literal substitution does not compile; the executed mutation bounds `x` by the glyphs' extent, which is the same semantics geometrically. The row now records what was executed and why the literal one is unavailable. --- pmacs-gpu/src/main.rs | 92 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c2dbb30..e828242 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3022,11 +3022,11 @@ impl App { // highlight; send a hover when the item under the pointer // changes from the daemon's current active row. if state.menu.is_some() { - // B5 — the menu owns this pointer path, and it is not text. - // Without this the I-beam that was showing when the menu - // opened stays on screen over the menu indefinitely: the - // early return below skips the icon application entirely. - state.apply_panel_cursor_icon(); + // No icon application here, deliberately. The menu's icon is + // settled when the menu OPENS (`MenuPrompt`), and motion + // inside an open menu changes no ownership — a call here + // would be a second writer that no row could distinguish + // from the first. let hit = state.menu_hit(x, y); let active = state.menu.as_ref().and_then(|m| m.active); if let Some((row, true)) = hit @@ -7224,6 +7224,13 @@ impl State { anchor_px: self.menu_anchor_px, }) }; + // B5 — menu ownership changes HERE, with no pointer + // motion. Opening while an I-beam is showing would leave + // it on screen over the menu until the pointer happened + // to move; closing would leave the arrow over text for + // just as long. The icon is a function of the state, so + // it is re-derived where the state changes. + self.apply_panel_cursor_icon(); self.request_redraw(); None } @@ -15059,7 +15066,11 @@ mod tests { /// A row whose only positive point sits over an actual glyph cannot /// see that: replacing the geometry with a byte hit-test passes it. /// - /// *Mutation: decide by `hit_test_source_byte` → this row.* + /// *Mutation, as executed:* bound `x` by the glyphs' extent — + /// `text_left + widest_display_columns * mono_advance` — which is + /// byte-hit-test semantics expressed geometrically. The literal + /// substitution is not available: `hit_test_source_byte` takes + /// `&mut self` and this helper is `&self`. #[test] fn b5_the_i_beam_covers_the_blank_past_a_short_lines_end() { use winit::window::CursorIcon; @@ -15082,13 +15093,72 @@ mod tests { ); } + /// B5 — the icon follows the MENU'S LIFECYCLE, which changes with no + /// pointer motion at all. + /// + /// `MenuPrompt` opens and closes the menu. Without re-deriving the + /// icon there, opening while an I-beam shows leaves it on screen + /// over the menu, and closing leaves the arrow over text — in both + /// cases until the pointer happens to move. + /// + /// Driven through `apply_attach_message`, the production path, and + /// asserting `last_cursor_icon` — the value actually written — not + /// the decision function. + /// + /// *Mutations, each firing this row: drop the + /// `apply_panel_cursor_icon()` call in the `MenuPrompt` arm; drop + /// the `menu.is_some()` guard in `pointer_over_text_content`.* + #[test] + fn b5_the_icon_follows_the_menus_lifecycle_without_pointer_motion() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + return; + }; + state.line_numbers = LineNumberMode::Absolute; + state.pointer_pos = Some(( + f64::from(state.text_left() + 8.0), + f64::from(TEXT_TOP + 4.0), + )); + state.apply_panel_cursor_icon(); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "setup: an I-beam is showing before the menu opens" + ); + + let _ = state.apply_attach_message(InstanceMessage::MenuPrompt { + buffer_id: BufferId::next(), + rows: vec![MenuPromptRow { + label: "Cut".into(), + separator: false, + }], + active: Some(0), + }); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "opening the menu clears the I-beam without any pointer motion" + ); + + // Empty rows close it. + let _ = state.apply_attach_message(InstanceMessage::MenuPrompt { + buffer_id: BufferId::next(), + rows: Vec::new(), + active: None, + }); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "and closing it restores the I-beam, the pointer never having \ + moved" + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// - /// The menu's motion path returns before the icon is applied, so an - /// I-beam showing when the menu opened would stay on screen over the - /// menu indefinitely. - /// - /// *Mutation: drop the `menu.is_some()` guard → this row.* + /// The decision half of the lifecycle row above, kept separate so a + /// failure says whether the *decision* or the *application* broke. #[test] fn b5_an_open_menu_is_not_text() { use winit::window::CursorIcon; From 7ed5c42d9ddc6c67c88f1be80367b14c11990f3a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:31:01 +0200 Subject: [PATCH 30/72] fix(gui-1b): B5 --- the snapshot path closes the menu too `MenuPrompt` was not the only writer. `BufferSnapshot` clears the menu --- a popup anchored in the prior buffer would hijack input --- and left the icon alone, so an open-menu arrow survived a buffer replacement over document text until the pointer moved. Menu state now has ONE writer, `set_menu`, which re-derives the icon. A third site added later gets it for free instead of reintroducing the same defect, and `self.menu = ` appears exactly once in the crate. The snapshot also needs the icon re-derived AFTER the reshape, for a different reason: it changes geometry. `text_left` is `TEXT_LEFT + gutter_width_px` and the gutter is sized from the line count, so a snapshot moves the text boundary under a stationary pointer --- a pixel that was gutter becomes text. BOTH MECHANISMS FIRE ON THE SNAPSHOT PATH, so at first each masked the other: removing either alone left every row green, and only removing both fired anything. That is the "one omission at a time" defect R4/R5 is written to avoid, reproduced here. Each now has its own witness: set_menu's apply removed -> 2 rows (the MenuPrompt legs) post-reshape apply removed -> 1 row (the new geometry leg) The geometry row moves the line count between one and four digits so the gutter widens and narrows under a stationary pointer, and asserts the icon follows. Its setup steps assert the gutter actually moved, so a fixture that stopped discriminating fails rather than passing quietly. --- pmacs-gpu/src/main.rs | 184 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 9 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e828242..a96bfc0 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -6764,7 +6764,7 @@ impl State { // global core instance, matching the producer's // surviving `last_minibuffer` baseline. self.search_prompt = None; - self.menu = None; + self.set_menu(None); self.status_facts = None; self.statusline_segments = None; self.status_runs = None; @@ -6818,6 +6818,14 @@ impl State { self.sync_buffer_dimensions(); self.reshape(); } + // B5 — re-derive the icon AFTER the geometry settles. + // `set_menu(None)` above ran before the reshape, so it + // decided against the OLD text bounds; a snapshot can + // move `text_left` (a different line count changes the + // gutter) and the minimap clip. This is not a second + // writer of menu state — it is the same rule applied to + // the other input the decision reads. + self.apply_panel_cursor_icon(); self.viewport_send_if_changed(buffer_id) } InstanceMessage::CrdtOp { buffer_id, op } => { @@ -7215,7 +7223,7 @@ impl State { // close it; otherwise anchor the popup at the remembered // right-click pixel. InstanceMessage::MenuPrompt { rows, active, .. } => { - self.menu = if rows.is_empty() { + let menu = if rows.is_empty() { None } else { Some(MenuLocal { @@ -7224,13 +7232,7 @@ impl State { anchor_px: self.menu_anchor_px, }) }; - // B5 — menu ownership changes HERE, with no pointer - // motion. Opening while an I-beam is showing would leave - // it on screen over the menu until the pointer happened - // to move; closing would leave the arrow over text for - // just as long. The icon is a function of the state, so - // it is re-derived where the state changes. - self.apply_panel_cursor_icon(); + self.set_menu(menu); self.request_redraw(); None } @@ -8320,6 +8322,25 @@ impl State { } } + /// **The single writer of menu state**, so the cursor icon cannot + /// drift out of step with it. + /// + /// GUI Stage 1b B5 makes the icon a function of menu ownership, and + /// menu state changes with **no pointer motion**: `MenuPrompt` + /// opens and closes it, and a `BufferSnapshot` clears it because a + /// popup anchored in the prior buffer would hijack input. Two call + /// sites setting the field directly is how one of them ends up + /// leaving a stale cursor — which is exactly what happened: the + /// snapshot path was missed, and an open-menu arrow survived a + /// buffer replacement over document text until the pointer moved. + /// + /// Routing both through here means a third site added later gets the + /// icon for free rather than reintroducing the same defect. + fn set_menu(&mut self, menu: Option) { + self.menu = menu; + self.apply_panel_cursor_icon(); + } + /// Apply the cursor icon [`Self::desired_cursor_icon`] chose to the /// real window. /// @@ -15155,6 +15176,151 @@ mod tests { ); } + /// B5 — a **buffer snapshot** closes the menu too, and the icon has + /// to follow that as well. + /// + /// `MenuPrompt` is not the only path that clears the menu: a + /// snapshot clears it because a popup anchored in the prior buffer + /// would hijack input. Missing that path left an open-menu arrow on + /// screen over document text until the pointer moved. + /// + /// *Mutation: set `self.menu` directly in the snapshot arm instead + /// of through `set_menu` → this row.* + #[test] + fn b5_a_buffer_snapshot_closes_the_menu_and_restores_the_i_beam() { + use winit::window::CursorIcon; + let text = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &text) else { + return; + }; + state.line_numbers = LineNumberMode::Absolute; + state.pointer_pos = Some(( + f64::from(state.text_left() + 8.0), + f64::from(TEXT_TOP + 4.0), + )); + + let _ = state.apply_attach_message(InstanceMessage::MenuPrompt { + buffer_id: BufferId::next(), + rows: vec![MenuPromptRow { + label: "Cut".into(), + separator: false, + }], + active: Some(0), + }); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "setup: the open menu owns the pointer" + ); + + let bid = BufferId::next(); + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, &text) + .expect("insert snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: bid, + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), + }); + + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "the snapshot closed the menu, so the I-beam returns without \ + the pointer moving" + ); + } + + /// B5 — a snapshot that changes GEOMETRY moves the I-beam boundary, + /// with no menu and no pointer motion involved. + /// + /// `text_left` is `TEXT_LEFT + gutter_width_px`, and the gutter is + /// sized from the line count. A snapshot that changes the number of + /// lines therefore moves the text boundary under a stationary + /// pointer: a pixel that was gutter becomes text, or the reverse. + /// + /// **This is the row that carries the post-reshape application.** + /// The menu row above cannot: on the snapshot path `set_menu`'s + /// apply and the post-reshape apply both fire, so each masks the + /// other and removing either alone leaves that row green. Removing + /// this one fires here. + /// + /// *Mutation: drop `apply_panel_cursor_icon()` after the reshape → + /// this row.* + #[test] + fn b5_a_snapshot_that_moves_the_gutter_moves_the_i_beam_boundary() { + use winit::window::CursorIcon; + // Ten lines: a one-digit gutter. + let narrow = "x\n".repeat(9); + let Some(mut state) = State::new_headless(640, 480, &narrow) else { + return; + }; + state.line_numbers = LineNumberMode::Absolute; + let narrow_left = state.text_left(); + + // A pixel just left of the current boundary: chrome now. + let probe = f64::from(narrow_left - 1.0); + state.pointer_pos = Some((probe, f64::from(TEXT_TOP + 4.0))); + state.apply_panel_cursor_icon(); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "setup: the probe pixel is gutter under the narrow gutter" + ); + + // Now a four-digit line count, which widens the gutter and + // pushes `text_left` further right — the probe stays chrome — + // then back to a one-digit count, which narrows it again. + let wide = "x\n".repeat(1200); + let bid = BufferId::next(); + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, &wide) + .expect("insert snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: bid, + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), + }); + assert!( + state.text_left() > narrow_left, + "setup: a larger line count must widen the gutter, else this \ + row measures nothing" + ); + + // A pixel that WAS text under the narrow gutter and is gutter + // under the wide one. + let inside_wide_gutter = f64::from(narrow_left + 2.0); + assert!( + inside_wide_gutter < f64::from(state.text_left()), + "setup: the probe must now fall inside the wider gutter" + ); + state.pointer_pos = Some((inside_wide_gutter, f64::from(TEXT_TOP + 4.0))); + state.apply_panel_cursor_icon(); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "setup: chrome under the wide gutter" + ); + + // Back to few lines: the same stationary pixel becomes text, and + // only the post-reshape application can notice. + let doc2 = loro::LoroDoc::new(); + doc2.get_text(LORO_TEXT_CONTAINER) + .insert(0, &narrow) + .expect("insert snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: BufferId::next(), + crdt_snapshot: doc2.export(loro::ExportMode::Snapshot).expect("export"), + }); + + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "the gutter narrowed under a stationary pointer, so the pixel \ + is text now and the icon must say so" + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// /// The decision half of the lifecycle row above, kept separate so a From a4627cdfbb57c390f547236dd1807da9415e0b47 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:42:26 +0200 Subject: [PATCH 31/72] fix(gui-1b): B5 --- re-derive the cursor icon where geometry settles The previous commit applied the icon in the `BufferSnapshot` arm after its reshape, and routed menu writes through a `set_menu` helper that applied it too. Both were wrong in the same way: they patched the two paths that had been noticed instead of the place the paths converge. Two consequences, both found in review. The snapshot's documented mutation did not fire. Replacing `set_menu(None)` with `self.menu = None` still passed, because the unconditional post-reshape apply in the same arm restored `Text`. The two appliers masked each other, so the single-writer property was an inspection claim wearing a witness's clothes, and the row's stated mutation named something the row could not see. And the snapshot is only one geometry transition. The line-number mode moves `text_left` through its own path; minimap arrival, panel appearance, window resize and font metrics move the text clip the same way. A stationary pointer could go from text to gutter, minimap, panel or outside with the icon still saying `Text`. Ten call sites reshape; three applied the icon. So the icon is now re-derived in `reshape`'s tail --- the one point every geometry settle already passes through --- and the two ad-hoc geometry appliers are gone, `set_menu` with them. Three production appliers remain, one per cause: pointer motion, menu ownership with no geometry, and geometry. Each is separately witnessed; the matrix is disjoint. Dropping the `reshape` tail fires the two snapshot rows and the new line-number row; dropping the `MenuPrompt` applier fires the lifecycle row alone; dropping the every-motion applier fires the motion row alone. The new row drives `InstanceMessage::LineNumbers` --- the production arm, not `apply_panel_cursor_icon` directly --- turning the gutter on under a stationary pointer and asserting the pixel it swallows stops being an I-beam. Its setup asserts the gutter actually moved past the probe, so the row cannot pass by measuring nothing. Gates at this tree: fmt clean; clippy --workspace --all-targets -D warnings clean; pmacs-gpu 306 (+1); --lib 1997; --lib --features crdt 2190; pmacs-protocol 32; git diff --check clean. --- pmacs-gpu/src/main.rs | 121 +++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a96bfc0..04dc01a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -6764,7 +6764,7 @@ impl State { // global core instance, matching the producer's // surviving `last_minibuffer` baseline. self.search_prompt = None; - self.set_menu(None); + self.menu = None; self.status_facts = None; self.statusline_segments = None; self.status_runs = None; @@ -6818,14 +6818,6 @@ impl State { self.sync_buffer_dimensions(); self.reshape(); } - // B5 — re-derive the icon AFTER the geometry settles. - // `set_menu(None)` above ran before the reshape, so it - // decided against the OLD text bounds; a snapshot can - // move `text_left` (a different line count changes the - // gutter) and the minimap clip. This is not a second - // writer of menu state — it is the same rule applied to - // the other input the decision reads. - self.apply_panel_cursor_icon(); self.viewport_send_if_changed(buffer_id) } InstanceMessage::CrdtOp { buffer_id, op } => { @@ -7232,7 +7224,11 @@ impl State { anchor_px: self.menu_anchor_px, }) }; - self.set_menu(menu); + self.menu = menu; + // B5 — menu ownership changed with no pointer motion, so + // the icon is re-derived here. This arm changes no + // geometry, so it is the only application it needs. + self.apply_panel_cursor_icon(); self.request_redraw(); None } @@ -8322,25 +8318,6 @@ impl State { } } - /// **The single writer of menu state**, so the cursor icon cannot - /// drift out of step with it. - /// - /// GUI Stage 1b B5 makes the icon a function of menu ownership, and - /// menu state changes with **no pointer motion**: `MenuPrompt` - /// opens and closes it, and a `BufferSnapshot` clears it because a - /// popup anchored in the prior buffer would hijack input. Two call - /// sites setting the field directly is how one of them ends up - /// leaving a stale cursor — which is exactly what happened: the - /// snapshot path was missed, and an open-menu arrow survived a - /// buffer replacement over document text until the pointer moved. - /// - /// Routing both through here means a third site added later gets the - /// icon for free rather than reintroducing the same defect. - fn set_menu(&mut self, menu: Option) { - self.menu = menu; - self.apply_panel_cursor_icon(); - } - /// Apply the cursor icon [`Self::desired_cursor_icon`] chose to the /// real window. /// @@ -10734,6 +10711,15 @@ impl State { self.normalize_code_scroll(); // Full restyle: release any held post-jump frame (Q#M6). self.styled_redraw_deadline = None; + // B5 — **the one place the cursor icon is re-derived after + // geometry.** The I-beam is decided against a boundary that + // moves without the pointer: `text_left` with the line-number + // mode's digit width, the text clip with minimap presence, + // panel appearance, window resize and font metrics. Every one + // of those settles by reshaping, so re-deriving here covers + // them all at once instead of leaving each new geometry path to + // remember a call it will not remember. + self.apply_panel_cursor_icon(); self.request_redraw(); } @@ -15184,8 +15170,8 @@ mod tests { /// would hijack input. Missing that path left an open-menu arrow on /// screen over document text until the pointer moved. /// - /// *Mutation: set `self.menu` directly in the snapshot arm instead - /// of through `set_menu` → this row.* + /// *Mutation: drop `apply_panel_cursor_icon()` from `reshape`'s + /// tail → this row.* #[test] fn b5_a_buffer_snapshot_closes_the_menu_and_restores_the_i_beam() { use winit::window::CursorIcon; @@ -15239,14 +15225,12 @@ mod tests { /// lines therefore moves the text boundary under a stationary /// pointer: a pixel that was gutter becomes text, or the reverse. /// - /// **This is the row that carries the post-reshape application.** - /// The menu row above cannot: on the snapshot path `set_menu`'s - /// apply and the post-reshape apply both fire, so each masks the - /// other and removing either alone leaves that row green. Removing - /// this one fires here. + /// This row and the menu row above reach the same hook by different + /// routes — menu ownership there, geometry here — and are kept + /// separate so a failure says which route broke. /// - /// *Mutation: drop `apply_panel_cursor_icon()` after the reshape → - /// this row.* + /// *Mutation: drop `apply_panel_cursor_icon()` from `reshape`'s tail + /// → this row, the menu row, and the line-number row below.* #[test] fn b5_a_snapshot_that_moves_the_gutter_moves_the_i_beam_boundary() { use winit::window::CursorIcon; @@ -15321,6 +15305,67 @@ mod tests { ); } + /// B5 — **the central geometry hook**: turning the line-number + /// gutter on moves the I-beam boundary under a stationary pointer. + /// + /// The snapshot arm is only one geometry transition. The + /// line-number mode changes `text_left`, and minimap arrival, panel + /// appearance, resize and font metrics move the text clip the same + /// way. A pointer that never moves can therefore go from text to + /// gutter with the icon still saying `Text`. All of them settle by + /// reshaping, which is why the re-derivation lives in `reshape`'s + /// tail rather than at each call site. This row drives the + /// production `InstanceMessage::LineNumbers` arm — not + /// `apply_panel_cursor_icon` directly — so it witnesses that hook + /// through a path a daemon message really takes. + /// + /// *Mutation: drop `apply_panel_cursor_icon()` from `reshape`'s tail + /// → this row.* + #[test] + fn b5_turning_the_line_number_gutter_on_moves_the_i_beam_boundary() { + use winit::window::CursorIcon; + let text = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &text) else { + return; + }; + // `Off` is the default: no gutter, so text starts at TEXT_LEFT. + assert_eq!( + state.line_numbers, + LineNumberMode::Off, + "setup: the gutter starts off" + ); + let bare_left = state.text_left(); + + // A pixel just inside the text with no gutter. + let probe = f64::from(bare_left + 2.0); + state.pointer_pos = Some((probe, f64::from(TEXT_TOP + 4.0))); + state.apply_panel_cursor_icon(); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "setup: the probe pixel is text while the gutter is off" + ); + + // Turn the gutter on through the daemon message. The pointer + // does not move. + let _ = state.apply_attach_message(InstanceMessage::LineNumbers { + buffer_id: BufferId::next(), + mode: LineNumberMode::Absolute, + }); + assert!( + probe < f64::from(state.text_left()), + "setup: the gutter must have swallowed the probe pixel, else \ + this row measures nothing" + ); + + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "the gutter appeared under a stationary pointer, so the pixel \ + is chrome now and the icon must say so" + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// /// The decision half of the lifecycle row above, kept separate so a From 392703bfc93451708af9c8cfefe27ac79cc5b77e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 14:43:05 +0200 Subject: [PATCH 32/72] docs(lane): B5 lands, and the shape of how it kept not landing Moves B5 from "owed outright" to landed at `a4627cd`, and records where its icon re-derivation ended up: `reshape`'s tail, rather than the two call sites that had been noticed. The recovery-relevant part is the failure mode, not the fix. Twice the patch went to a path someone had pointed at, and twice a second applier in the same arm restored the icon anyway --- so the row passed with its documented mutation applied. That is not a weak witness. It is a row that measures nothing while reading, in the diff, exactly like a row that measures something. --- docs/active-work.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 29b744a..8094ae4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,10 +319,23 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far:** B1's per-target fractional wheel residual (the -producer), B2's daemon-side horizontal panel leg, B3/B7's shared -`scroll_window_columns` with its saturated bound and wrap pin, and B4's -middle-click PRIMARY paste. +**Landed so far** (head `a4627cd`)**:** B1's per-target fractional +wheel residual (the producer), B2's daemon-side horizontal panel leg, +B3/B7's shared `scroll_window_columns` with its saturated bound and wrap +pin, B4's middle-click PRIMARY paste, and **B5's I-beam**, whose icon is +re-derived in **`reshape`'s tail** — the one point every geometry settle +already passes through, reached by the line-number mode, minimap +arrival, panel appearance, resize and font metrics alike. Three +production appliers remain, one per cause (pointer motion, menu +ownership without geometry, geometry), and each fires a disjoint set of +rows under its own removal. + +B5 took three rounds to get there. The first two patched the paths that +had been noticed — the snapshot arm, then a `set_menu` helper — and both +times the two appliers masked each other, so a row's documented mutation +named something the row could not see. **A witness whose mutation is +masked is not weaker evidence than a real one; it is evidence of +nothing, and it reads identically in the diff.** **Landed but NOT yet witnessed** — recovery needs this split, because "L1–L8 owed" reads as though none of the mechanism exists: @@ -335,7 +348,7 @@ middle-click PRIMARY paste. - **R4 and R5's residual resets**, as two separate clears beside `code_scroll_left` so omitting either is individually visible. -**Owed outright:** B5, B6's routing row, the **L1–L8 rows** for the +**Owed outright:** B6's routing row, the **L1–L8 rows** for the latch above, step 3's fractional both-axis panel witness, R4/R5's own replacement witnesses, and **B1's disposal half** — a residual keyed to a surface that goes away must go with it, and this frontend does not yet From 175cc7be1672350b631cfd6e1620afc640ff96fc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 19:08:05 +0200 Subject: [PATCH 33/72] fix(gui-1b): settle B5 across panel geometry transitions The B5 cursor icon was re-derived in reshape, but accepted PanelFrame messages changed the document boundary without reaching reshape. A stationary pointer therefore kept Text over a new panel, or RowResize after the panel disappeared. Route panel inset changes through one helper that synchronizes dimensions and reshapes. Keep content-only panel frames on the repaint path, so normal panel output does not rebuild the document. Geometry epoch advancement uses the same helper because it disowns the retained frame after resize/font handling has already reshaped against the old presentation. Add production-path witnesses for Present/Absent, an independent epoch invalidation row, and a discriminator keeping content-only frames off the reshape path. All four documented mutations fire. --- pmacs-gpu/src/main.rs | 219 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 8 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 04dc01a..eab85a4 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -7380,12 +7380,15 @@ impl State { } InstanceMessage::PanelFrame(payload) => { // The band changes the DOCUMENT's pixel height, so a panel - // that appears or disappears has to reshape the document - // buffers as well as request a repaint. Skipping the - // reshape leaves the code layer sized to the old boundary - // and the last lines painting under the band. - if self.apply_panel_payload(payload) { - self.sync_buffer_dimensions(); + // that appears, disappears, or changes row count has to + // reshape the document buffers. A content-only frame keeps + // the same inset and needs only a repaint — reshaping every + // live panel frame would put document work on the panel's + // ordinary repaint path. + let band_before = self.band_inset(); + if self.apply_panel_payload(payload) + && !self.reshape_if_panel_band_changed(band_before) + { self.request_redraw(); } None @@ -7617,6 +7620,21 @@ impl State { }) } + /// Reshape after a panel transition changed the document's bottom. + /// + /// Both directions terminate here: accepting/removing a frame in + /// `apply_attach_message`, and invalidating a retained frame by advancing + /// its geometry epoch. Content-only panel frames keep the same inset and + /// deliberately avoid the document reshape cost. + fn reshape_if_panel_band_changed(&mut self, before: PanelBandInset) -> bool { + if self.band_inset() == before { + return false; + } + self.sync_buffer_dimensions(); + self.reshape(); + true + } + /// The panel band's content rectangle in surface pixels: /// `(x, y, width, height)`, cells only — the divider sits above `y`. fn panel_content_rect(&self) -> Option<(f32, f32, f32, f32)> { @@ -7722,6 +7740,7 @@ impl State { if !self.panel_family.carries_panel() || self.panel.exhausted { return None; } + let band_before = self.band_inset(); let (total, advance) = self.declared_cell_total(); if trigger == GeometryTrigger::Surface && self.panel.geometry_epoch != 0 @@ -7742,18 +7761,24 @@ impl State { self.panel.drag = None; self.panel.hover_divider = false; self.panel.declared_advance = None; + self.reshape_if_panel_band_changed(band_before); return None; }; self.panel.geometry_epoch = next; self.panel.declared = Some(total); self.panel.declared_advance = advance; + // Advancing the epoch makes a retained frame stop being + // `presented()` until the daemon answers the new declaration. + // That removes its band after resize/font handling has already + // performed its own reshape, so settle the final visibility change. + self.reshape_if_panel_band_changed(band_before); Some((next, total)) } /// Apply an inbound `PanelFrame` payload. /// - /// Returns `true` when the band's appearance changed, so the caller - /// can request a redraw without guessing. + /// Returns `true` when the retained panel payload changed, so the caller + /// can distinguish a repaint/reflow from an atomic rejection or duplicate. /// /// Validation is atomic: a rejected frame leaves the retained one /// exactly as it was, because `PanelFrame::validate` is pure and runs @@ -15366,6 +15391,184 @@ mod tests { ); } + /// B5 — accepted panel messages settle the cursor icon through the same + /// geometry hook as every other document-boundary change. + /// + /// The pointer does not move across either transition. A first `Present` + /// turns a document pixel into panel content; `Absent` removes a divider + /// from under the pointer and clears its hover authority. Both messages + /// enter through `apply_attach_message`, the production receiver path. + /// + /// Content-only frames deliberately do not reshape: the discriminator is + /// the panel inset before/after the accepted payload, not merely a changed + /// frame. + /// + /// *Mutations: replace the band-change reshape in the `PanelFrame` arm + /// with `request_redraw()` → the appearance leg; omit the `hover_divider` + /// clear from `PanelFramePayload::Absent` → the removal leg.* + #[test] + fn b5_panel_messages_rederive_the_icon_without_pointer_motion() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + return; + }; + state.set_panel_wire(PANEL_MIN_VERSION); + let (geometry_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Surface) + .expect("a panel session declares its surface"); + let rows = 4; + let frame = panel_frame_of(rows, total.cols.max(1), geometry_epoch, 1); + + // Before the panel appears, a pixel in its future first row belongs + // to the document text area. + let future_band = PanelBandInset::installed(rows, state.fm); + let x = state.text_left() + 8.0; + let y = document_text_bottom(state.config.height, state.fm, future_band) + + state.fm.divider_height() + + state.fm.code_line_height() / 2.0; + state.pointer_pos = Some((f64::from(x), f64::from(y))); + state.apply_panel_cursor_icon(); + assert!( + state.pointer_over_text_content(), + "setup: text before Present" + ); + assert_eq!(state.last_cursor_icon, Some(CursorIcon::Text)); + + let _ = state.apply_attach_message(InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame), + )); + assert!( + matches!( + state.classify_pointer_surface(x, y), + PointerSurface::PanelCell(_) + ), + "setup: Present must put panel content under the stationary pointer" + ); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Default), + "panel appearance moves the stationary pointer off document text" + ); + + // Move once onto the divider, then keep the pointer stationary while + // Absent removes both the divider and its hover authority. + let (_, dy, _, dh) = state.panel_divider_rect().expect("present divider"); + let divider_point = (state.text_left() + 8.0, dy + dh / 2.0); + assert!(state.panel_divider_contains(divider_point.0, divider_point.1)); + state.pointer_pos = Some((f64::from(divider_point.0), f64::from(divider_point.1))); + assert!(state.set_panel_divider_hover(true)); + state.apply_panel_cursor_icon(); + assert_eq!(state.last_cursor_icon, Some(CursorIcon::RowResize)); + + let _ = state.apply_attach_message(InstanceMessage::PanelFrame(PanelFramePayload::Absent)); + assert!(state.panel.presented().is_none()); + assert!(!state.panel.hover_divider); + assert!( + state.pointer_over_text_content(), + "setup: the former divider pixel becomes document text" + ); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "panel removal replaces the stale resize icon without pointer motion" + ); + } + + /// B5 — a content-only panel frame does not reshape the document. + /// + /// Panel content can repaint continuously while its row count and inset + /// stay fixed. Routing every accepted frame through `reshape` would put a + /// full document rebuild on that ordinary path and would also release the + /// post-jump styled-redraw deadline. The deadline is the existing + /// observable effect used here to discriminate repaint from reshape. + /// + /// *Mutation: replace the inset comparison in the `PanelFrame` arm with + /// an unconditional `sync_buffer_dimensions(); reshape();` → this row.* + #[test] + fn b5_a_content_only_panel_frame_repaints_without_reshaping_the_document() { + let Some(mut state) = State::new_headless(640, 480, "document\n") else { + return; + }; + state.set_panel_wire(PANEL_MIN_VERSION); + let (geometry_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Surface) + .expect("a panel session declares its surface"); + let frame = panel_frame_of(4, total.cols.max(1), geometry_epoch, 1); + let _ = state.apply_attach_message(InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame.clone()), + )); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + state.styled_redraw_deadline = Some(deadline); + let mut repainted = frame; + repainted.cells[0] = terminal_cell(pmacs_protocol::Glyph::Char('y'), CellStyle::default()); + let inset_before = state.band_inset(); + let _ = state.apply_attach_message(InstanceMessage::PanelFrame( + PanelFramePayload::Present(repainted), + )); + + assert_eq!( + state.band_inset(), + inset_before, + "the row count is unchanged" + ); + assert_eq!( + state.styled_redraw_deadline, + Some(deadline), + "a content repaint must not perform a document reshape" + ); + } + + /// B5 — a new geometry declaration temporarily disowns the retained + /// panel frame, which is a panel-removal transition of its own. + /// + /// Resize and font handling reshape before the declaration is advanced. + /// Once its epoch changes, `presented()` rejects the old frame and the + /// panel inset disappears. This row isolates that later transition from + /// frame acceptance by installing the setup frame directly. + /// + /// *Mutation: omit `reshape_if_panel_band_changed(band_before)` after + /// advancing `geometry_epoch` → this row.* + #[test] + fn b5_geometry_redeclaration_rederives_the_icon_after_disowning_the_panel() { + use winit::window::CursorIcon; + let document = "fn main() {}\n".repeat(40); + let Some(mut state) = State::new_headless(640, 480, &document) else { + return; + }; + present_panel(&mut state, 4); + let (x, y, _, _) = state.panel_content_rect().expect("present panel"); + let point = ( + x + state.text_left() + 8.0, + y + state.fm.code_line_height() / 2.0, + ); + assert!(matches!( + state.classify_pointer_surface(point.0, point.1), + PointerSurface::PanelCell(_) + )); + state.pointer_pos = Some((f64::from(point.0), f64::from(point.1))); + state.apply_panel_cursor_icon(); + assert_eq!(state.last_cursor_icon, Some(CursorIcon::Default)); + + let _ = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("metrics always advance the panel geometry epoch"); + assert!( + state.panel.presented().is_none(), + "the retained frame answers the prior declaration" + ); + assert!( + state.pointer_over_text_content(), + "setup: without the disowned panel, its former cell is document text" + ); + assert_eq!( + state.last_cursor_icon, + Some(CursorIcon::Text), + "declaration invalidation settles the icon after its final geometry change" + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// /// The decision half of the lifecycle row above, kept separate so a From cc07642b04e954aa7070fe04134ea9b95f08cbf6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 19:18:39 +0200 Subject: [PATCH 34/72] docs(lane): record the completed B5 panel geometry seam Advance the GUI Stage 1b ledger to the immutable code head and correct the central-hook claim. Counting reshape callers missed two panel-band transitions: accepted frame changes and geometry epoch invalidation. Record the shared inset-change seam, the content-only repaint exception, the 309-row GPU pass, workspace clippy/fmt/diff verification, and all four executed mutations. --- docs/active-work.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8094ae4..eb50f68 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,23 +319,33 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (head `a4627cd`)**:** B1's per-target fractional +**Landed so far** (code head `175cc7b`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, and **B5's I-beam**, whose icon is -re-derived in **`reshape`'s tail** — the one point every geometry settle -already passes through, reached by the line-number mode, minimap -arrival, panel appearance, resize and font metrics alike. Three -production appliers remain, one per cause (pointer motion, menu -ownership without geometry, geometry), and each fires a disjoint set of -rows under its own removal. +re-derived in **`reshape`'s tail**. Panel inset transitions now terminate +at `reshape_if_panel_band_changed`: accepted `Present`/`Absent`/row-count +changes and a geometry declaration disowning the retained frame all +reach the hook, while a content-only frame repaints without rebuilding +the document. Three production appliers remain, one per cause (pointer +motion, menu ownership without geometry, geometry). -B5 took three rounds to get there. The first two patched the paths that -had been noticed — the snapshot arm, then a `set_menu` helper — and both -times the two appliers masked each other, so a row's documented mutation -named something the row could not see. **A witness whose mutation is -masked is not weaker evidence than a real one; it is evidence of -nothing, and it reads identically in the diff.** +B5's central-hook correction needed one more pass. Counting ten +`reshape` call sites did **not** establish that every boundary-changing +transition reached one: `PanelFrame` synchronized dimensions and +redrew without reshaping, and advancing `geometry_epoch` disowned the +retained frame *after* resize/font handling had reshaped against its old +presentation. The earlier two path patches — the snapshot arm, then a +`set_menu` helper — had the dual defect: each masked the other, so a +row's documented mutation named something the row could not see. **A +witness whose mutation is masked is not weaker evidence than a real +one; it is evidence of nothing, and it reads identically in the diff.** + +At `175cc7b`, all 309 GPU rows pass; workspace clippy with all targets +and `-D warnings`, fmt and `git diff --check` are clean. Four mutations +were executed and each fires its named row: omit accepted-frame reflow, +omit epoch-invalidation reflow, reshape content-only frames, and retain +divider hover on `Absent`. **Landed but NOT yet witnessed** — recovery needs this split, because "L1–L8 owed" reads as though none of the mechanism exists: From bd0815c240971b3da5424460a41e5c31de2a9a21 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 19:39:42 +0200 Subject: [PATCH 35/72] docs(gui-1b): the reshape-tail mutation fires five rows, not three The snapshot geometry row enumerated what dropping `apply_panel_cursor_icon()` from `reshape`'s tail would fire: "this row, the menu row, and the line-number row below." Two panel rows have since joined them, so the list read as exhaustive while being short by two. Executed the mutation at this tree: it fires the two snapshot rows, the line-number row, and both panel rows. The doc block now says so, and says the list is expected to grow --- one hook reached by more routes is the design, not drift, and a fixed enumeration would go stale at every new one. Verified: 12 B5 rows green; fmt, workspace clippy --all-targets -D warnings, and git diff --check clean. --- pmacs-gpu/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index eab85a4..eff5a50 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -15255,7 +15255,11 @@ mod tests { /// separate so a failure says which route broke. /// /// *Mutation: drop `apply_panel_cursor_icon()` from `reshape`'s tail - /// → this row, the menu row, and the line-number row below.* + /// → every row that reaches the hook through geometry: this one, the + /// menu row above, the line-number row, and the two panel rows + /// below. That list grows with each new geometry route, which is the + /// point of having one hook; the rows are separate so a failure + /// names the route.* #[test] fn b5_a_snapshot_that_moves_the_gutter_moves_the_i_beam_boundary() { use winit::window::CursorIcon; From 3bd9ad14e76961a820ffa0c2bff73dd3acd64439 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 19:43:21 +0200 Subject: [PATCH 36/72] docs(lane): the shared target directory's false compile red Records, in this lane's infrastructure notes rather than docs/ci-red-signatures.md, a red that has now fired twice locally. The registry is for CI reds; a row there would have to name a job and flavor this has never had. The signature is E0433, "cannot find `columns` in `pmacs_protocol`", four errors from a bare `cargo test -p pmacs-gpu`. Both times the module was entirely present --- columns.rs matching HEAD, `pub mod columns;` at lib.rs:40 in worktree and HEAD alike. Nothing was missing to find. CARGO_TARGET_DIR is exported globally here, one directory for every checkout, and `main` has no columns module at all --- the sharing refactor is unmerged 1b work. So a pmacs-protocol artifact built without the module shares a target directory with the branch that needs it. The entry states what was confirmed (the shared directory, the module's absence on main, the red clearing) and says plainly that which build deposited the stale artifact was not determined. Three of the four error sites are recorded. The fourth scrolled past the captured tail, so the entry says so instead of naming a likely candidate. The control is `scripts/gate` and its per-worktree CARGO_TARGET_DIR, not the touch that cleared it --- with the warning that the touch papers over a genuine deletion exactly as readily, and that the two reds look alike. agent-handoff.md:2609 already carries the general bare-cargo hazard; it did not carry this signature, count, or control. --- docs/active-work.md | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index eb50f68..8a82a06 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -381,6 +381,61 @@ the next lane does not rediscover them at review. instead. Running it by hand also surfaces a pre-existing unresolved link, `MathNode` at `pmacs-gpu/src/math_layout.rs:314`. +### A local false compile red from the shared target directory + +Not a CI signature, so it is **not** in `docs/ci-red-signatures.md` — +that registry is for CI reds, and a row there would claim a job and +flavor this has never had. It is a hazard of running a bare cargo +command instead of `scripts/gate`, and it has now fired **twice in this +lane**. + +**Signature.** From `cargo test -p pmacs-gpu`, four errors against the +bin `pmacs-gpu` test target: + +``` +error[E0433]: cannot find `columns` in `pmacs_protocol` + --> pmacs-gpu/src/main.rs:13291:21 +error: could not compile `pmacs-gpu` (bin "pmacs-gpu" test) + due to 4 previous errors +``` + +Three of the four were read off the second occurrence's output — +`main.rs:13291`, `:14517`, `:14578`; the fourth scrolled past the +captured tail and is **not** recorded here rather than guessed. All are +uses of the module the 1b branch added in `9e54cd2`. + +**Why it is false.** Both times, the module was fully present: +`pmacs-protocol/src/columns.rs` existed and matched HEAD, and +`pub mod columns;` stood at `pmacs-protocol/src/lib.rs:40` in both the +worktree and HEAD. Nothing was missing to find. + +**What it actually is.** `CARGO_TARGET_DIR` is exported globally on this +machine — `/home/jeans/build/cargo-target`, one directory shared by +every checkout — and `main` has no `columns` module at all (the sharing +refactor is unmerged 1b work). A `pmacs-protocol` artifact built without +the module therefore sits in the same target directory as the branch +that needs it, and gets reused. The confirmed facts are the shared +directory, the module's absence on `main`, and that the red cleared; +which build deposited the stale artifact — the `main` checkout, or this +worktree before `9e54cd2` — was not determined, and either has the same +shape. + +**Control.** `touch pmacs-protocol/src/lib.rs` forces the rebuild and +clears it. **But check before touching**: confirm `pub mod columns;` and +`columns.rs` are actually present and match HEAD first, because the +touch would just as happily paper over a real deletion, and this red is +indistinguishable by eye from that one. The durable fix is not to touch +at all but to run **`scripts/gate`**, whose per-worktree +`CARGO_TARGET_DIR` is exactly this problem's remedy — see +`docs/agent-handoff.md:2609`, which documents the general bare-cargo +hazard and the environment variable's override of `build.target-dir`, +but not this signature, its count, or its control. + +**The cost of misreading it.** It presents as a compile error naming a +missing module, which reads as a real regression — a deleted file, a +dropped `mod` line, a bad rebase. Both times the first instinct was to +look for what had been removed. Nothing had been. + ### And one coverage gap in CI, which shaped B4's design **No non-Linux leg runs this crate's tests.** `cargo test -p pmacs-gpu` From 7a534eeecaf43202a6a65897ce1eb2bd18ef88f6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 19:59:38 +0200 Subject: [PATCH 37/72] docs(lane): the registry does hold local reds --- fix the stated reason 3bd9ad1 justified keeping the shared-target false red out of docs/ci-red-signatures.md by asserting the registry is for CI reds and that a row would have to invent a job and flavor. Both halves are false. The registry carries many `local (Linux)` rows with precise invocation flavors --- one of them flavored "with sibling worktrees building concurrently", which is adjacent to this very mechanism --- and this incident's truthful flavor is plainly statable: local, bare `cargo test -p pmacs-gpu`, globally shared target. I asserted a property of a file I had not read. The conclusion happened to survive; the reason did not, and a reason that cannot be checked is the part that goes on to be reused. The entry now keeps it here for the reason that actually holds --- a machine-local artifact-state hazard with no CI occurrence, every one of them from bypassing scripts/gate --- and records that a CI appearance would belong in the registry with that flavor. It also declines the easy immunity claim: CI restores artifacts through rust-cache from a cache shared across branches, so the same mixing has a plausible CI analogue that has simply never been observed. The superseded reasoning is named in place rather than quietly replaced. --- docs/active-work.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8a82a06..ed2f652 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -383,11 +383,26 @@ the next lane does not rediscover them at review. ### A local false compile red from the shared target directory -Not a CI signature, so it is **not** in `docs/ci-red-signatures.md` — -that registry is for CI reds, and a row there would claim a job and -flavor this has never had. It is a hazard of running a bare cargo -command instead of `scripts/gate`, and it has now fired **twice in this -lane**. +Kept here rather than in `docs/ci-red-signatures.md` because it is a +**machine-local artifact-state hazard with no CI occurrence** — not +because the registry cannot represent local reds. It plainly can: it +carries many `local (Linux)` rows with precise invocation flavors, one +of them (the `04-lib-crdt` pair) even flavored *"with sibling worktrees +building concurrently."* This incident's truthful flavor would be +**local, bare `cargo test -p pmacs-gpu`, globally shared target**, and a +row could state it exactly. An earlier draft of this entry claimed the +registry was for CI reds only and that a row would have to invent a job +and flavor; that was false, and review caught it. + +What actually keeps it out is that the registry is live triage policy +for judging a red *run*, and every occurrence of this one has been on +this machine, from bypassing `scripts/gate`. **If it ever appears in +CI it belongs in the registry**, flavored as above — and that is not +impossible: CI restores artifacts through `Swatinem/rust-cache@v2` from +a cache shared across branches, so the same mixing has a plausible CI +analogue. It has simply never been observed there. + +It has now fired **twice in this lane**. **Signature.** From `cargo test -p pmacs-gpu`, four errors against the bin `pmacs-gpu` test target: From a0705399e2cafb3513e8be181cb4627ea7008926 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 20:21:55 +0200 Subject: [PATCH 38/72] docs(lane): withdraw the unverified CI cache analogue The local false compile red remains a machine-local artifact-state hazard with no CI occurrence. If the same fragments ever appear in CI, that run is a new incident and must be recorded under its actual job and flavor, not the local bare-cargo flavor. Withdraw the rust-cache explanation: workspace crates are not cached by default and GitHub cache visibility is scoped. Neither replace it with the equally unmeasured claim that CI builds fresh. --- docs/active-work.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ed2f652..247863a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -397,10 +397,15 @@ and flavor; that was false, and review caught it. What actually keeps it out is that the registry is live triage policy for judging a red *run*, and every occurrence of this one has been on this machine, from bypassing `scripts/gate`. **If it ever appears in -CI it belongs in the registry**, flavored as above — and that is not -impossible: CI restores artifacts through `Swatinem/rust-cache@v2` from -a cache shared across branches, so the same mixing has a plausible CI -analogue. It has simply never been observed there. +CI, that is a new incident and belongs in the registry under the +actual job and flavor that observed it** — not under this local +flavor. This evidence neither predicts nor excludes such an +occurrence. An earlier correction tried to keep that possibility open +by pointing at `Swatinem/rust-cache@v2`, but that mechanism was not +verified: the action's default does not cache workspace crates, and +GitHub cache visibility is scoped rather than shared indiscriminately +across branches. The unsupported mechanism is withdrawn instead of +being replaced with the equally unverified claim that CI builds fresh. It has now fired **twice in this lane**. From 2dccc2b69c9a837d84d870301cb140ed2760f4cd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 20:29:48 +0200 Subject: [PATCH 39/72] feat(gui-1b): B6's routing rows --- the minimap scrolls the document B6's mechanism landed with B1: WheelTarget::Minimap maps to its own ResidualOwner, and the horizontal leg already skips it. What was owed is the evidence, and apply_wheel had none --- the accumulator had unit rows, but nothing drove a wheel through dispatch_window_event to the effect. Three rows on EffectHarness, which holds a real App and a real daemon socket, so the classifier, the accumulator and the local step are all production code: - one notch over the minimap scrolls the document viewport by one notch and re-declares it, exactly as over text; - a part-notch over the minimap does not complete one over the document --- the surface-switch jump B1 forbids, and the minimap is its sharpest case because both surfaces move the same viewport, so sharing a bank would look harmless and produce a jump nothing explains. A third step gives the document bank the rest of its notch, so the row cannot pass by measuring nothing; - a horizontal notch over the minimap is inert while the same notch over text scrolls sideways. All three mutations fire, disjointly: an inert arm for the minimap fires the first, ResidualOwner::Document fires the second, dropping the horizontal guard fires the third. The third row is why EffectHarness now takes a document. Its first version asserted inertness over the default fixture and the setup assertion caught it twice: four-column lines pin the saturated right bound to zero, and wrapping --- on by default --- pins the left edge to zero regardless of target. Either would have reported inertness the row never tested. It now uses wide lines and turns wrap off through LineWrapFacts, the message production uses, asserting the wrap landed. `new()` keeps the 200-line fixture and its M22 rationale verbatim. Gates: fmt; clippy --workspace --all-targets -D warnings (which caught a float_cmp in the first draft of the inertness assertion); pmacs-gpu 312; --lib 1997; --lib --features crdt 2190; git diff --check. --- pmacs-gpu/src/main.rs | 242 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 234 insertions(+), 8 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index eff5a50..4d8ed4c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -4239,6 +4239,20 @@ impl EffectHarness { /// failure; without it the harness still panics rather than skips, /// because these rows are the whole of P2. fn new() -> Self { + // 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. + Self::with_document(&"line\n".repeat(200)) + } + + /// The same harness over a caller-chosen document, for rows whose + /// claim depends on the text's shape — B6's horizontal contrast + /// needs lines wider than the viewport, which the default fixture's + /// four columns can never provide. + fn with_document(document: &str) -> Self { let (client_stream, mut daemon) = std::os::unix::net::UnixStream::pair().expect("socketpair"); @@ -4260,14 +4274,7 @@ impl EffectHarness { .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` - // 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); + 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" @@ -4985,6 +4992,225 @@ mod input_routing_tests { ); } + /// A pixel inside the minimap band, and one inside the document + /// text. Both are asserted by their rows before use, so a fixture + /// whose geometry drifts fails loudly instead of quietly measuring + /// the wrong surface. + fn minimap_probe(h: &EffectHarness) -> (f64, f64) { + let state = h.app.state.as_ref().expect("harness state"); + let left = minimap_left(state.config.width).expect("the fixture has a minimap band"); + (f64::from(left + 2.0), f64::from(MINIMAP_TOP + 4.0)) + } + + fn document_probe(h: &EffectHarness) -> (f64, f64) { + let state = h.app.state.as_ref().expect("harness state"); + ( + f64::from(state.text_left() + 8.0), + f64::from(TEXT_TOP + 4.0), + ) + } + + fn move_pointer(h: &mut EffectHarness, (x, y): (f64, f64)) { + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(x, y), + }); + } + + fn wheel(dx: f32, dy: f32) -> WindowEvent { + WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(dx, -dy), + phase: TouchPhase::Moved, + } + } + + /// B6 — a wheel over the **minimap** scrolls the **document + /// viewport**, the same effect a wheel over the text has. + /// + /// The minimap is not a scrollable surface of its own: it is a + /// picture of the document, and turning the wheel over a picture of + /// the document moves the document. Click and drag over it remain + /// scrub, which is a different gesture on the same pixels. + /// + /// This is the routing half of B6. It reaches `apply_wheel` through + /// `dispatch_window_event`, so the classifier, the accumulator and + /// the local step are all production code here. + /// + /// *Mutation: give `WheelTarget::Minimap` its own inert arm ahead of + /// the one that applies the local line step → this row. (Deleting it + /// from that arm outright would not compile, so the mutation run is + /// the compiling equivalent: the minimap reaches no line step.)* + #[test] + fn b6_a_wheel_over_the_minimap_scrolls_the_document_viewport() { + let mut h = EffectHarness::new(); + let probe = minimap_probe(&h); + move_pointer(&mut h, probe); + assert_eq!( + h.app.classify_wheel_target(probe.0, probe.1), + WheelTarget::Minimap, + "setup: the probe pixel must be minimap, or this row measures \ + the document twice" + ); + + let step = h.feed(&wheel(0.0, 1.0)); + assert_eq!( + step.local, + vec![LocalEffect::Scroll { + top: WHEEL_LINES_PER_TICK as usize + }], + "one notch over the minimap moves the document one notch" + ); + assert!( + !step.outbound.is_empty(), + "a minimap wheel must re-declare the viewport, like any other \ + document scroll" + ); + assert!( + step.outbound + .iter() + .all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })), + "got {:?}", + step.outbound + ); + } + + /// B6 — the minimap banks into **its own** accumulator, so a + /// part-notch over it cannot complete a notch over the document. + /// + /// This is the surface-switch case B1 exists to forbid, and the + /// minimap is its sharpest instance precisely *because* both + /// surfaces move the same viewport: sharing one bank would look + /// harmless and produce a jump the user's last gesture does not + /// explain. Two part-notches on different surfaces must stay two + /// part-notches. + /// + /// The third step proves the row is not passing by measuring + /// nothing: the same document bank, given the rest of its notch, + /// does fire. + /// + /// *Mutation: map `WheelTarget::Minimap` to `ResidualOwner::Document` + /// → this row, at the second step.* + #[test] + fn b6_a_part_notch_over_the_minimap_does_not_complete_one_over_the_document() { + let mut h = EffectHarness::new(); + let minimap = minimap_probe(&h); + let document = document_probe(&h); + + move_pointer(&mut h, minimap); + assert_eq!( + h.app.classify_wheel_target(minimap.0, minimap.1), + WheelTarget::Minimap, + "setup: minimap pixel" + ); + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty() && step.outbound.is_empty(), + "0.6 of a notch is not a notch: {:?} {:?}", + step.local, + step.outbound + ); + + move_pointer(&mut h, document); + assert_eq!( + h.app.classify_wheel_target(document.0, document.1), + WheelTarget::Document, + "setup: document pixel" + ); + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty() && step.outbound.is_empty(), + "the minimap's 0.6 must not have been waiting in the \ + document's bank: {:?} {:?}", + step.local, + step.outbound + ); + + // The document's own bank still works: 0.6 + 0.6 completes it. + let step = h.feed(&wheel(0.0, 0.6)); + assert_eq!( + step.local, + vec![LocalEffect::Scroll { + top: WHEEL_LINES_PER_TICK as usize + }], + "the document's accumulator must still accumulate, or the \ + step above proves nothing" + ); + } + + /// B6 — the minimap's **horizontal** axis is inert. + /// + /// It banks vertically like any other target, but a sideways notch + /// over a fixed-width picture of the document has nothing to mean, + /// so §2a's enumeration rules it inert. The contrast is the point: + /// the identical event over document text does scroll sideways. + /// + /// *Mutation: drop the `!matches!(target, WheelTarget::Minimap)` + /// guard from the horizontal leg → this row.* + #[test] + fn b6_a_horizontal_wheel_over_the_minimap_is_inert() { + // Lines far wider than the viewport, so the saturated right + // bound leaves somewhere to scroll. The default fixture's four + // columns pin `max_left` to zero, which would make the contrast + // below vacuous — the setup assertion caught exactly that. + let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200)); + // Wrapping is on by default, and `scroll_by_columns` pins the + // left edge to zero while it is — so with wrap left alone, BOTH + // legs below would sit still and the row would report inertness + // it never tested. Turned off through the daemon message that + // production uses. + { + let buffer_id = h + .app + .state + .as_ref() + .expect("harness state") + .current_buffer_id + .expect("the harness stands in a buffer"); + let state = h.app.state.as_mut().expect("harness state"); + let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { + buffer_id, + wrap: false, + }); + assert_eq!( + state.buffer.wrap(), + Wrap::None, + "setup: the wrap-off message must have landed on this buffer" + ); + } + let minimap = minimap_probe(&h); + let document = document_probe(&h); + + let left_of = |h: &EffectHarness| { + h.app + .state + .as_ref() + .expect("harness state") + .code_scroll_left + }; + move_pointer(&mut h, minimap); + let before = left_of(&h); + h.feed(&wheel(1.0, 0.0)); + // Unchanged, not merely small: any real horizontal scroll is at + // least one character advance, which is orders above this. + assert!( + (left_of(&h) - before).abs() < f32::EPSILON, + "a horizontal notch over the minimap must not move the \ + document sideways: {before} -> {}", + left_of(&h) + ); + + // The same event over text, to show the delta was real and the + // row is not asserting that horizontal wheels do nothing at all. + move_pointer(&mut h, document); + h.feed(&wheel(1.0, 0.0)); + assert!( + left_of(&h) > before, + "setup: the same notch over text must scroll, else the \ + assertion above is vacuous" + ); + } + /// 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 From f441d3dc557b6eb80acaf069cfab4e7f452bf9e8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 20:45:29 +0200 Subject: [PATCH 40/72] fix(gui-1b): B6's inertness row asserted neither its surface nor silence Two defects in the horizontal row, both of the same kind: it asserted the absence of one effect and called that inertness. It never checked what its probes classified as. The other two B6 rows pin their targets; this one did not, so a probe that drifted onto panel chrome --- horizontally inert for an entirely different reason, and banking nowhere at all --- would have satisfied every assertion without touching B6. Both targets are now asserted exactly. Verified by moving the minimap probe off the band: all three rows fail at their setup. And it discarded both Steps, leaving an unchanged code_scroll_left as the whole of "inert". A notch that scrolled vertically, or put an event on the wire, would have passed. The minimap leg now asserts an empty transcript beside the unchanged origin. Verified by rerouting the minimap's horizontal leg to scroll_by_lines(1): the assertion fires, reporting [Scroll { top: 1 }]. The contrast leg keeps the left edge as its discriminator, and now says why: a horizontal document scroll is local and silent, so its transcript is empty too. Only code_scroll_left separates the two surfaces, and a reader who assumed otherwise would draw the wrong conclusion from the row's shape. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 312; git diff --check. --- pmacs-gpu/src/main.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 4d8ed4c..a0af0b4 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5189,8 +5189,27 @@ mod input_routing_tests { .code_scroll_left }; move_pointer(&mut h, minimap); + assert_eq!( + h.app.classify_wheel_target(minimap.0, minimap.1), + WheelTarget::Minimap, + "setup: the probe must be the minimap. Several other targets \ + are horizontally inert for their own reasons — panel chrome \ + banks nowhere at all — so a probe that drifted onto one \ + would satisfy every assertion below without testing B6" + ); let before = left_of(&h); - h.feed(&wheel(1.0, 0.0)); + let step = h.feed(&wheel(1.0, 0.0)); + // **Inert, not merely unmoved.** An unchanged left edge alone + // would still pass if the notch had produced a local effect or + // put an event on the wire, so the transcript is asserted empty + // beside it. + assert!( + step.local.is_empty() && step.outbound.is_empty(), + "a horizontal notch over the minimap must do nothing at all: \ + {:?} {:?}", + step.local, + step.outbound + ); // Unchanged, not merely small: any real horizontal scroll is at // least one character advance, which is orders above this. assert!( @@ -5202,7 +5221,17 @@ mod input_routing_tests { // The same event over text, to show the delta was real and the // row is not asserting that horizontal wheels do nothing at all. + // + // Its discriminator is the left edge, NOT the transcript: a + // horizontal document scroll is local and silent, so this leg's + // transcript is empty too. Only `code_scroll_left` separates the + // two surfaces. move_pointer(&mut h, document); + assert_eq!( + h.app.classify_wheel_target(document.0, document.1), + WheelTarget::Document, + "setup: the contrast probe must be document text" + ); h.feed(&wheel(1.0, 0.0)); assert!( left_of(&h) > before, From 4e6ca51607e004938f7eb27154eb59fafd2c7327 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 20:46:12 +0200 Subject: [PATCH 41/72] docs(lane): refresh the recovery checkpoint past B5 The ledger still pointed at 175cc7b as the code head and still listed B6's routing row as owed, two commits after it landed. A recovery checkpoint that lags is worse than none: it reads as current and sends the next session to a tree missing the work it is about to redo. Head is now f441d3d at 312 GPU rows. B6 moves out of "owed outright" into a block of its own, because the part worth recovering is not that it landed but what its fixture had to defeat: TWO independent conditions each make a horizontal-inertness claim vacuous --- four-column lines pin B7's saturated right bound to zero, and line wrap, on by default, makes scroll_by_columns return early and pin the left edge whatever the target. The default harness document has both. A row asserting "the minimap did not scroll sideways" passes under either while testing nothing, and the setup assertion caught it twice. Also records the review that followed: the same row pinned neither probe's target and discarded both Steps, so an unchanged code_scroll_left was the whole of "inert". And it notes the asymmetry a reader would otherwise get wrong --- the contrast leg cannot use the transcript, because a horizontal document scroll is local and silent, so both legs' transcripts are empty. The B5 paragraph keeps 175cc7b as its own historical anchor, now said plainly to be B5's checkpoint rather than the lane's, with the current head named beside it. --- docs/active-work.md | 55 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 247863a..e0350f9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,10 +319,11 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (code head `175cc7b`)**:** B1's per-target fractional +**Landed so far** (code head `f441d3d`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap -pin, B4's middle-click PRIMARY paste, and **B5's I-beam**, whose icon is +pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, +and **B5's I-beam**, whose icon is re-derived in **`reshape`'s tail**. Panel inset transitions now terminate at `reshape_if_panel_band_changed`: accepted `Present`/`Absent`/row-count changes and a geometry declaration disowning the retained frame all @@ -341,11 +342,14 @@ row's documented mutation named something the row could not see. **A witness whose mutation is masked is not weaker evidence than a real one; it is evidence of nothing, and it reads identically in the diff.** -At `175cc7b`, all 309 GPU rows pass; workspace clippy with all targets -and `-D warnings`, fmt and `git diff --check` are clean. Four mutations -were executed and each fires its named row: omit accepted-frame reflow, -omit epoch-invalidation reflow, reshape content-only frames, and retain -divider hover on `Absent`. +At `175cc7b` — B5's own checkpoint, not the lane's current one — all +309 GPU rows pass; workspace clippy with all targets and `-D warnings`, +fmt and `git diff --check` are clean. Four mutations were executed and +each fires its named row: omit accepted-frame reflow, omit +epoch-invalidation reflow, reshape content-only frames, and retain +divider hover on `Absent`. **The lane's current head is `f441d3d` at +312 GPU rows**; recover from the authoritative remote ref above, not +from this commit. **Landed but NOT yet witnessed** — recovery needs this split, because "L1–L8 owed" reads as though none of the mechanism exists: @@ -358,11 +362,38 @@ divider hover on `Absent`. - **R4 and R5's residual resets**, as two separate clears beside `code_scroll_left` so omitting either is individually visible. -**Owed outright:** B6's routing row, the **L1–L8 rows** for the -latch above, step 3's fractional both-axis panel witness, R4/R5's own -replacement witnesses, and **B1's disposal half** — a residual keyed to -a surface that goes away must go with it, and this frontend does not yet -track "that buffer is gone". +**Owed outright:** the **L1–L8 rows** for the latch above, step 3's +fractional both-axis panel witness, R4/R5's own replacement witnesses, +and **B1's disposal half** — a residual keyed to a surface that goes +away must go with it, and this frontend does not yet track "that buffer +is gone". + +**B6 (`2dccc2b`, tightened in `f441d3d`)** is three rows on +`EffectHarness`, the first production-path coverage `apply_wheel` has +ever had: a notch over the minimap scrolls the document viewport, a +part-notch over it cannot complete one over the document, and its +horizontal axis is inert. Its mechanism had landed with B1; only the +evidence was owed. + +Its fixture is the recovery-relevant part. **Two separate conditions +each make a horizontal-inertness claim vacuous**, and the default +harness document has both: four-column lines pin B7's saturated right +bound to zero, and line wrap — **on by default** — makes +`scroll_by_columns` return early and pin the left edge, whatever the +target. Under either, *nothing* scrolls sideways and a row asserting +"the minimap did not" passes without testing anything. The row uses +wide lines and turns wrap off through `LineWrapFacts`, asserting the +wrap landed; `EffectHarness::new()` keeps its 200-line fixture and M22 +rationale untouched, with `with_document` added beside it. + +Review then caught the same row asserting the absence of one effect and +calling it inertness: it pinned neither probe's target (panel chrome is +horizontally inert too, for unrelated reasons) and discarded both +`Step`s, so an unchanged `code_scroll_left` was the whole claim. It now +asserts both targets exactly and an empty transcript on the minimap +leg. **The contrast leg cannot use the transcript** — a horizontal +document scroll is local and silent, so both legs' transcripts are +empty and only `code_scroll_left` separates them. ### Two gate gaps this lane found, neither fixed here From 2a90aa2a7ab205787b0c3f57a4edcb12e3233f99 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 21:10:32 +0200 Subject: [PATCH 42/72] docs(lane): stop the checkpoint from certifying itself Name f441d3d as the latest verified code head, not the lane's current head. The documentation commit carrying that old wording made it false on arrival. Keep the authoritative remote ref as the recovery source so later documentation-only commits need no self-referential SHA update. --- docs/active-work.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index e0350f9..84625e6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -347,9 +347,12 @@ At `175cc7b` — B5's own checkpoint, not the lane's current one — all fmt and `git diff --check` are clean. Four mutations were executed and each fires its named row: omit accepted-frame reflow, omit epoch-invalidation reflow, reshape content-only frames, and retain -divider hover on `Absent`. **The lane's current head is `f441d3d` at -312 GPU rows**; recover from the authoritative remote ref above, not -from this commit. +divider hover on `Absent`. **The latest verified code head is `f441d3d` +at 312 GPU rows**; the authoritative remote ref above may be ahead with +documentation-only commits and remains the recovery source. Calling +`f441d3d` the lane's "current head" here would become false in the +commit carrying the sentence — the self-certifying checkpoint defect +this ledger has already recorded elsewhere. **Landed but NOT yet witnessed** — recovery needs this split, because "L1–L8 owed" reads as though none of the mechanism exists: From 9cb610ecd6f4ec756f15fb647eeb08d51086676f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 21:34:09 +0200 Subject: [PATCH 43/72] feat(gui-1b): the TUI half of the manual horizontal authority latch The ledger listed the latch as "landed but not yet witnessed". Writing L1 showed that is not what was landed. `manual_left_authority` exists on the GPU, is written in four places --- and is **read in none**. On the TUI it did not exist at all. What had landed was the arming; the preservation the latch exists for was nowhere. L1 measured it before anything was changed: a sideways wheel moved the origin to 30, and the very next paint put it back to 0. In the TUI a horizontal scroll was undone by the following frame. This commit implements clauses 2 through 5 for the TUI, per the framing's five-clause contract (Q#S1-11, revision 20), and witnesses each clause: - L1 preservation across a real paint_frame; - L3 release on a genuine cursor move; - L4 a vertical wheel PRESERVES authority (clause 3); - L5 the wheel moves neither point nor selection (clause 1); - L6 a notch the clamp absorbs arms nothing (clause 2's "effective"); - L7a widening the viewport re-clamps and KEEPS authority (clause 3); - L8 wrap clears the latch, not only the origin (clause 5). Three things the framing's L-table did not anticipate, all found by setup assertions rather than by reading: L4's stated hazard cannot arise in the setup the same table mandates. It says the vertical wheel carries point, so a naive release fires --- true, but only when the caret is INSIDE the viewport. `scroll_window` carries point through `pos_to_display`, which returns None for a position left of the edge (Q#HS7(c')), so with the caret outside, as every other row requires, the vertical wheel carries no point at all. L4 therefore places the caret inside and uses the LATCH as its discriminator, since the origin cannot discriminate there. Clause 3's preservation across a vertical wheel needs its own mechanism, not just an exemption: `scroll_window` refreshes `manual_left_cursor` to the point it dragged along, so clause 4's "genuine cursor change" test stays true. Keying release on the cursor byte alone releases exactly where clause 3 forbids it. And `paint_frame` runs the follow BEFORE resolving the frame's wrap mode and content width, so a geometry or wrap change reaches the follow one frame late. L7a and L8 paint twice and say why, rather than treating the second paint as a fudge. Seven mutations run, each biting its named rows: follow ignores authority -> L1, L3, L4 never releases -> L3 arms on any wheel -> L6 re-clamp releases -> L4, L7a wrap leaves the latch -> L8 wheel writes point -> L5 baseline never refreshed -> L4 The first bites L3 because removing the block removes the release with it; a narrower mutation would bite L1 alone. L3 asserts the origin DIFFERS rather than which way it moved, so the point-writing mutation no longer breaks its fixture instead of its claim. Still owed on this axis: L7b (content shrink), and the GPU's entire read side with L2 and L5's GPU leg. Gates: fmt; clippy --workspace --all-targets -D warnings; --lib 2004; --lib --features crdt 2197; pmacs-gpu 312; git diff --check. --- src/editor.rs | 415 +++++++++++++++++++++++++++++++++++++++++++++++++- src/window.rs | 20 +++ 2 files changed, 433 insertions(+), 2 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 6ad502d..5b1cf6b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3400,6 +3400,10 @@ impl EditorState { if window.last_wrap == crate::view::WrapMode::Wrap { if let Some(window) = core.windows.get_mut(&win_id) { window.view_left = 0; + // Clause 5: a wrapped buffer has nothing past the right + // edge, so neither an origin nor a latch defending one + // may survive. + window.manual_left_authority = false; } return false; } @@ -3427,6 +3431,12 @@ impl EditorState { } if let Some(window) = core.windows.get_mut(&win_id) { window.view_left = next; + // Clause 2, and only on an EFFECTIVE move: the early return + // above has already rejected a notch the clamp absorbed, so + // scrolling into a bound arms nothing and the next follow + // behaves normally. + window.manual_left_authority = true; + window.manual_left_cursor = window.cursor; } true } @@ -4725,6 +4735,16 @@ impl EditorState { if let Some(p) = new_cursor { aw.cursor = p; aw.goal_col = None; + // Lifetime clause 3 — **a vertical wheel PRESERVES manual + // horizontal authority**, and in this frontend the vertical + // wheel carries point. Clause 4 releases on a *genuine* + // cursor change, so the baseline moves with the point this + // viewport gesture dragged along; keying release on the + // cursor byte alone would release here, which clause 3 + // forbids outright. + if aw.manual_left_authority { + aw.manual_left_cursor = p; + } } } } @@ -5314,11 +5334,33 @@ impl CompletionPopupKey { /// what happened to the scroll indicator earlier in this very arc. What /// stays here is the part that is genuinely the TUI's: which window, /// which wrap mode, and which width. -fn horizontal_follow(window: &mut crate::window::Window, cursor_col: u32) { +fn horizontal_follow(window: &mut crate::window::Window, cursor_col: u32, max_left: Option) { if window.last_wrap == crate::view::WrapMode::Wrap { window.view_left = 0; + // Clause 5 again, on the path a wrap toggle actually takes: the + // origin-zeroing above is visible, a latch surviving it is not + // — until the return to `truncate`, where the caret rule should + // have resumed and would not. + window.manual_left_authority = false; return; } + if window.manual_left_authority { + if window.cursor == window.manual_left_cursor { + // Clause 3 — the origin is preserved, but not frozen: a + // narrower viewport or a shortened widest line lowers the + // maximum, and an origin past it would scroll the text off + // the screen entirely. Re-clamp and keep authority. + if let Some(max_left) = max_left { + window.view_left = window.view_left.min(max_left); + } + return; + } + // Clause 4 — a GENUINE cursor change releases, and normal + // following resumes on this same event rather than the next + // one. Release is driven by the cursor moving, never by this + // function running. + window.manual_left_authority = false; + } window.view_left = pmacs_protocol::scroll::follow_left(window.view_left, cursor_col, window.last_content_cols); } @@ -5358,7 +5400,17 @@ fn prepare_window_cursor_visible( .text_view .pos_to_display(buf, window.cursor, unscrolled); let cursor_row = coord.map_or(0, |d| d.row as usize); - horizontal_follow(window, coord.map_or(0, |d| d.col)); + // The clause-3 bound, computed ONLY while the latch is held: it + // reads the whole rope, and every paint paying for that would be a + // steep price for state most windows are never in. + let max_left = window.manual_left_authority.then(|| { + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut bytes); + crate::display_width::widest_line_columns(&String::from_utf8_lossy(&bytes)) + .saturating_sub(window.last_content_cols) + }); + horizontal_follow(window, coord.map_or(0, |d| d.col), max_left); match folds { // The logical cursor may sit on a hidden line (a shared fold, or // goto-line into one); the row that actually renders — and so @@ -10583,6 +10635,365 @@ mod tests { /// A wrapped line has nothing past the right edge, so an origin /// there would scroll a buffer sideways that has no sideways. /// + /// A buffer with one line far wider than any viewport these rows + /// use, so B7's `widest − viewport` bound can never absorb their + /// gestures and read as correct. + fn wide_fixture() -> EditorState { + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + // Tall as well as wide. L3 needs a line to move DOWN to and L4 + // needs somewhere to scroll: in a two-line document the vertical + // wheel has nothing to do, carries no point, and L4's setup + // assertion fires — which is how this was found. + content.extend_from_slice(&b"filler\n".repeat(200)); + fresh_with(&content) + } + + fn wheel(s: &mut EditorState, kind: crossterm::event::MouseEventKind, times: u32) { + for _ in 0..times { + s.dispatch_mouse(FrontendId::LOCAL, mouse(kind, 5, 5), term_size_24x80()); + } + } + + /// The state every L-row starts from: a real sideways wheel gesture + /// with the caret left at column 0 — **outside** the resulting + /// viewport. That is what makes the rows discriminate: with the + /// caret inside, `follow_left` returns the origin it was handed and + /// a held latch is indistinguishable from a released one. + fn scrolled_sideways() -> (EditorState, u32) { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + assert_eq!( + s.core.borrow().cursor(), + 0, + "setup: the caret must be at column 0, left of the manual \ + viewport, or every assertion below passes either way" + ); + wheel(&mut s, crossterm::event::MouseEventKind::ScrollRight, 10); + let manual = s.core.borrow().active_window().view_left; + assert!( + manual > 0, + "setup: the wheel must have moved the origin, else these \ + rows measure nothing" + ); + assert!( + s.core.borrow().active_window().manual_left_authority, + "setup: an effective move arms authority (clause 2)" + ); + (s, manual) + } + + /// L1 — **preservation, TUI.** A horizontal wheel origin survives a + /// real paint. + /// + /// The driver has to be a genuine `paint_frame`, because that is + /// what runs `prepare_window_cursor_visible` → `horizontal_follow`, + /// the code that would overwrite the origin. A unit call to the + /// helper cannot see a follow that runs inside a frame. + /// + /// **The cursor sits OUTSIDE the manual viewport**, which is what + /// makes the row discriminate at all: with the caret inside, + /// `follow_left` returns the origin it was given and held authority + /// looks identical to released authority. + /// + /// *Mutation: make the follow ignore manual authority (overwrite + /// unconditionally) → this row.* + #[test] + fn l1_a_manual_horizontal_origin_survives_a_tui_paint() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_truncated(&s, term_size_24x80()); + assert_eq!( + s.core.borrow().cursor(), + 0, + "setup: the caret is at column 0, so a released latch snaps \ + the origin back to 0 and a held one does not" + ); + + for _ in 0..10 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + } + let manual = s.core.borrow().active_window().view_left; + assert!( + manual > 0, + "setup: the wheel must have moved the origin, else this row \ + measures nothing" + ); + + // The real paint, and the whole point of the row. + paint_once(&s, term_size_24x80()); + + assert_eq!( + s.core.borrow().active_window().view_left, + manual, + "a paint must not drag the viewport back to the caret after \ + a deliberate horizontal scroll" + ); + } + + /// L3 — **release.** A genuine cursor move gives the caret back its + /// authority, on that same event. + /// + /// *Mutation: make manual authority never release → this row, and + /// only this row.* + #[test] + fn l3_a_genuine_cursor_move_releases_the_manual_origin() { + let (mut s, manual) = scrolled_sideways(); + + // A real cursor command, landing FAR RIGHT of the manual + // viewport. `Down` will not do: with the origin at 30 the caret + // renders at the left edge, so `Down` lands on column 30 — + // inside the manual viewport, where `follow_left` returns the + // origin it was handed and held and released are identical. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::End)); + paint_once(&s, term_size_24x80()); + + // The caret leaves the manual viewport, so a released latch + // moves the origin and a held one does not. Asserted as + // "different", not "smaller": which side it lands on depends on + // the caret's line, and a row that pinned the direction would + // break under mutations that have nothing to do with release. + assert_ne!( + s.core.borrow().active_window().view_left, + manual, + "a deliberate cursor move outranks a deliberate scroll: the \ + viewport must chase the caret again" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "and the latch is gone, not merely overridden once" + ); + } + + /// L4 — **cross-axis, TUI only.** A vertical wheel preserves the + /// horizontal origin (clause 3). + /// + /// This frontend's vertical wheel *carries point* — `scroll_window` + /// drags the caret along with the viewport — so a latch that + /// released on any cursor write would release here, and the user's + /// sideways gesture would evaporate on an unrelated scroll. The GPU + /// has no such row because its vertical wheel does not move point. + /// + /// *Mutation: stop refreshing `manual_left_cursor` in + /// `scroll_window` → this row.* + #[test] + fn l4_a_vertical_wheel_preserves_the_manual_horizontal_origin() { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + // **The caret must be INSIDE the manual viewport here**, unlike + // every other L-row. `scroll_window` carries point through + // `pos_to_display`, which returns `None` for a position left of + // the edge (Q#HS7(c′)) — so with the caret outside, the vertical + // wheel carries no point at all and the hazard this row exists + // for cannot arise. Put it at column 40, inside the 30-and-right + // viewport the gesture below produces. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Down)); + for _ in 0..40 { + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Right)); + } + wheel(&mut s, crossterm::event::MouseEventKind::ScrollRight, 10); + let manual = s.core.borrow().active_window().view_left; + assert!( + manual > 0 && s.core.borrow().active_window().manual_left_authority, + "setup: an effective sideways gesture, authority armed" + ); + let cursor_before = s.core.borrow().cursor(); + + wheel(&mut s, crossterm::event::MouseEventKind::ScrollDown, 1); + assert_ne!( + s.core.borrow().cursor(), + cursor_before, + "setup: the vertical wheel must actually carry point, else \ + the row does not exercise what it is about" + ); + paint_once(&s, term_size_24x80()); + + // **Authority is the discriminator, not the origin.** With the + // caret inside the viewport `follow_left` returns the origin it + // was handed, so held and released look alike there; the latch + // does not. + assert!( + s.core.borrow().active_window().manual_left_authority, + "a vertical wheel preserves horizontal authority (clause 3); \ + releasing here would discard the user's sideways gesture on \ + an unrelated scroll" + ); + assert_eq!( + s.core.borrow().active_window().view_left, + manual, + "and the origin itself is untouched" + ); + } + + /// L5 — a horizontal wheel moves the **viewport only** (clause 1). + /// + /// Q#S1-11 ruled (B): carrying point would be a new wire operation, + /// which 1b's non-protocol scope forbids outright. + /// + /// *Mutation: have the wheel path write point or selection → this + /// row, and only this row.* + #[test] + fn l5_a_horizontal_wheel_moves_neither_point_nor_selection() { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + let cursor_before = s.core.borrow().cursor(); + let selection_before = s.core.borrow().active_window().selection; + + wheel(&mut s, crossterm::event::MouseEventKind::ScrollRight, 10); + + assert_eq!( + s.core.borrow().cursor(), + cursor_before, + "the horizontal wheel is a viewport gesture" + ); + assert_eq!( + s.core.borrow().active_window().selection, + selection_before, + "and it does not touch the selection either" + ); + assert!( + s.core.borrow().active_window().view_left > 0, + "setup: it must still have scrolled, or this row passes by \ + doing nothing at all" + ); + } + + /// L6 — a notch the clamp **absorbs** arms nothing (clause 2's + /// "effective"). + /// + /// The distinction matters because an inert gesture that armed + /// authority would freeze the viewport against the caret for the + /// rest of the session, with nothing on screen to explain it. + /// + /// *Mutation: arm authority on any wheel event, effective or not → + /// this row, and only this row.* + #[test] + fn l6_a_notch_absorbed_by_the_clamp_arms_no_authority() { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + // The caret far out along the wide line, so a normal follow has + // somewhere to go and "the follow ran" is observable. + { + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + core.windows.get_mut(&id).expect("live window").cursor = 6 + 300; + } + + // Already at the left bound: this notch changes nothing. + wheel(&mut s, crossterm::event::MouseEventKind::ScrollLeft, 1); + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "setup: the notch must be absorbed, not merely small" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "a gesture with no effect confers no authority" + ); + + paint_once(&s, term_size_24x80()); + assert!( + s.core.borrow().active_window().view_left > 0, + "so the next follow moves the viewport normally" + ); + } + + /// L7a — **re-clamp on viewport widening**, authority retained + /// (clause 3). + /// + /// The maximum origin is `widest − viewport`, so a *wider* viewport + /// LOWERS it. Revision 14 had this backwards; narrowing raises the + /// ceiling and needs no clamp. The gesture is preserved at the new + /// bound rather than discarded. + /// + /// *Mutation: have the re-clamp release authority instead of + /// preserving it → this row and L7b.* + #[test] + fn l7a_widening_the_viewport_reclamps_the_origin_and_keeps_authority() { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + // Out to the right bound, so any lowering of the maximum must + // move the origin. + wheel(&mut s, crossterm::event::MouseEventKind::ScrollRight, 200); + let narrow_origin = s.core.borrow().active_window().view_left; + assert!(narrow_origin > 0, "setup: scrolled somewhere"); + + // TWO paints, and not as a fudge: `paint_frame` runs the follow + // (`prepare_window_cursor_visible`) BEFORE it resolves this + // frame's wrap mode and content width, so a geometry change + // reaches the follow on the frame after the one that carries it. + let wide = crate::cell::CellSize::new(24, 240); + paint_once(&s, wide); + paint_once(&s, wide); + + let after = s.core.borrow().active_window().view_left; + assert!( + after < narrow_origin, + "a wider viewport lowers the maximum origin, so the origin \ + must come down with it: {narrow_origin} -> {after}" + ); + assert!( + s.core.borrow().active_window().manual_left_authority, + "clamped, NOT released — the gesture survives at the new bound" + ); + } + + /// L8 — **wrap clears the latch**, not merely the origin (clause 5). + /// + /// The existing wrap rows assert the origin is zeroed. None of them + /// can see a **stale latch** surviving the wrap, because under wrap + /// the origin is pinned either way. It surfaces only on the return + /// to `truncate`, where the caret rule should govern again — and + /// would not. + /// + /// *Mutation: zero the origin on wrap but leave the latch set → + /// this row, and only this row.* + #[test] + fn l8_wrap_clears_the_latch_and_not_only_the_origin() { + let (s, _) = scrolled_sideways(); + let id = s.core.borrow().active_window_id(); + + set_line_wrap(&s, id, "wrap"); + // Twice, for the ordering reason L7a states: the follow runs + // before the frame resolves `last_wrap`, so the toggle reaches + // it on the next frame. + paint_once(&s, term_size_24x80()); + paint_once(&s, term_size_24x80()); + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "setup: wrap pins the origin — the part the existing rows \ + already cover" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "and the latch goes with it" + ); + + // Back to truncate: with the latch gone the caret rule governs, + // and the caret is at column 0. + paint_truncated(&s, term_size_24x80()); + { + let mut core = s.core.borrow_mut(); + let win = core.windows.get_mut(&id).expect("live window"); + win.cursor = 6 + 300; + } + paint_once(&s, term_size_24x80()); + assert!( + s.core.borrow().active_window().view_left > 0, + "a stale latch would have frozen the viewport at zero while \ + the caret sat 300 columns off-screen" + ); + } + /// *Mutation: drop the wrap guard in `scroll_window_columns` → this /// row, and only this row.* #[test] diff --git a/src/window.rs b/src/window.rs index eea37bf..b8d2824 100644 --- a/src/window.rs +++ b/src/window.rs @@ -384,6 +384,24 @@ pub struct Window { /// Always `0` while this window's buffer wraps; see /// [`LayoutCtx::effective_left`](crate::view::LayoutCtx::effective_left). pub view_left: u32, + /// GUI Stage 1b, lifetime clause 2 — **the user's horizontal origin + /// outranks the caret's**. + /// + /// Set when a deliberate horizontal scroll *effectively* moves + /// [`Self::view_left`]; while set, the caret-following pass + /// re-clamps the origin but does not drag it back. Without it a + /// sideways wheel is undone by the very next paint, because + /// `horizontal_follow` runs on every frame and knows only the + /// caret. + /// + /// Cleared by a genuine cursor move (clause 4), by wrap, and by + /// buffer replacement (clause 5). + pub manual_left_authority: bool, + /// The cursor as it stood when [`Self::manual_left_authority`] was + /// armed, so clause 4 can tell a *genuine* cursor change from the + /// follow merely running again. Meaningless while the latch is + /// clear. + pub manual_left_cursor: Position, /// Sticky display column for vertical motion. pub goal_col: Option, /// Number of text rows that fit in this window's viewport at last @@ -439,6 +457,8 @@ impl Window { selection: None, view_top: 0, view_left: 0, + manual_left_authority: false, + manual_left_cursor: 0, goal_col: None, last_visible_rows: 0, last_content_cols: 0, From cf75a51867e2fc4bd18236ce0946be5a7ed5f010 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 21:35:45 +0200 Subject: [PATCH 44/72] docs: U20, the shared-target red's third occurrence, and the latch entry Three records, all from the same session. **U20.** U6's composition-overhead test redded ALONE, its paired keystroke test passing in the same run. U6's own closing rule says that is a different incident, so it is filed as one rather than as a sixth U6 occurrence: U6's selector requires the pair, and its whole argument is that two unrelated subsystems failing at once is less likely than one loaded machine. One test alone does not carry that argument. The observing diff touches the paint path, so it was a live suspect and was tested instead of argued about --- three full-lib runs with it and three with the two files restored to HEAD, all six green. The margin is recorded per U11: 1.879 against a 1.10 budget, a third worse than U6's worst. The row says plainly that the size of the margin does not resolve whether this is load or regression, and that a contemporaneous load reading is the missing evidence. **The shared-target false red fired a third time**, giving the complete set of four error sites that the second occurrence's captured tail had cut to three. An earlier draft of that entry guessed the missing fourth was widest_display_columns; the third occurrence shows the guess was right, and the entry now says it was still right not to record it --- a signature that is usually right is one nobody can match against. **And the latch entry was wrong.** It listed the manual horizontal authority latch as "landed but not yet witnessed". It was written in four places and read in none on the GPU, and absent entirely on the TUI. An unread bool preserves nothing. The entry now carries the measurement that showed it --- origin 30, next paint 0 --- and the three things the framing's L-table did not anticipate, since those are what a recovering session would otherwise rediscover from scratch. --- docs/active-work.md | 67 +++++++++++++++++++++++++++++++++------ docs/ci-red-signatures.md | 34 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 84625e6..b913265 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (code head `f441d3d`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `9cb610e`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -354,18 +354,58 @@ documentation-only commits and remains the recovery source. Calling commit carrying the sentence — the self-certifying checkpoint defect this ledger has already recorded elsewhere. -**Landed but NOT yet witnessed** — recovery needs this split, because -"L1–L8 owed" reads as though none of the mechanism exists: +### The latch was never the thing the ledger said it was + +**This entry was wrong, and the correction is the recovery-relevant +part.** It listed the manual horizontal authority latch as "landed but +not yet witnessed". Writing L1 showed otherwise: +`manual_left_authority` existed on the GPU, was **written in four +places and read in none**, and did not exist on the TUI at all. What +had landed was the arming. The preservation the latch exists for was +nowhere, on either frontend. + +L1 measured it before anything changed: a sideways wheel moved the TUI +origin to 30, and the next paint put it back to 0. **A horizontal +scroll was undone by the following frame.** An unread `bool` preserves +nothing, and "landed but unwitnessed" is exactly how that reads from +the outside. + +**`9cb610e` implements clauses 2–5 for the TUI** and witnesses each: +L1 preservation across a real `paint_frame`, L3 release on a genuine +cursor move, L4 a vertical wheel preserving authority, L5 viewport-only, +L6 an absorbed notch arming nothing, L7a re-clamp on widening, L8 wrap +clearing the latch. Seven mutations run, each biting its named rows. + +Three things the framing's L-table did not anticipate, all surfaced by +setup assertions rather than by reading: + +- **L4's stated hazard cannot arise in the setup the same table + mandates.** The vertical wheel carries point only when the caret is + INSIDE the viewport — `pos_to_display` returns `None` left of the + edge (Q#HS7(c′)) — and every other L-row requires it outside. L4 + places the caret inside and uses the **latch** as its discriminator, + because the origin cannot discriminate there. +- **Clause 3 needs a mechanism, not an exemption.** `scroll_window` + refreshes `manual_left_cursor` to the point it dragged along, so + clause 4's "genuine cursor change" stays false. Keying release on the + cursor byte alone releases exactly where clause 3 forbids it. +- **`paint_frame` runs the follow BEFORE resolving the frame's wrap + mode and content width**, so a geometry or wrap change reaches the + follow one frame late. L7a and L8 paint twice and say why. + +**Still owed on this axis: L7b (content shrink), and the GPU's entire +read side with L2 and L5's GPU leg.** The GPU's four writes remain +inert until something consults them. + +**Landed but NOT yet witnessed:** -- the **manual horizontal authority latch** (`manual_left_authority`), - armed only by an *effective* move (clause 2); - **wrap and buffer-replacement clearing** of that latch and the origin (clause 5), on both the GPU's `scroll_by_columns` and the buffer replacement path; - **R4 and R5's residual resets**, as two separate clears beside `code_scroll_left` so omitting either is individually visible. -**Owed outright:** the **L1–L8 rows** for the latch above, step 3's +**Owed outright:** L7b and the GPU's whole read side (above), step 3's fractional both-axis panel witness, R4/R5's own replacement witnesses, and **B1's disposal half** — a residual keyed to a surface that goes away must go with it, and this frontend does not yet track "that buffer @@ -453,10 +493,17 @@ error: could not compile `pmacs-gpu` (bin "pmacs-gpu" test) due to 4 previous errors ``` -Three of the four were read off the second occurrence's output — -`main.rs:13291`, `:14517`, `:14578`; the fourth scrolled past the -captured tail and is **not** recorded here rather than guessed. All are -uses of the module the 1b branch added in `9e54cd2`. +**THREE occurrences now**, all local. The third (at `9cb610e`) gave the +complete set of four sites, which the second's captured tail had cut to +three: at that head they were `main.rs:9049`, `:13546`, `:14772` and +`:14833` — every use of the module the 1b branch added in `9e54cd2`. +Line numbers drift as the file grows; the module path is the stable +part of the signature. + +An earlier draft of this entry guessed the missing fourth site was +`widest_display_columns`. The third occurrence shows that guess was +right — and it was still right not to record it, because a signature +that is *usually* right is one nobody can match against. **Why it is false.** Both times, the module was fully present: `pmacs-protocol/src/columns.rs` existed and matched HEAD, and diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 690cc52..d3364b0 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1083,6 +1083,40 @@ regression in two unrelated subsystems at once is far less likely than one loaded machine. If a future run reds **one** of these without the other, that is a different incident and should be judged as one. +### U20 — U6's composition test reds ALONE, which U6 says is a new incident + +Recorded during GUI Stage 1b, 2026-09-01, local (Linux). **This row +exists because U6's closing rule says it must**: *"If a future run reds +one of these without the other, that is a different incident and should +be judged as one."* This is that run. It is filed separately rather than +as a sixth U6 occurrence, because U6's selector requires **both** tests +failing together and its whole argument rests on the pair — a real +regression in two unrelated subsystems at once being less likely than +one loaded machine. One test alone does not carry that argument. + +| field | value | +|---|---| +| **selector** | `--lib editor::tests::composition_overhead_under_ten_percent`, **alone** — `optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` passed in the same run | +| **job / flavor** | local (Linux), bare `cargo test --lib` (default features), full-lib load, **not** `scripts/gate` | +| **required fragments** | `composition machinery added more than 10% overhead` + a ratio; the run also printed `dispatch overhead : 87.9%` and `realistic overhead : 82.1%` | +| **status** | **ONE occurrence.** Not reproduced in six subsequent full-lib runs — three on the observing tree and three on the same tree with the observing diff reverted | +| **margin** | **1.879 against a 1.10 budget** (`single=158789 ns, dispatch=298366 ns`). Recorded because U11 taught this registry what a missing margin costs. This is far the largest margin any occurrence of this selector has shown: U6's five were 1.297, 1.182, 1.592, 1.527 and one unrecorded | +| **what IS established** | the observing diff (`9cb610e`, the TUI horizontal-authority latch) adds work to the paint path, so it was a live suspect. It was tested rather than argued about: three full-lib runs with the diff and three with `src/editor.rs` and `src/window.rs` restored to HEAD. **All six green.** The single failure is therefore not reproducible either way | +| **what is NOT** | why it fired. The load confound is once again unmeasured — no `/proc/loadavg` reading was taken at the moment of failure, which is the same gap U15 exists to close | + +**The margin is the part that does not fit.** 1.879 is not a budget +missed by a hair; it is the machinery costing nearly twice what the +budget allows, and it is a third again worse than U6's worst. A +load-sensitive assertion can produce that, but so can a real +regression, and this row does not pretend the size of the margin +resolves the question. What it does establish is that the observing +diff is not the cause, because six controlled runs say so. + +**If this selector reds alone again, this row is where it goes**, and +the first thing to capture is a contemporaneous load reading — the one +piece of evidence that would separate the two explanations and that +neither U6 nor this row has at the moment of failure. + ### U7 — a *different* wall-clock render-budget test reds each sweep Recorded during worker identity Stage 1 review round 3, 2026-08-09. From a2d5b2657b223428086f170324ef73d16ea511ee Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 21:59:58 +0200 Subject: [PATCH 45/72] fix(gui-1b): clause 5 for the TUI's three buffer-replacement paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latch commit implemented clause 5's wrap half and left its replacement half undone. Three paths replace a window's buffer --- switch_active_buffer_for, install_buffer_in_window, and the daemon's align_primary_document_window --- and none cleared view_left or the latch. Two of them already reset cursor, selection and view_top one line at a time; the horizontal origin was simply missing from the list. A successor inherited both, rendering sideways with nothing about that buffer to explain it. The GPU carries this reset for exactly that symptom. One `Window::forget_manual_horizontal_origin` rather than three copies, so a fourth path gets the rule by calling it, with each call site removable on its own --- which is what lets each leg have its own row. L8b, L8c and L8d, one per site; dropping any one call fires only its own. L8d lives in daemon.rs because the function is private there, arms the latch through a real wheel gesture rather than by writing fields, and is deliberately NOT crdt-gated like its neighbour, so it runs in the default --lib leg too. L7a asserted only that the origin came DOWN, which any arbitrary reduction satisfies. It now asserts `widest − viewport` exactly, with the fixture's widest named as a constant so the row and the fixture cannot drift apart. Mutation-checked with an off-by-one clamp, which the old assertion could not see and the new one fires on. And L4's rationale was false as written. It said the caret stays inside the viewport after the vertical wheel; with short filler lines the caret clamps to their end, LEFT of the origin, so the origin discriminated too and the row's stated reason for using the latch instead did not hold. The filler lines are now 120 columns wide and the row asserts the caret is still inside afterwards, through `pos_to_display` --- the same rule production uses to decide whether the wheel can carry point at all. One repair of my own making: the helper landed between `#[must_use]` and `layout_ctx`, stealing the attribute and leaving that function wearing the tail of my doc comment. Third time this file's neighbours have been damaged by an insertion. The method now sits after `layout_ctx`, whose attribute and body were read back intact. Gates: fmt; clippy --workspace --all-targets -D warnings (which caught the stolen attribute); --lib 2007; --lib --features crdt 2200; pmacs-gpu 312; git diff --check. --- src/daemon.rs | 93 +++++++++++++++++++++++++++++ src/editor.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++- src/editor_core.rs | 2 + src/window.rs | 20 +++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 549facb..e95810b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3847,6 +3847,7 @@ fn align_primary_document_window( win.cursor = 0; win.selection = None; win.overlays.clear(); + win.forget_manual_horizontal_origin(); } Some(win_id) } @@ -5556,6 +5557,98 @@ mod tests { ); } + /// GUI Stage 1b, lifetime clause 5 — **the daemon's alignment path + /// is a buffer replacement too**, and must forget a manual + /// horizontal origin like the other two. + /// + /// `align_primary_document_window` re-points a window at the buffer + /// its frontend declares. That is a replacement by any measure: a + /// sideways origin carried across it renders the successor scrolled + /// with nothing about that buffer to explain it. This row lives + /// here rather than beside L8b/L8c because the function is private + /// to this module. + /// + /// The latch is armed the production way — a real wheel gesture + /// through `dispatch_mouse` — not by writing the fields, so the row + /// cannot pass against a state the running editor never reaches. + /// + /// **Not `crdt`-gated**, unlike its neighbour above: nothing here + /// needs the feature, and gating it would keep it out of the + /// default `--lib` leg for no reason — the same blind spot that + /// already lets `crdt`-only code go unlinted locally. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `align_primary_document_window` → this row.* + #[test] + fn l8d_the_alignment_path_clears_a_manual_horizontal_origin() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + + let mut editor = EditorState::new(); + let wide = |name: &str| { + let core = editor.core.borrow(); + let mut content = b"wide\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + core.registry + .borrow_mut() + .create_from_bytes(name.to_owned(), &content) + }; + let first = wide("first"); + let second = wide("second"); + let fid = FrontendId(99); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + // The window starts on LOCAL's narrow scratch buffer, where + // B7's `widest − viewport` bound is zero and every notch is + // absorbed. Put a wide buffer under it first — through the very + // function under test — so the gesture below can be effective. + align_primary_document_window(&mut editor, fid, first); + + for _ in 0..10 { + editor.dispatch_mouse( + fid, + MouseEvent { + kind: MouseEventKind::ScrollRight, + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + crate::cell::CellSize::new(24, 80), + ); + } + let armed = editor + .core + .borrow() + .active_window_for(fid) + .expect("the semantic frontend has a window") + .view_left; + assert!( + armed > 0 + && editor + .core + .borrow() + .active_window_for(fid) + .expect("window") + .manual_left_authority, + "setup: a real wheel gesture must have moved the origin and \ + armed authority, else this row measures nothing" + ); + + align_primary_document_window(&mut editor, fid, second); + + let win = editor.core.borrow(); + let win = win.active_window_for(fid).expect("window"); + assert_eq!( + win.view_left, 0, + "the successor must not inherit the predecessor's sideways \ + viewport" + ); + assert!(!win.manual_left_authority, "nor the authority defending it"); + } + /// B1 input/display alignment: a semantic frontend's window is bound /// to LOCAL's attach-time buffer, but the buffer it *displays* is /// the one it declares via `Viewport`. `align_primary_document_window` diff --git a/src/editor.rs b/src/editor.rs index 5b1cf6b..f61df64 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10638,15 +10638,29 @@ mod tests { /// A buffer with one line far wider than any viewport these rows /// use, so B7's `widest − viewport` bound can never absorb their /// gestures and read as correct. + /// The fixture's widest line, in columns. Named so L7a's exact + /// bound and the fixture that produces it cannot drift apart. + const WIDEST_COLUMNS: u32 = 400; + fn wide_fixture() -> EditorState { let mut content = b"short\n".to_vec(); - content.extend_from_slice(&b"w".repeat(400)); + content.extend_from_slice(&b"w".repeat(WIDEST_COLUMNS as usize)); content.push(b'\n'); // Tall as well as wide. L3 needs a line to move DOWN to and L4 // needs somewhere to scroll: in a two-line document the vertical // wheel has nothing to do, carries no point, and L4's setup // assertion fires — which is how this was found. - content.extend_from_slice(&b"filler\n".repeat(200)); + // + // The filler lines are **wide too**, and that is L4's + // requirement specifically: a caret landing on a SHORT line + // clamps to its end, which is left of the manual origin and so + // outside the viewport — the very state in which the vertical + // wheel carries no point. 120 columns keeps them clear of the + // 400-column line that fixes `widest`. + for _ in 0..200 { + content.extend_from_slice(&b"f".repeat(120)); + content.push(b'\n'); + } fresh_with(&content) } @@ -10656,6 +10670,22 @@ mod tests { } } + /// Whether the caret is inside the window's horizontal viewport, + /// asked the way production asks it: `pos_to_display` returns + /// `None` for a position LEFT of the edge (Q#HS7(c′)), which is the + /// exact condition that decides whether a vertical wheel can carry + /// point at all. + fn caret_inside_viewport(s: &EditorState) -> bool { + let core = s.core.borrow(); + let win = core.active_window(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(win.buffer_id).expect("live buffer"); + win.text_view + .pos_to_display(buf, win.cursor, win.layout_ctx()) + .is_some() + } + /// The state every L-row starts from: a real sideways wheel gesture /// with the caret left at column 0 — **outside** the resulting /// viewport. That is what makes the rows discriminate: with the @@ -10814,6 +10844,14 @@ mod tests { "setup: the vertical wheel must actually carry point, else \ the row does not exercise what it is about" ); + assert!( + caret_inside_viewport(&s), + "setup: and it must still be INSIDE the viewport afterwards. \ + A caret that landed on a short line would clamp to that \ + line's end, left of the origin — and then the origin would \ + discriminate too, so the claim below about the latch being \ + the only discriminator would be false" + ); paint_once(&s, term_size_24x80()); // **Authority is the discriminator, not the origin.** With the @@ -10934,12 +10972,24 @@ mod tests { paint_once(&s, wide); paint_once(&s, wide); + // **The exact bound, not merely a smaller number.** `widest − + // viewport` is the whole content of clause 3's re-clamp; an + // assertion that the origin merely fell would accept any + // arbitrary reduction, including an off-by-one that leaves a + // column of text permanently unreachable. let after = s.core.borrow().active_window().view_left; + let cols = s.core.borrow().active_window().last_content_cols; + let expected = WIDEST_COLUMNS.saturating_sub(cols); assert!( after < narrow_origin, "a wider viewport lowers the maximum origin, so the origin \ must come down with it: {narrow_origin} -> {after}" ); + assert_eq!( + after, expected, + "and it must land on `widest − viewport` exactly \ + ({WIDEST_COLUMNS} − {cols})" + ); assert!( s.core.borrow().active_window().manual_left_authority, "clamped, NOT released — the gesture survives at the new bound" @@ -10994,6 +11044,98 @@ mod tests { ); } + /// A second wide buffer, so a successor window has somewhere to + /// scroll and "the origin came back to zero" is not just the only + /// value available. + fn other_wide_buffer(s: &EditorState) -> crate::buffer::BufferId { + let mut content = b"other\n".to_vec(); + content.extend_from_slice(&b"o".repeat(WIDEST_COLUMNS as usize)); + content.push(b'\n'); + s.lua_host + .registry() + .borrow_mut() + .create_from_bytes("other", &content) + } + + /// L8, replacement leg — **a buffer switch clears the origin AND + /// the latch** (clause 5's second half). + /// + /// The origin describes the document being shown. Carried into a + /// successor it renders the new buffer scrolled sideways with + /// nothing about that buffer to explain it. The GPU has had this + /// reset since it hit the symptom; the TUI's three replacement + /// paths had neither half. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `switch_active_buffer_for` → this row.* + #[test] + fn l8b_switching_the_active_buffer_clears_the_origin_and_the_latch() { + let (s, _) = scrolled_sideways(); + let other = other_wide_buffer(&s); + + s.core + .borrow_mut() + .switch_active_buffer(other) + .expect("switch to the successor buffer"); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "the successor must not inherit the predecessor's sideways \ + viewport" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "nor the authority defending it" + ); + + // And the latch is really gone, not merely the origin: put the + // caret far out in the NEW buffer and the follow must move. + { + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + core.windows.get_mut(&id).expect("live window").cursor = 6 + 300; + } + paint_once(&s, term_size_24x80()); + assert!( + s.core.borrow().active_window().view_left > 0, + "a stale latch would have frozen the successor's viewport at \ + zero with its caret 300 columns off-screen" + ); + } + + /// L8, replacement leg — the same for **`install_buffer_in_window`**, + /// the path that targets an explicit window rather than the active + /// one. + /// + /// A separate row because the clear is a separate call: one helper + /// on `Window`, but each call site removable on its own, so a + /// forgotten one is individually visible. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `install_buffer_in_window` → this row.* + #[test] + fn l8c_installing_a_buffer_in_a_window_clears_the_origin_and_the_latch() { + let (s, _) = scrolled_sideways(); + let other = other_wide_buffer(&s); + let win = s.core.borrow().active_window_id(); + + s.core + .borrow_mut() + .install_buffer_in_window(win, other) + .expect("install the successor buffer"); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "an explicit install inherits nothing either" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "and drops the latch with it" + ); + } + /// *Mutation: drop the wrap guard in `scroll_window_columns` → this /// row, and only this row.* #[test] diff --git a/src/editor_core.rs b/src/editor_core.rs index 01e2fe6..e98f6d6 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -4075,6 +4075,7 @@ impl EditorCore { window.cursor = 0; window.selection = None; window.view_top = 0; + window.forget_manual_horizontal_origin(); window.goal_col = None; Ok(()) } @@ -5650,6 +5651,7 @@ impl EditorCore { aw.cursor = 0; aw.selection = None; aw.view_top = 0; + aw.forget_manual_horizontal_origin(); aw.goal_col = None; Ok(()) } diff --git a/src/window.rs b/src/window.rs index b8d2824..8963c51 100644 --- a/src/window.rs +++ b/src/window.rs @@ -487,6 +487,26 @@ impl Window { } } + /// Forget a manual horizontal origin because this window is + /// adopting a **different buffer** (lifetime clause 5). + /// + /// The origin is a fact about the document being shown, not about + /// the window. Carried into a successor it renders the new buffer + /// scrolled sideways with nothing about that buffer to explain it, + /// until some later cursor motion repairs it by accident. The GPU + /// learned this once already — `code_scroll_left` has its own line + /// in that frontend's replacement reset, added after exactly this + /// symptom — and the TUI's three replacement paths had neither the + /// origin reset nor the latch clear. + /// + /// One helper rather than three copies, so a fourth replacement + /// path gets the rule by calling it; each **call site** stays + /// individually removable, which is what keeps its own row honest. + pub fn forget_manual_horizontal_origin(&mut self) { + self.view_left = 0; + self.manual_left_authority = false; + } + /// Width in cells this window's line-number gutter occupies, or `0` /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// the renderer caps this against the window width and applies it as a From 0ce3544bf03bdbe7326fac4a89cbd7b84068439b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 22:00:21 +0200 Subject: [PATCH 46/72] docs: U20 claimed exoneration the reruns cannot support Two corrections to U20 and the lane record for a2d5b26. U20 said six green control runs establish that the observing diff is not the cause. They do not. They establish non-reproduction in six runs, which is all a rerun ever establishes in this registry --- a tree that fails intermittently can carry a changed failure rate that six runs are far too few to see. Treating non-reproduction as exoneration is exactly the reasoning this file refuses when a rerun turns a red green. What the controls actually do is remove the easy story and leave the question open, and the row now says so. The margin comparison was also arithmetic dressed as a phrase. "A third again worse" than U6's 1.592 is not what 1.879 is: as a ratio it is 1.18x, as budget excess (0.879 over versus 0.592 over) it is 1.48x. Both numbers are now given, with the note that they answer different questions --- which is why neither gets compressed into an adjective. The ledger records clause 5's replacement half and the two witness repairs that came with it, both being assertions that looked strict and were not: an origin that merely came down rather than landing on the exact bound, and a rationale about the caret's position that the fixture made false. --- docs/active-work.md | 27 ++++++++++++++++++++++++++- docs/ci-red-signatures.md | 20 +++++++++++++------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b913265..5f4f830 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `9cb610e`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `a2d5b26`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -393,6 +393,31 @@ setup assertions rather than by reading: mode and content width**, so a geometry or wrap change reaches the follow one frame late. L7a and L8 paint twice and say why. +**`a2d5b26` then closed clause 5's replacement half**, which the first +latch commit had left undone: three TUI paths replace a window's buffer +— `switch_active_buffer_for`, `install_buffer_in_window` and the +daemon's `align_primary_document_window` — and **none cleared +`view_left` or the latch**. Two already reset cursor, selection and +`view_top` a line at a time; the horizontal origin was simply missing +from the list. One `Window::forget_manual_horizontal_origin`, three +call sites, three rows (L8b/L8c/L8d), each firing only on its own call +site's removal. L8d sits in `daemon.rs` because that function is +private there. + +Two witness repairs in the same commit, both of the same shape — an +assertion that looked strict and was not: + +- **L7a asserted only that the origin came DOWN.** Any arbitrary + reduction satisfied that, including an off-by-one that strands a + column. It now asserts `widest − viewport` exactly, against a named + fixture constant, and is mutation-checked with that off-by-one. +- **L4's stated rationale was false.** It claimed the caret stays + inside the viewport after the vertical wheel; with short filler lines + the caret clamped to their end, LEFT of the origin — so the origin + discriminated too and the reason given for using the latch instead + did not hold. The filler is now 120 columns and the row asserts the + caret is still inside, through `pos_to_display`. + **Still owed on this axis: L7b (content shrink), and the GPU's entire read side with L2 and L5's GPU leg.** The GPU's four writes remain inert until something consults them. diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index d3364b0..84b80c7 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1100,17 +1100,23 @@ one loaded machine. One test alone does not carry that argument. | **job / flavor** | local (Linux), bare `cargo test --lib` (default features), full-lib load, **not** `scripts/gate` | | **required fragments** | `composition machinery added more than 10% overhead` + a ratio; the run also printed `dispatch overhead : 87.9%` and `realistic overhead : 82.1%` | | **status** | **ONE occurrence.** Not reproduced in six subsequent full-lib runs — three on the observing tree and three on the same tree with the observing diff reverted | -| **margin** | **1.879 against a 1.10 budget** (`single=158789 ns, dispatch=298366 ns`). Recorded because U11 taught this registry what a missing margin costs. This is far the largest margin any occurrence of this selector has shown: U6's five were 1.297, 1.182, 1.592, 1.527 and one unrecorded | -| **what IS established** | the observing diff (`9cb610e`, the TUI horizontal-authority latch) adds work to the paint path, so it was a live suspect. It was tested rather than argued about: three full-lib runs with the diff and three with `src/editor.rs` and `src/window.rs` restored to HEAD. **All six green.** The single failure is therefore not reproducible either way | +| **margin** | **1.879 against a 1.10 budget** (`single=158789 ns, dispatch=298366 ns`). Recorded because U11 taught this registry what a missing margin costs. It is the largest this selector has shown: U6's were 1.297, 1.182, 1.592, 1.527 and one unrecorded. Against the previous worst of 1.592 that is **1.18× as large as a ratio, or 1.48× as much budget excess** (0.879 over vs 0.592 over) — two different numbers for two different questions, which is why neither is summarized here as a single adjective | +| **what IS established** | the observing diff (`9cb610e`, the TUI horizontal-authority latch) adds work to the paint path, so it was a live suspect. Three full-lib runs with the diff and three with `src/editor.rs` and `src/window.rs` restored to HEAD: **all six green.** That establishes **non-reproduction in six runs, and nothing more** — this registry's own rule is that reruns establish intermittence only | | **what is NOT** | why it fired. The load confound is once again unmeasured — no `/proc/loadavg` reading was taken at the moment of failure, which is the same gap U15 exists to close | **The margin is the part that does not fit.** 1.879 is not a budget missed by a hair; it is the machinery costing nearly twice what the -budget allows, and it is a third again worse than U6's worst. A -load-sensitive assertion can produce that, but so can a real -regression, and this row does not pretend the size of the margin -resolves the question. What it does establish is that the observing -diff is not the cause, because six controlled runs say so. +budget allows. A load-sensitive assertion can produce that, but so can +a real regression, and this row does not pretend the size of the margin +resolves the question. + +**Nor do the six controlled runs.** An earlier draft of this row said +they establish that the observing diff is not the cause. They do not: a +tree that fails intermittently can carry a *changed failure rate* that +six runs are far too few to see, and treating non-reproduction as +exoneration is precisely the reasoning this registry refuses when a +rerun turns a red green. What the controls do is remove the easy +story — "the diff obviously did it" — and leave the question open. **If this selector reds alone again, this row is where it goes**, and the first thing to capture is a contemporaneous load reading — the one From deb3f0b1013a599d608c9d996e10d09d05ff2ada Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 23:51:50 +0200 Subject: [PATCH 47/72] fix(gui-1b): the fourth replacement path, and a probe open at one end The replacement census was taken by recall and was short. `kill_buffer` rebinds every window showing the doomed buffer to a fallback, resetting cursor, selection and view_top a line at a time --- with the horizontal origin missing from that list exactly as the other three had it. Retaken by grepping every write of a window's buffer_id rather than by listing the paths someone had thought of. Four production sites: switch_active_buffer_for, install_buffer_in_window, kill_buffer's fallback rebind, and the daemon's align_primary_document_window. EditorCore::from_bytes also assigns one and is deliberately excluded --- a fresh core has no prior origin to inherit --- and the helper's doc now names that exclusion so the next census need not re-decide it. The remaining seven buffer_id writes are test code. L8e covers the new site and fires only on its own call's removal. And L4's caret probe was open at one end. `pos_to_display(..).is_some()` proves the caret is not LEFT of the viewport, which is the condition deciding whether the vertical wheel carries point --- but a caret past the RIGHT edge also returns Some, and there a normal follow moves the origin. That is precisely the state in which the row's claim, that the latch is its only discriminator, would be false. The probe now requires `col < last_content_cols`. Verified by walking the caret 380 columns right: the setup assertion fires, where `is_some()` alone let it pass. Gates: fmt; clippy --workspace --all-targets -D warnings; --lib 2008; --lib --features crdt 2201; pmacs-gpu 312; git diff --check. --- src/editor.rs | 68 ++++++++++++++++++++++++++++++++++++++-------- src/editor_core.rs | 1 + src/window.rs | 18 +++++++++++- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index f61df64..67e6f07 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10670,11 +10670,16 @@ mod tests { } } - /// Whether the caret is inside the window's horizontal viewport, - /// asked the way production asks it: `pos_to_display` returns - /// `None` for a position LEFT of the edge (Q#HS7(c′)), which is the - /// exact condition that decides whether a vertical wheel can carry - /// point at all. + /// Whether the caret is inside the window's horizontal viewport — + /// **both edges**. + /// + /// `pos_to_display` returns `None` for a position LEFT of the edge + /// (Q#HS7(c′)), which is the condition deciding whether a vertical + /// wheel can carry point at all. On its own that is only half the + /// question: a caret past the RIGHT edge still returns `Some`, and + /// there a normal follow would move the origin — so a row claiming + /// the latch is its only discriminator would be wrong in exactly + /// the way `is_some()` cannot see. The column bound closes it. fn caret_inside_viewport(s: &EditorState) -> bool { let core = s.core.borrow(); let win = core.active_window(); @@ -10683,7 +10688,7 @@ mod tests { let buf = reg.get(win.buffer_id).expect("live buffer"); win.text_view .pos_to_display(buf, win.cursor, win.layout_ctx()) - .is_some() + .is_some_and(|coord| coord.col < win.last_content_cols) } /// The state every L-row starts from: a real sideways wheel gesture @@ -10846,11 +10851,12 @@ mod tests { ); assert!( caret_inside_viewport(&s), - "setup: and it must still be INSIDE the viewport afterwards. \ - A caret that landed on a short line would clamp to that \ - line's end, left of the origin — and then the origin would \ - discriminate too, so the claim below about the latch being \ - the only discriminator would be false" + "setup: and it must still be INSIDE the viewport afterwards, \ + on BOTH edges. Past either one a normal follow would move \ + the origin — left, if the caret clamped to a short line's \ + end; right, if it ran off the far side — and then the \ + origin would discriminate too, making the claim below \ + about the latch being the only discriminator false" ); paint_once(&s, term_size_24x80()); @@ -11104,6 +11110,46 @@ mod tests { ); } + /// L8, replacement leg — **killing the displayed buffer** rebinds + /// the window to a fallback, which is a replacement like any other. + /// + /// This path was missed by the first census, which listed the + /// replacement sites someone had thought of rather than the ones a + /// grep for `buffer_id` writes turns up. It resets cursor, + /// selection and `view_top` a line at a time, exactly like the + /// other two in this file, and had the horizontal origin missing + /// from the same list. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `kill_buffer`'s fallback rebind → this row.* + #[test] + fn l8e_killing_the_displayed_buffer_clears_the_origin_and_the_latch() { + let (s, _) = scrolled_sideways(); + let doomed = s.core.borrow().active_window().buffer_id; + + s.core + .borrow_mut() + .kill_buffer(doomed) + .expect("the scratch buffer stands as a fallback"); + + assert_ne!( + s.core.borrow().active_window().buffer_id, + doomed, + "setup: the window must actually have been rebound, else \ + this row measures nothing" + ); + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "a fallback rebind must not inherit the dead buffer's \ + sideways viewport" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "nor the authority defending it" + ); + } + /// L8, replacement leg — the same for **`install_buffer_in_window`**, /// the path that targets an explicit window rather than the active /// one. diff --git a/src/editor_core.rs b/src/editor_core.rs index e98f6d6..2f89714 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -5416,6 +5416,7 @@ impl EditorCore { win.cursor = 0; win.selection = None; win.view_top = 0; + win.forget_manual_horizontal_origin(); win.goal_col = None; } } diff --git a/src/window.rs b/src/window.rs index 8963c51..cbcb613 100644 --- a/src/window.rs +++ b/src/window.rs @@ -499,9 +499,25 @@ impl Window { /// symptom — and the TUI's three replacement paths had neither the /// origin reset nor the latch clear. /// - /// One helper rather than three copies, so a fourth replacement + /// One helper rather than a copy per site, so a new replacement /// path gets the rule by calling it; each **call site** stays /// individually removable, which is what keeps its own row honest. + /// + /// **The census, taken by grepping every write of a window's + /// `buffer_id` rather than by recalling which paths exist** — an + /// earlier version of this doc said "three" and was wrong, because + /// it listed the paths someone had thought of. Four production + /// sites rebind a live window to a different buffer: + /// `EditorCore::switch_active_buffer_for`, + /// `EditorCore::install_buffer_in_window`, + /// `EditorCore::kill_buffer`'s fallback rebind, and the daemon's + /// `align_primary_document_window`. Each has its own row + /// (L8b–L8e). + /// + /// `EditorCore::from_bytes` also assigns `buffer_id`, and is + /// **deliberately not on that list**: it builds a fresh core whose + /// window has no prior origin to inherit. Named here so the next + /// census does not have to re-decide it. pub fn forget_manual_horizontal_origin(&mut self) { self.view_left = 0; self.manual_left_authority = false; From 75a037aecb6ea830358ac551d6167d05f2c553ed Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 23:52:02 +0200 Subject: [PATCH 48/72] docs: the census was wrong twice, and "budget excess" named the wrong budget The ledger repeated a2d5b26's "three replacement paths", which was a count taken by recall. It is four, and the entry now says how the correct one was obtained --- grep every write of a window's buffer_id --- along with the one site deliberately excluded and why, so the next census does not re-decide it. This is the second count in this lane stated from memory and found short by review; the first was reshape call sites that no geometry path reached. The pattern is worth the sentence it costs: both times the list was of things that had been thought of, and both times review found the member nobody had. U20's margin comparison named the wrong baseline. 0.879 and 0.592 are overheads above 1.0, not excesses over the 1.10 budget --- those are 0.779 and 0.492, which make the ratio 1.58x rather than 1.48x. All three numbers are now given with what each measures. Also records that L4's caret probe was closed at its right edge, and why is_some() alone could not see that end. --- docs/active-work.md | 33 ++++++++++++++++++++++----------- docs/ci-red-signatures.md | 2 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5f4f830..66f86fb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `a2d5b26`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `deb3f0b`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -393,16 +393,23 @@ setup assertions rather than by reading: mode and content width**, so a geometry or wrap change reaches the follow one frame late. L7a and L8 paint twice and say why. -**`a2d5b26` then closed clause 5's replacement half**, which the first -latch commit had left undone: three TUI paths replace a window's buffer -— `switch_active_buffer_for`, `install_buffer_in_window` and the -daemon's `align_primary_document_window` — and **none cleared -`view_left` or the latch**. Two already reset cursor, selection and +**Clause 5's replacement half took two passes, and the first census +was wrong.** `a2d5b26` named "three TUI paths" — the ones that had been +thought of. A grep for every write of a window's `buffer_id` finds +**four** production sites: `switch_active_buffer_for`, +`install_buffer_in_window`, **`kill_buffer`'s fallback rebind**, and +the daemon's `align_primary_document_window`. None cleared `view_left` +or the latch. `EditorCore::from_bytes` also assigns one and is +deliberately excluded — it builds a fresh core with no prior origin to +inherit — recorded so the next census need not re-decide it. + +**A census taken by recall is not a census.** This is the second time +in this lane a count was stated from memory and found short by review; +the first was `reshape` call sites that no geometry path reached. Two already reset cursor, selection and `view_top` a line at a time; the horizontal origin was simply missing -from the list. One `Window::forget_manual_horizontal_origin`, three -call sites, three rows (L8b/L8c/L8d), each firing only on its own call -site's removal. L8d sits in `daemon.rs` because that function is -private there. +from the list. One `Window::forget_manual_horizontal_origin`, four call sites, four +rows (L8b–L8e), each firing only on its own call site's removal. L8d +sits in `daemon.rs` because that function is private there. Two witness repairs in the same commit, both of the same shape — an assertion that looked strict and was not: @@ -416,7 +423,11 @@ assertion that looked strict and was not: the caret clamped to their end, LEFT of the origin — so the origin discriminated too and the reason given for using the latch instead did not hold. The filler is now 120 columns and the row asserts the - caret is still inside, through `pos_to_display`. + caret is still inside, through `pos_to_display` — **on both edges.** + `is_some()` alone rules out only the left one; a caret past the RIGHT + edge still returns `Some`, and there a normal follow moves the + origin, which is exactly the state the row claims cannot + discriminate. The probe requires `col < last_content_cols`. **Still owed on this axis: L7b (content shrink), and the GPU's entire read side with L2 and L5's GPU leg.** The GPU's four writes remain diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 84b80c7..fd52888 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1100,7 +1100,7 @@ one loaded machine. One test alone does not carry that argument. | **job / flavor** | local (Linux), bare `cargo test --lib` (default features), full-lib load, **not** `scripts/gate` | | **required fragments** | `composition machinery added more than 10% overhead` + a ratio; the run also printed `dispatch overhead : 87.9%` and `realistic overhead : 82.1%` | | **status** | **ONE occurrence.** Not reproduced in six subsequent full-lib runs — three on the observing tree and three on the same tree with the observing diff reverted | -| **margin** | **1.879 against a 1.10 budget** (`single=158789 ns, dispatch=298366 ns`). Recorded because U11 taught this registry what a missing margin costs. It is the largest this selector has shown: U6's were 1.297, 1.182, 1.592, 1.527 and one unrecorded. Against the previous worst of 1.592 that is **1.18× as large as a ratio, or 1.48× as much budget excess** (0.879 over vs 0.592 over) — two different numbers for two different questions, which is why neither is summarized here as a single adjective | +| **margin** | **1.879 against a 1.10 budget** (`single=158789 ns, dispatch=298366 ns`). Recorded because U11 taught this registry what a missing margin costs. It is the largest this selector has shown: U6's were 1.297, 1.182, 1.592, 1.527 and one unrecorded. Against the previous worst of 1.592: **1.18× as a ratio**; **1.48× as much overhead above the 1.0 no-overhead baseline** (0.879 vs 0.592); **1.58× as much excess over the 1.10 budget** (0.779 vs 0.492). Three numbers for three questions, which is why none is compressed into an adjective — an earlier draft called the middle one "budget excess", which it is not: the budget is 1.10, not 1.0 | | **what IS established** | the observing diff (`9cb610e`, the TUI horizontal-authority latch) adds work to the paint path, so it was a live suspect. Three full-lib runs with the diff and three with `src/editor.rs` and `src/window.rs` restored to HEAD: **all six green.** That establishes **non-reproduction in six runs, and nothing more** — this registry's own rule is that reruns establish intermittence only | | **what is NOT** | why it fired. The load confound is once again unmeasured — no `/proc/loadavg` reading was taken at the moment of failure, which is the same gap U15 exists to close | From 2ec509b6d09d09d4ecb891b9065d41fe200b1166 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:11:58 +0200 Subject: [PATCH 49/72] docs(gui-1b): finish the four-path census correction The implementation and census name four live-window buffer replacement paths, but two test/helper comments still called the set three and the ledger still said only two paths already reset cursor, selection and view_top. Correct all three counts: four replacement paths, three of which already carried the other TUI replacement resets. --- docs/active-work.md | 11 ++++++----- src/editor.rs | 2 +- src/window.rs | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 66f86fb..215a6d2 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -405,11 +405,12 @@ inherit — recorded so the next census need not re-decide it. **A census taken by recall is not a census.** This is the second time in this lane a count was stated from memory and found short by review; -the first was `reshape` call sites that no geometry path reached. Two already reset cursor, selection and -`view_top` a line at a time; the horizontal origin was simply missing -from the list. One `Window::forget_manual_horizontal_origin`, four call sites, four -rows (L8b–L8e), each firing only on its own call site's removal. L8d -sits in `daemon.rs` because that function is private there. +the first was `reshape` call sites that no geometry path reached. Three +of the four already reset cursor, selection and `view_top` a line at a +time; the horizontal origin was simply missing from the list. One +`Window::forget_manual_horizontal_origin`, four call sites, four rows +(L8b–L8e), each firing only on its own call site's removal. L8d sits in +`daemon.rs` because that function is private there. Two witness repairs in the same commit, both of the same shape — an assertion that looked strict and was not: diff --git a/src/editor.rs b/src/editor.rs index 67e6f07..5c4ff37 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -11069,7 +11069,7 @@ mod tests { /// The origin describes the document being shown. Carried into a /// successor it renders the new buffer scrolled sideways with /// nothing about that buffer to explain it. The GPU has had this - /// reset since it hit the symptom; the TUI's three replacement + /// reset since it hit the symptom; the TUI's four replacement /// paths had neither half. /// /// *Mutation: drop the `forget_manual_horizontal_origin()` call diff --git a/src/window.rs b/src/window.rs index cbcb613..67fbe8c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -496,7 +496,7 @@ impl Window { /// until some later cursor motion repairs it by accident. The GPU /// learned this once already — `code_scroll_left` has its own line /// in that frontend's replacement reset, added after exactly this - /// symptom — and the TUI's three replacement paths had neither the + /// symptom — and the TUI's four replacement paths had neither the /// origin reset nor the latch clear. /// /// One helper rather than a copy per site, so a new replacement From feda8517f897c9df4e4dae37545d1203f586d2cf Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:30:59 +0200 Subject: [PATCH 50/72] feat(gui-1b): L7b --- clause 3's other half, a content shrink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L7a moves the viewport; this moves the content. The maximum origin is `widest − viewport`, so shortening the widest line lowers it with the viewport untouched --- the half of clause 3's promise widening alone cannot witness. The shrink goes through apply_active_edit, the production edit path, not the registry directly: an edit that left the window's TextView stale would have the row measuring a document state the running editor never holds. The bound is asserted exactly, as L7a's now is --- the new `widest − viewport`, not merely a smaller number. Both documented mutations fire: re-clamp releasing authority instead of preserving it bites L7a and L7b together, as the framing predicts, and an off-by-one clamp bites both exact bounds. Gates: fmt; clippy --workspace --all-targets -D warnings (which caught an items-after-statements const); --lib 2009; --lib --features crdt 2202; git diff --check. --- src/editor.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/editor.rs b/src/editor.rs index 5c4ff37..56b1f5a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -11002,6 +11002,72 @@ mod tests { ); } + /// L7b — **re-clamp on CONTENT shrink**, authority retained + /// (clause 3). + /// + /// L7a moves the viewport; this moves the content. The maximum is + /// `widest − viewport`, so shortening the widest line lowers it + /// with the viewport untouched — the other half of clause 3's + /// promise, which widening alone cannot witness. + /// + /// The shrink goes through `apply_active_edit`, the production edit + /// path, rather than the registry directly: an edit that left the + /// window's `TextView` stale would have this row measuring a + /// document state the running editor never holds. + /// + /// *Mutation: have the re-clamp release authority instead of + /// preserving it → this row and L7a. An off-by-one on the clamp → + /// this row's exact bound.* + /// What L7b shortens the widest line to. Above the 120-column + /// filler, so it stays the widest line and the row measures a + /// maximum it actually set. + const SHRUNK_TO: u32 = 150; + + #[test] + fn l7b_shrinking_the_widest_line_reclamps_the_origin_and_keeps_authority() { + let mut s = wide_fixture(); + paint_truncated(&s, term_size_24x80()); + wheel(&mut s, crossterm::event::MouseEventKind::ScrollRight, 200); + let before = s.core.borrow().active_window().view_left; + assert!(before > 0, "setup: scrolled somewhere"); + + // Cut the 400-column line down to `SHRUNK_TO`, leaving the + // 120-column filler lines below it — so the new widest is that + // width and the viewport never changed. + let line_start = 6u64; + s.core + .borrow_mut() + .apply_active_edit(crate::buffer::EditOp::Delete { + range: crate::rope::Range::new( + line_start + u64::from(SHRUNK_TO), + line_start + u64::from(WIDEST_COLUMNS), + ), + }) + .expect("shorten the widest line"); + + // Twice, for the ordering L7a states. + paint_once(&s, term_size_24x80()); + paint_once(&s, term_size_24x80()); + + let after = s.core.borrow().active_window().view_left; + let cols = s.core.borrow().active_window().last_content_cols; + assert!( + after < before, + "a shorter widest line lowers the maximum origin: \ + {before} -> {after}" + ); + assert_eq!( + after, + SHRUNK_TO.saturating_sub(cols), + "and it lands on the NEW `widest − viewport` exactly \ + ({SHRUNK_TO} − {cols})" + ); + assert!( + s.core.borrow().active_window().manual_left_authority, + "clamped, NOT released — the gesture survives at the new bound" + ); + } + /// L8 — **wrap clears the latch**, not merely the origin (clause 5). /// /// The existing wrap rows assert the origin is zeroed. None of them From ec6444ed25a6163a018d2e83a38856d6d2ebb1a6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:35:39 +0200 Subject: [PATCH 51/72] feat(gui-1b): the GPU's clause 3, and why its L2 cannot witness the latch The framing offers L2 --- wheel sideways, then a height-only resize --- as the row that witnesses manual horizontal authority on the GPU. It cannot. Measured before anything was added: the origin survives that resize with `manual_left_authority` never read anywhere in the frontend. What preserves it is Q#F6's painted-before policy. `resize` runs `ensure_caret_painted` only when the caret was painted, and a caret the user has scrolled off screen is not painted --- so the follow that would snap the origin back never runs. Whenever the caret IS painted it is inside the viewport, where `follow_left` returns the origin it was handed. Either way the latch is unreachable. What the GPU actually lacked was the other half of clause 3. Nothing brought the origin DOWN when the maximum fell, for the same reason: the follow that would is skipped in exactly that state. Measured: scroll to the right bound at 640px, widen to 1600px, and the origin stayed 960px past the new maximum --- most of the viewport blank with the text off its left edge. `clamp_code_scroll_left` at reshape's tail, beside B5's icon hook and for the same reason: one point every geometry settle already passes through. Gated on a non-zero origin, because it scans for the widest line and most windows are never in this state. L2 keeps both legs and says plainly that the first is required behavior, not a witness of the latch. L5's GPU leg makes the stronger statement available here: the wire stays silent, because on this frontend moving point means telling the daemon. One process note. The L5 mutation appeared not to fire; it had failed to compile. A mutation that does not build produces exactly the output of a test that passes, and only reading past the grep separated them. Both were re-run after the mutant compiled: dropping the clamp fires L2's widening leg, an off-by-one fires its exact bound, and a wire event on the horizontal leg fires L5. Still owed: the framing's L2 wording, which promises a witness this frontend cannot provide. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 314; --lib 2009; --lib --features crdt 2202; git diff --check. --- pmacs-gpu/src/main.rs | 192 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a0af0b4..eb798dc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5075,6 +5075,63 @@ mod input_routing_tests { ); } + /// L5, GPU leg — a horizontal wheel moves the **viewport only**. + /// + /// Q#S1-11 ruled (B): carrying point would be a new wire operation, + /// which 1b's non-protocol scope forbids. The TUI leg asserts point + /// and selection directly; here the stronger statement is available + /// — **the wire stays silent**, because on this frontend moving + /// point means telling the daemon. + /// + /// *Mutation: have the horizontal leg send a cursor event → this + /// row.* + #[test] + fn l5_a_horizontal_wheel_on_the_gpu_moves_neither_point_nor_the_wire() { + let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200)); + { + let buffer_id = h + .app + .state + .as_ref() + .expect("harness state") + .current_buffer_id + .expect("the harness stands in a buffer"); + let state = h.app.state.as_mut().expect("harness state"); + let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { + buffer_id, + wrap: false, + }); + assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off"); + } + let document = document_probe(&h); + move_pointer(&mut h, document); + assert_eq!( + h.app.classify_wheel_target(document.0, document.1), + WheelTarget::Document, + "setup: document text" + ); + let cursor_before = h.app.state.as_ref().expect("state").own_cursor; + + let step = h.feed(&wheel(1.0, 0.0)); + + assert!( + h.app.state.as_ref().expect("state").code_scroll_left > 0.0, + "setup: the notch must actually have scrolled, or this row \ + passes by doing nothing" + ); + assert_eq!( + h.app.state.as_ref().expect("state").own_cursor, + cursor_before, + "the horizontal wheel is a viewport gesture" + ); + assert!( + step.outbound.is_empty(), + "and it tells the daemon nothing: moving point here would be \ + a wire operation, got {:?}", + step.outbound + ); + } + /// B6 — the minimap banks into **its own** accumulator, so a /// part-notch over it cannot complete a notch over the document. /// @@ -11000,9 +11057,51 @@ impl State { // them all at once instead of leaving each new geometry path to // remember a call it will not remember. self.apply_panel_cursor_icon(); + // GUI Stage 1b, lifetime clause 3 — the horizontal origin is + // **clamped** by the same settling, for the same reason. + self.clamp_code_scroll_left(); self.request_redraw(); } + /// Bring the horizontal origin back inside `0 ..= widest − viewport` + /// (lifetime clause 3). + /// + /// Geometry and content both move that bound: a **wider** viewport + /// lowers it, and so does a shortened widest line. + /// + /// **Nothing else brings the origin down.** `horizontal_follow` + /// would, but it runs only when the caret is painted (Q#F6's + /// painted-before policy) — and the caret is not painted precisely + /// when the user has scrolled it off screen, which is exactly the + /// state a stale origin survives in. Measured before this existed: + /// after a scroll to the right bound at 640px and a widen to + /// 1600px, the origin stayed 960px past the new maximum, leaving + /// most of the viewport blank with the text off its left edge. + /// + /// Gated on a non-zero origin because it scans the document for the + /// widest line, and every reshape paying for that would be a steep + /// price for a state most windows are never in. + fn clamp_code_scroll_left(&mut self) { + if self.code_scroll_left <= 0.0 { + return; + } + if self.buffer.wrap() != Wrap::None { + self.code_scroll_left = 0.0; + return; + } + let advance = self.mono_advance(); + let width = self.text_bounds_right() as f32 - self.text_left(); + if advance <= 0.0 || width <= 0.0 { + return; + } + let cols = (width / advance).floor().max(0.0) as u32; + let max_left = self.widest_display_columns().saturating_sub(cols); + let current = (self.code_scroll_left / advance).round().max(0.0) as u32; + if current > max_left { + self.code_scroll_left = max_left as f32 * advance; + } + } + /// Ask the window to repaint. A no-op headless (no window), where the /// render tests drive `render_offscreen` directly (F-014). fn request_redraw(&self) { @@ -15828,6 +15927,99 @@ mod tests { ); } + /// L2 — **the GPU's horizontal origin across geometry changes.** + /// + /// Two legs, and the framing's expectation about the first was + /// wrong in a way worth recording. + /// + /// *Preserved by a height-only resize.* The framing offers this as + /// the row that witnesses **manual authority** on this frontend. It + /// does not, and cannot: measured at the head that introduced this + /// row, the origin survives a height-only resize with + /// `manual_left_authority` **never read anywhere in the frontend**. + /// What preserves it is Q#F6's painted-before policy — `resize` + /// runs `ensure_caret_painted` only when the caret was painted, and + /// a caret the user has scrolled off screen is not painted. So the + /// follow that would snap the origin back never runs. The leg is + /// kept because the behaviour is required; it is documented here as + /// **not** a witness of the latch. + /// + /// *Clamped by a widening resize* (clause 3). A wider viewport + /// LOWERS the maximum `widest − viewport`, and nothing else brings + /// the origin down — precisely because the follow is skipped in + /// this state. Before `clamp_code_scroll_left` existed, a scroll to + /// the right bound at 640px followed by a widen to 1600px left the + /// origin **960px past the new maximum**, most of the viewport + /// blank and the text off its left edge. + /// + /// *Mutation: drop `clamp_code_scroll_left()` from `reshape`'s tail + /// → the widening leg. Clamp to `max_left - 1` → its exact bound.* + #[test] + fn l2_the_horizontal_origin_survives_height_and_is_clamped_by_width() { + let mut text = "w".repeat(400); + text.push('\n'); + for _ in 0..200 { + text.push_str("filler\n"); + } + let Some(mut state) = State::new_headless(640, 480, &text) else { + return; + }; + let bid = BufferId::next(); + state.current_buffer_id = Some(bid); + state.own_cursor = Some(OwnCursor { + buffer_id: bid, + byte: 0, + }); + // Wrapping is on by default and pins the origin to zero, so + // without this both legs would measure nothing. + let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { + buffer_id: bid, + wrap: false, + }); + assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off"); + + // Out to the right bound. + state.scroll_by_columns(1000); + let scrolled = state.code_scroll_left; + assert!(scrolled > 0.0, "setup: the origin must have moved"); + assert!( + !state.caret_painted_in_code_clip(), + "setup: the caret is off screen, which is the state in which \ + the follow is skipped — and so the state the origin has to \ + survive on its own" + ); + + // Leg 1: a height-only resize leaves the origin alone. + state.resize(640, 600); + assert!( + (state.code_scroll_left - scrolled).abs() < f32::EPSILON, + "a taller window is not a horizontal event: {scrolled} -> {}", + state.code_scroll_left + ); + + // Leg 2: a wider one lowers the maximum, and the origin comes + // down to meet it — exactly, not merely somewhere lower. + state.resize(1600, 600); + let advance = state.mono_advance(); + let width = state.text_bounds_right() as f32 - state.text_left(); + let cols = (width / advance).floor().max(0.0) as u32; + let max_left = state.widest_display_columns().saturating_sub(cols); + assert!( + state.code_scroll_left < scrolled, + "a wider viewport must bring the origin down" + ); + assert!( + (state.code_scroll_left - max_left as f32 * advance).abs() < advance / 2.0, + "and it must land on `widest − viewport` exactly: {} vs {}", + state.code_scroll_left, + max_left as f32 * advance + ); + assert!( + state.code_scroll_left > 0.0, + "clamped, NOT discarded — the gesture survives at the new bound" + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// /// The decision half of the lifecycle row above, kept separate so a From a60793b3cab8b89c3e650f7b460f58ca50354b52 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:36:02 +0200 Subject: [PATCH 52/72] docs(lane): the GPU latch is unreachable; record what replaced L2 L7b and the GPU work, and one finding the next session should not have to rediscover: the framing's L2 is offered as the GPU's manual-authority witness and cannot be one. The origin survives a height-only resize with manual_left_authority never read anywhere in that frontend, because Q#F6's painted-before policy skips the follow whenever the caret is off screen --- and when the caret is on screen it is inside the viewport, where follow_left is a no-op. Both branches leave the latch unreachable. The real gap was clause 3's clamp, and it is recorded with the number that shows it: 960px past the maximum after a widen, most of the viewport blank. The entry marks L2's wording as owed to the FRAMING rather than the code, and says explicitly that deleting the GPU's four inert writes versus giving them a reader is a framing decision. Recording it as owed rather than settling it in passing is the point --- an approved contract does not get amended by whoever notices it is wrong. --- docs/active-work.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 215a6d2..00bb8fe 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `deb3f0b`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `ec6444e`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -430,9 +430,36 @@ assertion that looked strict and was not: origin, which is exactly the state the row claims cannot discriminate. The probe requires `col < last_content_cols`. -**Still owed on this axis: L7b (content shrink), and the GPU's entire -read side with L2 and L5's GPU leg.** The GPU's four writes remain -inert until something consults them. +**L7b landed at `feda851`** — clause 3's content half, the shrink that +lowers `widest − viewport` with the viewport untouched, asserted on the +exact new bound and firing on both the release mutation and an +off-by-one. + +### The GPU's latch is unreachable, and its real gap was the clamp + +`ec6444e`. **The framing's L2 cannot witness what it is offered for.** +It proposes wheel-sideways then a height-only resize as the GPU's +manual-authority row. Measured before anything was added: the origin +survives that resize with `manual_left_authority` **never read anywhere +in the frontend**. Q#F6's painted-before policy is what preserves it — +`resize` runs `ensure_caret_painted` only when the caret was painted, +and a caret scrolled off screen is not painted, so the follow that +would snap the origin back never runs. When the caret IS painted it is +inside the viewport, where `follow_left` returns the origin it was +given. **The latch is unreachable on this frontend by either branch.** + +What the GPU actually lacked was clause 3's clamp, for the same reason: +nothing brought the origin DOWN when the maximum fell, because the only +code that would is the follow that is skipped in that state. +**Measured: scroll to the right bound at 640px, widen to 1600px, and +the origin stayed 960px past the new maximum** — most of the viewport +blank, the text off its left edge. `clamp_code_scroll_left` now sits at +`reshape`'s tail, beside B5's icon hook and for the same reason. + +**Owed to the framing, not to the code: L2's wording**, which promises a +witness this frontend cannot provide. The GPU's four `manual_left_authority` +writes are still read by nothing; whether they should be deleted or given +a reader is a framing decision, not one to take in passing. **Landed but NOT yet witnessed:** From 269c52e5a780f6e073e9f158616da1dcd73cd693 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:45:31 +0200 Subject: [PATCH 53/72] feat(gui-1b): delete the GPU latch; split its rows; clamp the fast path Per the decision: the contract is behavioral, and the two frontends need not share a representation. manual_left_authority, its initializer and its four writes are gone, and scroll_by_columns no longer returns a bool nobody read. GPU authority is structural --- the follow reaches the origin only through ensure_caret_painted, which Q#F6's painted-before policy skips exactly when the user has scrolled the caret off screen, and when the caret is on screen it is inside the viewport where follow_left is a no-op. There is no follow to outrank. L7b, GPU, found a live gap before it could witness anything. Q#R1's keystroke case re-shapes only the affected line through try_reshape_line and skips the full reshape --- and skipped clause 3's clamp with it. A one-line delete that shortens the widest line could therefore leave the viewport past the end of the text, with no later event to repair it. The clamp now runs on that branch too, and the row drives apply_loro_text_delta_batches and asserts the line count did not change, so it cannot pass by taking the full-reshape branch instead. The combined row is split four ways, each with its own mutation: L2 (height-only preserves --- the policy, not a latch), L7a (widening clamps to the exact bound), L7b (content shrink through the incremental path), L3 (a moved CursorByte pulls the viewport back). L3 exists because removing the flag would otherwise leave the "until the cursor changes" boundary unwitnessed --- the one thing the flag's name claimed to govern. Its first version re-announced the caret's current byte, which the arm treats as inert by design; it now moves it. L2's mutation necessarily bites L7a --- an unconditional follow snaps the origin to the caret before that row's widen --- and the doc names that rather than leaving a predicted bite unexplained. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 317; --lib 2009; git diff --check. --- pmacs-gpu/src/main.rs | 277 +++++++++++++++++++++++++++++++----------- 1 file changed, 206 insertions(+), 71 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index eb798dc..f9189cb 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1883,7 +1883,6 @@ struct State { /// EFFECTIVELY moved the origin makes it authoritative, and the /// caret follow leaves it alone until the cursor position actually /// changes. A move fully absorbed by the clamp arms nothing. - manual_left_authority: bool, /// The icon last written to the window, so a per-motion call is a /// comparison rather than a platform round-trip. last_cursor_icon: Option, @@ -6465,7 +6464,6 @@ impl State { last_pointer_sent_byte: None, last_pointer_down: None, minimap_scrub_active: false, - manual_left_authority: false, last_cursor_icon: None, #[cfg(test)] test_selections: HashMap::new(), @@ -6777,6 +6775,14 @@ impl State { .contains('\n'); if geometry_changed || !(single_line_edit && self.try_reshape_line(edits[0])) { self.reshape(); + } else { + // The incremental path deliberately skips `reshape` — and + // skipped clause 3's clamp with it. A one-line edit can + // shorten the widest line, which lowers + // `widest − viewport`, so the origin has to come down here + // too or a keystroke leaves the viewport past the end of + // the text. + self.clamp_code_scroll_left(); } if geometry_changed && caret_was_painted { self.ensure_caret_painted(); @@ -7112,9 +7118,6 @@ impl State { // serves both. self.wheel_residuals.clear_document(); self.wheel_residuals.clear_minimap(); - // Manual horizontal authority is viewport state tied to - // the document being shown (lifetime clause 5). - self.manual_left_authority = false; self.last_viewport_sent = None; // Vterm Stage 3 — a snapshot ALWAYS leaves terminal // mode, including a terminal→terminal switch. The prior @@ -9118,29 +9121,39 @@ impl State { /// edge, so an origin — and a latch that would defend it — must not /// survive. /// - /// Returns whether the origin actually moved. Clause 2's "effective - /// move": one fully absorbed by the clamp arms nothing. - fn scroll_by_columns(&mut self, columns: i64) -> bool { + /// **This frontend keeps no authority flag**, and the difference + /// from the TUI is deliberate. There, `horizontal_follow` runs on + /// every paint and would drag the origin back to the caret, so a + /// latch is the only thing that can outrank it. Here the follow + /// runs only through `ensure_caret_painted`, which Q#F6's + /// painted-before policy skips whenever the caret is off screen — + /// and a caret the user has scrolled away from is off screen. The + /// preservation is **structural**: there is no follow to outrank. + /// + /// A flag was carried here for a while, written in four places and + /// read in none. Giving it a reader would have duplicated the + /// painted-before policy and needed a cursor baseline of its own to + /// avoid suppressing genuine cursor movement, so it was removed + /// rather than completed. The contract is behavioral; the two + /// frontends are not required to share a representation. + fn scroll_by_columns(&mut self, columns: i64) { if self.buffer.wrap() != Wrap::None { self.code_scroll_left = 0.0; - self.manual_left_authority = false; - return false; + return; } let advance = self.mono_advance(); let width = self.text_bounds_right() as f32 - self.text_left(); if advance <= 0.0 || width <= 0.0 { - return false; + return; } let viewport_cols = (width / advance).floor().max(0.0) as u32; let max_left = self.widest_display_columns().saturating_sub(viewport_cols); let current = (self.code_scroll_left / advance).round().max(0.0) as i64; let next = (current + columns).clamp(0, i64::from(max_left)); if next == current { - return false; + return; } self.code_scroll_left = next as f32 * advance; - self.manual_left_authority = true; - true } /// Move `code_scroll_left` so the caret's column is on screen @@ -15927,43 +15940,16 @@ mod tests { ); } - /// L2 — **the GPU's horizontal origin across geometry changes.** - /// - /// Two legs, and the framing's expectation about the first was - /// wrong in a way worth recording. - /// - /// *Preserved by a height-only resize.* The framing offers this as - /// the row that witnesses **manual authority** on this frontend. It - /// does not, and cannot: measured at the head that introduced this - /// row, the origin survives a height-only resize with - /// `manual_left_authority` **never read anywhere in the frontend**. - /// What preserves it is Q#F6's painted-before policy — `resize` - /// runs `ensure_caret_painted` only when the caret was painted, and - /// a caret the user has scrolled off screen is not painted. So the - /// follow that would snap the origin back never runs. The leg is - /// kept because the behaviour is required; it is documented here as - /// **not** a witness of the latch. - /// - /// *Clamped by a widening resize* (clause 3). A wider viewport - /// LOWERS the maximum `widest − viewport`, and nothing else brings - /// the origin down — precisely because the follow is skipped in - /// this state. Before `clamp_code_scroll_left` existed, a scroll to - /// the right bound at 640px followed by a widen to 1600px left the - /// origin **960px past the new maximum**, most of the viewport - /// blank and the text off its left edge. - /// - /// *Mutation: drop `clamp_code_scroll_left()` from `reshape`'s tail - /// → the widening leg. Clamp to `max_left - 1` → its exact bound.* - #[test] - fn l2_the_horizontal_origin_survives_height_and_is_clamped_by_width() { + /// A wide document scrolled to its right bound, with the caret left + /// at byte 0 — off screen to the left, which is the state every GPU + /// row below depends on and the state the follow is skipped in. + fn scrolled_to_the_right_bound(width: u32, height: u32) -> Option<(State, BufferId, f32)> { let mut text = "w".repeat(400); text.push('\n'); for _ in 0..200 { text.push_str("filler\n"); } - let Some(mut state) = State::new_headless(640, 480, &text) else { - return; - }; + let mut state = State::new_headless(width, height, &text)?; let bid = BufferId::next(); state.current_buffer_id = Some(bid); state.own_cursor = Some(OwnCursor { @@ -15971,48 +15957,99 @@ mod tests { byte: 0, }); // Wrapping is on by default and pins the origin to zero, so - // without this both legs would measure nothing. + // without this every row here would measure nothing. let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { buffer_id: bid, wrap: false, }); assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off"); - - // Out to the right bound. state.scroll_by_columns(1000); - let scrolled = state.code_scroll_left; - assert!(scrolled > 0.0, "setup: the origin must have moved"); + let origin = state.code_scroll_left; + assert!(origin > 0.0, "setup: the origin must have moved"); assert!( !state.caret_painted_in_code_clip(), - "setup: the caret is off screen, which is the state in which \ - the follow is skipped — and so the state the origin has to \ - survive on its own" + "setup: the caret must be off screen — that is the state the \ + follow is skipped in, and so the state these rows are about" ); + Some((state, bid, origin)) + } - // Leg 1: a height-only resize leaves the origin alone. - state.resize(640, 600); - assert!( - (state.code_scroll_left - scrolled).abs() < f32::EPSILON, - "a taller window is not a horizontal event: {scrolled} -> {}", - state.code_scroll_left - ); - - // Leg 2: a wider one lowers the maximum, and the origin comes - // down to meet it — exactly, not merely somewhere lower. - state.resize(1600, 600); + /// The horizontal maximum `widest − viewport`, in pixels, as the + /// production clamp computes it. + fn max_left_px(state: &mut State) -> f32 { let advance = state.mono_advance(); let width = state.text_bounds_right() as f32 - state.text_left(); let cols = (width / advance).floor().max(0.0) as u32; - let max_left = state.widest_display_columns().saturating_sub(cols); + state.widest_display_columns().saturating_sub(cols) as f32 * advance + } + + /// L2 — **the GPU preserves a manual horizontal origin + /// structurally**, with no authority flag anywhere. + /// + /// The framing offers this row as the GPU's manual-authority + /// witness and says the height-only resize invokes "a real follow". + /// **It does not.** `resize` runs `ensure_caret_painted` only when + /// the caret was painted, and the setup every L-row requires — the + /// caret outside the manual viewport — is exactly when it is not. + /// Q#F6's painted-before policy skips the follow, so there is + /// nothing for a latch to outrank. This frontend carried such a + /// flag for a while, written in four places and read in none; it + /// was removed rather than completed. + /// + /// So this row witnesses the *policy*, which is what actually holds + /// the origin here. + /// + /// *Mutation: have `resize` call `ensure_caret_painted()` + /// unconditionally → this row, and necessarily L7a: an + /// unconditional follow snaps the origin to the caret before that + /// row's widen, so its "came down from the manual origin" and + /// "still non-zero" assertions cannot survive either. The + /// dependency is unavoidable, not a witness failing to fire.* + #[test] + fn l2_a_height_only_resize_preserves_the_horizontal_origin() { + let Some((mut state, _bid, origin)) = scrolled_to_the_right_bound(640, 480) else { + return; + }; + + state.resize(640, 600); + assert!( - state.code_scroll_left < scrolled, + (state.code_scroll_left - origin).abs() < f32::EPSILON, + "a taller window is not a horizontal event: {origin} -> {}", + state.code_scroll_left + ); + } + + /// L7a, GPU — **a widening resize clamps the origin to the exact + /// bound** (clause 3). + /// + /// A wider viewport LOWERS the maximum `widest − viewport`, and + /// nothing else brings the origin down: the follow that would is + /// skipped in precisely this state, per L2. Before the clamp + /// existed, a scroll to the right bound at 640px followed by a + /// widen to 1600px left the origin **960px past the new maximum** — + /// most of the viewport blank with the text off its left edge. + /// + /// *Mutation: drop `clamp_code_scroll_left()` from `reshape`'s tail + /// → this row. Clamp to `max_left - 1` → its exact bound.* + #[test] + fn l7a_gpu_a_widening_resize_clamps_the_origin_to_the_exact_bound() { + let Some((mut state, _bid, origin)) = scrolled_to_the_right_bound(640, 480) else { + return; + }; + + state.resize(1600, 480); + + let expected = max_left_px(&mut state); + let advance = state.mono_advance(); + assert!( + state.code_scroll_left < origin, "a wider viewport must bring the origin down" ); assert!( - (state.code_scroll_left - max_left as f32 * advance).abs() < advance / 2.0, - "and it must land on `widest − viewport` exactly: {} vs {}", - state.code_scroll_left, - max_left as f32 * advance + (state.code_scroll_left - expected).abs() < advance / 2.0, + "and it must land on `widest − viewport` exactly: {} vs {expected}", + state.code_scroll_left ); assert!( state.code_scroll_left > 0.0, @@ -16020,6 +16057,104 @@ mod tests { ); } + /// L7b, GPU — **a content shrink clamps too, through the + /// incremental edit path** (clause 3's other half). + /// + /// This row exists because that path **bypasses `reshape`**. Q#R1's + /// keystroke case re-shapes only the affected line and skips the + /// full slice reshape — and skipped the clamp with it. A one-line + /// delete can shorten the widest line, which lowers the maximum, so + /// a single keystroke could leave the viewport past the end of the + /// text with no later event to repair it. + /// + /// The edit goes through `apply_loro_text_delta_batches`, the + /// production delta path, and the row asserts the line count did + /// not change — because a line-structure change would fall back to + /// the full reshape and witness the wrong branch. + /// + /// *Mutation: drop `clamp_code_scroll_left()` from the incremental + /// branch → this row, and only this row.* + #[test] + fn l7b_gpu_a_one_line_shrink_clamps_through_the_incremental_path() { + let Some((mut state, _bid, origin)) = scrolled_to_the_right_bound(640, 480) else { + return; + }; + let lines_before = state.current_line_starts.len(); + + // Cut the 400-column line down to 150. One edit, no newline, + // no line-count change: Q#R1's incremental case. + let shrink = vec![ + loro::TextDelta::Retain { + retain: 150, + attributes: None, + }, + loro::TextDelta::Delete { delete: 250 }, + ]; + state + .apply_loro_text_delta_batches(&[shrink]) + .expect("the one-line shrink applies"); + assert_eq!( + state.current_line_starts.len(), + lines_before, + "setup: the line count must not change, or the edit takes \ + the full-reshape branch and this row witnesses the wrong one" + ); + + let expected = max_left_px(&mut state); + let advance = state.mono_advance(); + assert!( + state.code_scroll_left < origin, + "a shorter widest line lowers the maximum: {origin} -> {}", + state.code_scroll_left + ); + assert!( + (state.code_scroll_left - expected).abs() < advance / 2.0, + "and the origin lands on the new bound exactly: {} vs {expected}", + state.code_scroll_left + ); + } + + /// L3, GPU — **a genuine cursor move ends the preservation.** + /// + /// With no flag to clear, the boundary is entirely structural: the + /// origin is preserved only while the caret stays off screen, and a + /// `CursorByte` that moves it runs `ensure_caret_painted`, whose + /// follow pulls the viewport to the new caret. Removing the dead + /// flag would otherwise leave the "until the cursor changes" edge + /// unwitnessed — the one thing the flag's name claimed to govern. + /// + /// Driven through `apply_attach_message`, the production receiver. + /// + /// *Mutation: gate the `ensure_caret_painted()` call in the + /// `CursorByte` arm so it does not run on a move → this row.* + #[test] + fn l3_gpu_a_moved_cursor_pulls_the_viewport_back_to_the_caret() { + let Some((mut state, bid, origin)) = scrolled_to_the_right_bound(640, 480) else { + return; + }; + + // A byte the caret is not already on: the arm gates on `moved`, + // and re-announcing the same position is deliberately inert. + // Column 5 is far left of the manual viewport, so the follow + // has somewhere to go. + assert_eq!( + state.own_cursor.map(|c| c.byte), + Some(0), + "setup: the caret starts at byte 0" + ); + let _ = state.apply_attach_message(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos: 5, + }); + + assert!( + state.code_scroll_left < origin, + "a deliberate cursor move outranks a deliberate scroll: the \ + viewport must chase the caret again: {origin} -> {}", + state.code_scroll_left + ); + } + /// B5 — an open context menu owns its pixels, and they are not text. /// /// The decision half of the lifecycle row above, kept separate so a From 2166c4290b4bdf44f0fa72f63266fa164ce32f12 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:45:45 +0200 Subject: [PATCH 54/72] docs: GPU authority is structural, and the framing said otherwise The framing claimed L2's height-only resize "invokes the real follow path (resize -> ensure_caret_painted -> horizontal_follow)". It does not, in the setup L2 itself mandates: the caret is deliberately outside the manual viewport, and the painted-before policy skips the follow precisely there. The paragraph now carries the correction and its consequence --- that the GPU needs no authority flag, because there is no follow to outrank --- along with the decision to delete rather than complete it, and the four rows that replace the single one. The ledger's stale claims go with it. "Landed but not yet witnessed" still listed wrap and buffer-replacement clearing, which L8 and L8b-L8e now cover and whose GPU half described a latch that no longer exists; "owed outright" still listed L7b and the GPU read side. Both are corrected rather than quietly dropped, since the entry's purpose is to tell a recovering session what is actually left. Also records the shared-target false red's FOURTH occurrence, at the same four module uses with drifted line numbers --- which is the entry's own point about which parts of that signature are stable. --- docs/active-work.md | 48 +++++++++++++++++++++++--------- docs/gui-stage1-input-framing.md | 36 ++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 00bb8fe..7db8ac3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `ec6444e`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `269c52e`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -457,21 +457,41 @@ blank, the text off its left edge. `clamp_code_scroll_left` now sits at `reshape`'s tail, beside B5's icon hook and for the same reason. **Owed to the framing, not to the code: L2's wording**, which promises a -witness this frontend cannot provide. The GPU's four `manual_left_authority` -writes are still read by nothing; whether they should be deleted or given -a reader is a framing decision, not one to take in passing. +witness this frontend cannot provide. + +**Settled (user decision, 2026-09-02): the GPU latch is deleted, not +completed.** The contract is behavioral, and the two frontends are not +required to share a representation; a reader would have duplicated the +painted-before policy and needed a cursor baseline of its own to avoid +suppressing genuine cursor movement. `manual_left_authority`, its +initializer and its four writes are gone, and `scroll_by_columns` +no longer returns an unused `bool`. **GPU authority is structural**, +and the framing now says so. + +The GPU's four rows: **L2** (height-only preserves — the policy, not a +latch), **L7a** (widening clamps to the exact bound), **L7b** (a +content shrink clamps **through the incremental edit path**), and +**L3** (a moved `CursorByte` pulls the viewport back). Each fires on +its own mutation; L2's also necessarily bites L7a, which its doc names. + +**L7b, GPU, is the one that found a live gap.** Q#R1's keystroke case +re-shapes only the affected line through `try_reshape_line` and skips +the full `reshape` — **and skipped clause 3's clamp with it**. A +one-line delete shortening the widest line could leave the viewport +past the end of the text with no later event to repair it. The clamp +now runs on that branch too. **Landed but NOT yet witnessed:** -- **wrap and buffer-replacement clearing** of that latch and the origin - (clause 5), on both the GPU's `scroll_by_columns` and the buffer - replacement path; - **R4 and R5's residual resets**, as two separate clears beside `code_scroll_left` so omitting either is individually visible. -**Owed outright:** L7b and the GPU's whole read side (above), step 3's -fractional both-axis panel witness, R4/R5's own replacement witnesses, -and **B1's disposal half** — a residual keyed to a surface that goes +(The wrap and buffer-replacement clearing that stood here is done: the +TUI's four replacement paths carry L8b–L8e, wrap carries L8, and the +GPU half of that bullet described the latch that no longer exists.) + +**Owed outright:** step 3's fractional both-axis panel witness, R4/R5's +own replacement witnesses, and **B1's disposal half** — a residual keyed to a surface that goes away must go with it, and this frontend does not yet track "that buffer is gone". @@ -557,12 +577,14 @@ error: could not compile `pmacs-gpu` (bin "pmacs-gpu" test) due to 4 previous errors ``` -**THREE occurrences now**, all local. The third (at `9cb610e`) gave the +**FOUR occurrences now**, all local. The third (at `9cb610e`) gave the complete set of four sites, which the second's captured tail had cut to three: at that head they were `main.rs:9049`, `:13546`, `:14772` and `:14833` — every use of the module the 1b branch added in `9e54cd2`. -Line numbers drift as the file grows; the module path is the stable -part of the signature. +The fourth (2026-09-02, mid-edit) hit the same four uses at +`:9109`, `:13658`, `:14884` and `:14945`. **Line numbers drift as the +file grows and are not part of the signature; the module path is**, and +the count of four is stable across all of them. An earlier draft of this entry guessed the missing fourth site was `widest_display_columns`. The third occurrence shows that guess was diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index bb7a7e0..7f46060 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -841,9 +841,39 @@ released snaps to the caret's. **L2 is height-only for the same reason L3 leaves the viewport.** A resize that changes width also changes the clamp, so the origin could move for a reason unrelated to authority and the row would not -discriminate. Height-only invokes the real follow path -(`resize` → `ensure_caret_painted` → `horizontal_follow`) while leaving -horizontal geometry fixed. +discriminate. + +**CORRECTION (revision 21, implementation).** This paragraph said +height-only "invokes the real follow path +(`resize` → `ensure_caret_painted` → `horizontal_follow`)". **That is +false in the setup L2 itself requires.** `resize` runs +`ensure_caret_painted` only when the caret was painted, and L2's caret +is deliberately outside the manual viewport — so Q#F6's painted-before +policy skips the follow, and no follow runs at all. + +**The consequence is that the GPU needs no authority flag.** The TUI +does: `horizontal_follow` runs on every paint there and would drag the +origin back, so only a latch can outrank it. On the GPU the follow +reaches the origin only through `ensure_caret_painted`, which is +skipped exactly when the user has scrolled the caret off screen — and +when the caret IS on screen it is inside the viewport, where +`follow_left` returns the origin it was handed. **GPU authority is +structural: there is no follow to outrank.** + +A flag was carried there for a while, written in four places and read +in none. **It has been removed rather than completed** (user decision, +2026-09-02): giving it a reader would have duplicated the +painted-before policy and needed a cursor baseline of its own to avoid +suppressing genuine cursor movement. **The contract is behavioral; the +two frontends are not required to share a representation.** + +So the GPU's rows are: **L2** (height-only preserves, witnessing the +policy — mutation: follow unconditionally), **L7a** (widening clamps to +the exact bound), **L7b** (a content shrink clamps **through the +incremental edit path**, which bypasses `reshape` and so bypassed the +clamp), and **L3** (a moved `CursorByte` pulls the viewport back — +the "until the cursor changes" boundary, which without a flag is the +only thing that marks it). **L7 needs both legs.** Clause 3 promises re-clamping on *geometry and content* changes; widening the viewport witnesses only the geometry From 7f7f1563a497fdc75ad42f6ebbcacc73e6adc937 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 10:34:17 +0200 Subject: [PATCH 55/72] test(gui-1b): make the GPU lifetime witnesses discriminate Pin L7b to the incremental reshape branch with a sentinel that a full reshape clears, rather than treating unchanged line count as proof that the eligible fast path succeeded. Make GPU L3 assert both the accepted CursorByte and the exact pixel origin produced by following column five, so an arbitrary partial move cannot satisfy the row. Remove the deleted authority field's orphaned documentation from the cursor-icon field and stop describing the GPU wrap path as clearing a latch that no longer exists. --- pmacs-gpu/src/main.rs | 44 +++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index f9189cb..3af25ee 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1879,10 +1879,6 @@ struct State { /// selection, until release. Never sends `Pointer` events — /// the viewport is frontend-owned. minimap_scrub_active: bool, - /// GUI Stage 1b, Q#S1-11 clause 2: a horizontal wheel that - /// EFFECTIVELY moved the origin makes it authoritative, and the - /// caret follow leaves it alone until the cursor position actually - /// changes. A move fully absorbed by the clamp arms nothing. /// The icon last written to the window, so a per-motion call is a /// comparison rather than a platform round-trip. last_cursor_icon: Option, @@ -9116,10 +9112,9 @@ impl State { /// Clamping at the widest line's *full* width would let the origin /// pass every glyph and leave the viewport blank. /// - /// **Wrap pins the origin to zero** and clears manual authority - /// (lifetime clause 5): a wrapped buffer has nothing past the right - /// edge, so an origin — and a latch that would defend it — must not - /// survive. + /// **Wrap pins the origin to zero** (lifetime clause 5): a wrapped + /// buffer has nothing past the right edge, so no horizontal origin + /// may survive. /// /// **This frontend keeps no authority flag**, and the difference /// from the TUI is deliberate. There, `horizontal_follow` runs on @@ -16080,6 +16075,12 @@ mod tests { return; }; let lines_before = state.current_line_starts.len(); + // A full `reshape` clears this hold; the incremental line path + // deliberately does not. Keep a sentinel so the checked-in row + // proves it reached the branch whose clamp it claims to witness, + // rather than relying on a mutation run outside the suite. + let incremental_sentinel = std::time::Instant::now() + std::time::Duration::from_mins(1); + state.styled_redraw_deadline = Some(incremental_sentinel); // Cut the 400-column line down to 150. One edit, no newline, // no line-count change: Q#R1's incremental case. @@ -16096,8 +16097,15 @@ mod tests { assert_eq!( state.current_line_starts.len(), lines_before, - "setup: the line count must not change, or the edit takes \ - the full-reshape branch and this row witnesses the wrong one" + "setup: the line count must not change, or this is not a \ + single-line incremental candidate" + ); + assert_eq!( + state.styled_redraw_deadline, + Some(incremental_sentinel), + "setup: a full reshape clears this sentinel; retaining it \ + proves `try_reshape_line` succeeded and the incremental \ + clamp branch actually ran" ); let expected = max_left_px(&mut state); @@ -16147,12 +16155,28 @@ mod tests { byte_pos: 5, }); + assert_eq!( + state.own_cursor, + Some(OwnCursor { + buffer_id: bid, + byte: 5, + }), + "setup: the production receiver must accept the moved cursor" + ); + let advance = state.mono_advance(); + let expected = 5.0 * advance; assert!( state.code_scroll_left < origin, "a deliberate cursor move outranks a deliberate scroll: the \ viewport must chase the caret again: {origin} -> {}", state.code_scroll_left ); + assert!( + (state.code_scroll_left - expected).abs() < 0.01, + "and normal follow puts column 5 at the left edge exactly: \ + {} vs {expected}", + state.code_scroll_left + ); } /// B5 — an open context menu owns its pixels, and they are not text. From 07cd60a038f887ea5180e3af326986a54e93ab91 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 10:36:39 +0200 Subject: [PATCH 56/72] docs(gui-1b): make revision 21's lifetime tables authoritative Rewrite the live witness and mutation tables around the decision that GPU horizontal authority is structural, while the TUI owns the literal latch. Correct L2's driver, give L3 and L7 their frontend-specific instances, scope L6 and L8 to the TUI state they can observe, and record the GPU clamp paths and their real mutation dependencies. Mark the framing amendment revision 21, discharge the ledger's stale owed wording, and record that L7b now proves the incremental path with a full-reshape sentinel rather than inferring it from unchanged line count. Keep historical checkpoints distinct from the one current verified code head. --- docs/active-work.md | 33 ++++++++------ docs/gui-stage1-input-framing.md | 74 +++++++++++++++++++------------- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7db8ac3..c89c01f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `269c52e`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `7f7f156`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -347,12 +347,13 @@ At `175cc7b` — B5's own checkpoint, not the lane's current one — all fmt and `git diff --check` are clean. Four mutations were executed and each fires its named row: omit accepted-frame reflow, omit epoch-invalidation reflow, reshape content-only frames, and retain -divider hover on `Absent`. **The latest verified code head is `f441d3d` -at 312 GPU rows**; the authoritative remote ref above may be ahead with -documentation-only commits and remains the recovery source. Calling -`f441d3d` the lane's "current head" here would become false in the -commit carrying the sentence — the self-certifying checkpoint defect -this ledger has already recorded elsewhere. +divider hover on `Absent`. **B6's verified checkpoint is `f441d3d` at +312 GPU rows**; the latest verified code head is named once, at the top +of this block. The authoritative remote ref above may be ahead with +documentation-only commits and remains the recovery source. Calling a +fixed checkpoint the lane's "current head" here would become false in +the next code commit — the self-certifying checkpoint defect this +ledger has already recorded elsewhere. ### The latch was never the thing the ledger said it was @@ -437,9 +438,10 @@ off-by-one. ### The GPU's latch is unreachable, and its real gap was the clamp -`ec6444e`. **The framing's L2 cannot witness what it is offered for.** -It proposes wheel-sideways then a height-only resize as the GPU's -manual-authority row. Measured before anything was added: the origin +`ec6444e`. **The framing's L2 could not witness the latch it was offered +as evidence for.** It proposes wheel-sideways then a height-only resize +as the GPU's manual-authority row. Measured before anything was added: +the origin survives that resize with `manual_left_authority` **never read anywhere in the frontend**. Q#F6's painted-before policy is what preserves it — `resize` runs `ensure_caret_painted` only when the caret was painted, @@ -456,8 +458,10 @@ the origin stayed 960px past the new maximum** — most of the viewport blank, the text off its left edge. `clamp_code_scroll_left` now sits at `reshape`'s tail, beside B5's icon hook and for the same reason. -**Owed to the framing, not to the code: L2's wording**, which promises a -witness this frontend cannot provide. +**This was owed to the framing, not to the code: L2's wording promised a +latch witness this frontend cannot provide. Revision 21 discharges it** +by naming the painted-before policy as the mechanism and splitting the +GPU rows by the behavior each actually witnesses. **Settled (user decision, 2026-09-02): the GPU latch is deleted, not completed.** The contract is behavioral, and the two frontends are not @@ -479,7 +483,10 @@ re-shapes only the affected line through `try_reshape_line` and skips the full `reshape` — **and skipped clause 3's clamp with it**. A one-line delete shortening the widest line could leave the viewport past the end of the text with no later event to repair it. The clamp -now runs on that branch too. +now runs on that branch too. The row keeps a redraw-deadline sentinel +that full `reshape` clears, so it positively proves the incremental +branch ran; unchanged line count alone would only make that branch +eligible, not establish that `try_reshape_line` succeeded. **Landed but NOT yet witnessed:** diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 7f46060..672a55e 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,7 +1,9 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 20 — §2a RE-MEASURED at `0ec13b3`, and three base -facts corrected.** Revision 18's §2a was measured at `72da24a`. That +**Status: revision 21 — revision 20's §2a re-measurement at `0ec13b3` +stands; the GPU lifetime rows now describe structural authority rather +than a latch that never had a reader.** Revision 18's §2a was measured +at `72da24a`. That base is now **167 commits back**, and #242 and #243 landed on exactly the pointer paths 1b builds on, so every 1b anchor is stale again — which §2a's own history predicted would happen. @@ -794,7 +796,9 @@ undefined, which is the part that decides whether the feature works. 5. **Wrap and buffer replacement clear it and pin the origin to zero.** This is the existing rule (`horizontal_follow`'s wrap branch, and the GPU's wrap branch at `:8120`; `:8089` is only the -`horizontal_follow` call); authority must not survive either. +`horizontal_follow` call). On the TUI the latch must be cleared too; the +GPU has no latch, so clearing the origin is the whole representation of +this rule there. ##### What B7 and B3 must witness @@ -814,29 +818,30 @@ vacuous witness of exactly the kind this framing keeps producing. ##### The discriminating setup, required by every row -**Revision 15 stated this for L3 alone. It is required by all of -them.** Every row below asserts "the origin is X"; if the cursor sits -*inside* the manually scrolled viewport, `follow_left` returns that -same origin, so **held authority and released authority produce -identical state** and the row passes either way. Visible state -coincides; the assertion proves nothing. +**Revision 15 stated this for L3 alone. It is required by every row +whose discriminator is the origin.** If the cursor sits *inside* the +manually scrolled viewport, `follow_left` returns that same origin, so +preserved and released authority produce identical visible state and +an origin assertion proves nothing. -**Setup for L1, L2, L4, L6, L7 and L8, not only L3: the cursor is at a -column OUTSIDE the manual viewport**, so the two outcomes are -distinguishable — authority held keeps the wheel origin, authority -released snaps to the caret's. +**Setup for L1, L2, L3, L6, L7 and the TUI's L8: the cursor is at a +column OUTSIDE the manual viewport**, so preservation keeps the wheel +origin and release snaps to the caret. **L4 is the deliberate +exception:** the TUI's vertical wheel carries point only when the caret +is inside, so L4 puts it inside and uses the latch itself — not the +coincident origin — as its discriminator. -| # | witness | driver (cursor outside the manual viewport throughout) | +| # | witness | driver (cursor outside unless the row says otherwise) | |---|---|---| | L1 | preservation, TUI | wheel sideways → **a real paint** (`paint_frame` → `prepare_window_cursor_visible`) | -| L2 | preservation, GPU | wheel sideways → **a HEIGHT-ONLY `resize`** — real follow, horizontal geometry unchanged | -| L3 | release | wheel sideways → a genuine cursor-position change, landing outside the manual viewport | +| L2 | preservation, GPU | wheel sideways → **a HEIGHT-ONLY `resize`**; Q#F6's painted-before policy skips the follow, preserving the origin structurally | +| L3 | release, **both frontends** | wheel sideways → a genuine cursor-position change outside the manual viewport; TUI releases its latch, GPU accepts a moved `CursorByte` and follows immediately | | L4 | cross-axis, **TUI only** | wheel sideways → wheel **vertically**; vertical wheel carries point in the TUI (`scroll_window`), so a naive authority-on-any-cursor-write releases here. Clause 3 says the origin survives | | L5 | point and selection unmoved | wheel sideways on both frontends → point and selection byte-identical (clause 1) | -| L6 | clamp-absorbed motion does not arm | at the bound already, wheel further → origin unchanged **and authority NOT armed**, so the next follow moves normally (clause 2's "effective") | -| L7a | re-clamp on **viewport widening** | wheel sideways → **widen** the viewport → origin re-clamped to the new maximum, authority still held (clause 3) | -| L7b | re-clamp on **content shrink** | wheel sideways → **shorten the widest line** so the maximum falls → origin re-clamped, authority still held | -| L8 | wrap and buffer replacement clear the LATCH | wheel sideways → toggle to `Wrap` (and separately, replace the buffer) → origin zero **and authority cleared**, verified by a following `truncate` toggle where the caret rule governs again (clause 5) | +| L6 | clamp-absorbed motion does not arm, **TUI only** | at the bound already, wheel further → origin unchanged **and latch NOT armed**, so the next paint follows normally (clause 2's "effective"); the GPU has no authority state to arm | +| L7a | re-clamp on **viewport widening**, both frontends | wheel sideways → **widen** the viewport → origin re-clamped to the exact new maximum; TUI latch remains held, GPU preservation remains structural | +| L7b | re-clamp on **content shrink**, both frontends | wheel sideways → **shorten the widest line** so the maximum falls → origin re-clamped exactly; GPU drives the incremental edit path that bypasses full `reshape` | +| L8 | wrap and buffer replacement clear the **TUI latch** | wheel sideways → toggle to `Wrap` (and separately, replace the buffer) → origin zero and latch cleared, verified by a following `truncate` where the caret rule governs again. GPU origin resets are existing Stage 5 evidence; it has no latch | **L2 is height-only for the same reason L3 leaves the viewport.** A resize that changes width also changes the clamp, so the origin could @@ -881,10 +886,12 @@ half. **L7b shrinks the content** — the maximum is `widest − viewport`, so a shortened widest line lowers it with the viewport untouched. -**L8 is not covered by the existing wrap-origin rows.** Those assert the -origin is zeroed; they cannot see a **stale latch** surviving the wrap, -which surfaces only on the return to `truncate` when the caret rule -should have resumed and does not. +**The TUI's L8 is not covered by its existing wrap-origin rows.** Those +assert the origin is zeroed; they cannot see a stale TUI latch surviving +the wrap, which surfaces only on the return to `truncate` when the caret +rule should have resumed and does not. The GPU has no corresponding +state: its existing wrap and snapshot rows assert the complete origin +reset directly. Mutations. Per §6's dependency-aware rule, each must **bite its named rows**; where one *necessarily* breaks dependents, the dependency is @@ -897,12 +904,16 @@ that does not occur reads afterwards as a witness that failed to fire. | mutation | must bite | necessary dependents | |---|---|---| -| follow ignores manual authority (always overwrites) | L1, L2 | L4, L7a, L7b — each asserts a preserved origin, which cannot survive an unconditional overwrite | -| manual authority never releases | **L3 only** | none | -| authority armed by *any* wheel event, effective or not | L6 | none | -| re-clamp releases authority instead of preserving it | L7a, L7b | none | -| wrap/replacement zeroes the origin but leaves the latch set | L8 | none | -| the wheel path writes point or selection | **L5 only** | none | +| TUI follow ignores manual authority (always overwrites) | TUI L1 | TUI L7a/L7b — their outside caret makes an unconditional follow discard the preserved origin | +| GPU resize follows an unpainted caret unconditionally | GPU L2 | GPU L7a — the follow snaps to the caret before the widen, so the clamped gesture cannot remain non-zero | +| TUI manual authority never releases on a genuine cursor move | TUI L3 only | none | +| GPU moved `CursorByte` does not run `ensure_caret_painted` | GPU L3 only | none | +| TUI authority armed by *any* wheel event, effective or not | TUI L6 | none | +| TUI re-clamp releases authority instead of preserving it | TUI L7a/L7b | none | +| GPU omits the `reshape`-tail clamp / its exact bound is off by one | GPU L7a | the shared off-by-one also bites GPU L7b's exact bound | +| GPU omits the incremental-path clamp | GPU L7b only | none | +| TUI wrap/replacement zeroes the origin but leaves the latch set | TUI L8/L8b–L8e, at the affected site | none | +| either frontend's wheel path writes point or selection | that frontend's L5 leg | none | **The two removed dependencies were assumptions, not derivations.** @@ -921,7 +932,8 @@ them** — observed, with the run named, never predicted in advance. Clause 5's *origin* half is already implemented for the caret path; the existing wrap-guard removal named in the B7 row remains its mutation. -**L8 covers the half that is new — the latch.** +**TUI L8/L8b–L8e cover the half that is new there — the latch.** The +GPU has no second half. ### CORRECTION 6 — B3's right bound is vaguer than B7's, for the same bound From 302ce1409532ee1cebf7cef33b28ede3f91e6fcd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 10:43:01 +0200 Subject: [PATCH 57/72] test(gui-1b): step 3's fractional panel wheel, per axis Revision 20 sharpened this witness because its earlier form was satisfiable with the mechanism it protects entirely broken: a whole tick passes straight through #243's vertical receiver even if B1's accumulator discards every sub-tick it is given. The row now feeds fractional input end to end --- a first 0.6 produces no gesture, a second completes exactly one --- and does it per axis, because a single accumulator fed by both axes passes any one-axis row. The ordering is the point of the middle leg: 0.6 vertical, then 0.6 horizontal. Under one shared accumulator the horizontal delta would finish the vertical one and fire a gesture there. Under per-(surface, axis) banks it reaches nothing, which is what the row asserts before completing either axis. Observed on the wire, filtered to PanelPointer / PanelPointerMapped, because a panel gesture's whole effect is the event it sends. Two setup facts the harness forced, both asserted rather than assumed. It negotiates the MAPPED family, which refuses a frame carrying no mapping generation, so the row drops to the legacy wire like every other panel row here. And it has already made its one surface declaration, so the re-declaration uses the Metrics trigger; a second Surface declaration is suppressed by design and returns None. The row asserts the frame was accepted before touching it, and asserts the probe is a panel CELL --- panel chrome banks nothing at all and would satisfy every "no gesture" assertion for entirely the wrong reason. Both documented mutations fire: rounding the notch instead of banking it fires the sub-threshold legs, and collapsing the two axes into one accumulator fires the cross-axis leg. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 318; git diff --check. --- pmacs-gpu/src/main.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3af25ee..cea0e0f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -21851,6 +21851,138 @@ mod tests { frame } + /// Step 3 — **the panel's fractional wheel, end to end, per axis.** + /// + /// Revision 20 sharpened this witness because its earlier form was + /// satisfiable with the mechanism it protects entirely broken: a + /// whole tick passes straight through #243's vertical receiver even + /// if B1's accumulator discards every sub-tick it is given. So the + /// row requires **fractional input** — a first sub-threshold delta + /// produces **no** gesture, and accumulated same-panel deltas + /// produce **exactly one** — and it requires that on **each axis** + /// separately, because a single accumulator fed by both axes passes + /// any one-axis row. + /// + /// Driven through `dispatch_window_event` and observed on the wire, + /// which is where a panel gesture actually goes. + /// + /// *Mutations: round the notch instead of banking it → the + /// sub-threshold legs (a 0.6 delta becomes a whole tick); bank into + /// one accumulator per surface instead of per (surface, axis) → the + /// second axis's first leg, which the first axis's leftover + /// completes.* + #[test] + fn step3_a_panel_wheel_needs_a_whole_notch_on_each_axis() { + use winit::dpi::PhysicalPosition; + use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + + let mut h = EffectHarness::new(); + // The harness already declared its surface geometry; answer that + // declaration with a frame, through the production receiver. + { + let state = h.app.state.as_mut().expect("harness state"); + // The harness negotiates the MAPPED family, which refuses a + // frame with no retained mapping generation. Drop to the + // legacy wire and re-declare, the way every other panel row + // in this file does, then answer that declaration through + // the production receiver. + state.set_panel_wire(PANEL_MIN_VERSION); + // Metrics, not Surface: the harness already made its one + // surface declaration, and a second is suppressed by design. + let (epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("metrics always advance the panel geometry epoch"); + let frame = panel_frame_of(4, total.cols.max(1), epoch, 1); + let _ = state.apply_attach_message(InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame), + )); + assert!( + state.panel.presented().is_some(), + "setup: the frame must be accepted, or every assertion \ + below is about a panel that is not there" + ); + } + let _ = h.read_until_sentinel(); + + // A pixel inside the panel's content, so the wheel target is a + // cell rather than the band's chrome. + let (px, py, _, ph) = h + .app + .state + .as_ref() + .expect("harness state") + .panel_content_rect() + .expect("the panel is presented"); + let point = (f64::from(px + 4.0), f64::from(py + ph / 2.0)); + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(point.0, point.1), + }); + assert!( + matches!( + h.app.classify_wheel_target(point.0, point.1), + WheelTarget::PanelCell { .. } + ), + "setup: the probe must be a panel CELL — panel chrome banks \ + nothing at all, and would satisfy every 'no gesture' \ + assertion below for the wrong reason" + ); + + let mut wheel = |dx: f32, dy: f32| { + h.feed(&WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(dx, -dy), + phase: TouchPhase::Moved, + }) + }; + let gestures = |step: &Step| { + step.outbound + .iter() + .filter(|e| { + matches!( + e, + pmacs_protocol::FrontendEvent::PanelPointer { .. } + | pmacs_protocol::FrontendEvent::PanelPointerMapped { .. } + ) + }) + .count() + }; + + // Vertical: 0.6 of a notch is not a notch. + let step = wheel(0.0, 0.6); + assert_eq!( + gestures(&step), + 0, + "a sub-threshold vertical delta must reach the panel as \ + nothing at all" + ); + // Horizontal, before the vertical bank is completed: if the two + // axes shared one accumulator, this 0.6 would finish the + // vertical 0.6 and fire here. + let step = wheel(0.6, 0.0); + assert_eq!( + gestures(&step), + 0, + "and a sub-threshold horizontal delta must not be completed \ + by the vertical one banked before it" + ); + + // Completing each axis produces exactly one gesture, not two, + // and not the sum of everything banked so far. + let step = wheel(0.0, 0.6); + assert_eq!( + gestures(&step), + 1, + "0.6 + 0.6 is one vertical notch, and exactly one" + ); + let step = wheel(0.6, 0.0); + assert_eq!( + gestures(&step), + 1, + "and the horizontal axis completes on its own count" + ); + } + /// A2B-4 (contrast assertion) — installing a panel moves every /// document-owned boundary by exactly the band's pixel height while /// every status-owned boundary stays pixel-identical. From e5ab16cf122f3bdb904b7463dd9b7c47a706f315 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 10:44:31 +0200 Subject: [PATCH 58/72] test(gui-1b): R4 and R5 --- residuals do not survive a replacement The two clears beside code_scroll_left have had a unit row asserting they are distinct operations, with that row saying plainly it was not R4/R5's witness. These are: a sub-tick banked over the predecessor, the buffer replaced through the production BufferSnapshot receiver, and a sub-tick over the successor reaching nothing. Left standing, a bank completes in the successor a notch the user began in its predecessor --- a jump with nothing on screen to explain it. R4 covers the document's bank and, with it, chrome's: chrome's owner IS the document's, so one reset serves both. It also completes the successor's own 0.6 + 0.6 afterwards, so it cannot pass by having broken accumulation outright rather than by having reset it. R5 covers the minimap's, which B6 made independent. One clear omitted at a time, as the framing requires: dropping clear_document fires R4 alone and dropping clear_minimap fires R5 alone, so neither field hides behind the other's reset. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 320; git diff --check. --- pmacs-gpu/src/main.rs | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index cea0e0f..48ff836 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5127,6 +5127,121 @@ mod input_routing_tests { ); } + /// Replace the harness's document with a fresh buffer, through the + /// production `BufferSnapshot` receiver. + fn replace_the_buffer(h: &mut EffectHarness) { + let text = "line\n".repeat(200); + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, &text) + .expect("insert snapshot text"); + let state = h.app.state.as_mut().expect("harness state"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: BufferId::next(), + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), + }); + } + + /// R4 — **the document's residual does not survive a buffer + /// replacement.** + /// + /// The bank is viewport state about the document being shown. Left + /// standing across a replacement it completes a notch in the + /// successor that the user began in its predecessor — a jump with + /// nothing on screen to explain it. The chrome residual is the same + /// bank (chrome's owner IS the document's), so one reset serves + /// both. + /// + /// *Mutation: omit `clear_document()` from the snapshot arm → this + /// row.* + #[test] + fn r4_a_buffer_replacement_drops_the_documents_wheel_residual() { + let mut h = EffectHarness::new(); + let document = document_probe(&h); + move_pointer(&mut h, document); + assert_eq!( + h.app.classify_wheel_target(document.0, document.1), + WheelTarget::Document, + "setup: document text" + ); + + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty(), + "setup: 0.6 of a notch banks and does nothing yet: {:?}", + step.local + ); + + replace_the_buffer(&mut h); + move_pointer(&mut h, document); + + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty(), + "the successor starts from zero: a notch begun in the \ + previous document must not complete in this one, got {:?}", + step.local + ); + + // And the successor's own bank still works, so the row is not + // passing by having broken accumulation outright. + let step = h.feed(&wheel(0.0, 0.6)); + assert_eq!( + step.local, + vec![LocalEffect::Scroll { + top: WHEEL_LINES_PER_TICK as usize + }], + "0.6 + 0.6 within the successor is one notch" + ); + } + + /// R5 — **the minimap's residual is dropped by the same + /// replacement, and by its own clear.** + /// + /// B6 gives the minimap an accumulator independent of the + /// document's, so it needs its own reset. Two clears rather than + /// one combined call, deliberately: a single "forgot to reset" + /// would bite both legs and prove neither field is individually + /// covered. + /// + /// *Mutation: omit `clear_minimap()` from the snapshot arm → this + /// row, and not R4.* + #[test] + fn r5_a_buffer_replacement_drops_the_minimaps_wheel_residual() { + let mut h = EffectHarness::new(); + let minimap = minimap_probe(&h); + move_pointer(&mut h, minimap); + assert_eq!( + h.app.classify_wheel_target(minimap.0, minimap.1), + WheelTarget::Minimap, + "setup: the minimap band" + ); + + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty(), + "setup: 0.6 banks and does nothing yet: {:?}", + step.local + ); + + replace_the_buffer(&mut h); + let minimap = minimap_probe(&h); + move_pointer(&mut h, minimap); + assert_eq!( + h.app.classify_wheel_target(minimap.0, minimap.1), + WheelTarget::Minimap, + "setup: still the minimap after the replacement" + ); + + let step = h.feed(&wheel(0.0, 0.6)); + assert!( + step.local.is_empty(), + "the minimap's bank starts from zero in the successor too, \ + got {:?}", + step.local + ); + } + /// B6 — the minimap banks into **its own** accumulator, so a /// part-notch over it cannot complete a notch over the document. /// From 241e82e30edd44e36bce142f1b562910b43fc74c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 13:15:05 +0200 Subject: [PATCH 59/72] feat(gui-1b): B1's disposal half --- a bank does not outlive its panel Identity keying answers panel A versus panel B on its own: the bank is keyed by BufferId, so a different buffer starts from zero for free. It cannot answer close-and-reopen of the SAME persistent buffer. The successor carries the same id, so nothing about the key distinguishes it from the panel the user was actually scrolling, and a notch begun in a panel that no longer exists completes in its replacement. The Absent arm already resets nine pieces of panel state one line at a time --- frame, plan, drag, hover, pointer_held, last cell, gesture cell, generation. The wheel residual was missing from that list, the same shape of omission as the horizontal origin missing from the TUI's four replacement resets. The row closes the panel and reopens it at panel_epoch 2 on the same buffer, which is precisely the case buffer_id cannot see, and asserts the reopened panel's first sub-tick reaches nothing. Dropping clear_panels() from the Absent arm fires it and nothing else. This closes the last of B1's owed evidence. Gates: fmt; clippy --workspace --all-targets -D warnings (which caught a needless mut); pmacs-gpu 321; --lib 2009; git diff --check. --- pmacs-gpu/src/main.rs | 164 +++++++++++++++++++++++++++++++++++------- 1 file changed, 138 insertions(+), 26 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 48ff836..2a686d8 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8242,6 +8242,14 @@ impl State { self.panel.last_pointer_cell = None; self.panel.gesture_last_content_cell = None; self.panel.last_pointer_generation = None; + // B1's disposal half: a residual banked against a + // surface that no longer exists must go with it. The + // bank is keyed by `BufferId`, which distinguishes + // panel A from panel B — but not a panel closed and + // REOPENED on the same persistent buffer, where the + // successor would inherit a notch the user began in a + // panel that is gone. + self.wheel_residuals.clear_panels(); had } // §5b G8b — a mapped session REJECTS the legacy family @@ -21966,6 +21974,35 @@ mod tests { frame } + /// Bring an `EffectHarness` to a presented panel at `panel_epoch`. + /// + /// Two harness facts this works around, both asserted rather than + /// assumed. It negotiates the **mapped** family, which refuses a + /// frame carrying no mapping generation, so this drops to the + /// legacy wire like every other panel row here. And it has already + /// made its one **surface** declaration, so the re-declaration uses + /// `Metrics`; a second surface declaration is suppressed by design + /// and returns `None`. + fn present_panel_in_harness(h: &mut EffectHarness, panel_epoch: u64) { + { + let state = h.app.state.as_mut().expect("harness state"); + state.set_panel_wire(PANEL_MIN_VERSION); + let (epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("metrics always advance the panel geometry epoch"); + let frame = panel_frame_of(4, total.cols.max(1), epoch, panel_epoch); + let _ = state.apply_attach_message(InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame), + )); + assert!( + state.panel.presented().is_some(), + "setup: the frame must be accepted, or every assertion \ + that follows is about a panel that is not there" + ); + } + let _ = h.read_until_sentinel(); + } + /// Step 3 — **the panel's fractional wheel, end to end, per axis.** /// /// Revision 20 sharpened this witness because its earlier form was @@ -21992,32 +22029,7 @@ mod tests { use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; let mut h = EffectHarness::new(); - // The harness already declared its surface geometry; answer that - // declaration with a frame, through the production receiver. - { - let state = h.app.state.as_mut().expect("harness state"); - // The harness negotiates the MAPPED family, which refuses a - // frame with no retained mapping generation. Drop to the - // legacy wire and re-declare, the way every other panel row - // in this file does, then answer that declaration through - // the production receiver. - state.set_panel_wire(PANEL_MIN_VERSION); - // Metrics, not Surface: the harness already made its one - // surface declaration, and a second is suppressed by design. - let (epoch, total) = state - .next_geometry_declaration(GeometryTrigger::Metrics) - .expect("metrics always advance the panel geometry epoch"); - let frame = panel_frame_of(4, total.cols.max(1), epoch, 1); - let _ = state.apply_attach_message(InstanceMessage::PanelFrame( - PanelFramePayload::Present(frame), - )); - assert!( - state.panel.presented().is_some(), - "setup: the frame must be accepted, or every assertion \ - below is about a panel that is not there" - ); - } - let _ = h.read_until_sentinel(); + present_panel_in_harness(&mut h, 1); // A pixel inside the panel's content, so the wheel target is a // cell rather than the band's chrome. @@ -22098,6 +22110,106 @@ mod tests { ); } + /// B1's **disposal half** — a residual banked against a surface that + /// goes away does not outlive it. + /// + /// Identity keying answers panel A versus panel B: the bank is keyed + /// by `BufferId`, so a different buffer starts from zero for free. + /// **It cannot answer close-and-reopen of the same persistent + /// buffer**, which is the case this row builds — the successor + /// carries the same id, so nothing about the key distinguishes it + /// from the panel the user was actually scrolling. Without an + /// explicit discard, a notch begun in a panel that no longer exists + /// completes in its replacement. + /// + /// The `Absent` arm already resets nine pieces of panel state one + /// line at a time; the wheel residual was missing from that list, + /// exactly as the horizontal origin was missing from the TUI's + /// replacement resets. + /// + /// *Mutation: drop `clear_panels()` from the `Absent` arm → this + /// row.* + #[test] + fn b1_a_panel_that_goes_away_takes_its_wheel_residual_with_it() { + use winit::dpi::PhysicalPosition; + use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + + let mut h = EffectHarness::new(); + present_panel_in_harness(&mut h, 1); + + let cell_probe = |h: &EffectHarness| { + let (px, py, _, ph) = h + .app + .state + .as_ref() + .expect("harness state") + .panel_content_rect() + .expect("the panel is presented"); + (f64::from(px + 4.0), f64::from(py + ph / 2.0)) + }; + let point_and_bank = |h: &mut EffectHarness| { + let point = cell_probe(h); + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(point.0, point.1), + }); + assert!( + matches!( + h.app.classify_wheel_target(point.0, point.1), + WheelTarget::PanelCell { .. } + ), + "setup: the probe must be a panel CELL" + ); + h.feed(&WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, -0.6), + phase: TouchPhase::Moved, + }) + }; + let gestures = |step: &Step| { + step.outbound + .iter() + .filter(|e| { + matches!( + e, + pmacs_protocol::FrontendEvent::PanelPointer { .. } + | pmacs_protocol::FrontendEvent::PanelPointerMapped { .. } + ) + }) + .count() + }; + + let step = point_and_bank(&mut h); + assert_eq!( + gestures(&step), + 0, + "setup: 0.6 banks against this panel and sends nothing" + ); + + // The panel closes, then reopens on the SAME buffer — a new + // presentation of a persistent buffer, which `panel_epoch` + // distinguishes and `buffer_id` cannot. + { + let state = h.app.state.as_mut().expect("harness state"); + let _ = + state.apply_attach_message(InstanceMessage::PanelFrame(PanelFramePayload::Absent)); + assert!( + state.panel.presented().is_none(), + "setup: the panel is gone" + ); + } + let _ = h.read_until_sentinel(); + present_panel_in_harness(&mut h, 2); + + let step = point_and_bank(&mut h); + assert_eq!( + gestures(&step), + 0, + "the reopened panel starts from zero: a notch begun in the \ + panel that closed must not complete in this one" + ); + } + /// A2B-4 (contrast assertion) — installing a panel moves every /// document-owned boundary by exactly the band's pixel height while /// every status-owned boundary stays pixel-identical. From 45775c06915634b9fa32f2a197080034f89c3d83 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 13:16:10 +0200 Subject: [PATCH 60/72] docs(lane): 1b's implementation and evidence are complete Both owed lists are now empty. Nothing is landed-but-unwitnessed, and nothing is owed outright; the next step is the full pre-PR gate. The three that closed are recorded with what each actually required, because two of them were not simply "write the row": Step 3's witness needed the ORDER to be the argument --- 0.6 vertical then 0.6 horizontal, both reaching nothing --- since a shared accumulator would have the second complete the first. B1's disposal half needed mechanism. BufferId keying distinguishes panel A from panel B for free but cannot see a panel closed and reopened on the same persistent buffer, and the Absent arm reset nine pieces of panel state one line at a time with the wheel residual missing from the list. That is the third time this lane has found the same omission shape: a hand-maintained reset list with one field left off it. --- docs/active-work.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c89c01f..d0279f8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -319,7 +319,7 @@ Framing this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and the both-axis effect witness still owed. -**Landed so far** (latest verified code head `7f7f156`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `241e82e`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -488,19 +488,31 @@ that full `reshape` clears, so it positively proves the incremental branch ran; unchanged line count alone would only make that branch eligible, not establish that `try_reshape_line` succeeded. -**Landed but NOT yet witnessed:** +**Nothing is landed-but-unwitnessed on this lane any more.** R4 and R5 +now have their replacement rows (`e5ab16c`), one clear omitted at a +time so neither field hides behind the other's reset; the wrap and +buffer-replacement clearing that stood here is covered by L8 and +L8b–L8e, and that bullet's GPU half described the latch that no longer +exists. -- **R4 and R5's residual resets**, as two separate clears beside - `code_scroll_left` so omitting either is individually visible. +**Owed outright: nothing. The implementation and its evidence are +complete**, and the lane's next step is the full pre-PR gate. -(The wrap and buffer-replacement clearing that stood here is done: the -TUI's four replacement paths carry L8b–L8e, wrap carries L8, and the -GPU half of that bullet described the latch that no longer exists.) +The last three closed in order: -**Owed outright:** step 3's fractional both-axis panel witness, R4/R5's -own replacement witnesses, and **B1's disposal half** — a residual keyed to a surface that goes -away must go with it, and this frontend does not yet track "that buffer -is gone". +- **step 3's fractional both-axis panel witness** (`302ce14`) — 0.6 + vertical, then 0.6 horizontal, both reaching nothing, because a + single accumulator fed by both axes would have the second complete + the first. Rounding instead of banking fires the sub-threshold legs; + collapsing the axes fires the cross-axis one. +- **R4 and R5** (`e5ab16c`). +- **B1's disposal half** (`241e82e`) — and it needed mechanism, not + just a row. `BufferId` keying distinguishes panel A from panel B for + free, but **not a panel closed and reopened on the same persistent + buffer**, where the successor carries the same key. The `Absent` arm + reset nine pieces of panel state one line at a time with the wheel + residual missing from the list — the same omission shape as the + horizontal origin missing from the TUI's four replacement resets. **B6 (`2dccc2b`, tightened in `f441d3d`)** is three rows on `EffectHarness`, the first production-path coverage `apply_wheel` has From a7006faf475a59c60bdac8fbfbf4e1d0f74fdf29 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 13:37:33 +0200 Subject: [PATCH 61/72] fix(gui-1b): terminal disposal, and two rows that a broken bank passed Three residuals from review, all of the same family --- a claim that looked covered and was not. B1's disposal was implemented for panels only. Terminal(BufferId) is a residual owner too, and exit_terminal_mode dropped four terminal-only caches without dropping the bank, so leaving a terminal and re-entering the SAME one inherited it: the key is the buffer id and it has not changed. clear_terminals() now runs at that teardown, with a row that enters, banks 0.6, leaves, re-enters the same buffer, and finds nothing waiting. The type's own doc still said disposal was unimplemented and owed; it now says where both halves live. R5 and the panel-disposal row stopped after the successor's first 0.6 produced nothing. Unlike R4, neither completed the successor's own 0.6 + 0.6 --- so both passed just as well against an accumulator that banks nothing at all, which is exactly the state they exist to rule out. Both now carry the completion leg and assert the exact expected effect. Demonstrated rather than asserted: with accumulation broken outright (bank = delta instead of bank += delta) those two rows now fail, and before this change they did not. The Absent arm resets EIGHT fields before the new clear, not nine. Both places that said nine are corrected. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 322; --lib 2009; git diff --check. --- docs/active-work.md | 9 ++- pmacs-gpu/src/main.rs | 169 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index d0279f8..c59ab71 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -315,9 +315,12 @@ before the rewrite, not after, so nothing depended on a local-only ref surviving. Framing -`docs/gui-stage1-input-framing.md`, **revision 20** — §2a re-measured at +`docs/gui-stage1-input-framing.md`, **revision 21** — §2a re-measured at this base, the panel-replay prerequisite recorded as DISCHARGED by #243, -and the both-axis effect witness still owed. +and (revision 21) the GPU's authority recorded as **structural**, its +lifetime tables made frontend-specific. **The both-axis effect witness +is no longer owed**; see the completion note below, which is the +authority on what this lane still has outstanding. **Landed so far** (latest verified code head `241e82e`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, @@ -510,7 +513,7 @@ The last three closed in order: just a row. `BufferId` keying distinguishes panel A from panel B for free, but **not a panel closed and reopened on the same persistent buffer**, where the successor carries the same key. The `Absent` arm - reset nine pieces of panel state one line at a time with the wheel + reset eight pieces of panel state one line at a time with the wheel residual missing from the list — the same omission shape as the horizontal origin missing from the TUI's four replacement resets. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 2a686d8..d77e9bd 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2204,11 +2204,12 @@ impl WheelTarget { /// away must go away with it, or it is spent on whatever later takes /// that identity. /// -/// **Disposal is NOT implemented yet, and is owed before this slice -/// ships.** It needs a signal for "this panel/terminal buffer is gone", -/// which this frontend does not currently track, and it needs its own -/// witness. It is recorded here rather than stubbed, because a helper -/// nothing calls reads as a contract met. +/// **Disposal is implemented at both teardowns**, because the key +/// alone cannot see a surface closed and REOPENED on the same buffer: +/// the successor carries the same `BufferId`, so nothing distinguishes +/// it from the surface the user was actually scrolling. +/// `PanelFramePayload::Absent` clears the panel banks and +/// `exit_terminal_mode` clears the terminal ones. #[derive(Debug, Default)] struct WheelResiduals { /// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`. @@ -2245,12 +2246,22 @@ impl WheelResiduals { } /// Drop every panel bank. Panel chrome consumes both axes and must - /// leave nothing that could combine with cell input later. + /// leave nothing that could combine with cell input later, and a + /// panel that goes away takes its bank with it (B1's disposal). fn clear_panels(&mut self) { self.banks .retain(|owner, _| !matches!(owner, ResidualOwner::Panel(_))); } + /// Drop every terminal bank — the same disposal, for the surface + /// with the same problem. `Terminal(BufferId)` distinguishes + /// terminal A from terminal B, but not leaving a terminal and + /// re-entering **the same** one, where the bank's key is unchanged. + fn clear_terminals(&mut self) { + self.banks + .retain(|owner, _| !matches!(owner, ResidualOwner::Terminal(_))); + } + #[cfg(test)] fn bank_of(&self, owner: ResidualOwner) -> Option<(f32, f32)> { self.banks.get(&owner).copied() @@ -5240,6 +5251,19 @@ mod input_routing_tests { got {:?}", step.local ); + + // And the successor's minimap bank still accumulates, so this + // row cannot pass by the accumulator simply being broken — + // which every "nothing happened" assertion above would accept. + let step = h.feed(&wheel(0.0, 0.6)); + assert_eq!( + step.local, + vec![LocalEffect::Scroll { + top: WHEEL_LINES_PER_TICK as usize + }], + "0.6 + 0.6 over the successor's minimap is one notch of \ + DOCUMENT scroll, which is what a minimap wheel moves" + ); } /// B6 — the minimap banks into **its own** accumulator, so a @@ -7911,6 +7935,10 @@ impl State { self.terminal_frame_error_latched = false; self.last_terminal_size_sent = None; self.last_terminal_pointer_cell = None; + // B1's disposal, terminal half. Re-entering the SAME terminal + // buffer would otherwise inherit the bank, because the owner + // key is the buffer id and it has not changed. + self.wheel_residuals.clear_terminals(); } /// Drop the band and every cache behind it. @@ -22122,7 +22150,7 @@ mod tests { /// explicit discard, a notch begun in a panel that no longer exists /// completes in its replacement. /// - /// The `Absent` arm already resets nine pieces of panel state one + /// The `Absent` arm already resets eight pieces of panel state one /// line at a time; the wheel residual was missing from that list, /// exactly as the horizontal origin was missing from the TUI's /// replacement resets. @@ -22208,6 +22236,133 @@ mod tests { "the reopened panel starts from zero: a notch begun in the \ panel that closed must not complete in this one" ); + + // And the reopened panel's own bank still accumulates. Without + // this leg the row passes just as well against an accumulator + // that banks nothing at all, which is the state it exists to + // rule out. + let step = point_and_bank(&mut h); + assert_eq!( + gestures(&step), + 1, + "0.6 + 0.6 within the reopened panel is one gesture, and \ + exactly one: {:?}", + step.outbound + ); + assert!( + matches!( + step.outbound.last(), + Some(pmacs_protocol::FrontendEvent::PanelPointer { + kind: pmacs_protocol::MouseKind::ScrollDown, + .. + }) + ), + "and it is a downward panel scroll: {:?}", + step.outbound + ); + } + + /// B1's disposal half, **terminal side** — leaving a terminal and + /// re-entering **the same** one starts from zero. + /// + /// `Terminal(BufferId)` distinguishes terminal A from terminal B + /// for free. It cannot distinguish leave-and-re-enter of one + /// terminal, because the key does not change — so without an + /// explicit discard at teardown, a notch begun before leaving + /// completes on the way back in. + /// + /// `exit_terminal_mode` already dropped four terminal-only caches; + /// the wheel residual was missing from that list, exactly as it was + /// from the panel's `Absent` arm. + /// + /// *Mutation: drop `clear_terminals()` from `exit_terminal_mode` → + /// this row.* + #[test] + fn b1_a_terminal_that_is_left_takes_its_wheel_residual_with_it() { + use winit::dpi::PhysicalPosition; + use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + + let mut h = EffectHarness::new(); + let terminal_buffer = BufferId::from_raw(91); + // A frame for a buffer this window is not showing is ignored, so + // the window has to be on the terminal's buffer first. + h.app + .state + .as_mut() + .expect("harness state") + .current_buffer_id = Some(terminal_buffer); + let enter = |h: &mut EffectHarness| { + let state = h.app.state.as_mut().expect("harness state"); + state.apply_terminal_frame(plain_terminal_frame(terminal_buffer, "hello", 40)); + assert!( + state.terminal.is_some(), + "setup: the frame must put this frontend in terminal mode" + ); + }; + let point = (f64::from(TEXT_LEFT + 8.0), f64::from(TEXT_TOP + 4.0)); + let bank = |h: &mut EffectHarness| { + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(point.0, point.1), + }); + assert!( + matches!( + h.app.classify_wheel_target(point.0, point.1), + WheelTarget::Terminal { .. } + ), + "setup: the probe must resolve to the TERMINAL, not the \ + document underneath it" + ); + h.feed(&WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, -0.6), + phase: TouchPhase::Moved, + }) + }; + + enter(&mut h); + let step = bank(&mut h); + assert!( + step.outbound.is_empty(), + "setup: 0.6 banks against this terminal and sends nothing, \ + got {:?}", + step.outbound + ); + + { + let state = h.app.state.as_mut().expect("harness state"); + state.exit_terminal_mode(); + } + let _ = h.read_until_sentinel(); + enter(&mut h); + + let step = bank(&mut h); + assert!( + step.outbound.is_empty(), + "the re-entered terminal starts from zero: a notch begun \ + before leaving must not complete on the way back in, got \ + {:?}", + step.outbound + ); + + // And the re-entered terminal's own bank still works, so this + // row cannot pass by having broken accumulation outright. + let step = bank(&mut h); + assert_eq!( + step.outbound.len(), + 1, + "0.6 + 0.6 within the re-entered terminal is one notch, and \ + exactly one: {:?}", + step.outbound + ); + assert!( + matches!( + step.outbound[0], + pmacs_protocol::FrontendEvent::TerminalPointer { .. } + ), + "and it reaches the terminal, not something else: {:?}", + step.outbound + ); } /// A2B-4 (contrast assertion) — installing a panel moves every From e50f38ae205cf065a73fb5daac3b9ae04c131e51 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 13:47:18 +0200 Subject: [PATCH 62/72] test(gui-1b): step 3 witnesses the panel viewport, not the emitted event The framing says it in as many words: "Not 'a PanelPointer was emitted' --- the observable effect on the panel's viewport." The row I wrote filtered and counted panel-pointer events, which is the blind spot the framing exists to close rather than the defect it guards against. It would have passed if the vertical axis emitted a horizontal gesture, if the receiver dropped what arrived, or if some other event accompanied a sub-threshold delta. Both halves now run in one row. The PRODUCER is this frontend's apply_wheel, reached through dispatch_window_event. The RECEIVER is a real pmacs::editor::EditorState with a live panel window, driven through classify_panel_pointer + apply_panel_pointer --- the pair the daemon itself calls --- and the assertion is the panel window's (view_top, view_left). Per axis: a sub-threshold delta puts nothing on the wire and moves the viewport by nothing; the delta that completes the notch moves it by exactly one step, on that axis and not the other. That needs the editor crate, so pmacs-gpu gains a DEV-dependency on pmacs --- test-only, never in the shipped graph --- and pmacs gains three #[doc(hidden)] test-support methods beside the ones already there: install_panel_view_for_test (which daemon.rs's own semantic_panel_view now delegates to, so there is one fixture rather than two), seed_window_buffer_for_test, and window_view_origin_for_test. Three things the row failed on before passing, each now a named setup fact rather than a silent dependency. The panel buffer starts empty and scroll_window clamps to line_count - 1, so an unseeded panel cannot scroll at all. Seeding it is not enough either: TextView caches the line partition it was built with, so the window has to be handed a rebuilt view. And the receiver re-derives the panel grid from an accepted geometry declaration --- without one every coordinate is outside a grid that does not exist and the gesture is Refused before it can do anything. The mutation that matters is the one no emission count could see: dropping PKind::ScrollLeft/ScrollRight from the daemon's panel arm --- the receiver half, the axis whose arm did not exist before B2 --- fires this row. So do rounding instead of banking, and collapsing the two axes into one accumulator. Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 322; --lib 2009; --lib --features crdt 2202; git diff --check. --- Cargo.lock | 1 + pmacs-gpu/Cargo.toml | 5 + pmacs-gpu/src/main.rs | 259 ++++++++++++++++++++++++++++++------------ src/daemon.rs | 55 +-------- src/editor.rs | 116 +++++++++++++++++++ 5 files changed, 311 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3a5447..08d3ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "env_logger", "glyphon", "loro", + "pmacs", "pmacs-protocol", "pollster", "sys-locale", diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 53f6618..08399ca 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -70,3 +70,8 @@ unicode-width = "0.2" [dev-dependencies] tempfile = "3" +# TEST-ONLY, and never a runtime dependency: step 3's panel-wheel +# witness has to observe the RECEIVER's effect, not the event this +# frontend emits, because the defect it exists for is precisely +# "the frontend emits and the receiver discards". +pmacs = { path = ".." } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index d77e9bd..7e2d2a1 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -21972,6 +21972,18 @@ mod tests { // Bottom panel Stage 2B-3 — the GPU band // =================================================================== + fn panel_frame_of_buffer( + buffer_id: BufferId, + rows: u32, + cols: u32, + geometry_epoch: u64, + panel_epoch: u64, + ) -> PanelFrame { + let mut frame = panel_frame_of(rows, cols, geometry_epoch, panel_epoch); + frame.buffer_id = buffer_id; + frame + } + fn panel_frame_of(rows: u32, cols: u32, geometry_epoch: u64, panel_epoch: u64) -> PanelFrame { let cells = (0..(rows as usize * cols as usize)) .map(|_| terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default())) @@ -22012,13 +22024,17 @@ mod tests { /// `Metrics`; a second surface declaration is suppressed by design /// and returns `None`. fn present_panel_in_harness(h: &mut EffectHarness, panel_epoch: u64) { + present_panel_of_buffer(h, BufferId::from_raw(77), panel_epoch); + } + + fn present_panel_of_buffer(h: &mut EffectHarness, buffer_id: BufferId, panel_epoch: u64) { { let state = h.app.state.as_mut().expect("harness state"); state.set_panel_wire(PANEL_MIN_VERSION); let (epoch, total) = state .next_geometry_declaration(GeometryTrigger::Metrics) .expect("metrics always advance the panel geometry epoch"); - let frame = panel_frame_of(4, total.cols.max(1), epoch, panel_epoch); + let frame = panel_frame_of_buffer(buffer_id, 4, total.cols.max(1), epoch, panel_epoch); let _ = state.apply_attach_message(InstanceMessage::PanelFrame( PanelFramePayload::Present(frame), )); @@ -22031,36 +22047,51 @@ mod tests { let _ = h.read_until_sentinel(); } - /// Step 3 — **the panel's fractional wheel, end to end, per axis.** + /// The RECEIVER half of step 3: a real editor with a bottom panel, + /// seeded with something to scroll in both directions, and with a + /// frame geometry accepted. /// - /// Revision 20 sharpened this witness because its earlier form was - /// satisfiable with the mechanism it protects entirely broken: a - /// whole tick passes straight through #243's vertical receiver even - /// if B1's accumulator discards every sub-tick it is given. So the - /// row requires **fractional input** — a first sub-threshold delta - /// produces **no** gesture, and accumulated same-panel deltas - /// produce **exactly one** — and it requires that on **each axis** - /// separately, because a single accumulator fed by both axes passes - /// any one-axis row. + /// Two things here are load-bearing, and each was found by the row + /// failing without it. The panel buffer starts **empty**, and + /// `scroll_window` clamps to `line_count - 1`, so an unseeded panel + /// cannot scroll at all. And the receiver re-derives the panel grid + /// from an accepted geometry declaration — without one, every + /// coordinate is outside a grid that does not exist and the gesture + /// is `Refused` before it can have any effect. + fn panel_receiver() -> ( + pmacs::editor::EditorState, + pmacs::protocol::FrontendId, + pmacs::window::WindowId, + BufferId, + ) { + let editor = pmacs::editor::EditorState::new(); + let fid = pmacs::protocol::FrontendId(4242); + let (_document, panel) = editor.install_panel_view_for_test(fid, true); + let panel = panel.expect("the fixture installs a panel window"); + let wide_line = "w".repeat(400); + editor.seed_window_buffer_for_test(panel, &format!("{wide_line}\n").repeat(50)); + let panel_buffer = editor + .window_buffer_for_test(panel) + .expect("the panel window has a buffer"); + let _ = + editor.accept_semantic_frame_geometry(fid, 1, pmacs_protocol::CellSize::new(24, 80)); + (editor, fid, panel, panel_buffer) + } + + /// The PRODUCER half of step 3: this frontend, presenting a panel + /// for the very buffer the receiver is showing — so the gestures it + /// emits are about the same window the assertions read — with the + /// pointer parked on a panel **cell**. /// - /// Driven through `dispatch_window_event` and observed on the wire, - /// which is where a panel gesture actually goes. - /// - /// *Mutations: round the notch instead of banking it → the - /// sub-threshold legs (a 0.6 delta becomes a whole tick); bank into - /// one accumulator per surface instead of per (surface, axis) → the - /// second axis's first leg, which the first axis's leftover - /// completes.* - #[test] - fn step3_a_panel_wheel_needs_a_whole_notch_on_each_axis() { + /// Panel chrome banks nothing at all, and a probe that drifted onto + /// it would satisfy every "nothing moved" assertion for entirely + /// the wrong reason, so the target is asserted here. + fn panel_producer(panel_buffer: BufferId) -> EffectHarness { use winit::dpi::PhysicalPosition; - use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + use winit::event::DeviceId; let mut h = EffectHarness::new(); - present_panel_in_harness(&mut h, 1); - - // A pixel inside the panel's content, so the wheel target is a - // cell rather than the band's chrome. + present_panel_of_buffer(&mut h, panel_buffer, 1); let (px, py, _, ph) = h .app .state @@ -22078,63 +22109,145 @@ mod tests { h.app.classify_wheel_target(point.0, point.1), WheelTarget::PanelCell { .. } ), - "setup: the probe must be a panel CELL — panel chrome banks \ - nothing at all, and would satisfy every 'no gesture' \ - assertion below for the wrong reason" + "setup: the probe must be a panel CELL" ); + h + } - let mut wheel = |dx: f32, dy: f32| { - h.feed(&WindowEvent::MouseWheel { - device_id: DeviceId::dummy(), - delta: MouseScrollDelta::LineDelta(dx, -dy), - phase: TouchPhase::Moved, - }) + /// Step 3 — **the panel wheel's END-TO-END effect, on both axes, + /// driven by fractional input.** + /// + /// The framing is explicit that an emission-only witness will not + /// do: *"Not 'a `PanelPointer` was emitted' — the observable effect + /// on the panel's viewport."* The defect it guards is exactly "the + /// frontend emits and the receiver discards", and a row that counts + /// emitted events reproduces that blind spot rather than catching + /// it — it would pass if the vertical axis emitted a horizontal + /// gesture, if the receiver dropped it, or if some other event + /// accompanied a sub-threshold delta. + /// + /// So this row runs both halves. The **producer** is this + /// frontend's `apply_wheel`, reached through + /// `dispatch_window_event`. The **receiver** is a real + /// `pmacs::editor::EditorState` with a live panel window, driven + /// through `classify_panel_pointer` + `apply_panel_pointer`, the + /// pair the daemon itself calls. The assertion is the panel + /// window's `(view_top, view_left)`. + /// + /// Per axis: a first sub-threshold delta moves the viewport by + /// **nothing** and puts **nothing** on the wire, and the delta that + /// completes the notch moves it by **exactly one step** — once, not + /// twice, and not the sum of everything banked. + /// + /// *Mutations: round the notch instead of banking it → the + /// sub-threshold legs; bank into one accumulator per surface rather + /// than per (surface, axis) → the second axis's first leg, which + /// the first axis's leftover completes; drop `PKind::ScrollLeft` / + /// `ScrollRight` from the daemon's panel arm → the horizontal + /// completion, which no emission count can see.* + #[test] + fn step3_a_panel_wheel_moves_the_panel_viewport_once_per_notch_per_axis() { + use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + + let (mut editor, fid, panel, panel_buffer) = panel_receiver(); + let origin = |editor: &pmacs::editor::EditorState| { + editor + .window_view_origin_for_test(panel) + .expect("the panel window is live") }; - let gestures = |step: &Step| { + assert_eq!(origin(&editor), (0, 0), "setup: the panel starts home"); + + let mut h = panel_producer(panel_buffer); + + // One turn of the wheel, carried all the way through: the + // frontend's events are replayed into the editor exactly as the + // daemon replays them. + let turn = + |h: &mut EffectHarness, editor: &mut pmacs::editor::EditorState, dx: f32, dy: f32| { + let step = h.feed(&WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(dx, -dy), + phase: TouchPhase::Moved, + }); + let mut replayed = 0usize; + for event in &step.outbound { + if let pmacs_protocol::FrontendEvent::PanelPointer { + buffer_id, + coord, + kind, + mods, + .. + } = event + { + let disposition = + editor.classify_panel_pointer(fid, *buffer_id, *coord, *kind); + editor.apply_panel_pointer(fid, &disposition, *coord, *kind, *mods); + replayed += 1; + } + } + (step, replayed) + }; + + // Vertical, sub-threshold: nothing on the wire, nothing moves. + let (step, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); + assert!( + step.outbound.is_empty(), + "a sub-threshold vertical delta must put NOTHING on the \ + wire, got {:?}", step.outbound - .iter() - .filter(|e| { - matches!( - e, - pmacs_protocol::FrontendEvent::PanelPointer { .. } - | pmacs_protocol::FrontendEvent::PanelPointerMapped { .. } - ) - }) - .count() - }; - - // Vertical: 0.6 of a notch is not a notch. - let step = wheel(0.0, 0.6); - assert_eq!( - gestures(&step), - 0, - "a sub-threshold vertical delta must reach the panel as \ - nothing at all" ); - // Horizontal, before the vertical bank is completed: if the two - // axes shared one accumulator, this 0.6 would finish the - // vertical 0.6 and fire here. - let step = wheel(0.6, 0.0); + assert_eq!(replayed, 0); assert_eq!( - gestures(&step), - 0, - "and a sub-threshold horizontal delta must not be completed \ - by the vertical one banked before it" + origin(&editor), + (0, 0), + "and the panel viewport must not move" ); - // Completing each axis produces exactly one gesture, not two, - // and not the sum of everything banked so far. - let step = wheel(0.0, 0.6); - assert_eq!( - gestures(&step), - 1, - "0.6 + 0.6 is one vertical notch, and exactly one" + // Horizontal, sub-threshold, with the vertical bank still + // standing: one accumulator fed by both axes would complete + // here and scroll. + let (step, _) = turn(&mut h, &mut editor, 0.6, 0.0); + assert!( + step.outbound.is_empty(), + "a sub-threshold horizontal delta must not be completed by \ + the vertical one banked before it, got {:?}", + step.outbound ); - let step = wheel(0.6, 0.0); assert_eq!( - gestures(&step), - 1, - "and the horizontal axis completes on its own count" + origin(&editor), + (0, 0), + "and still nothing has moved on either axis" + ); + + // Completing the vertical notch: the viewport moves ONE step + // down, and the horizontal origin stays put. + let (_, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); + assert_eq!(replayed, 1, "one notch is one gesture"); + let after_vertical = origin(&editor); + assert!( + after_vertical.0 > 0, + "the completed vertical notch must scroll the panel" + ); + assert_eq!( + after_vertical.1, 0, + "and must not move it sideways: a vertical notch that \ + emitted a horizontal gesture would show up exactly here" + ); + + // Completing the horizontal notch: sideways this time, and the + // vertical origin does not move again. + let (_, replayed) = turn(&mut h, &mut editor, 0.6, 0.0); + assert_eq!(replayed, 1, "one notch is one gesture"); + let after_horizontal = origin(&editor); + assert!( + after_horizontal.1 > 0, + "the completed horizontal notch must scroll the panel \ + sideways — the axis whose receiver arm did not exist before \ + B2, and which no emission count can see" + ); + assert_eq!( + after_horizontal.0, after_vertical.0, + "and must not scroll it vertically a second time" ); } diff --git a/src/daemon.rs b/src/daemon.rs index e95810b..f32ff6c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -6416,58 +6416,9 @@ mod tests { fid: FrontendId, with_panel: bool, ) -> (crate::window::WindowId, Option) { - use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; - - let mut core = editor.core.borrow_mut(); - let doc_buf = core.active_window().buffer_id; - let document = crate::window::WindowId::next(); - let doc_view = { - let reg = core.registry.borrow(); - crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")) - }; - core.windows - .insert(document, Window::new(document, doc_buf, doc_view)); - let panel = with_panel.then(|| { - let panel_buf = core.registry.borrow_mut().create("*panel*"); - let panel_id = crate::window::WindowId::next(); - let panel_view = { - let reg = core.registry.borrow(); - crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")) - }; - let mut window = Window::new(panel_id, panel_buf, panel_view); - let mut params = WindowParams::default(); - params.side = Some(crate::window::Side::Bottom); - params.fixed_rows = Some(4); - window.params = params; - core.windows.insert(panel_id, window); - panel_id - }); - let layout = match panel { - Some(panel) => Layout { - root: LayoutNode::Split { - orientation: Orientation::Horizontal, - children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], - weights: vec![1, 1], - }, - }, - None => Layout::single(document), - }; - core.register_frontend_view( - fid, - FrontendView { - layout, - active: document, - fold_projection: false, - // Stage 2B-2 is dark: production negotiation still sets - // this `false` for every semantic session, so the - // projection is exercised through a test-only view (the - // framing's §7.2.2 posture). - panel_capable: true, - frame_geometry: None, - panel_hidden: false, - }, - ); - (document, panel) + // One fixture, shared with `pmacs-gpu`'s step-3 effect witness, + // which needs a real panel window to observe an effect on. + editor.install_panel_view_for_test(fid, with_panel) } fn session(version: u32, semantic: bool) -> crate::presence::SessionState { diff --git a/src/editor.rs b/src/editor.rs index 56b1f5a..f5f2652 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2956,6 +2956,122 @@ impl EditorState { } } + /// Install a frontend view with a bottom panel, for tests that need + /// a real panel window to observe an effect on. + /// + /// **Test support, not production.** It exists because the panel + /// receiver's effect is only observable against a live side window, + /// and `pmacs-gpu`'s step-3 witness has to see that effect rather + /// than the event it emits — the defect it guards is precisely + /// "the frontend emits and the receiver discards", which an + /// emission-only row reproduces instead of catching. + /// + /// Returns `(document, panel)`. + #[doc(hidden)] + pub fn install_panel_view_for_test( + &self, + frontend_id: FrontendId, + with_panel: bool, + ) -> (WindowId, Option) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let mut core = self.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let document = WindowId::next(); + let doc_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let panel = with_panel.then(|| { + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let panel_id = WindowId::next(); + let panel_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")) + }; + let mut window = Window::new(panel_id, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + window.params = params; + core.windows.insert(panel_id, window); + panel_id + }); + let layout = match panel { + Some(panel) => Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + None => Layout::single(document), + }; + core.register_frontend_view( + frontend_id, + FrontendView { + layout, + active: document, + fold_projection: false, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + } + + /// Replace a window's buffer contents, so a test can give a panel + /// something to scroll. Without it the `*panel*` buffer is empty + /// and every scroll clamps to zero — a viewport row against it + /// would measure nothing. + #[doc(hidden)] + pub fn seed_window_buffer_for_test(&self, win_id: WindowId, text: &str) { + let core = self.core.borrow(); + let Some(buffer_id) = core.windows.get(&win_id).map(|w| w.buffer_id) else { + return; + }; + let registry = core.registry.clone(); + drop(core); + { + let mut reg = registry.borrow_mut(); + if let Ok(buf) = reg.get_mut(buffer_id) { + let _ = buf.set_generated_contents(text.as_bytes()); + } + } + // **And rebuild the window's view.** `TextView` caches the line + // partition it was built with, and `scroll_window` reads its + // `line_count` — so a window left holding the pre-seed view + // clamps every scroll to zero and a viewport row against it + // measures nothing. + let view = { + let reg = registry.borrow(); + reg.get(buffer_id).ok().map(crate::text_view::TextView::new) + }; + if let Some(view) = view + && let Some(window) = self.core.borrow_mut().windows.get_mut(&win_id) + { + window.text_view = view; + } + } + + /// A window's viewport origin, `(view_top, view_left)` — the pair a + /// panel-wheel effect moves. + #[doc(hidden)] + pub fn window_view_origin_for_test(&self, win_id: WindowId) -> Option<(usize, u32)> { + let core = self.core.borrow(); + let window = core.windows.get(&win_id)?; + Some((window.view_top, window.view_left)) + } + + /// The buffer a window is showing. + #[doc(hidden)] + pub fn window_buffer_for_test(&self, win_id: WindowId) -> Option { + Some(self.core.borrow().windows.get(&win_id)?.buffer_id) + } + /// Classify an authenticated panel gesture, WITHOUT applying it /// (Q#BP-R4). /// From a3b0bb8f077ac6e40a6482dd4c3884ccc4dcce89 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 13:47:37 +0200 Subject: [PATCH 63/72] docs(lane): step 3 counted events; record what it counts now The completion note said step 3 was closed. It was closed with an emission-only row, against a framing that rejects emission-only rows in as many words. The entry now says what the first version measured, what replaced it, and the structural cost --- a dev-dependency from pmacs-gpu on pmacs, three doc(hidden) test-support methods, and one shared panel fixture where daemon.rs previously had its own. It also names the mutation that distinguishes the two versions: dropping the daemon's panel horizontal arm. No count of emitted events can see that, which is the whole reason the framing asked for an effect. --- docs/active-work.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c59ab71..5722721 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -322,7 +322,7 @@ lifetime tables made frontend-specific. **The both-axis effect witness is no longer owed**; see the completion note below, which is the authority on what this lane still has outstanding. -**Landed so far** (latest verified code head `241e82e`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `e50f38a`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -503,14 +503,23 @@ complete**, and the lane's next step is the full pre-PR gate. The last three closed in order: -- **step 3's fractional both-axis panel witness** (`302ce14`) — 0.6 - vertical, then 0.6 horizontal, both reaching nothing, because a - single accumulator fed by both axes would have the second complete - the first. Rounding instead of banking fires the sub-threshold legs; - collapsing the axes fires the cross-axis one. +- **step 3's fractional both-axis panel witness** (`302ce14`, rewritten + at `e50f38a`). The first version **counted emitted events**, which is + the blind spot the framing exists to close — it says so directly: + *"Not 'a `PanelPointer` was emitted' — the observable effect on the + panel's viewport."* It now runs both halves in one row: this + frontend's `apply_wheel` as producer, a real `EditorState` with a live + panel window as receiver, and the panel's `(view_top, view_left)` as + the assertion. **`pmacs-gpu` gained a DEV-dependency on `pmacs`** for + it, and `pmacs` three `#[doc(hidden)]` test-support methods; the + daemon's own `semantic_panel_view` delegates to one of them, so there + is a single panel fixture rather than two. The mutation that settles + it is the receiver-side one no emission count could see: dropping + `PKind::ScrollLeft`/`ScrollRight` from the daemon's panel arm fires + this row. - **R4 and R5** (`e5ab16c`). -- **B1's disposal half** (`241e82e`) — and it needed mechanism, not - just a row. `BufferId` keying distinguishes panel A from panel B for +- **B1's disposal half** (`241e82e` for panels, `a7006fa` for + terminals) — and it needed mechanism, not just a row. `BufferId` keying distinguishes panel A from panel B for free, but **not a panel closed and reopened on the same persistent buffer**, where the successor carries the same key. The `Absent` arm reset eight pieces of panel state one line at a time with the wheel From 9ec4ff1f338cbff8e4b7c69e0e6c9bb97c0f1c63 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 14:13:55 +0200 Subject: [PATCH 64/72] test(gui-1b): make the panel effect witness exact Step 3 previously proved that each completed fractional bank emitted one panel event and moved the expected axis, but its positive assertions only required the viewport origin to be greater than zero. A receiver applying two steps therefore passed a witness whose contract says exactly one. Assert the geometry declaration is accepted, require a completely empty Step for each sub-threshold turn, require no frontend-local effect and one correctly directed event for each completion, and pin both resulting viewport origins to the frontend's one-notch constants. Mutation checks: doubling the vertical receiver step fails at (6, 0), doubling the horizontal receiver step fails at (3, 6), and adding a local document scroll beside the panel event fails on the completion transcript. Focused witness green outside the socket-restricted sandbox. Package-wide all-target clippy with -D warnings, cargo fmt --check, and git diff --check are clean. --- pmacs-gpu/src/main.rs | 115 +++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 7e2d2a1..45d75e8 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -22073,8 +22073,11 @@ mod tests { let panel_buffer = editor .window_buffer_for_test(panel) .expect("the panel window has a buffer"); - let _ = - editor.accept_semantic_frame_geometry(fid, 1, pmacs_protocol::CellSize::new(24, 80)); + assert_eq!( + editor.accept_semantic_frame_geometry(fid, 1, pmacs_protocol::CellSize::new(24, 80),), + pmacs::editor_core::GeometryUpdate::Advanced, + "setup: the receiver must accept the geometry declaration" + ); (editor, fid, panel, panel_buffer) } @@ -22144,8 +22147,15 @@ mod tests { /// than per (surface, axis) → the second axis's first leg, which /// the first axis's leftover completes; drop `PKind::ScrollLeft` / /// `ScrollRight` from the daemon's panel arm → the horizontal - /// completion, which no emission count can see.* + /// completion, which no emission count can see; double either + /// receiver step → that axis's exact-origin assertion; add a + /// frontend-local document scroll beside the panel event → the + /// completion transcript.* #[test] + #[allow( + clippy::too_many_lines, + reason = "one ordered four-turn sequence; splitting would hide which residual each turn carries" + )] fn step3_a_panel_wheel_moves_the_panel_viewport_once_per_notch_per_axis() { use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; @@ -22190,11 +22200,13 @@ mod tests { // Vertical, sub-threshold: nothing on the wire, nothing moves. let (step, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); - assert!( - step.outbound.is_empty(), - "a sub-threshold vertical delta must put NOTHING on the \ - wire, got {:?}", - step.outbound + assert_eq!( + step, + Step { + local: Vec::new(), + outbound: Vec::new(), + }, + "a sub-threshold vertical delta must have NO local or wire effect" ); assert_eq!(replayed, 0); assert_eq!( @@ -22206,13 +22218,17 @@ mod tests { // Horizontal, sub-threshold, with the vertical bank still // standing: one accumulator fed by both axes would complete // here and scroll. - let (step, _) = turn(&mut h, &mut editor, 0.6, 0.0); - assert!( - step.outbound.is_empty(), + let (step, replayed) = turn(&mut h, &mut editor, 0.6, 0.0); + assert_eq!( + step, + Step { + local: Vec::new(), + outbound: Vec::new(), + }, "a sub-threshold horizontal delta must not be completed by \ - the vertical one banked before it, got {:?}", - step.outbound + the vertical one banked before it" ); + assert_eq!(replayed, 0); assert_eq!( origin(&editor), (0, 0), @@ -22221,33 +22237,72 @@ mod tests { // Completing the vertical notch: the viewport moves ONE step // down, and the horizontal origin stays put. - let (_, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); - assert_eq!(replayed, 1, "one notch is one gesture"); - let after_vertical = origin(&editor); + let (step, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); assert!( - after_vertical.0 > 0, - "the completed vertical notch must scroll the panel" + step.local.is_empty(), + "a panel wheel has no frontend-local effect: {:?}", + step.local ); assert_eq!( - after_vertical.1, 0, - "and must not move it sideways: a vertical notch that \ - emitted a horizontal gesture would show up exactly here" + step.outbound.len(), + 1, + "one completed notch must emit exactly one event and nothing \ + alongside it: {:?}", + step.outbound + ); + assert!( + matches!( + step.outbound[0], + pmacs_protocol::FrontendEvent::PanelPointer { + kind: pmacs_protocol::MouseKind::ScrollDown, + .. + } + ), + "the vertical notch must be one downward panel gesture: {:?}", + step.outbound + ); + assert_eq!(replayed, 1, "one notch is one gesture"); + let after_vertical = origin(&editor); + assert_eq!( + after_vertical, + (WHEEL_LINES_PER_TICK as usize, 0), + "one vertical notch is exactly one line-step, on that axis \ + only" ); // Completing the horizontal notch: sideways this time, and the // vertical origin does not move again. - let (_, replayed) = turn(&mut h, &mut editor, 0.6, 0.0); - assert_eq!(replayed, 1, "one notch is one gesture"); - let after_horizontal = origin(&editor); + let (step, replayed) = turn(&mut h, &mut editor, 0.6, 0.0); assert!( - after_horizontal.1 > 0, - "the completed horizontal notch must scroll the panel \ - sideways — the axis whose receiver arm did not exist before \ - B2, and which no emission count can see" + step.local.is_empty(), + "a panel wheel has no frontend-local effect: {:?}", + step.local ); assert_eq!( - after_horizontal.0, after_vertical.0, - "and must not scroll it vertically a second time" + step.outbound.len(), + 1, + "one completed notch must emit exactly one event and nothing \ + alongside it: {:?}", + step.outbound + ); + assert!( + matches!( + step.outbound[0], + pmacs_protocol::FrontendEvent::PanelPointer { + kind: pmacs_protocol::MouseKind::ScrollRight, + .. + } + ), + "the horizontal notch must be one rightward panel gesture: {:?}", + step.outbound + ); + assert_eq!(replayed, 1, "one notch is one gesture"); + let after_horizontal = origin(&editor); + assert_eq!( + after_horizontal, + (WHEEL_LINES_PER_TICK as usize, WHEEL_COLUMNS_PER_TICK as u32,), + "one horizontal notch is exactly one column-step, while the \ + vertical origin remains unchanged" ); } From fd9401e57f583708b9ca63a3fc452b05bd5fbaf0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 14:17:09 +0200 Subject: [PATCH 65/72] docs(lane): record the exact step-3 witness Advance the verified code checkpoint to 9ec4ff1, correct the cross-crate test-support method count from three to four, and record why the earlier greater-than-zero assertions did not establish exactly one viewport step. Capture the exact transcript, exact-origin, and geometry-acceptance assertions together with the three mutation results that discriminate them. The full pre-PR gate remains the next action. --- docs/active-work.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5722721..607d222 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -322,7 +322,7 @@ lifetime tables made frontend-specific. **The both-axis effect witness is no longer owed**; see the completion note below, which is the authority on what this lane still has outstanding. -**Landed so far** (latest verified code head `e50f38a`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `9ec4ff1`)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -511,12 +511,23 @@ The last three closed in order: frontend's `apply_wheel` as producer, a real `EditorState` with a live panel window as receiver, and the panel's `(view_top, view_left)` as the assertion. **`pmacs-gpu` gained a DEV-dependency on `pmacs`** for - it, and `pmacs` three `#[doc(hidden)]` test-support methods; the + it, and `pmacs` four `#[doc(hidden)]` test-support methods; the daemon's own `semantic_panel_view` delegates to one of them, so there is a single panel fixture rather than two. The mutation that settles it is the receiver-side one no emission count could see: dropping `PKind::ScrollLeft`/`ScrollRight` from the daemon's panel arm fires - this row. + this row. Review found one residual at `a3b0bb8`: the row said + **exactly one step** while asserting only that each origin became + greater than zero. `9ec4ff1` pins the exact `(view_top, view_left)` + after both completions, asserts the entire sub-threshold `Step` is + empty, requires one correctly directed outbound event and no local + effect on each completion, and asserts the receiver's geometry + declaration returned `Advanced`. Doubling the vertical and horizontal + receiver steps fails at `(6, 0)` and `(3, 6)` respectively; adding a + local document scroll beside the panel event fails on the completion + transcript. The focused row is green outside the socket-restricted + sandbox; package-wide all-target clippy, fmt and `git diff --check` + are clean. The full pre-PR gate remains next. - **R4 and R5** (`e5ab16c`). - **B1's disposal half** (`241e82e` for panels, `a7006fa` for terminals) — and it needed mechanism, not just a row. `BufferId` keying distinguishes panel A from panel B for From 04ebd2f39d753b25b261b84e3b7f123529978cfb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 14:47:47 +0200 Subject: [PATCH 66/72] docs: U21, and two ways I misread a gate that had already failed The pre-PR gate redded at step 03-lib on m6_1_pty_canonical_mode_keeps_kernel_echo, alone, with U2's familiar `stty -a output was: ""`. Recorded as U21 rather than folded into U2 or U9: U2's selector is the RAW test, and U9's requires the canonical test and composition_overhead together in 11-sweep. Here canonical redded with no companion at all, in the narrowest step, which no existing row has shown. The evidence is unusually good for this family. The same selector passed in steps 04-lib-crdt and 15-sweep of the SAME run, on the same tree, minutes apart --- an in-run control that excludes the tree without relying on a rerun at different conditions. Three later full --lib runs and one isolated run are also green. The load confound is once again unmeasured at the moment of failure; the readings that exist were taken afterwards and are recorded as such. Two invocation mistakes are recorded in the lane notes because both made a failed gate look like a running one. nohup leaves SIGINT ignored and the gate refuses to start in that state --- correctly --- printing two lines and exiting in under a second. My monitor filtered for step banners and test results, so it matched none of that, and fifteen minutes of silence read as progress. And a trailing `; echo "exit=$?"` reported the echo's status, so the harness logged exit 0 for a gate whose own summary said FAILED. --- docs/active-work.md | 27 +++++++++++++++++++++++++++ docs/ci-red-signatures.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 607d222..6a23608 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -581,6 +581,33 @@ the next lane does not rediscover them at review. instead. Running it by hand also surfaces a pre-existing unresolved link, `MathNode` at `pmacs-gpu/src/math_layout.rs:314`. +### `scripts/gate` refuses to run under `nohup` + +Not a red — a **refusal**, and worth recording because it looks like +neither success nor failure. Launched with `nohup`, the gate printed + +``` +pmacs: SIGINT is ignored; run this command with SIGINT deliverable +gate: REFUSING TO RUN (status=1 token=valid) --- no stage has run. +``` + +and exited in under a second. `nohup` leaves SIGINT ignored, and the +gate declines to start in that state rather than run stages nobody can +interrupt. **The guard is right**; the way it was hit is the lesson. +Background the gate through the harness's own mechanism, not `nohup`. + +**The expensive half was the watch, not the refusal.** A monitor was +armed on the output file filtering for step banners and test results — +so a gate that never started produced exactly what a gate still running +produces: nothing. Fifteen minutes of silence read as progress. The +question to ask before arming any watch is *if this process died right +now, would my filter emit anything?*, and here the answer was no. + +**A trailing `; echo` in the same launch also swallowed the exit code.** +`./scripts/gate … > log 2>&1; echo "exit=$?"` reports the *echo's* +status, so the harness recorded exit 0 for a gate that failed at step +03. Read the gate's own summary line, never the wrapper's. + ### A local false compile red from the shared target directory Kept here rather than in `docs/ci-red-signatures.md` because it is a diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index fd52888..c50d0d6 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1123,6 +1123,37 @@ the first thing to capture is a contemporaneous load reading — the one piece of evidence that would separate the two explanations and that neither U6 nor this row has at the moment of failure. +### U21 — the canonical PTY test reds ENTIRELY alone, in `03-lib` + +Recorded during GUI Stage 1b's pre-PR gate, 2026-09-02, local (Linux). +**A new incident by U6's rule and U9's, not a fourth occurrence of +either** — see the relation cells below, which are the point of the row. + +| field | value | +|---|---| +| **selector** | `--lib process::tests::m6_1_pty_canonical_mode_keeps_kernel_echo`, **alone** — no second selector redded anywhere in the run | +| **job / flavor** | local (Linux), `scripts/gate` step `03-lib`, per-worktree target dir, inside a full gate | +| **required fragments** | ``canonical mode should leave echo enabled (no `-echo` flag); stty -a output was: ""`` | +| **NOT fragments** | the `:LINE` suffix (`src/process.rs:3981` at this head) and the pass/fail counts — both occurrence-specific | +| **status** | **one occurrence**, `2008 passed; 1 failed; 3 ignored` | +| **what IS established — an IN-RUN control, the strongest kind** | the same selector **passed in step `04-lib-crdt` and step `15-sweep` of the SAME gate run**, on the same tree, minutes apart. The tree is therefore excluded as a cause without relying on a later rerun at different conditions. Also green in three subsequent full `--lib` runs and once isolated (`1 passed`, 0.01s) | +| **what is NOT** | cause. **The load confound is once again not measured at the moment of failure** — the readings that exist were taken afterwards: 1-minute 4.15, 4.38, 6.43, 6.54 with the 5-minute figure at 9.6–10.0, i.e. a genuinely busy machine, but *after* the fact. This is the same gap U15 exists to close and it is still open | +| **relation to U2 — same family, different selector** | U2's fragment is identical (`stty -a output was: ""`) but its selector names `m6_1_pty_raw_mode_disables_kernel_echo`. Raw passed here | +| **relation to U9 — the closest row, and still not a match** | U9's selector requires the canonical PTY test **and** `composition_overhead_under_ten_percent` failing together in `11-sweep`. Here the PTY test redded **with no companion at all**, in `03-lib`, and the composition test passed. U9's own framing distinguishes "canonical alone" from *raw*; this is canonical alone from *everything*, which no existing row has shown | + +**What this adds to the family is the isolation.** Every prior +occurrence of this fragment came paired — raw with canonical (U2's +second), or canonical with a budget test (U9). A single PTY assertion +failing by itself, in the narrowest step, with two green runs of the +same selector bracketing it in the same gate, is the cleanest evidence +yet that the fragment reports **a delivery or collection failure rather +than anything about termios** — which is exactly what U2 says the +fragment can and cannot show. + +**Do not fold this into U2 or U9.** Both rows exist because their +authors resisted the same pull, and each records a combination the +others cannot see. + ### U7 — a *different* wall-clock render-budget test reds each sweep Recorded during worker identity Stage 1 review round 3, 2026-08-09. From 9a3a78f222f4a6d234483cf401c53871378247a5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 15:48:23 +0200 Subject: [PATCH 67/72] docs(lane): the pre-PR gate is green at 04ebd2f All sixteen steps of scripts/gate, run 20260902T125049Z-4162684: fmt, clippy, lib 2009, lib-crdt 2202, the eight touched acceptance suites, m4 168, gpu 322, the workspace sweep at 121 targets and 4138 passed, and diff-check. It took three attempts and neither failure was the tree. The first refused under nohup with no stage run; the second was SIGTERM'd mid-sweep by a background limit while still compiling, having run no test; the one real red before them was U21's PTY signature at 03-lib, a selector that passed twice more in that same run. The latest verified CODE head stays 9ec4ff1 --- the commits since are documentation --- and the gate ran at 04ebd2f, which is what the line now says. The lane is ready for its PR, and the honest statement of residual risk is U21, not this work. --- docs/active-work.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6a23608..8f6ef4e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -322,7 +322,7 @@ lifetime tables made frontend-specific. **The both-axis effect witness is no longer owed**; see the completion note below, which is the authority on what this lane still has outstanding. -**Landed so far** (latest verified code head `9ec4ff1`)**:** B1's per-target fractional +**Landed so far** (latest verified code head `9ec4ff1`; **full gate green at `04ebd2f`**)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, From 3a2816b019e250dabc735337bb6c2c864de35a11 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 16:06:09 +0200 Subject: [PATCH 68/72] docs: correct Stage 1b gate evidence Narrow U21 to same-run intermittence without excluding an intermittent tree effect, and preserve the empty-stdout mechanism boundary from U2. Correct the shell-status explanation, record the exit-143 sweep termination, and mark the full pre-PR gate complete. --- docs/active-work.md | 32 +++++++++++++++++++++++++------- docs/ci-red-signatures.md | 15 ++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8f6ef4e..5d565fc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -322,7 +322,8 @@ lifetime tables made frontend-specific. **The both-axis effect witness is no longer owed**; see the completion note below, which is the authority on what this lane still has outstanding. -**Landed so far** (latest verified code head `9ec4ff1`; **full gate green at `04ebd2f`**)**:** B1's per-target fractional +**Landed so far** (latest verified code head `9ec4ff1`; **full gate green +at `04ebd2f`**, run `20260902T125049Z-4162684`, all 16 steps)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -499,7 +500,8 @@ L8b–L8e, and that bullet's GPU half described the latch that no longer exists. **Owed outright: nothing. The implementation and its evidence are -complete**, and the lane's next step is the full pre-PR gate. +complete.** The full pre-PR gate is green at `04ebd2f`; the lane is +held immediately before opening its PR. The last three closed in order: @@ -527,7 +529,8 @@ The last three closed in order: local document scroll beside the panel event fails on the completion transcript. The focused row is green outside the socket-restricted sandbox; package-wide all-target clippy, fmt and `git diff --check` - are clean. The full pre-PR gate remains next. + are clean. The code later passed the full pre-PR gate at `04ebd2f`; + no further gate is owed before the PR. - **R4 and R5** (`e5ab16c`). - **B1's disposal half** (`241e82e` for panels, `a7006fa` for terminals) — and it needed mechanism, not just a row. `BufferId` keying distinguishes panel A from panel B for @@ -603,10 +606,25 @@ produces: nothing. Fifteen minutes of silence read as progress. The question to ask before arming any watch is *if this process died right now, would my filter emit anything?*, and here the answer was no. -**A trailing `; echo` in the same launch also swallowed the exit code.** -`./scripts/gate … > log 2>&1; echo "exit=$?"` reports the *echo's* -status, so the harness recorded exit 0 for a gate that failed at step -03. Read the gate's own summary line, never the wrapper's. +**A trailing `; echo` in the same launch also hid the exit code from +the caller.** In +`./scripts/gate … > log 2>&1; echo "exit=$?"`, `$?` is expanded before +`echo` runs, so the text printed is the **gate's** status. The compound +shell command itself, however, returns the later **echo's** status — +normally zero — so the harness recorded exit 0 for a gate that failed +at step 03. Preserve and re-emit the status explicitly, or read the +gate's own summary line; do not treat the wrapper's status as the +gate's. + +**A later attempt was terminated by the harness, not by a test red.** +Run `20260902T124755Z-4122046` reached step `15-sweep`; that stage's log +contains two compilation lines followed by `Terminated`, with **zero +`test result:` lines**, and the gate reported `FAILED (exit 143)`. +The preceding fourteen stages had run successfully, but the sweep had +not executed a test. This is evidence that the harness's background +lifetime expired, not evidence about the tree. A monitor must observe +process exit as well as filtered gate output, and the launch mechanism +must outlive the full gate. ### A local false compile red from the shared target directory diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index c50d0d6..0d41df5 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1136,19 +1136,20 @@ either** — see the relation cells below, which are the point of the row. | **required fragments** | ``canonical mode should leave echo enabled (no `-echo` flag); stty -a output was: ""`` | | **NOT fragments** | the `:LINE` suffix (`src/process.rs:3981` at this head) and the pass/fail counts — both occurrence-specific | | **status** | **one occurrence**, `2008 passed; 1 failed; 3 ignored` | -| **what IS established — an IN-RUN control, the strongest kind** | the same selector **passed in step `04-lib-crdt` and step `15-sweep` of the SAME gate run**, on the same tree, minutes apart. The tree is therefore excluded as a cause without relying on a later rerun at different conditions. Also green in three subsequent full `--lib` runs and once isolated (`1 passed`, 0.01s) | +| **what IS established — two IN-RUN repetitions** | the same selector **passed in step `04-lib-crdt` and step `15-sweep` of the SAME gate run**, on the same tree, minutes after the step-03 red. This establishes within-run intermittence and excludes a deterministic failure on that tree. It does **not** exclude the tree as a cause of an intermittent failure or a changed failure rate. Also green in three subsequent full `--lib` runs and once isolated (`1 passed`, 0.01s) | | **what is NOT** | cause. **The load confound is once again not measured at the moment of failure** — the readings that exist were taken afterwards: 1-minute 4.15, 4.38, 6.43, 6.54 with the 5-minute figure at 9.6–10.0, i.e. a genuinely busy machine, but *after* the fact. This is the same gap U15 exists to close and it is still open | | **relation to U2 — same family, different selector** | U2's fragment is identical (`stty -a output was: ""`) but its selector names `m6_1_pty_raw_mode_disables_kernel_echo`. Raw passed here | | **relation to U9 — the closest row, and still not a match** | U9's selector requires the canonical PTY test **and** `composition_overhead_under_ten_percent` failing together in `11-sweep`. Here the PTY test redded **with no companion at all**, in `03-lib`, and the composition test passed. U9's own framing distinguishes "canonical alone" from *raw*; this is canonical alone from *everything*, which no existing row has shown | **What this adds to the family is the isolation.** Every prior occurrence of this fragment came paired — raw with canonical (U2's -second), or canonical with a budget test (U9). A single PTY assertion -failing by itself, in the narrowest step, with two green runs of the -same selector bracketing it in the same gate, is the cleanest evidence -yet that the fragment reports **a delivery or collection failure rather -than anything about termios** — which is exactly what U2 says the -fragment can and cannot show. +second), or canonical with a budget test (U9). Here one PTY assertion +failed by itself in the narrowest step, then the same selector passed +twice later in the same gate. The fragment establishes only that **the +supervisor collected empty stdout**; it shows no incorrect termios +state and does not distinguish among {the child never wrote, PTY +delivery lost the bytes, event collection missed them}. That is the +same evidence boundary U2 records. **Do not fold this into U2 or U9.** Both rows exist because their authors resisted the same pull, and each records a combination the From c4b150517972406db312226f3ef3bcc0ae800489 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 16:46:11 +0200 Subject: [PATCH 69/72] docs(lane): 1b is in review as PR #247 Opened against main at 0ec13b3, 13 files, on the gate-green head. The lane block is IN REVIEW rather than IMPLEMENTING, and carries the PR's URL so a recovering session finds it without searching. The standing rule is restated in place rather than assumed: not to be merged unprompted. --- docs/active-work.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5d565fc..42b1317 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -301,7 +301,12 @@ waits for a signal that is not coming. - **THE FIRST DISPATCH IMMEDIATELY FOUND A RED ON `main`**, which is what this lane was built for. See the proptest entry below. -## GUI arc Stage 1b — pointer and scroll — IMPLEMENTING +## GUI arc Stage 1b — pointer and scroll — IN REVIEW (PR #247) + +**PR #247** — `https://github.com/levineuwirth/pmacs/pull/247`, opened +2026-09-02 against `main` at `0ec13b3`, 13 files, on the gate-green +head. **Not to be merged unprompted.** + **Branch `gui-stage1b-pointer-scroll`, rebased onto `0ec13b3`.** The **remote ref `githubsucks/gui-stage1b-pointer-scroll` is From ef2592020dab656c00e130248739bca8f5ee54b1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 20:02:09 +0200 Subject: [PATCH 70/72] fix(gpu): present horizontal wheel and reset replaced panels Request a frame whenever frontend-local horizontal scrolling changes the GPU origin, so the waiting event loop presents the new viewport without unrelated input. Discard panel wheel residuals on accepted mapped and legacy identity replacements, while preserving them across a geometry-only re-grid of the same panel. Add production-path witnesses for the redraw and both frame families, plus the geometry negative control. Refresh the Stage 1b framing as revision 22 with the review findings and their discriminators. --- docs/gui-stage1-input-framing.md | 32 ++- pmacs-gpu/src/main.rs | 346 ++++++++++++++++++++++++++++++- 2 files changed, 368 insertions(+), 10 deletions(-) diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 672a55e..a82b631 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,13 +1,30 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 21 — revision 20's §2a re-measurement at `0ec13b3` -stands; the GPU lifetime rows now describe structural authority rather -than a latch that never had a reader.** Revision 18's §2a was measured +**Status: revision 22 — IMPLEMENTED, IN REVIEW as PR #247. Revision +21's GPU structural-authority correction stands; the first PR review +closed one presentation gap and one incomplete disposal path without +changing a ruling.** Revision 18's §2a was measured at `72da24a`. That base is now **167 commits back**, and #242 and #243 landed on exactly the pointer paths 1b builds on, so every 1b anchor is stale again — which §2a's own history predicted would happen. +**Revision 22 closes two review findings in the implementation and in +the evidence.** First, the GPU's document-horizontal wheel changed +`code_scroll_left` but requested no redraw. The event loop returns to +`ControlFlow::Wait`, and this axis deliberately sends no viewport wire +event, so the stored origin could remain invisible until an unrelated +frame. B2 now includes a request-count witness whose origin assertion +still passes when the request is removed — the redraw is the +discriminator, not inferred from state. Second, B1's panel disposal ran +on `Absent` only. A direct accepted `Present` → `Present` replacement +could retain panel A's bank; A → B → A could later spend it. Separate +mapped and legacy rows drive the real wheel producer across a same-buffer, +new-`panel_epoch` replacement with no `Absent`. A geometry-only re-grid +is the negative control: it is still the same panel surface and keeps +the fraction. **No ruling changes; two paths that failed to implement +the existing rulings are now covered.** + **All four load-bearing claims were re-verified and all four SURVIVE:** 1. **Quantization still happens BEFORE routing.** `:3254` quantizes, @@ -497,6 +514,13 @@ defect as leaking between kinds but invisible to a kind-level row. with panel B (the `buffer_id`/`panel_epoch` replacement acceptance 49 already models), motion over B must **start from zero**. *Mutation: key the residual by "the panel" rather than by panel identity.* + **Revision 22 adds the disposal discriminator R2 alone could not + supply:** A starting from zero is satisfied by `BufferId` keying even + while A's stale bank remains. Drive a direct accepted A → B → A (or + same-buffer/new-`panel_epoch`) replacement with no `Absent`; A's first + successor half-notch does nothing and its second completes. Run it in + both mapped and legacy frame families. A geometry-only successor is a + negative control and preserves the bank. - **R3 — terminal A→terminal B:** the same, across two terminal buffers. *Mutation: key the residual by "the terminal".* @@ -1214,7 +1238,7 @@ The crate has **exactly one** executable `event_loop.exit()`, in | # | Contract | Witness | Mutation | |---|---|---|---| | B1 | Residual per **axis and surface** — primary document, panel document and minimap are distinct owners | deltas discarded | share one accumulator → primary↔panel or document↔minimap surface-switch jump | -| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert | +| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert; omit the GPU local redraw request → `code_scroll_left` changes while the waiting event loop never presents it | | B3 | Each document surface clamps at its own content bounds; never a negative origin, and wrap pins horizontal origin to zero | no horizontal scroll to clamp | remove a clamp → **at-bounds row: that surface's origin goes negative and the view blanks**; reuse primary bounds for a panel → unequal-width surface row fails | | B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only | | B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only | diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 45d75e8..6a0f8fa 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1636,6 +1636,12 @@ struct State { /// on a windowless `State`. Test-only. #[cfg(test)] render_calls: u64, + /// How many redraws production requested. Test-only: a headless + /// `State` has no [`Window`] to retain the request, so without this + /// counter a row can see viewport state change while missing that the + /// live event loop will remain asleep and never present it. + #[cfg(test)] + redraw_requests: std::cell::Cell, // `None` in the headless render-test path (F-014): a windowless State // that renders to an offscreen texture instead of a surface. window: Option>, @@ -2204,12 +2210,14 @@ impl WheelTarget { /// away must go away with it, or it is spent on whatever later takes /// that identity. /// -/// **Disposal is implemented at both teardowns**, because the key -/// alone cannot see a surface closed and REOPENED on the same buffer: -/// the successor carries the same `BufferId`, so nothing distinguishes -/// it from the surface the user was actually scrolling. -/// `PanelFramePayload::Absent` clears the panel banks and -/// `exit_terminal_mode` clears the terminal ones. +/// **Disposal is implemented at both teardowns and at accepted panel +/// replacement.** The key alone cannot see a surface closed and REOPENED +/// on the same buffer: the successor carries the same `BufferId`, so +/// nothing distinguishes it from the surface the user was actually +/// scrolling. `PanelFramePayload::Absent` clears the panel banks, +/// accepted `Present`/`PresentMapped` identity replacements clear them +/// without waiting for an `Absent`, and `exit_terminal_mode` clears the +/// terminal ones. #[derive(Debug, Default)] struct WheelResiduals { /// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`. @@ -5138,6 +5146,66 @@ mod input_routing_tests { ); } + /// B2, GPU presentation half — changing the frontend-local horizontal + /// origin requests the frame that makes the change visible. + /// + /// The event loop sleeps in `ControlFlow::Wait`, and this axis sends no + /// viewport message that could provoke a daemon frame. Inspecting + /// `code_scroll_left` alone therefore cannot distinguish a working + /// gesture from one whose new origin remains invisible until some + /// unrelated repaint. + /// + /// *Mutation: omit `request_redraw()` from `scroll_by_columns` → this + /// row. The origin assertion still passes, proving the redraw counter is + /// the discriminator rather than corroboration.* + #[test] + fn b2_a_gpu_horizontal_wheel_requests_the_frame_that_displays_it() { + let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200)); + { + let buffer_id = h + .app + .state + .as_ref() + .expect("harness state") + .current_buffer_id + .expect("the harness stands in a buffer"); + let state = h.app.state.as_mut().expect("harness state"); + let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { + buffer_id, + wrap: false, + }); + assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off"); + } + let document = document_probe(&h); + move_pointer(&mut h, document); + assert_eq!( + h.app.classify_wheel_target(document.0, document.1), + WheelTarget::Document, + "setup: document text" + ); + let state = h.app.state.as_ref().expect("state"); + let left_before = state.code_scroll_left; + let redraws_before = state.redraw_requests.get(); + + let step = h.feed(&wheel(1.0, 0.0)); + + let state = h.app.state.as_ref().expect("state"); + assert!( + state.code_scroll_left > left_before, + "setup: the wheel must change the origin, or a redraw is not owed" + ); + assert_eq!( + state.redraw_requests.get(), + redraws_before + 1, + "the local-only origin change must wake the waiting event loop" + ); + assert!( + step.outbound.is_empty(), + "the redraw is locally requested, not induced by wire traffic: {:?}", + step.outbound + ); + } + /// Replace the harness's document with a fresh buffer, through the /// production `BufferSnapshot` receiver. fn replace_the_buffer(h: &mut EffectHarness) { @@ -6548,6 +6616,8 @@ impl State { let mut state = Self { #[cfg(test)] render_calls: 0, + #[cfg(test)] + redraw_requests: std::cell::Cell::new(0), window, device, queue, @@ -8320,6 +8390,13 @@ impl State { { return false; } + // B1's disposal half applies to a direct accepted + // replacement too, not only to `Absent`. The bank key is + // the buffer id, so without this a panel A -> B -> A + // sequence can spend A's pre-replacement fraction when A + // returns. Run only after every refusal/duplicate check: + // an unaccepted successor owns no state to reset. + self.discard_replaced_panel_wheel_residual(&frame); // NOTE: the gesture-latch reset on an identity change is // R-d, owned by `panel-pointer-replay`. It is not // duplicated here — two branches resetting the same @@ -8375,6 +8452,11 @@ impl State { self.panel.last_pointer_cell = None; self.panel.gesture_last_content_cell = None; } + // The pointer latch above includes a geometry change; the + // wheel bank does not. A fractional notch belongs to the + // panel presentation and survives a re-grid of that same + // panel, but never a panel/buffer replacement. + self.discard_replaced_panel_wheel_residual(&frame); let plan = TerminalPaintPlan::build_grid( frame.size, &frame.cells, @@ -8389,6 +8471,22 @@ impl State { } } + /// Drop panel wheel state when an accepted frame replaces the panel + /// presentation that owned it. + /// + /// `BufferId` alone is not an identity: a persistent buffer can leave + /// and later return as a new `panel_epoch`. Conversely, a geometry-only + /// change leaves the same scroll surface in place, so it does not spend + /// or discard a fractional notch. + fn discard_replaced_panel_wheel_residual(&mut self, successor: &PanelFrame) { + let replaced = self.panel.frame.as_ref().is_some_and(|current| { + current.buffer_id != successor.buffer_id || current.panel_epoch != successor.panel_epoch + }); + if replaced { + self.wheel_residuals.clear_panels(); + } + } + /// Reshape one cosmic-text buffer per planned panel run. /// /// One buffer per RUN for the same reason terminal mode does it: a @@ -9284,7 +9382,11 @@ impl State { /// frontends are not required to share a representation. fn scroll_by_columns(&mut self, columns: i64) { if self.buffer.wrap() != Wrap::None { + let changed = self.code_scroll_left != 0.0; self.code_scroll_left = 0.0; + if changed { + self.request_redraw(); + } return; } let advance = self.mono_advance(); @@ -9300,6 +9402,11 @@ impl State { return; } self.code_scroll_left = next as f32 * advance; + // Unlike a vertical wheel, this path emits no Viewport and + // rebuilds no lines. Nothing else wakes the `ControlFlow::Wait` + // event loop, so changing the origin without requesting a frame + // leaves the new viewport invisible until an unrelated redraw. + self.request_redraw(); } /// Move `code_scroll_left` so the caret's column is on screen @@ -11264,6 +11371,9 @@ impl State { /// Ask the window to repaint. A no-op headless (no window), where the /// render tests drive `render_offscreen` directly (F-014). fn request_redraw(&self) { + #[cfg(test)] + self.redraw_requests + .set(self.redraw_requests.get().saturating_add(1)); if let Some(window) = &self.window { window.request_redraw(); } @@ -22430,6 +22540,230 @@ mod tests { ); } + /// Exercise B1's panel-disposal rule across a direct, accepted + /// `Present` -> `Present` identity replacement, with no intervening + /// `Absent` to clear the bank for us. + /// + /// The same persistent buffer returns under a new `panel_epoch`, which + /// is the case `ResidualOwner::Panel(BufferId)` cannot distinguish. A + /// first successor half-notch must do nothing; a second must still + /// complete, so the row cannot pass against an accumulator that banks + /// nothing. + fn install_replacement_test_panel(h: &mut EffectHarness, mapped: bool) { + let state = h.app.state.as_mut().expect("harness state"); + state.set_panel_wire(if mapped { + pmacs_protocol::PANEL_MAPPING_MIN_VERSION + } else { + PANEL_MIN_VERSION + }); + let (geometry_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("metrics advance the panel geometry"); + let frame = panel_frame_of_buffer( + BufferId::from_raw(77), + 4, + total.cols.max(1), + geometry_epoch, + 1, + ); + let payload = if mapped { + PanelFramePayload::PresentMapped { + frame, + mapping_generation: 1, + } + } else { + PanelFramePayload::Present(frame) + }; + assert!( + state.apply_panel_payload(payload), + "setup: the initial {} frame must be accepted", + if mapped { "mapped" } else { "legacy" } + ); + } + + fn point_at_replacement_test_panel(h: &mut EffectHarness) { + use winit::dpi::PhysicalPosition; + use winit::event::DeviceId; + let (px, py, _, ph) = h + .app + .state + .as_ref() + .expect("harness state") + .panel_content_rect() + .expect("the panel is presented"); + let point = (f64::from(px + 4.0), f64::from(py + ph / 2.0)); + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(point.0, point.1), + }); + assert!( + matches!( + h.app.classify_wheel_target(point.0, point.1), + WheelTarget::PanelCell { .. } + ), + "setup: the probe must be a panel cell" + ); + } + + fn panel_half_notch(h: &mut EffectHarness) -> Step { + use winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; + + h.feed(&WindowEvent::MouseWheel { + device_id: DeviceId::dummy(), + delta: MouseScrollDelta::LineDelta(0.0, -0.6), + phase: TouchPhase::Moved, + }) + } + + fn panel_gesture_count(step: &Step) -> usize { + step.outbound + .iter() + .filter(|event| { + matches!( + event, + pmacs_protocol::FrontendEvent::PanelPointer { .. } + | pmacs_protocol::FrontendEvent::PanelPointerMapped { .. } + ) + }) + .count() + } + + fn replace_test_panel_directly(h: &mut EffectHarness, mapped: bool) { + let state = h.app.state.as_mut().expect("harness state"); + let mut successor = state.panel.frame.clone().expect("retained frame"); + successor.panel_epoch += 1; + let payload = if mapped { + PanelFramePayload::PresentMapped { + frame: successor, + mapping_generation: 1, + } + } else { + PanelFramePayload::Present(successor) + }; + assert!( + state.apply_panel_payload(payload), + "the direct identity replacement must be accepted" + ); + assert_eq!( + state.panel.frame.as_ref().map(|frame| frame.panel_epoch), + Some(2), + "setup: the successor, not the predecessor, is retained" + ); + } + + fn assert_panel_replacement_discards_wheel_residual(mapped: bool) { + let mut h = EffectHarness::new(); + install_replacement_test_panel(&mut h, mapped); + point_at_replacement_test_panel(&mut h); + + let step = panel_half_notch(&mut h); + assert_eq!( + panel_gesture_count(&step), + 0, + "setup: the predecessor banks 0.6 and emits nothing" + ); + + // Direct replacement: no `Absent`, no pointer motion and the same + // buffer id. Only the accepted successor identity can discard the + // predecessor's bank. + replace_test_panel_directly(&mut h, mapped); + + let step = panel_half_notch(&mut h); + assert_eq!( + panel_gesture_count(&step), + 0, + "the successor starts from zero; the predecessor's 0.6 is gone: {:?}", + step.outbound + ); + let step = panel_half_notch(&mut h); + assert_eq!( + panel_gesture_count(&step), + 1, + "the successor's own 0.6 + 0.6 still completes exactly once: {:?}", + step.outbound + ); + assert!( + matches!( + step.outbound.last(), + Some( + pmacs_protocol::FrontendEvent::PanelPointer { + kind: pmacs_protocol::MouseKind::ScrollDown, + .. + } | pmacs_protocol::FrontendEvent::PanelPointerMapped { + kind: pmacs_protocol::MouseKind::ScrollDown, + .. + } + ) + ), + "the completed successor notch is downward: {:?}", + step.outbound + ); + } + + /// Current production sessions negotiate the mapped family. + /// + /// *Mutation: omit the replacement discard from `PresentMapped` → + /// this row only.* + #[test] + fn b1_a_mapped_panel_replacement_discards_the_predecessors_residual() { + assert_panel_replacement_discards_wheel_residual(true); + } + + /// The legacy family remains supported at the protocol floor. + /// + /// *Mutation: omit the replacement discard from `Present` → this row + /// only.* + #[test] + fn b1_a_legacy_panel_replacement_discards_the_predecessors_residual() { + assert_panel_replacement_discards_wheel_residual(false); + } + + /// A geometry declaration re-grids the same panel; it does not replace + /// the scroll surface that owns an in-progress fractional notch. + /// + /// *Mutation: include `geometry_epoch` in + /// `discard_replaced_panel_wheel_residual`'s identity comparison → this + /// row. The second half-notch no longer completes.* + #[test] + fn b1_a_panel_geometry_change_preserves_the_same_panels_residual() { + let mut h = EffectHarness::new(); + install_replacement_test_panel(&mut h, false); + point_at_replacement_test_panel(&mut h); + + let step = panel_half_notch(&mut h); + assert_eq!( + panel_gesture_count(&step), + 0, + "setup: the first 0.6 banks against the panel" + ); + + { + let state = h.app.state.as_mut().expect("harness state"); + let (geometry_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("metrics advance the geometry epoch"); + let mut regridded = state.panel.frame.clone().expect("retained frame"); + regridded.geometry_epoch = geometry_epoch; + regridded.size.cols = total.cols.max(1); + regridded.cells.resize( + regridded.size.rows as usize * regridded.size.cols as usize, + terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default()), + ); + assert!( + state.apply_panel_payload(PanelFramePayload::Present(regridded)), + "the matching frame must settle the new geometry" + ); + } + + let step = panel_half_notch(&mut h); + assert_eq!( + panel_gesture_count(&step), + 1, + "the same panel keeps its fraction across a re-grid: {:?}", + step.outbound + ); + } + /// B1's disposal half, **terminal side** — leaving a terminal and /// re-entering **the same** one starts from zero. /// From 5fcc3a3febd1aba673098eb7807c3bdfcef2edc5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 20:11:38 +0200 Subject: [PATCH 71/72] docs: record PR 247 review fixes and gate Advance the Stage 1b framing reference to revision 22 and record the GPU presentation and direct panel-replacement fixes from the first PR review. Pin the head-exact sixteen-stage review gate at ef25920, including the four executed mutations, 326 GPU tests, and clean stage-log reads. --- docs/active-work.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 42b1317..8863975 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -320,15 +320,16 @@ before the rewrite, not after, so nothing depended on a local-only ref surviving. Framing -`docs/gui-stage1-input-framing.md`, **revision 21** — §2a re-measured at +`docs/gui-stage1-input-framing.md`, **revision 22** — §2a re-measured at this base, the panel-replay prerequisite recorded as DISCHARGED by #243, and (revision 21) the GPU's authority recorded as **structural**, its lifetime tables made frontend-specific. **The both-axis effect witness is no longer owed**; see the completion note below, which is the authority on what this lane still has outstanding. -**Landed so far** (latest verified code head `9ec4ff1`; **full gate green -at `04ebd2f`**, run `20260902T125049Z-4162684`, all 16 steps)**:** B1's per-target fractional +**Landed so far** (latest verified code head `ef25920`; **full review +gate green at that exact head**, run `20260902T180422Z-311523`, all 16 +steps)**:** B1's per-target fractional wheel residual (the producer), B2's daemon-side horizontal panel leg, B3/B7's shared `scroll_window_columns` with its saturated bound and wrap pin, B4's middle-click PRIMARY paste, **B6's minimap wheel routing**, @@ -505,8 +506,38 @@ L8b–L8e, and that bullet's GPU half described the latch that no longer exists. **Owed outright: nothing. The implementation and its evidence are -complete.** The full pre-PR gate is green at `04ebd2f`; the lane is -held immediately before opening its PR. +complete.** The original pre-PR gate is green at `04ebd2f`; after the +first PR review's two implementation fixes, the full review gate is +green at code head `ef25920`, run `20260902T180422Z-311523`. PR #247 +remains open and unmerged. + +### PR #247 review 1 — presentation and direct panel replacement + +Two findings were implementation defects, not documentation residuals. + +- **B2 changed `code_scroll_left` without requesting a redraw.** The + event loop returns to `ControlFlow::Wait`, and horizontal document + scrolling deliberately emits no viewport wire event. The stored + origin therefore could remain invisible until unrelated input. The + new production-path row asserts both halves independently: the origin + changes and the redraw-request count advances exactly once. Removing + the request leaves the first assertion green and fires the second. +- **B1 disposed panel banks on `Absent`, but not on a direct accepted + identity replacement.** `ResidualOwner::Panel(BufferId)` cannot + distinguish a persistent buffer returning under a new `panel_epoch`, + so A's fraction could survive A → B → A and be spent by the successor. + Separate mapped and legacy rows drive the real producer through a + same-buffer/new-epoch replacement with no `Absent`; omitting either + branch's discard fires only that family. A geometry-only re-grid is + the negative control: it is the same panel surface, preserves the + bank, and fails if `geometry_epoch` is folded into wheel identity. + +All four mutations were built and executed, not inferred from grep. +`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` reports **326 passed**. +The full 16-stage gate at `ef25920` passed fmt, workspace/all-targets +clippy, lib, lib-crdt, all eight touched acceptance suites, m4, GPU, +the workspace sweep and diff-check. Reading every stage log finds zero +`FAILED` lines and zero non-zero-failure `test result:` lines. The last three closed in order: From 72a34889a21b38fa93c2ed5334dd192461cab6e6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 20:45:31 +0200 Subject: [PATCH 72/72] docs(lane): CI is green at 5fcc3a3, 14 of 14 The run that was mid-flight at the last update has finished: 14 checks, zero non-success, against the current head rather than an earlier one, and the PR reports MERGEABLE/CLEAN. Recorded because CI state is volatile and the ledger is what a recovering session reads. Test (crdt) passing is the one worth naming: it is the flavor scripts/gate's clippy step cannot lint locally, which this project has been bitten by before. --- docs/active-work.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 8863975..629c84f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -307,6 +307,12 @@ waits for a signal that is not coming. 2026-09-02 against `main` at `0ec13b3`, 13 files, on the gate-green head. **Not to be merged unprompted.** +**CI is GREEN at `5fcc3a3`: 14 of 14 checks, zero non-success**, and the +PR reports `MERGEABLE` / `CLEAN`. The two macOS legs are the long poles +(13m47s and 21m12s); `Test (crdt)` — the flavor `scripts/gate`'s clippy +step cannot lint locally — passed at 11m21s. Checks ran against the +current head, not an earlier one. + **Branch `gui-stage1b-pointer-scroll`, rebased onto `0ec13b3`.** The **remote ref `githubsucks/gui-stage1b-pointer-scroll` is