feat(gpu): horizontal scroll — QoL Stage 5, closing the long-lines arc (#223)
* docs: frame QoL Stage 5, GPU horizontal scroll Stage 4 merged as #222, so the lane advances to its last stage. Rule 4 still does not apply — the arc closes when Stage 5 merges, not before. THE FRAMING'S FIRST FINDING CORRECTS STAGE 4'S. §1.3 there said the GPU "needs a mechanism that does not exist", named it the fact most likely to invert the cost estimate, and I endorsed the Stage 4/5 split partly on that basis. Half of it holds: `Scroll::horizontal` really is discarded throughout, because glyphon 0.11 never applies it when placing glyphs — three doc sites and three asserting tests. But that is not the only mechanism. The document `TextArea` already carries an explicit `left` origin and a `TextBounds` clip whose `left` is `gutter_clip_left`, and horizontal scroll is `left: text_left - offset_px` with the clip unchanged. glyphon then drops what falls left of the gutter — the same "paint from column 0, clip at the edge" shape the grid renderer uses, expressed in pixels. It is machinery the file already depends on, not new machinery. The split stays right for the reason that survives: the three consumers Stage 4 named — caret (`code_byte_px`), decoration geometry (`push_glyph_extent_rects`), hit testing (`gutter_aware_rel_x`) — each produce x relative to `text_left()` and each need the same offset, applied ONCE or they disagree. Shipping that inside Stage 4 would have made one reviewable change into two unreviewable ones. But it was justified partly by an overstatement, and saying so is cheaper than letting a future reader inherit it. No wire, no version bump: the GPU owns its viewport locally, exactly as it owns `scroll_top` and `code_scroll_residual`. The parallel with `ui.line-wrap` is misleading and the doc says why — the MODE is buffer state and needed v22, the OFFSET is viewport state and needs nothing. Five questions, each with my vote. Q#G3 is the one I am least sure of: the GPU can resolve a proportional family, where "column" has no fixed pixel width, so column-for-column parity with the TUI is unachievable. I lean to defining the behavior in pixels and accepting imprecise correspondence rather than gating a navigation feature on a font choice — but that is a product call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: Stage 5 revision 2 — a clip, not just an offset Two functional findings and two record repairs. Q#G3 WAS BUILT ON A FALSE PREMISE, and the correction makes the lane stricter rather than looser. Revision 1 said the GPU can resolve a proportional family and proposed accepting a new TUI/GPU divergence to accommodate it. It cannot: `family_is_monospace_everywhere` gates the family across all four weight/style combinations, `apply_font_facts` falls back when that fails, and `unresolvable_and_proportional_families_fall_back` REQUIRES the fallback. Answered as monospace-only by the font contract that already exists — and the consequence is that the TUI-parity witness becomes UNCONDITIONAL for every font the GPU supports. Revision 1 would have introduced a font-dependent behavior difference to solve a problem the codebase had already solved, in the lane whose purpose is removing unchosen divergence. "THREE CONSUMERS" WAS INCOMPLETE IN A WAY THAT WOULD HAVE SHIPPED A DEFECT. Shifting the `TextArea` clips glyphon's text because glyphon honors `TextBounds`. The manual quad and squiggle renderers have no code-area scissor at all — nothing stops them painting into the gutter, and today nothing needs to, because no code-relative x can be negative. Scrolling makes that false. So the framing now requires TWO shared things: one screen↔code transform, and one code clip rectangle every code-relative painter intersects with. The paths are tabulated with sites — caret rect (`:9698`), caret-painted predicate (`:9734`), glyph extent rects (`:9766`), inline math origins (`:9434`), completion anchor (`:7606`). The two caret sites are the sharpest, and one of them falsifies a claim revision 1 made: `:9734` has no left-edge test, so "the scroll indicator inherits the fix" was false — `code_byte_painted` reuses it and would call an off-left byte painted. And `:9698` does not merely lack a check, it DOCUMENTS the absence as safe ("the caret x can't precede `text_left`"). A comment asserting an invariant this lane deletes is worse than silence. Q#G2: "inert under wrap" was too weak. The offset must be RESET to zero on the wrap transition, as the TUI already does — `horizontal_follow` assigns `view_left = 0` on the wrap branch. Inertness hides a stale value that reappears the moment the buffer toggles back to `truncate`, before any cursor motion. G5 gains a witness that an inertness-only implementation fails. RECORDS. Rule 4's Stage-5 removal precondition was not actually met: the handoff still described Stage 4 as upcoming work. Stage 4's durable facts are now transferred — the unsnapped per-window column with a per-line effective edge, the line-absolute walk, the three-way cell designation, `Viewport::visible_cols` and its five adopters, the wrap-branch reset, the `#[serde(default)]` persistence, and the absence of any wire. The ledger's "Stage 4 ahead" / "Stage 4 plan" text is corrected to Stage 5, and its Rule 4 note now says the removal is legitimate BECAUSE those bullets exist. And the journey-step claim is withdrawn. Revision 1 said this lane completes journey step 4; step 4 is scored on welcome/help/tutorial discoverability and COHERENCE.md:395 holds it Partial for reasons this lane does not touch (`C-h` deletes a word, no tutorial). Restated as preserving interface comprehension with no scorecard movement. §16 is the direct target. Writing an unearned mark into a scorecard is how a coherence document stops being ground truth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: Stage 5 revision 3 — four corrections, one of them impossible Q#G1 CONTRADICTED THE Q#G3 ANSWER IN THE SAME DOCUMENT. It still said the GPU's font "need not be monospace" and that Q#G3 makes "column" ill-defined — both falsified by the answer two sections below, in the same revision that wrote it. The pixel-storage vote is unchanged, but its reasons narrow to the ones that survive, and the conversion is now stated as EXACT: columns × the supported monospace advance. That is what makes the unconditional parity witness checkable at all. Also removed `follow_cursor`, which I invented. The GPU's pass is `ensure_caret_painted`, and it is now named rather than cited by line — robust against the transposition that put these two sites at each other's line numbers in review. Q#G2 WAS MISSING THE BUFFER-SNAPSHOT RESET. The GPU zeroes `scroll_top` and `code_scroll_residual` when a snapshot installs a new buffer; the horizontal offset must reset there for the same reason. Without it a buffer switch INHERITS the previous document's leftward viewport, showing the new buffer scrolled sideways until a cursor motion repairs it — a worse symptom than the wrap case, because nothing about the new buffer explains it. THE GUTTER ASSERTION WAS IMPOSSIBLE, not merely imprecise. Revision 2 proposed asserting that nothing paints left of `gutter_clip_left`. With line numbers on, the gutter DELIBERATELY holds digit glyphs and diagnostic-sign quads, so that assertion fails on a correct implementation — a test that can only be satisfied by removing the gutter. Replaced with the checkable form of the same intent: the gutter rectangle is byte-identical before and after a horizontal scroll, and the left-edge rule is checked against code-relative geometry only. It still catches a code painter bleeding into the gutter, because that changes those pixels. THE COMPLETION ANCHOR HIDES, IT DOES NOT CLOSE. `completion_anchor_px` already returns `None` when the anchor scrolls out, so nothing draws while the daemon-owned completion state and its key handling are retained; actual closure is `CompletionPopup { anchor: None }`, which is the daemon's to send. Revision 2 said "closes", which would have had a viewport-geometry lane quietly redefining when a completion ends. Specified as: no completion paint while the anchor is off-left, popup reappears when it scrolls back, session semantics unchanged. Ledger drift fixed: it still called the framing revision 1 with five questions open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: Stage 5 revision 4 — witnesses for the two rules that had none Both additions cover requirements the framing had already stated and then left untested, which is how a rule becomes a comment. THE SNAPSHOT RESET (Q#G2). Revision 3 added the buffer-snapshot reset and tested only the wrap one. The witness now scrolls buffer A to a non-zero offset, installs a buffer B snapshot, and asserts the offset is zero and B renders at its code origin BEFORE any `CursorByte` arrives. The pre-cursor scoping is the entire test. A later cursor motion repairs the offset regardless, so a witness that waits for one cannot distinguish "reset on snapshot" from "repaired on first motion" — and the second is the defect. Same shape as the wrap witness, which is also scoped to before any motion, and for the same reason. THE MINIMAP (Q#G4). The vote is "no movement", and the implementation already supports it: the minimap derives from the summary, the surface dimensions and `scroll_top`, with no horizontal input. So the witness pins an existing property rather than requesting work — which is exactly why it is worth writing. An offset threaded one seam too far would break it silently, and nothing else in G5 would notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: Stage 5 approved, five questions resolved G1 pixels with exact conversion via the supported monospace advance; G2 automatic cursor-follow only, zeroing on both the wrap transition and BufferSnapshot; G3 monospace-only by the existing font contract; G4 minimap unchanged; G5 accepted whole, including the snapshot-reset and minimap-stability witnesses. The scope boundary is restated in both documents because it is what keeps this lane small: local GPU viewport state, no wire message, no protocol bump, no command surface, no minimap movement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * feat(gpu): horizontal scroll — the transform, the clip, and both resets Stage 5, partial. The mechanism and lifecycle are in; two consumers and the whole verification set are NOT yet done — see the tail of this message, which is a status, not a summary. WHAT IS IN. The offset, `code_scroll_left`, in pixels (Q#G1). Column parity stays exact because the code font is monospace by contract, so `columns × advance` is a definition rather than an approximation. Local viewport state: no wire, no version bump. One screen↔code transform (`code_x_to_screen` / `screen_x_to_code`) and one code clip (`code_clip_left` / `survives_code_clip_left`), which is the pair framing §1.1 requires. Written before any consumer moved, because five sites deriving the same offset independently is how the caret and the glyphs it sits among come to disagree. The glyph-side mechanism is one line: the document `TextArea`'s `left` shifts while its `bounds.left` stays at the gutter, so glyphon clips and the gutter keeps its own pixels. BOTH LIFECYCLE RESETS (Q#G2), which were the two rules most likely to be left as comments. The wrap transition zeroes the offset in `apply_line_wrap` — inertness would park a stale value that reappears the instant the buffer toggles back to `truncate`. The buffer snapshot zeroes it beside `scroll_top` and `code_scroll_residual`, or a buffer switch inherits the previous document's leftward viewport and shows the new buffer scrolled sideways until a cursor motion repairs it. `code_caret_rect_in_clip` gains its left-edge test, and its comment is REWRITTEN rather than extended: it used to assert "the caret x can't precede `text_left`", an invariant this stage deletes. A comment asserting something a later stage falsifies is worse than silence. That also repairs `code_byte_painted`, which reuses it — revision 1's claim that the scroll indicator "inherits the fix" was false precisely here. `gutter_aware_rel_x` is now the exact inverse of the transform, with the gutter clamp applied in screen space first: a click in the gutter band means "the first visible column", which after scrolling is the offset, not column 0. The completion anchor HIDES when scrolled off-left and does not close — the daemon owns completion state and its key handling, and closure is `CompletionPopup { anchor: None }`, which is the daemon's to send. `horizontal_follow` mirrors the TUI's: automatic only, scroll just far enough, so a caret already visible never moves the view. It runs after `normalize_code_scroll` because it reads the caret's laid-out x, which vertical normalization can change. WHAT IS NOT IN, and must land before this is reviewable: - `push_glyph_extent_rects` — washes, squiggles and selection extents still paint at unshifted x and are not cropped at the gutter. - Inline math origins (`:9434`) — same. - Every Q#G5 witness. The 228 existing GPU tests pass, which says only that nothing regressed at offset 0; not one of them exercises a non-zero offset. Gates so far: fmt; clippy --workspace --all-targets -D warnings; PMACS_REQUIRE_GPU=1 -p pmacs-gpu 228/0; git diff --check. The full two-configuration sweep is deliberately not claimed — the lane is not finished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * feat(gpu): the last two painters move, and twelve witnesses say so Completes Stage 5. `62fb93e` landed the transform, the clip and both resets but left two code-relative painters at unshifted x and the whole Q#G5 witness set unwritten; its 228 green tests said only that nothing regressed at offset 0. The two painters: - `push_glyph_extent_rects` — selection/search washes, peer presence and diagnostic squiggles. Shifted through `code_x_to_screen`, then CROPPED at the gutter rather than dropped: a selection running in from off the left edge must paint the part that is visible. That is the same boundary Stage 4's review caught the TUI painter getting wrong, and it would have been easy to reproduce here. - Inline math. The glyph mini-buffers only needed their origin moved — their layer already carries the code area's `TextBounds`. The fraction rules are quads in the background batch with no scissor of their own, so those are cropped by hand. `crop_to_code_clip_left` is the crop, and `survives_code_clip_left` now delegates to it, so a caret the crop would discard is never painted. One boundary rule, not two that agree today. TWELVE WITNESSES, EACH MUTATION-TESTED. Eleven production mutations — unshifted wash x, uncropped wash, unshifted math origin, uncropped math rule, untested caret left edge, missing snapshot reset, missing wrap reset, unhidden completion anchor, unscrolled glyphs, inverted hit-test sign, pixel-instead-of-column snap — each fail the intended witness as an ASSERTION failure, not a compile error. The minimap-stability witness was mutation-tested separately by threading the offset into `minimap_vertex_bytes`. That battery earned its keep immediately. The gutter byte-identity test's "the code area must actually have moved" assertion is satisfied by a decoration wash and the caret alone, so it PASSED with `TextArea.left` pinned to `text_left` — the entire glyph-side mechanism was unwitnessed and nothing in review would have shown it. Its replacement isolates the glyph layer: no decorations, and a source line carrying no caret, whose band is blank at offset 0 and inked after. ONE DELIBERATE STEP OUTSIDE THE APPROVED SCOPE, and it needs a ruling. Q#G5 asks for frontend agreement that is "checkable rather than asserted". Two tests in two crates asserting the same literal is not that; it is the structural duplication `pmacs-protocol::scroll`'s own module docs condemn, and that module exists because THIS ARC already shipped that defect — the scroll indicator, fixed in one copy and left wrong in the other. So the follow rule moved to `pmacs_protocol:📜:follow_left`, beside `classify`, and both frontends call it: `src/editor.rs::horizontal_follow` delegates, and the GPU converts px <-> columns around it, exact by Q#G3. The cost is that Stage 5 now touches `src/editor.rs`, which "local GPU viewport state" does not cover. No wire message and no version bump — the same argument `classify` already makes. If rejected, reverting is small: restore the four-line conditional, drop `follow_left` and its four protocol tests, rewrite the parity witness as a two-sided pin. GATES, both configurations, five ambient roots isolated: fmt; clippy `--workspace --all-targets -D warnings`; `--lib` 1920 and `--lib --features crdt` 2105; horizontal_scroll 11, long_line_readable 3, line_wrap 6, full_grid_resync 1; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 239; `-p pmacs-protocol --lib` 29; both full workspace sweeps; `git diff --check`. TWO SWEEP FAILURES, NEITHER THIS LANE'S, both logged: - R8, new row: `flat_listview_consumers_render_byte_identically...` fails DETERMINISTICALLY, and the merge-base control is done — it fails identically on `main`. The row renders with a leading directory stripped; it is a prefix strip, not width truncation, and the mechanism is NOT diagnosed. Deliberately not fixed here. - U3: the R7 selector failed once and passed on rerun. Recorded as a new incident, NOT an R7 match — different flavor, and its fragments are unverified because I filtered the sweep output before reading it. U2 records me making that exact mistake already. Sweeps go to a file from now on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * fix(gpu): the completion anchor is a point, and the witness now says where Review round 1. One defect, and a lesson about the witnesses that missed it. THE DEFECT. `completion_anchor_px` reused `survives_code_clip_left` and passed `line_height` as the horizontal extent — a VERTICAL dimension standing in for a horizontal one. The predicate is `screen_x + w > code_clip_left()`, so an anchor up to a whole line height left of the gutter "survived". `completion_dropdown_rect` bounds `ax` against the right margin only, so that x reached the popup's left edge and painted over the line numbers. An anchor is a position between glyphs. It has no width, and the popup it places is drawn to its right. So the predicate is a point: `screen_x < code_clip_left()`. The absent left clamp downstream stays absent, deliberately. This predicate is what guarantees `ax >= code_clip_left()`; a second clamp would be a duplicate of the same rule, which is the failure mode this stage's shared-transform design exists to avoid. It is witnessed instead. THE LESSON, which is the more useful half. The existing test placed the anchor 200px off-left — and 200px off-left fails a width-based predicate too, so it stayed green straight through the defect. The mutation battery agreed with it, because every mutation asked only whether REMOVING a check was caught, never whether the check had the right shape. A boundary must be tested AT the boundary. The new witness straddles it by ±0.05px — the same anchor either side of the edge, which no width-based predicate can separate — and additionally asserts the popup's own left edge stays out of the gutter, making "no left clamp needed downstream" a checked claim rather than a comment. Verified both ways: the new witness fails against the original predicate, the old one passes against it. THE AUDIT that finding prompted. Stage 5 has one other left-edge predicate, the caret's. Its use of `survives_code_clip_left(rect.x, rect.w)` is correct — a caret quad genuinely is `CARET_WIDTH` wide — and it was also only tested far from the edge. It is now walked ACROSS the boundary a column at a time, asserting painted carets are wholly inside the code area and hidden ones wholly outside. That pins an argument that was load-bearing and invisible: because `horizontal_follow` snaps to whole columns, a caret is never partly behind the gutter, since `CARET_WIDTH` (2px) is far below any code advance. Substituting `rect.h` for `rect.w` — the exact error above — fails it. An over-width smaller than one advance does not, and that is the invariant rather than a gap. SCOPE. `follow_left` recorded as the one approved exception to "local GPU viewport state" in the framing doc, new §1.2a: what it is, why the Q#G5 parity witness cannot be real without it, and what it does not do — no viewport state moved, no wire message, no version bump. GATES, both configurations, five ambient roots isolated, sweeps redirected to files per U3's lesson: fmt; clippy `--workspace --all-targets -D warnings`; `--lib` 1920 and crdt 2105; `-p pmacs-protocol --lib` 29; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 241; horizontal_scroll 11, long_line_readable 3, line_wrap 6, full_grid_resync 1; both full workspace sweeps; `git diff --check`. The only sweep failure is R8, confirmed by its recorded fragments — pre-existing, deterministic, merge-base controlled against `main`, and not this lane's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: Stage 5 is PR #223, head 55faa45 The ledger said "no PR opened yet", which stopped being true the moment it was. Records the PR, its head SHA, and the standing do-not-merge. Rule 4 still applies at merge, not now: the long-lines lane stays until #223 lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai * docs: the tip is the ref, not a SHA the commit itself invalidates The previous commit wrote "head 55faa45" into the ledger and, by existing, made it false — recording the PR moved the head to 4902048. A SHA pinned in a document that the act of writing it stales is a trap, not a record. The ledger already states the correct convention two paragraphs down ("the authoritative tip — the ref, not a SHA"); this follows it, and says to verify CI against the PR's live headRefOid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b56d16069
commit
9a26ac8f9f
|
|
@ -435,21 +435,21 @@ Whether `docs/ci-red-signatures.md` should grow a short non-row section
|
|||
for this class is an open question for its owner, not something this
|
||||
lane decided.
|
||||
|
||||
## Long lines (QoL arc) — Stage 3 MERGED as #221; Stage 4 is PR #222 OPEN; Stage 5 ahead
|
||||
## Long lines (QoL arc) — Stages 3 and 4 MERGED (#221, #222); Stage 5 closes it
|
||||
|
||||
**Rewritten, not removed.** Rule 4 removes a lane when its ARC is done;
|
||||
this one has Stage 4 ahead. Stage 3's durable facts are absorbed into
|
||||
`docs/agent-handoff.md` §1 — rule 4's precondition, satisfied rather
|
||||
than deferred — so what remains here is the Stage 4 plan and only the
|
||||
Stage 3 residue that constrains it.
|
||||
this one has **Stage 5** ahead. The durable facts of Stages 3 AND 4 are
|
||||
both in `docs/agent-handoff.md` §1 — rule 4's precondition, satisfied
|
||||
rather than deferred — so what remains here is the Stage 5 plan and
|
||||
only the residue from earlier stages that constrains it.
|
||||
|
||||
> **RULE 4 DOES NOT APPLY AT STAGE 4's MERGE.** The arc closes at
|
||||
> **Stage 5** (GPU horizontal scroll), not Stage 4. Q#HS1 split the GPU
|
||||
> out deliberately and time-boxed it; retiring this lane when the TUI
|
||||
> half merges would orphan exactly the half the time box exists to
|
||||
> guarantee, and would do so while `truncate` is still a dead end in
|
||||
> the GUI. **Do not remove this block until Stage 5 has merged**, and
|
||||
> read Q#HS1's four time-box items before concluding otherwise.
|
||||
> **RULE 4 APPLIES AT STAGE 5's MERGE, AND NOT BEFORE.** The arc closes
|
||||
> at Stage 5 (GPU horizontal scroll). Q#HS1 split the GPU out
|
||||
> deliberately and time-boxed it; retiring this lane at Stage 4's merge
|
||||
> would have orphaned exactly the half the time box exists to
|
||||
> guarantee, while `truncate` was still a dead end in the GUI. **Do not
|
||||
> remove this block until Stage 5 has merged** — and when it does, the
|
||||
> handoff bullets are what makes removal legitimate rather than lossy.
|
||||
|
||||
**Branch `horizontal-scroll`**, based on `githubsucks/main` @ `02f3ec3`
|
||||
(the #221 merge). `githubsucks/horizontal-scroll` is the authoritative
|
||||
|
|
@ -457,9 +457,85 @@ tip — the ref, not a SHA, since any edit to this block advances past
|
|||
whatever SHA it records. Recover:
|
||||
`git fetch githubsucks && git checkout horizontal-scroll`.
|
||||
|
||||
**Status: `docs/horizontal-scroll-framing.md` revision 4 — APPROVED
|
||||
2026-08-07. Stage 4 implemented; **PR #222 OPEN**, awaiting review. Do
|
||||
not merge unprompted.**
|
||||
**Stage 4 MERGED as #222** (`2b56d16`). Long lines are now reachable
|
||||
**in the TUI**; the GUI half is Stage 5.
|
||||
|
||||
**Branch `gpu-horizontal-scroll`**, based on `githubsucks/main` @
|
||||
`2b56d16`. `githubsucks/gpu-horizontal-scroll` is the authoritative tip
|
||||
— the ref, not a SHA. Recover:
|
||||
`git fetch githubsucks && git checkout gpu-horizontal-scroll`.
|
||||
|
||||
**Status: PR #223 OPEN, awaiting CI. DO NOT MERGE until the user says
|
||||
so.** `https://github.com/levineuwirth/pmacs/pull/223`. Framing revision
|
||||
4 approved 2026-08-07; review round 1 answered 2026-08-08 (the
|
||||
completion point-predicate defect below).
|
||||
|
||||
**The tip is the ref, `githubsucks/gpu-horizontal-scroll`, not a SHA
|
||||
written here.** The first version of this line pinned `55faa45` — which
|
||||
the very commit that wrote it invalidated, because recording the PR
|
||||
moved the head. Verify CI against the PR's live `headRefOid`, never
|
||||
against a SHA quoted in a document.
|
||||
|
||||
G1 pixels (exact conversion via the
|
||||
supported monospace advance); G2 automatic cursor-follow only, zeroing
|
||||
on **both** wrap transition and `BufferSnapshot`; G3 monospace-only by
|
||||
the existing font contract; G4 minimap unchanged; G5 accepted whole —
|
||||
**all twelve witnesses written and mutation-tested** (see below).
|
||||
|
||||
**Scope: local GPU viewport state.** No wire message, no protocol bump,
|
||||
no command surface, no minimap movement.
|
||||
|
||||
**ONE APPROVED EXCEPTION TO THAT SCOPE** (user, 2026-08-08; recorded in
|
||||
the framing doc at §1.2a). Q#G5's
|
||||
TUI-parity witness asks for agreement that is "checkable rather than
|
||||
asserted". Two tests in two crates asserting the same literal is not
|
||||
that — it is exactly the structural duplication
|
||||
`pmacs-protocol::scroll`'s own module docs condemn, and that module
|
||||
exists because **this arc already shipped that defect** (the scroll
|
||||
indicator, fixed in one copy and left wrong in the other). So the follow
|
||||
rule moved to `pmacs_protocol::scroll::follow_left`, beside `classify`,
|
||||
and **both** frontends call it: `src/editor.rs::horizontal_follow`
|
||||
delegates, and the GPU converts px ↔ columns around it (exact by Q#G3).
|
||||
|
||||
What it costs: Stage 5 touches `src/editor.rs`, which the scope line
|
||||
above does not cover. What it buys: the two frontends *cannot* choose
|
||||
different edges. The approval turned on what it does **not** do — it
|
||||
moves no viewport state, adds no wire message, and needs no
|
||||
protocol-version bump.
|
||||
|
||||
**Review round 1 (2026-08-08) — one non-zero-offset defect, fixed.**
|
||||
`completion_anchor_px` reused `survives_code_clip_left` and passed
|
||||
`line_height` as the horizontal extent: **a vertical dimension standing
|
||||
in for a horizontal one**. An anchor up to a line-height left of the
|
||||
gutter survived, and `completion_dropdown_rect` clamps `ax` against the
|
||||
right margin only — so the popup painted over the line numbers. Now a
|
||||
point predicate, `screen_x < code_clip_left()`.
|
||||
|
||||
**The lesson is about the witness, not the predicate.** The existing
|
||||
test placed the anchor 200px off-left, which fails a width-based
|
||||
predicate too — it stayed green straight through the defect and the
|
||||
mutation battery agreed with it, because the battery only ever asked
|
||||
whether *removing* the check was caught. **A boundary this stage cares
|
||||
about must be tested AT the boundary**: the replacement straddles the
|
||||
edge by ±0.05px and additionally asserts the popup's own left edge stays
|
||||
out of the gutter, which is what makes "`completion_dropdown_rect` needs
|
||||
no left clamp" a checked claim rather than a comment.
|
||||
|
||||
**Its first finding corrects Stage 4's framing.** §1.3 there said the
|
||||
GPU "needs a mechanism that does not exist", and I endorsed the
|
||||
Stage 4/5 split partly on that basis. Half of it was right —
|
||||
`Scroll::horizontal` really is discarded, because glyphon 0.11 never
|
||||
applies it — but the document `TextArea` already carries an explicit
|
||||
`left` origin and a `TextBounds` clip, and shifting that origin is the
|
||||
same "paint from 0, clip at the edge" shape the grid uses. The split
|
||||
stays right (the three consumers below are real work), but it was
|
||||
justified partly by an overstatement.
|
||||
|
||||
The real work is applying **one** offset to the three consumers Stage
|
||||
4's framing did name correctly: the caret (`code_byte_px`), decoration
|
||||
geometry (`push_glyph_extent_rects`), and hit testing
|
||||
(`gutter_aware_rel_x`). **No wire and no version bump** — the GPU owns
|
||||
its viewport locally, exactly as it owns `scroll_top`.
|
||||
|
||||
**What Stage 4 shipped**, beyond the `view_left` contract below:
|
||||
|
||||
|
|
@ -486,6 +562,32 @@ not merge unprompted.**
|
|||
incident this session with a complete signature, so a matchable row
|
||||
rather than a `U` note.
|
||||
|
||||
**What Stage 5 shipped, beyond the offset itself:**
|
||||
|
||||
- **`crop_to_code_clip_left`, and `survives_code_clip_left` delegating
|
||||
to it.** One boundary rule, so a caret the crop would discard is never
|
||||
painted. The washes **crop** rather than drop — a selection running in
|
||||
from off the left edge must paint the part that IS visible, which is
|
||||
the same boundary Stage 4's review caught the TUI painter getting
|
||||
wrong.
|
||||
- **Twelve witnesses, each mutation-tested.** Eleven production
|
||||
mutations (unshifted wash x, uncropped wash, unshifted math origin,
|
||||
uncropped math rule, untested caret left edge, missing snapshot reset,
|
||||
missing wrap reset, unhidden completion anchor, unscrolled glyphs,
|
||||
inverted hit-test sign, pixel-instead-of-column snap) each fail the
|
||||
intended witness as an **assertion** failure, not a compile error.
|
||||
The minimap-stability witness was mutation-tested separately by
|
||||
threading the offset into `minimap_vertex_bytes`.
|
||||
- **The glyph-motion witness exists because the first pass lacked it.**
|
||||
The gutter byte-identity test's "the code area must actually have
|
||||
moved" assertion is satisfied by a decoration wash and the caret
|
||||
alone: it **passed with `TextArea.left` pinned to `text_left`**. The
|
||||
mutation battery caught that, not review. Its replacement isolates the
|
||||
glyph layer — no decorations, and a source line carrying no caret.
|
||||
- **`R8`** in `docs/ci-red-signatures.md` — a **deterministic**,
|
||||
pre-existing `m4_acceptance` listview failure, confirmed on `main` by
|
||||
merge-base control. Not this lane's, and deliberately not fixed here.
|
||||
|
||||
**Answered by the user 2026-08-07:**
|
||||
|
||||
- **Q#HS1 — the GPU is Stage 5, not Stage 4.** A conscious, bounded
|
||||
|
|
@ -536,7 +638,7 @@ and a source comment, neither of which a user sees if they set the mode
|
|||
in `init.lua`. **A real, small user-facing gap shipped in #221**;
|
||||
amending the description is now a Stage 4 deliverable (framing §6).
|
||||
|
||||
### What Stage 3 shipped that Stage 4 must live with
|
||||
### What Stages 3 and 4 shipped that Stage 5 must live with
|
||||
|
||||
- **`ui.line-wrap` is BUFFER-local** (`ConfigKind::Enum`,
|
||||
`wrap`/`truncate`, default `wrap`). Q#LL2 recorded the consequence
|
||||
|
|
|
|||
|
|
@ -85,10 +85,10 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
|
||||
## 1. Where the project stands (2026-08-07)
|
||||
|
||||
- **QoL arc — Stages 1-3 merged (#219, #220, #221); Stages 4 AND 5
|
||||
remain, and the arc closes at Stage 5.** From one daily-driver
|
||||
report: terminal zoom broke TUI rendering and did nothing in the GUI,
|
||||
and a long line was unreadable past the edge.
|
||||
- **QoL arc — Stages 1-4 merged (#219, #220, #221, #222); Stage 5
|
||||
remains, and the arc closes there.** From one daily-driver report:
|
||||
terminal zoom broke TUI rendering and did nothing in the GUI, and a
|
||||
long line was unreadable past the edge.
|
||||
- **#219** made the grid TUI honor `full_grid`, so a post-resize
|
||||
resync blanks the host before repainting.
|
||||
- **#220** gave the GUI native zoom over the font preference that
|
||||
|
|
@ -125,24 +125,54 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
drawable clip. Neither `view_range` (it carries
|
||||
`SCROLL_OVERSCAN` past the window) nor `scroll_top` (it ignores
|
||||
`code_scroll_residual`) can answer it.
|
||||
- **Stage 4 is horizontal scroll in the TUI; Stage 5 is the GPU**, a
|
||||
split decided rather than inherited (framing Q#HS1) and time-boxed:
|
||||
Stage 5 is the immediately-next QoL lane after Stage 4 merges, and
|
||||
`wrap` stays the default until it lands — which is what keeps the
|
||||
divergence invisible to anyone who has not opted in.
|
||||
- **#222 made `truncate` navigable in the TUI**: moving the cursor
|
||||
past the right edge scrolls the view. **Automatic only** — no
|
||||
commands, no bindings (framing Q#HS2). Durable facts:
|
||||
- **`view_left` is an UNSNAPPED per-window display column, and each
|
||||
line derives its own effective edge.** A setter-time snap cannot
|
||||
exist: one column can bisect a wide glyph on one line and be an
|
||||
ordinary boundary on the next, so no single snapped value is
|
||||
canonical for every visible line, and snapping per line would
|
||||
break vertical alignment. Per-window (not buffer, unlike
|
||||
`ui.line-wrap`) because two panes on one buffer scroll
|
||||
independently.
|
||||
- **The walk stays LINE-absolute; only the emit translates.** Tab
|
||||
expansion depends on the absolute column from the line start, so
|
||||
a walk beginning at the edge puts tab stops in the wrong place.
|
||||
- **Every visible cell has a designated byte**: ordinary character →
|
||||
its own start; bisected wide glyph → the **glyph's start** (its
|
||||
trailing cell paints a styled blank, never a `Continuation`, which
|
||||
would name an off-screen cell); tab expansion → the byte **after**
|
||||
the tab (pre-existing, preserved). The wide glyph rounds backward
|
||||
and the tab forward, on one principle: every cell maps to the byte
|
||||
a user would mean by clicking it.
|
||||
- **`Viewport::visible_cols` is the single clip rule, with five
|
||||
adopters** — syntax/LSP styling, diagnostic underlines, search
|
||||
washes, `BufferStyleOverlay`, and the selection painter. Review
|
||||
found the first version had translated only the base glyph walk,
|
||||
leaving decorations painting at absolute columns; a second round
|
||||
found the selection painter keeping its own copy on a false
|
||||
premise about needing a different width.
|
||||
`StyleSpanOverlay`/`VirtualCellOverlay` are **excluded** by
|
||||
contract: viewport-relative already.
|
||||
- **`view_left` is reset to 0 on the wrap branch**, not merely
|
||||
ignored — inertness would leave a stale offset that reappears on
|
||||
the next toggle back to `truncate`.
|
||||
- **Persisted per leaf at `DESKTOP_VERSION = 1`** via
|
||||
`#[serde(default)]`. Nothing else in `src/desktop.rs` carries that
|
||||
attribute, so removing it makes serde reject every desktop saved
|
||||
before the field existed. A literal v1 JSON fixture guards it.
|
||||
- **No wire.** `view_left` is per-window viewport state, so no
|
||||
protocol message and no version bump.
|
||||
- **Stage 5 is the GPU half** — a split decided rather than inherited
|
||||
(framing Q#HS1), time-boxed: it is the immediately-next QoL lane,
|
||||
and **`wrap` stays the default until it lands**, which keeps the
|
||||
divergence invisible to anyone who has not opted in. Framing in
|
||||
`docs/gpu-horizontal-scroll-framing.md`.
|
||||
|
||||
`truncate` is incomplete without scroll: text past the right edge is
|
||||
currently *unreachable*. **That caveat is NOT in the setting's
|
||||
description** — `builtin/runtime/linewrap.lua:23` says only
|
||||
"truncate at the edge", and the word appears in
|
||||
`ui.toggle-line-wrap`'s status message and a source comment, which
|
||||
a user who sets the mode in `init.lua` never sees. A small
|
||||
user-facing gap shipped in #221; amending the description is a
|
||||
Stage 4 deliverable.
|
||||
|
||||
**Rule 4 must not retire the long-lines lane when Stage 4 merges** —
|
||||
the arc closes at Stage 5. Framing in
|
||||
`docs/horizontal-scroll-framing.md`.
|
||||
**Rule 4 removes the long-lines lane when Stage 5 merges** — the
|
||||
arc closes there, and these bullets are the precondition it depends
|
||||
on.
|
||||
|
||||
- **`main` @ `db1bbe9`.** The **tree primitive #217** — `listview` rows
|
||||
take optional `depth`/`id`, collapse is primitive-owned, folding is
|
||||
|
|
|
|||
|
|
@ -226,6 +226,30 @@ readiness helpers exist, whether they can be one, and what each
|
|||
promises. Patching this call site alone would leave the same question
|
||||
open under a fourth selector.
|
||||
|
||||
### R8 — LSP listview row renders a path relative to a root the test does not expect
|
||||
|
||||
The first row here that is **not intermittent**. It reproduces on every
|
||||
run, and the merge-base control has already been done — so the one
|
||||
question this registry exists to answer is settled for it, and settled
|
||||
against the branch that found it.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `--test m4_acceptance flat_listview_consumers_render_byte_identically_after_the_tree_extension` |
|
||||
| **job / flavor** | local (Linux), any invocation — isolated single-test runs included. Not load-sensitive |
|
||||
| **required fragments** | `the flat references row renders verbatim` **and** a `left` value that is the `right` value with a **leading directory removed** |
|
||||
| **status** | **deterministic locally, NOT attributed to the long-lines arc** |
|
||||
| **what IS established** | it fails identically on `main` and on `gpu-horizontal-scroll` with the working tree stashed — a merge-base control, not an inference from "my diff looks unrelated". The rendered row is the expected path with `/tmp/` stripped: `.tmpPZsycN/r.rs:12:3` vs `/tmp/.tmpPZsycN/r.rs:12:3` |
|
||||
| **what is NOT** | the mechanism. **This is a prefix strip, not a width truncation** — the fixture declares `CellSize::new(40, 100)`, so 25 characters fit with room to spare, and the missing text is at the front. The likeliest reading is that the row is rendered relative to a workspace root that resolves to `/tmp` on this machine, which would make it depend on `TMPDIR`. **Not verified**, and the alternative — that the renderer relativizes against something else entirely — is not excluded |
|
||||
| **why CI is green** | unestablished. If the `TMPDIR` reading is right, a runner whose temp dir is not directly under the relativization root would never see it. That is a hypothesis, not a finding |
|
||||
| **retirement** | a diagnosis of what the row is rendered relative to, then either fixing the renderer or making the assertion state the relativization it expects. A green run on a machine with a different `TMPDIR` retires nothing |
|
||||
|
||||
**Not this lane's to fix, and deliberately not fixed here.** Stage 5
|
||||
touches `pmacs-gpu`, `pmacs-protocol::scroll`, and `horizontal_follow`
|
||||
in `src/editor.rs`; it has no path to the LSP listview renderer. Fixing
|
||||
it inside this branch would put an unrelated, undiagnosed change in a
|
||||
viewport PR.
|
||||
|
||||
## Retired rows
|
||||
|
||||
**These stay here on purpose.** A retirement is a claim that a mechanism
|
||||
|
|
@ -460,6 +484,36 @@ be matched either. Recorded so a recurrence is recognisable.
|
|||
| **what is NOT** | any mechanism. Not reproduced in a later full `--tests --no-fail-fast` sweep (108 targets, exit 0) nor in 3 isolated `--lib` runs (1917/0 each) |
|
||||
| **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red |
|
||||
|
||||
### U3 — the R7 selector again, fragments lost the same way U2's were
|
||||
|
||||
`attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream`
|
||||
failed once during long-lines Stage 5's default-features sweep and
|
||||
passed on the recaptured rerun.
|
||||
|
||||
**This is not recorded as an R7 match, and the distinction is the
|
||||
point.** R7's job/flavor is `--features crdt`; this was
|
||||
default-features. More importantly its three required fragments
|
||||
(`transient sequence must attach`, `Handshake(Io(`, `BrokenPipe` /
|
||||
`code: 32`) are **unverified**, because the run's output was filtered to
|
||||
the failing test names before it was read. By this file's own matching
|
||||
rule that makes it a new incident, not a recurrence.
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
|
||||
| **job / flavor** | local (Linux), `cargo test --workspace --no-fail-fast` — **default features**, unlike R7 |
|
||||
| **required fragments** | **none captured** |
|
||||
| **status** | **new incident, not reproduced** |
|
||||
| **what IS established** | one failure; a recaptured rerun of the same command was green for this test, and the `--features crdt` sweep was green for it too. Per the rerun rule: **intermittence only** |
|
||||
| **what is NOT** | whether it is R7's mechanism. It may well be. Nothing in hand shows it |
|
||||
|
||||
**The recurring mistake is mine, and it is now twice.** U2 records the
|
||||
identical loss — "output was filtered to the `FAILED` line" — and I did
|
||||
it again here by piping a sweep through `grep`. The fix is mechanical:
|
||||
**redirect a full sweep to a file and grep the file**, never the live
|
||||
stream. A signature that is cheap to capture and impossible to
|
||||
reconstruct should never be traded for terminal brevity.
|
||||
|
||||
**The retirements are not occurrences and do not close the log.** R1 and
|
||||
R3 stay live, and each retired row keeps its signature so a later red
|
||||
matching one reopens it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
# GPU horizontal scroll — QoL Stage 5
|
||||
|
||||
**Status: revision 4 — APPROVED 2026-08-07. All five questions
|
||||
resolved. Implementation may begin within this scope.**
|
||||
|
||||
- **Q#G1** — the GPU-local offset is stored in **pixels**; parity
|
||||
conversion is exact via the supported monospace advance.
|
||||
- **Q#G2** — **automatic cursor-follow only**; reset on **both** the
|
||||
wrap transition and `BufferSnapshot`, with the specified pre-motion
|
||||
witnesses.
|
||||
- **Q#G3** — monospace-only, by the font contract that already exists.
|
||||
- **Q#G4** — the minimap does not move.
|
||||
- **Q#G5** — the complete verification set is accepted, including the
|
||||
snapshot-reset and minimap-stability witnesses.
|
||||
|
||||
**Scope boundary, restated because it is what makes this lane small:**
|
||||
local GPU viewport state only. **No wire message, no protocol bump, no
|
||||
command surface, no minimap movement.**
|
||||
|
||||
Revision 3 fixes four things review found in revision 2: Q#G1 still
|
||||
carried two claims Q#G3 had already falsified; Q#G2 was missing the
|
||||
**buffer-snapshot** reset; Q#G5's "nothing paints into the gutter" is
|
||||
**impossible** as written, because the gutter legitimately holds line
|
||||
numbers and diagnostic signs; and an off-left completion anchor
|
||||
**hides** the popup rather than closing it, which is a protocol
|
||||
distinction this lane must not blur.
|
||||
|
||||
Revision 2 had answered two functional findings: §1.1's "three
|
||||
consumers" was incomplete because the manual quad/squiggle renderers
|
||||
have **no code-area scissor at all**, and Q#G2's "inert under wrap" was
|
||||
too weak — the offset must be **reset to zero**, as the TUI already
|
||||
does.
|
||||
|
||||
**This closes the QoL arc.** Stage 1 (#219) made the TUI survive
|
||||
terminal zoom; Stage 2 (#220) gave the GUI native zoom; Stage 3 (#221)
|
||||
added `ui.line-wrap`; Stage 4 (#222) made `truncate` navigable **in the
|
||||
TUI only**. Stage 5 is the GPU half, and it is the lane Q#HS1's time box
|
||||
exists to guarantee: it is the immediately-next QoL work, and `wrap`
|
||||
stays the default until it lands.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stage 4's framing was wrong about the hard part
|
||||
|
||||
**§1.3 of `docs/horizontal-scroll-framing.md` said the GPU "needs a
|
||||
mechanism that does not exist" and called it the fact most likely to
|
||||
invert the cost estimate. It was half right and the half it got wrong
|
||||
is the expensive half.**
|
||||
|
||||
What it got right: `Scroll::horizontal` **is** discarded throughout,
|
||||
because glyphon 0.11 never applies it when placing glyphs
|
||||
(`pmacs-gpu/src/main.rs:1611`, `:6316`, `:8020`, asserted at `:16266`,
|
||||
`:16337`, `:16737`). Scrolling via cosmic-text's own scroll is not
|
||||
available.
|
||||
|
||||
What it got wrong: that is not the only mechanism. **The document
|
||||
`TextArea` already carries an explicit origin and a clip rectangle**
|
||||
(`pmacs-gpu/src/main.rs:8712`):
|
||||
|
||||
```rust
|
||||
TextArea {
|
||||
buffer: &self.buffer,
|
||||
left: text_left, // paint origin
|
||||
top: TEXT_TOP,
|
||||
bounds: TextBounds { left: gutter_clip_left, right: text_bounds_right, .. },
|
||||
..
|
||||
}
|
||||
```
|
||||
|
||||
Horizontal scroll is `left: text_left - offset_px` with `bounds.left`
|
||||
unchanged. glyphon clips to `bounds`, so glyphs pushed left of the
|
||||
gutter simply are not painted — which is the same "paint from column 0,
|
||||
clip at the edge" shape the grid renderer uses, expressed in pixels.
|
||||
|
||||
**This is a mechanism the file already relies on**, not a new one:
|
||||
`gutter_clip_left` exists precisely so the gutter does not get painted
|
||||
over.
|
||||
|
||||
**Why the correction matters beyond the estimate.** Stage 4's framing
|
||||
used §1.3 to justify splitting the GPU out, and I endorsed the split on
|
||||
that basis. The split is still right — the *consumers* below are real
|
||||
work, and shipping them inside Stage 4 would have made one reviewable
|
||||
change into two unreviewable ones — but it was justified partly by a
|
||||
claim that overstated the difficulty. Recorded here rather than quietly
|
||||
dropped.
|
||||
|
||||
### 1.1 The real work is a shared transform AND a shared clip
|
||||
|
||||
**Revision 1 said "three consumers" and that was wrong in a way that
|
||||
would have shipped a defect.** Shifting the `TextArea` clips *glyphon's*
|
||||
text, because glyphon honors `TextBounds`. **The manual quad and
|
||||
squiggle renderers have no code-area scissor at all** — nothing stops
|
||||
them painting into the gutter, and today nothing needs to, because no
|
||||
code-relative x can be negative. Scrolling makes that false.
|
||||
|
||||
So Stage 5 needs **two** shared things, not one offset:
|
||||
|
||||
1. **One screen↔code transform.** `code_x → screen_x` is
|
||||
`text_left() - offset_px + code_x`, and hit testing is its exact
|
||||
inverse. Applied once, in one place, or the consumers disagree.
|
||||
2. **One code clip rectangle.** `[gutter_clip_left, text_bounds_right)`
|
||||
— the same bounds the `TextArea` gets, expressed for the paths
|
||||
glyphon does not clip. **Every code-relative painter must intersect
|
||||
with it**, and that is a new obligation, not a threading exercise.
|
||||
|
||||
The paths that need both:
|
||||
|
||||
| path | site | what breaks without the clip |
|
||||
|---|---|---|
|
||||
| caret rect in clip | `:9698` | **asserts the caret cannot precede `text_left`** — its comment says so explicitly. False after scrolling; the caret paints over the gutter |
|
||||
| caret-painted predicate | `:9734` | same missing left-edge test. **So revision 1's claim that the scroll indicator "inherits the fix" is FALSE** — `code_byte_painted` reuses this and would call an off-left byte painted |
|
||||
| glyph extent rects | `:9766` | washes, squiggles and selection extents must be **cropped** at the gutter edge, not merely offset |
|
||||
| inline math origins | `:9434` | math boxes derive from the code origin and would render into the gutter |
|
||||
| completion anchor | `:7606` | an off-left anchor must **hide** the popup — it already returns `None` when scrolled out, and closure is the daemon's `CompletionPopup { anchor: None }`, not this lane's |
|
||||
|
||||
The two caret sites are the sharpest: `:9698` does not merely lack a
|
||||
check, it **documents the absence as safe** (*"right of the gutter isn't
|
||||
needed: the caret x can't precede `text_left`"*). A comment asserting an
|
||||
invariant this lane deletes is worse than silence, so it must be
|
||||
rewritten rather than merely joined by a new test.
|
||||
|
||||
### 1.2 No wire, and that is not an accident
|
||||
|
||||
The GPU **owns its viewport locally** — `scroll_top` and
|
||||
`code_scroll_residual` are local state, never sent. A horizontal offset
|
||||
is the same kind of state, so Stage 5 adds **no protocol message and no
|
||||
version bump**, exactly as Stage 4 added none.
|
||||
|
||||
This is worth stating because the parallel with `ui.line-wrap` is
|
||||
misleading: the *mode* is buffer state and needed `LineWrapFacts` at
|
||||
v22, but the *offset* is viewport state and needs nothing.
|
||||
|
||||
### 1.2a The one approved exception to "local GPU viewport state"
|
||||
|
||||
**Approved by the user 2026-08-08**, after implementation raised it.
|
||||
The scope line for this stage is *local GPU viewport state — no wire
|
||||
message, protocol bump, command surface, or minimap movement*. One
|
||||
change lands outside it, and only one:
|
||||
|
||||
**`pmacs_protocol::scroll::follow_left`.** The follow rule — *scroll the
|
||||
minimum distance that puts the cursor back inside* — now lives in the
|
||||
protocol crate beside `classify`, and **both** frontends call it:
|
||||
`src/editor.rs::horizontal_follow` delegates, and the GPU converts
|
||||
px ↔ columns around it (exact, per Q#G1/Q#G3).
|
||||
|
||||
*Why it is not scope creep.* Q#G5 requires a TUI-parity witness that is
|
||||
"checkable rather than asserted". Two tests in two crates asserting the
|
||||
same literal is not that — it is the structural duplication
|
||||
`pmacs-protocol::scroll`'s own module docs condemn, and **that module
|
||||
exists because this arc already shipped exactly that defect**: the
|
||||
scroll indicator, fixed in one copy and left wrong in the other. Without
|
||||
a shared rule there is no way to make the witness real.
|
||||
|
||||
*What it does not do*, which is what keeps it narrow and is the basis of
|
||||
the approval: it moves **no viewport state** (the TUI still owns
|
||||
`view_left`, the GPU still owns `code_scroll_left`), adds **no wire
|
||||
message**, and needs **no protocol-version bump**. `follow_left` is a
|
||||
pure function over values each side already holds — the identical
|
||||
argument `classify`'s module docs already make for living there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open questions
|
||||
|
||||
### Q#G1 — is the stored offset a column or a pixel count?
|
||||
|
||||
The TUI stores a display **column** (`view_left`). The GPU paints in
|
||||
**pixels**.
|
||||
|
||||
**Revision 2 left two claims here that its own Q#G3 answer had already
|
||||
falsified**: that the GPU's font "need not be monospace", and that Q#G3
|
||||
makes "column" ill-defined. Neither is true — the code font is
|
||||
monospace by contract, so a column has one well-defined width, measured
|
||||
by the existing `ADVANCE_PROBE` helper.
|
||||
|
||||
*My vote is unchanged — store **pixels** — but the reasons narrow to
|
||||
the ones that survive:* the GPU's other viewport state is already
|
||||
pixel-flavoured (`code_scroll_residual` is a float), and a pixel offset
|
||||
composes with the clip rectangle without rounding at every frame.
|
||||
|
||||
**Conversion is therefore exact, not approximate.** `columns × the
|
||||
supported monospace advance` is the definition, and it is what makes
|
||||
the unconditional TUI-parity witness in Q#G5 checkable at all. A
|
||||
proportional font would have made this a lossy conversion; the font
|
||||
contract means it never is.
|
||||
|
||||
### Q#G2 — how is the offset moved?
|
||||
|
||||
Stage 4 chose **automatic only**: the cursor-visibility pass gains a
|
||||
horizontal component. The GPU's analogous pass is
|
||||
**`ensure_caret_painted`** — named rather than cited by line, since
|
||||
revision 2 also invented a `follow_cursor` that does not exist.
|
||||
|
||||
*My vote: mirror it exactly*, so the two frontends agree on when the
|
||||
view moves. A GUI is also the place a horizontal **wheel/trackpad**
|
||||
gesture exists — but that is an explicit-scroll surface, which Stage 4
|
||||
deliberately deferred, and adding it here would make the frontends
|
||||
disagree again in the lane that exists to stop that.
|
||||
|
||||
**And the wrap transition must RESET the offset to zero, not merely
|
||||
ignore it.** Revision 1 said "inert under wrap", which is too weak:
|
||||
inertness hides a stale value that reappears the moment the buffer
|
||||
toggles back to `truncate`, before any cursor motion. The TUI does not
|
||||
rely on inertness — `horizontal_follow` (`src/editor.rs`) assigns
|
||||
`view_left = 0` on the wrap branch and returns.
|
||||
|
||||
The GPU must specify the **identical lifecycle**, and `apply_line_wrap`
|
||||
is where it belongs, beside the reflow it already performs.
|
||||
|
||||
**And a second reset the TUI has no analogue for: the buffer
|
||||
snapshot.** The GPU zeroes `scroll_top` and `code_scroll_residual`
|
||||
whenever a snapshot installs a new buffer — the offset is viewport
|
||||
state tied to the document being shown, and it must reset there for the
|
||||
same reason they do. Without it a buffer switch **inherits the previous
|
||||
document's leftward viewport**, showing the new buffer scrolled
|
||||
sideways until a cursor motion repairs it.
|
||||
|
||||
That is a worse symptom than the wrap case, because nothing about the
|
||||
new buffer explains it. Both resets are lifecycle requirements with
|
||||
their own witnesses (Q#G5), not properties that fall out of the
|
||||
transform.
|
||||
|
||||
### Q#G3 — proportional fonts **ANSWERED: they do not occur**
|
||||
|
||||
> **Revision 1 asked the wrong question, from a false premise.** It said
|
||||
> "the GPU can resolve a non-monospace family" and proposed accepting a
|
||||
> new TUI/GPU divergence to accommodate it.
|
||||
>
|
||||
> **The GPU does not resolve proportional code fonts.**
|
||||
> `family_is_monospace_everywhere` (`pmacs-gpu/src/main.rs:8199`) gates
|
||||
> the family across all four weight/style combinations, `apply_font_facts`
|
||||
> (`:8235`) falls back when it fails, and
|
||||
> `unresolvable_and_proportional_families_fall_back` **requires** that
|
||||
> fallback. A proportional family cannot become the code font.
|
||||
>
|
||||
> So the answer is **monospace-only, by the font contract that already
|
||||
> exists** — not a new constraint this lane imposes, and not a
|
||||
> divergence to negotiate. Revision 1 would have introduced a
|
||||
> font-dependent behavior difference to solve a problem the codebase had
|
||||
> already solved, in the lane whose entire purpose is removing
|
||||
> unchosen divergence.
|
||||
>
|
||||
> **The consequence for Q#G5**: the monospace TUI-parity witness is
|
||||
> **unconditional** for every font the GPU supports, rather than gated
|
||||
> on a font check. That is a stronger test, and it exists only because
|
||||
> the premise was corrected.
|
||||
|
||||
Pixel storage (Q#G1) is unaffected and still preferred — but its
|
||||
column↔pixel conversion is now defined against **the supported
|
||||
monospace advance**, which is a well-defined quantity rather than an
|
||||
approximation.
|
||||
|
||||
### Q#G4 — does the minimap move?
|
||||
|
||||
The minimap draws line-shape bands. Horizontal scroll does not change
|
||||
which lines exist.
|
||||
|
||||
*My vote: no.* The minimap is a whole-document overview; scrolling the
|
||||
text sideways should not scroll it.
|
||||
|
||||
### Q#G5 — what does the verification look like?
|
||||
|
||||
The GPU suite is headless-render based (`headless_or_skip`), and
|
||||
`PMACS_REQUIRE_GPU=1` makes a missing adapter a failure rather than a
|
||||
skip.
|
||||
|
||||
Sketch, pending Q#G1/G2/G4:
|
||||
|
||||
- **The gutter is unchanged by scrolling.** Revision 2 proposed
|
||||
asserting that *nothing* paints left of `gutter_clip_left`, which is
|
||||
**impossible**: with line numbers on, the gutter deliberately holds
|
||||
digit glyphs and diagnostic-sign quads. The assertion would fail on a
|
||||
correct implementation.
|
||||
|
||||
The checkable form of the same intent: **the gutter rectangle is
|
||||
byte-identical before and after a horizontal scroll**, and separately,
|
||||
only *code-relative* output geometry is inspected for the left-edge
|
||||
rule. That still catches the failure a per-painter test would miss —
|
||||
a code painter bleeding into the gutter changes those pixels — while
|
||||
remaining true of a working build.
|
||||
- **A left-clipped caret predicate.** `code_caret_rect_in_clip` and
|
||||
`caret_painted_in_code_clip` must both report *not painted* for a
|
||||
caret scrolled off-left. This is what makes the scroll indicator
|
||||
correct; revision 1 wrongly assumed it came for free.
|
||||
- **Math and rule clipping**: an inline math box and a decoration rule
|
||||
whose origins are left of the edge are cropped or culled, not drawn.
|
||||
- **An off-left completion anchor HIDES the popup; it does not close
|
||||
it** — and the boundary is a **point**, tested at the edge.
|
||||
|
||||
*Added after review found the first implementation wrong here.* It
|
||||
reused `survives_code_clip_left` and passed `line_height` as the
|
||||
horizontal extent: a vertical dimension standing in for a horizontal
|
||||
one. An anchor up to a line-height left of the gutter therefore
|
||||
survived, and `completion_dropdown_rect` bounds `ax` against the right
|
||||
margin only — so the popup painted over the line numbers. An anchor is
|
||||
a position between glyphs with no width of its own, so the predicate
|
||||
is `screen_x < code_clip_left()`.
|
||||
|
||||
**The far-off-left witness cannot catch this**, which is why it stayed
|
||||
green: 200px off-left fails a width-based predicate too. The witness
|
||||
must **straddle** the edge — the same anchor a fraction of a pixel
|
||||
either side — and assert the popup's own left edge stays out of the
|
||||
gutter on the visible side.
|
||||
|
||||
The distinction is a protocol one and revision 2 got it wrong.
|
||||
`completion_anchor_px` returns `None`, so nothing draws — but the
|
||||
daemon-owned completion state and its key handling are retained.
|
||||
Actual closure is `CompletionPopup { anchor: None }`, which is the
|
||||
daemon's to send. So the witness is: **no completion paint while the
|
||||
anchor is off-left, and the popup reappears when it scrolls back into
|
||||
view** — session semantics unchanged. A lane about viewport geometry
|
||||
must not quietly redefine when a completion ends.
|
||||
- **The three consumers agree with the shifted origin** — caret,
|
||||
decoration rect, and hit test at one non-zero offset, since a partial
|
||||
fix shows up as disagreement between them.
|
||||
- **Round-trip hit testing**: pixel → byte → pixel at a non-zero
|
||||
offset.
|
||||
- **The wrap transition resets to zero** (Q#G2): scroll right under
|
||||
`truncate`, toggle to `wrap`, toggle back, and assert the offset is 0
|
||||
**before any cursor motion**. An inertness-only implementation passes
|
||||
a "wrap looks right" test and fails this one.
|
||||
- **The buffer snapshot resets to zero** (Q#G2, second lifecycle rule).
|
||||
Revision 3 added the requirement and left it untested, which is how a
|
||||
lifecycle rule quietly becomes a comment. Scroll to a non-zero offset
|
||||
in buffer A, install a **buffer B snapshot**, and assert the offset is
|
||||
0 **and B renders at its code origin — before any `CursorByte`
|
||||
arrives**. The pre-cursor scoping is the whole test: a later cursor
|
||||
motion repairs the offset anyway, so a witness that waits for one
|
||||
cannot distinguish "reset on snapshot" from "repaired on first
|
||||
motion", which is precisely the bug.
|
||||
- **The minimap does not move** (Q#G4). The implementation already
|
||||
supports the vote — the minimap derives from the summary, the surface
|
||||
dimensions and `scroll_top`, with no horizontal input — so this pins
|
||||
an existing property rather than asking for new work, and that is the
|
||||
reason to write it: an offset threaded one seam too far would break
|
||||
it silently. Assert **equal minimap vertices (or pixels) before and
|
||||
after a non-zero horizontal offset**, with vertical state unchanged.
|
||||
- **A TUI-parity witness**, now **unconditional** (Q#G3): the same
|
||||
buffer and the same column offset yield the same first visible
|
||||
character in both frontends. This is what makes "the frontends agree"
|
||||
checkable rather than asserted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Coherence impact (§20)
|
||||
|
||||
- **§16 Semantic Frontend Architecture — the direct target.** This lane
|
||||
*closes* the divergence Stage 4 opened deliberately. The release notes
|
||||
should say the gap is closed, since Stage 4's said it existed.
|
||||
- **Journey step 4 is NOT completed by this lane, and revision 1
|
||||
claimed it was.** Step 4 ("Understand interface") is scored on
|
||||
**welcome / help / tutorial discoverability**, and `COHERENCE.md:395`
|
||||
holds it **Partial** for reasons this lane does not touch: `C-h`
|
||||
deletes a word (deliberately, §18) and there is no tutorial.
|
||||
Long-line reachability is interface *comprehension*, which the step
|
||||
benefits from without being scored on.
|
||||
|
||||
So the honest claim is **preserving and improving interface
|
||||
comprehension**, with no scorecard movement — and a scorecard edit
|
||||
here would need new journey evidence, which this lane does not
|
||||
produce. Recorded because writing an unearned ✔ into a scorecard is
|
||||
how a coherence document stops being ground truth.
|
||||
- **After this merges, Q#HS6 becomes live**: whether `wrap` remains the
|
||||
default is revisitable on use evidence once parity exists. Not part
|
||||
of this stage.
|
||||
- **Rule 4 applies at this merge**: the arc is done, so the long-lines
|
||||
lane in `docs/active-work.md` is removed — after its durable facts
|
||||
reach `docs/agent-handoff.md`, which is the precondition, not a
|
||||
formality.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -102,6 +102,51 @@ pub fn classify(
|
|||
}
|
||||
}
|
||||
|
||||
/// Move a horizontal viewport's left edge so `cursor_col` is visible,
|
||||
/// returning the new edge. All three arguments and the result are
|
||||
/// **columns**.
|
||||
///
|
||||
/// # Why this is shared, and why in columns
|
||||
///
|
||||
/// This is the horizontal twin of [`classify`], and it is here for the
|
||||
/// same reason spelled out in the module docs: the two frontends were
|
||||
/// about to hold one rule twice. The TUI stores its edge as a column
|
||||
/// (`Window::view_left`); `pmacs-gpu` stores pixels, because its
|
||||
/// geometry comes from cosmic-text advances. Long-lines Stage 5 Q#G1
|
||||
/// settles that difference as a **conversion, not a second rule** — the
|
||||
/// GPU rejects non-monospace code fonts (Q#G3), so px ↔ column is exact
|
||||
/// through the resolved advance, and the GPU divides on the way in and
|
||||
/// multiplies on the way out.
|
||||
///
|
||||
/// Columns, not pixels, is the shared unit because it is the one both
|
||||
/// sides can name. A pixel rule would force the TUI into float
|
||||
/// arithmetic over a quantity that is integral by construction, and an
|
||||
/// off-by-one from rounding there is a character the user cannot read —
|
||||
/// the whole complaint this arc answers.
|
||||
///
|
||||
/// # The rule
|
||||
///
|
||||
/// Scroll the minimum distance that puts the cursor back inside, so a
|
||||
/// cursor already visible never moves the view. `width == 0` means
|
||||
/// nothing has been laid out yet: no column is visible, so no edge is
|
||||
/// better than another and the current one stands.
|
||||
#[must_use]
|
||||
pub fn follow_left(left: u32, cursor_col: u32, width: u32) -> u32 {
|
||||
if width == 0 {
|
||||
return left;
|
||||
}
|
||||
if cursor_col < left {
|
||||
cursor_col
|
||||
} else if cursor_col >= left.saturating_add(width) {
|
||||
// `+ 1` puts the cursor's own column at the right edge rather
|
||||
// than one past it. No underflow: this arm implies
|
||||
// `cursor_col >= width`.
|
||||
cursor_col.saturating_add(1) - width
|
||||
} else {
|
||||
left
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -215,4 +260,52 @@ mod tests {
|
|||
"correct by arithmetic here, not by saturation"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor already inside the window never moves the edge — the
|
||||
/// property that separates "follow" from "center".
|
||||
#[test]
|
||||
fn a_visible_cursor_leaves_the_edge_alone() {
|
||||
for col in 10..90 {
|
||||
assert_eq!(follow_left(10, col, 80), 10, "column {col} is visible");
|
||||
}
|
||||
}
|
||||
|
||||
/// Both edges, minimally.
|
||||
#[test]
|
||||
fn the_edge_moves_the_minimum_distance_in_each_direction() {
|
||||
// Left: the cursor's own column becomes the first visible one.
|
||||
assert_eq!(follow_left(10, 4, 80), 4);
|
||||
// Right: the cursor's own column becomes the LAST visible one,
|
||||
// which is `+ 1 - width`, not `- width`. Dropping the `+ 1`
|
||||
// parks the caret one column off the right edge — invisible,
|
||||
// and the exact defect this arc reports.
|
||||
assert_eq!(follow_left(10, 90, 80), 11);
|
||||
assert_eq!(follow_left(0, 79, 80), 0, "the last column still fits");
|
||||
assert_eq!(follow_left(0, 80, 80), 1, "one past it scrolls by one");
|
||||
}
|
||||
|
||||
/// Nothing laid out yet: no column is visible, so the edge stands
|
||||
/// rather than snapping to a cursor whose geometry is unknown.
|
||||
#[test]
|
||||
fn a_zero_width_viewport_holds_its_edge() {
|
||||
assert_eq!(follow_left(7, 0, 0), 7);
|
||||
assert_eq!(follow_left(7, 9999, 0), 7);
|
||||
}
|
||||
|
||||
/// The saturating arms are reachable arithmetic, not decoration.
|
||||
///
|
||||
/// At `cursor_col == u32::MAX` the saturation absorbs the `+ 1`, so
|
||||
/// the edge lands one column short of showing that column. Asserted
|
||||
/// as the value it actually produces rather than the value the rule
|
||||
/// would like: a line 4·10⁹ columns wide does not occur, and a test
|
||||
/// that lied about this arm to look tidy would be worse than the
|
||||
/// one-column imprecision it hid.
|
||||
#[test]
|
||||
fn extreme_columns_do_not_panic() {
|
||||
assert_eq!(follow_left(0, u32::MAX, 80), u32::MAX - 80);
|
||||
assert_eq!(follow_left(u32::MAX, 0, 80), 0);
|
||||
// `left + width` overflows; the cursor is nonetheless left of
|
||||
// the edge, so the first arm decides and nothing wraps.
|
||||
assert_eq!(follow_left(u32::MAX - 1, 5, 80), 5);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4277,20 +4277,21 @@ impl CompletionPopupKey {
|
|||
/// so the offset is pinned to 0 rather than merely ignored. Leaving a
|
||||
/// stale non-zero value would surface the moment the buffer toggled
|
||||
/// back to `truncate`.
|
||||
///
|
||||
/// **The arithmetic itself lives in `pmacs_protocol::scroll`** (Stage 5,
|
||||
/// Q#G5), beside `classify`, and for the same reason its module docs
|
||||
/// give: `pmacs-gpu` needs the identical rule and cannot see this crate.
|
||||
/// Held twice, the two copies drift — which is not hypothetical, it is
|
||||
/// 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) {
|
||||
if window.last_wrap == crate::view::WrapMode::Wrap {
|
||||
window.view_left = 0;
|
||||
return;
|
||||
}
|
||||
let cols = window.last_content_cols;
|
||||
if cols == 0 {
|
||||
return; // not rendered yet; nothing to be visible within
|
||||
}
|
||||
if cursor_col < window.view_left {
|
||||
window.view_left = cursor_col;
|
||||
} else if cursor_col >= window.view_left.saturating_add(cols) {
|
||||
window.view_left = cursor_col + 1 - cols;
|
||||
}
|
||||
window.view_left =
|
||||
pmacs_protocol::scroll::follow_left(window.view_left, cursor_col, window.last_content_cols);
|
||||
}
|
||||
|
||||
/// Scroll one window so its cursor stays visible, reckoning in
|
||||
|
|
|
|||
Loading…
Reference in New Issue