Merge pull request #247 from levineuwirth/gui-stage1b-pointer-scroll

GUI arc Stage 1b — pointer and scroll
This commit is contained in:
Levi Neuwirth 2026-09-02 21:08:47 +02:00 committed by GitHub
commit 27743fd8a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 6382 additions and 203 deletions

1
Cargo.lock generated
View File

@ -2627,6 +2627,7 @@ dependencies = [
"env_logger",
"glyphon",
"loro",
"pmacs",
"pmacs-protocol",
"pollster",
"sys-locale",

View File

@ -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

View File

@ -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.

File diff suppressed because it is too large Load Diff

View File

@ -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 = ".." }

File diff suppressed because it is too large Load Diff

View File

@ -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"
);
}
}

View File

@ -37,6 +37,7 @@
//! directly.
pub mod cell;
pub mod columns;
pub mod crdt;
pub mod ids;
pub mod message;

View File

@ -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<crate::window::WindowId>) {
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

View File

@ -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`.

File diff suppressed because it is too large Load Diff

View File

@ -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(())
}

View File

@ -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<u32>,
/// 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