18 KiB
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):
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:
- One screen↔code transform.
code_x → screen_xistext_left() - offset_px + code_x, and hit testing is its exact inverse. Applied once, in one place, or the consumers disagree. - One code clip rectangle.
[gutter_clip_left, text_bounds_right)— the same bounds theTextAreagets, 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, andunresolvable_and_proportional_families_fall_backrequires 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_clipandcaret_painted_in_code_clipmust 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_leftand passedline_heightas 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, andcompletion_dropdown_rectboundsaxagainst 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 isscreen_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_pxreturnsNone, so nothing draws — but the daemon-owned completion state and its key handling are retained. Actual closure isCompletionPopup { 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 towrap, 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
CursorBytearrives. 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:395holds it Partial for reasons this lane does not touch:C-hdeletes 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
wrapremains 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.mdis removed — after its durable facts reachdocs/agent-handoff.md, which is the precondition, not a formality.