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/docs/active-work.md b/docs/active-work.md index 1abcecf..629c84f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -301,6 +301,471 @@ 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 — 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.** + +**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 +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 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 `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**, +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 +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'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` — 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`. **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 + +**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. + +**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. 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: + +- **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` — **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`. + +**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 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, +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. + +**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 +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. 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. + +**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. + +**Owed outright: nothing. The implementation and its evidence are +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: + +- **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` 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. 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 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 + 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 + 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 +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 + +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`. + +### `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 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 + +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, 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**. + +**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 +``` + +**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`. +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 +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 +`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` +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. **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`) - **MERGED 2026-08-31T18:56:09Z** at approved head `093d677`, merge diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 690cc52..0d41df5 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -1083,6 +1083,78 @@ 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. 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 | + +**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. 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 +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 — 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). 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 +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. diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 308bb75..a82b631 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,5 +1,264 @@ # GUI arc, Stage 1 — input foundation (framing) +**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, + `: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** — 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 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. + +**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 +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); 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 +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. + +**§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; its citations are HISTORICAL (`72da24a`).** 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; 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 + 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. 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 + "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; 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; 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 +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 @@ -19,18 +278,28 @@ 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:** §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.** +**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 `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 +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,13 +387,653 @@ 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 — 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 +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 = 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 + +**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` `: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 +(`: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 +lines and returns on zero: + +```rust +let lines = match delta { LineDelta(_, y) => (-y * WHEEL_LINES_PER_TICK).round() as i64, … }; +if lines == 0 { return; } +``` + +`: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` (`: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 + +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 `: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` (`: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 `: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." + +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 | vertical today | vertical RULED | residual owner | horizontal RULED | +|---|---|---|---|---|---| +| 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()` (`: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` (`: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 +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. + +##### "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.* + **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".* + +**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: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 +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 +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 +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. + +**The terminal answer follows from the encoder.** `sgr_mouse_report` +(`src/terminal/input.rs`) already encodes `ScrollLeft` as **66** and +`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 +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 `:6813`). Citing a fixture as production is +precisely the error B1's enumeration exists to prevent, and it hid a +real defect. + +**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**."* + +**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 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 + *"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 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 +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. + +**Steps 1 and 2 are DONE. Step 3 is 1b's, undiminished.** + +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, 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 +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 +panel scrolls." + +**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 +contracts are unaffected; the **implementation shape** is. + +- **B3 — "no horizontal scroll to clamp".** The GPU has a horizontal + 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`** (`: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. +- **B4 — "no middle-click path".** 1-pre already built the landing + 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` + (`: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 + +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: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. + +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: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` (`: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 `: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. + +**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. + +#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **RULED: (B), viewport only** + +**(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` (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`, +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` (`: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. + +##### 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 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. +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). 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 + +Rows against the **real call sites**. A unit test of a helper cannot see +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()` (`:10296`) goes +straight to `render_to_view` and **never calls `horizontal_follow`**; +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. + +##### The discriminating setup, required by every row + +**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, 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 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`**; 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, **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 +move for a reason unrelated to authority and the row would not +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 +half. **L7b shrinks the content** — the maximum is +`widest − viewport`, so a shortened widest line lowers it with the +viewport untouched. + +**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 +named rather than treated as a failure of the mutation. + +**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 | +|---|---|---| +| 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.** + +- *"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. +**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 + +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` (`: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 +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 -`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 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. + +**#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, 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 +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 `AttachRequest.initial_size` for a semantic session is a **named, @@ -313,10 +1222,23 @@ 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.** §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` 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 +> **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 | |---|---|---|---| | 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 | @@ -329,8 +1251,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 @@ -621,6 +1550,17 @@ 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`. +**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 `./scripts/gate --acceptance gpu_invocation_acceptance` plus touched 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 9b2c113..6a0f8fa 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}; @@ -724,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, @@ -1587,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. @@ -1626,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>, @@ -1869,6 +1885,16 @@ struct State { /// selection, until release. Never sends `Pointer` events — /// the viewport is frontend-owned. minimap_scrub_active: 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>, + /// 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 +2135,147 @@ 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 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)`. + 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 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, 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() + } +} + /// A live divider drag (Q#BP15a, parent acceptance 47). #[derive(Clone, Copy, Debug)] struct PanelDrag { @@ -2869,6 +3036,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() { + // 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 @@ -2888,9 +3060,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) => { @@ -3242,73 +3420,189 @@ 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::MiddlePress`] — GUI Stage 1b B4. + /// + /// 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; + #[cfg(not(test))] + let is_linux = cfg!(target_os = "linux"); + let Some(source) = paste_source_for(is_linux) else { + return; + }; + 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 + /// 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 - } - 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 + // **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, -y), + winit::event::MouseScrollDelta::PixelDelta(p) => ( + if notch_px_x > 0.0 { + p.x as f32 / notch_px_x } else { - ProtocolMouseKind::ScrollDown - }; - self.send_terminal_pointer(buffer_id, coord, kind, mods); + 0.0 + }, + if notch_px_y > 0.0 { + -(p.y as f32) / notch_px_y + } 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 => { + // 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(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}"); + } + } + // **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 * WHEEL_COLUMNS_PER_TICK as i64); + } + } } } @@ -3353,6 +3647,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, .. @@ -3794,12 +4089,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. @@ -3817,6 +4116,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)), @@ -3953,6 +4253,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"); @@ -3974,14 +4288,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" @@ -3993,6 +4300,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), @@ -4385,7 +4694,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), @@ -4399,6 +4707,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 @@ -4690,6 +5006,499 @@ 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 + ); + } + + /// 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 + ); + } + + /// 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) { + 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 + ); + + // 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 + /// 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); + 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); + 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!( + (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. + // + // 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, + "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 @@ -4708,13 +5517,163 @@ 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 + /// all. + /// + /// The two seam rows cannot see this: mutating + /// `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. + /// + /// 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 `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}; + 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)); + + // 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_eq!( + release, + Step { + local: Vec::new(), + outbound: Vec::new() + }, + "the release does nothing at all; the gesture fires once, on \ + the press" + ); + } + + /// 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: /// nothing local, nothing outbound. The counterpart to the routing /// row that calls it claimed-and-dropped. #[test] fn an_unused_button_produces_no_effect_of_any_kind() { let mut h = EffectHarness::new(); - let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); + // `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 { @@ -4851,7 +5810,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, @@ -5059,6 +6021,74 @@ 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; + +/// 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, Hash)] +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 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 +/// 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. +/// +/// **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 + } +} + +/// 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 @@ -5586,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, @@ -5637,6 +6669,10 @@ impl State { last_pointer_sent_byte: None, last_pointer_down: None, minimap_scrub_active: false, + last_cursor_icon: None, + #[cfg(test)] + test_selections: HashMap::new(), + wheel_residuals: WheelResiduals::default(), edge_scroll_dir: None, edge_scroll_last: None, styled_redraw_deadline: None, @@ -5944,6 +6980,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(); @@ -6118,10 +7162,54 @@ 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) + } + + /// 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 + /// 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> { + #[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(), + #[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 } } @@ -6224,6 +7312,17 @@ 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(); self.last_viewport_sent = None; // Vterm Stage 3 — a snapshot ALWAYS leaves terminal // mode, including a terminal→terminal switch. The prior @@ -6636,7 +7735,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 { @@ -6645,6 +7744,11 @@ impl State { anchor_px: self.menu_anchor_px, }) }; + 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 } @@ -6796,12 +7900,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 @@ -6898,6 +8005,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. @@ -7033,6 +8144,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)> { @@ -7138,6 +8264,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 @@ -7158,18 +8285,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 @@ -7207,6 +8340,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 @@ -7249,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 @@ -7304,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, @@ -7318,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 @@ -7734,20 +8903,85 @@ 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. - 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 - }); + /// **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) { + 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 { + // 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; + }; + 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. @@ -8090,6 +9324,91 @@ impl State { self.request_redraw(); } + /// Widest display line **in the whole document**, in columns — + /// B7's upper-bound input. + /// + /// **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. + /// + /// **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 { + pmacs_protocol::columns::widest_line_columns(&self.current_text) + } + + /// 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** (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 + /// 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 { + 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(); + let width = self.text_bounds_right() as f32 - self.text_left(); + if advance <= 0.0 || width <= 0.0 { + 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; + } + 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 /// (Stage 5, framing Q#G2 — automatic only). /// @@ -9995,12 +11314,66 @@ 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(); + // 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) { + #[cfg(test)] + self.redraw_requests + .set(self.redraw_requests.get().saturating_add(1)); if let Some(window) = &self.window { window.request_redraw(); } @@ -12515,7 +13888,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 { @@ -12529,13 +13902,16 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape { } } -fn advance_minimap_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) - } +/// Advance a display column past one character. +/// +/// **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 { + pmacs_protocol::columns::advance_char(u32::try_from(col).unwrap_or(u32::MAX), ch) as usize } fn minimap_style_color(style: CellStyle) -> [f32; 4] { @@ -13757,8 +15133,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, @@ -13802,12 +15185,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; } } } @@ -14095,6 +15489,1109 @@ 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'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 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}" + ); + } + + /// **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 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), + "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. + #[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)); + } + + /// 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 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" + ); + } + + /// 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 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" + ); + } + + /// 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 — 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, 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; + // 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 — 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 — 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: 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; + 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 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()` from `reshape`'s tail + /// → 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; + // 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 — **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 — 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" + ); + } + + /// 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 mut state = State::new_headless(width, height, &text)?; + 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 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"); + state.scroll_by_columns(1000); + 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 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)) + } + + /// 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; + 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 - 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 - 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, + "clamped, NOT discarded — the gesture survives at the new bound" + ); + } + + /// 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(); + // 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. + 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 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); + 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_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. + /// + /// 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; + 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 + /// 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. + /// + /// 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() { + 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 + /// 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. + /// + /// **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() { + 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; @@ -19585,6 +22082,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())) @@ -19615,6 +22124,749 @@ 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) { + 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_buffer(buffer_id, 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(); + } + + /// 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. + /// + /// 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"); + 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) + } + + /// 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**. + /// + /// 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; + + let mut h = EffectHarness::new(); + present_panel_of_buffer(&mut h, panel_buffer, 1); + 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" + ); + h + } + + /// 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; 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}; + + 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") + }; + 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_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!( + origin(&editor), + (0, 0), + "and the panel viewport must not move" + ); + + // Horizontal, sub-threshold, with the vertical bank still + // standing: one accumulator fed by both axes would complete + // here and scroll. + 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" + ); + assert_eq!(replayed, 0); + assert_eq!( + 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 (step, replayed) = turn(&mut h, &mut editor, 0.0, 0.6); + assert!( + step.local.is_empty(), + "a panel wheel has no frontend-local effect: {:?}", + step.local + ); + assert_eq!( + 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 (step, replayed) = turn(&mut h, &mut editor, 0.6, 0.0); + assert!( + step.local.is_empty(), + "a panel wheel has no frontend-local effect: {:?}", + step.local + ); + assert_eq!( + 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" + ); + } + + /// 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 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. + /// + /// *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" + ); + + // 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 + ); + } + + /// 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. + /// + /// `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 /// document-owned boundary by exactly the band's pixel height while /// every status-owned boundary stays pixel-identical. 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 4394d99..f32ff6c 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` @@ -6323,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 { @@ -7671,6 +7715,70 @@ mod tests { editor.core.borrow().windows[&panel].cursor } + /// 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" + ); + } + /// P1 — a press on the band's MODE LINE begins nothing. /// /// The merge made this arm the latch, because `Consumed` and diff --git a/src/display_width.rs b/src/display_width.rs index c80670e..6b8f7cf 100644 --- a/src/display_width.rs +++ b/src/display_width.rs @@ -7,20 +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 — B7's right bound. +/// +/// **Delegates to [`pmacs_protocol::columns::widest_line_columns`]**, +/// which both frontends call. +#[must_use] +pub fn widest_line_columns(text: &str) -> u32 { + pmacs_protocol::columns::widest_line_columns(text) } /// Display width of the valid UTF-8 prefix of `bytes`. diff --git a/src/editor.rs b/src/editor.rs index 7be75ce..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). /// @@ -3356,23 +3472,91 @@ 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; + // 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; + } + 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; + // 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 + } + /// Byte under a panel cell, resolved against the SIDE window's own /// `view_top` and fold map. /// @@ -3907,6 +4091,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 @@ -4076,6 +4267,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; } @@ -4646,6 +4851,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; + } } } } @@ -4654,6 +4869,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 @@ -5230,11 +5450,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); } @@ -5274,7 +5516,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 @@ -6946,6 +7198,60 @@ 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); + } + + /// 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 { let s = EditorState::new(); let new_id = s @@ -10309,6 +10615,891 @@ 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_truncated(&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_truncated(&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_truncated(&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" + ); + // **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" + ); + } + + /// 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" + ); + } + + /// 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. + /// + /// 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(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. + // + // 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) + } + + 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()); + } + } + + /// 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(); + 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_and(|coord| coord.col < win.last_content_cols) + } + + /// 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" + ); + assert!( + caret_inside_viewport(&s), + "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()); + + // **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); + + // **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" + ); + } + + /// 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 + /// 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" + ); + } + + /// 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 four 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 — **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. + /// + /// 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] + 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); + // 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()); + 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_eq!( + s.core.borrow().active_window().view_left, + 0, + "setup: a wrapped buffer starts pinned at zero" + ); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "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"); + + // **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(); + (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, + u16::try_from(target_row).unwrap_or(0), + u16::try_from(target_col).unwrap_or(0), + ), + size, + ); + + assert_eq!( + s.core.borrow().windows[&other].view_left - before_other, + SCROLL_COLUMNS as u32, + "the INACTIVE pane, under the pointer, moves by one notch" + ); + assert_eq!( + 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(), + active, + "a wheel names a pane; it does not focus it" + ); + } + /// 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 diff --git a/src/editor_core.rs b/src/editor_core.rs index 01e2fe6..2f89714 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(()) } @@ -5415,6 +5416,7 @@ impl EditorCore { win.cursor = 0; win.selection = None; win.view_top = 0; + win.forget_manual_horizontal_origin(); win.goal_col = None; } } @@ -5650,6 +5652,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 eea37bf..67fbe8c 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, @@ -467,6 +487,42 @@ 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 four replacement paths had neither the + /// origin reset nor the latch clear. + /// + /// 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; + } + /// 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