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