pmacs/docs/gpu-horizontal-scroll-frami...

18 KiB
Raw Permalink Blame History

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#G2automatic 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:

  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 FALSEcode_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 locallyscroll_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.