pmacs/docs/horizontal-scroll-framing.md

14 KiB
Raw Blame History

Horizontal scroll — QoL Stage 4

Status: revision 2 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the user (2026-08-07); Q#HS3HS5 stand; Q#HS7 is NEW and BLOCKING. No implementation may begin.

This closes the QoL arc opened by one daily-driver report. Stage 1 (#219) made the TUI survive terminal zoom; Stage 2 (#220) gave the GUI native zoom; Stage 3 (#221) added ui.line-wrap and made wrap the default. Stage 4 is the other half of the user's own sentence:

long lines need to either wrap somehow or be scrollable. […] This should also be something that the user can configure, whether to wrap or scrollable.

Stage 3 shipped the mode. It did not ship the navigation: under truncate, text past the right edge is not merely off-screen but unreachable.

Revision 1 claimed that caveat "is recorded in the setting's description". It is not. builtin/runtime/linewrap.lua:23 says only "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge." The word "unreachable" appears in ui.toggle-line-wrap's status message and in a source comment — neither of which a user sees if they set ui.line-wrap = "truncate" in init.lua and never invoke the toggle. That is a real, if small, user-facing gap shipped in #221, and §6 makes amending the description a Stage 4 deliverable rather than leaving the false claim standing.


1. What is actually there today

Verified in the tree at 02f3ec3, not recalled. Stage 3's revision 1 inverted its whole cost model by assuming both frontends consumed the same CellGrid; every claim below carries a citation for that reason.

1.1 There is no horizontal scroll anywhere

No view_left, scroll_left, or hscroll in src/ or builtin/. The window carries view_top, cursor, and goal_col (src/window.rs:374-376) and nothing horizontal. This is greenfield — not "extend the vertical mechanism sideways", because there is no shared abstraction to extend.

1.2 The grid walk starts every line at column 0

paint_line (src/text_view.rs:266) walks from the line's first character with no offset parameter, exactly as before Stage 3 — wrapping changed where rows break, not where the walk starts. place_of_byte and byte_at_place have the same shape.

So view_left enters the same functions Stage 3 just rewrote, and the wrap rule must stay written exactly once (advance_wrapped, src/text_view.rs:396). A second copy differing by an offset is the defect Stage 3 spent its review budget avoiding.

1.3 The GPU cannot use cosmic-text's horizontal scroll

The finding most likely to invert the cost estimate, so it leads.

Scroll::horizontal is discarded throughout the GPU, and not by oversight: glyphon 0.11 never applies it when placing glyphs. Documented at pmacs-gpu/src/main.rs:1611, :6316, :8020, and asserted by tests at :16266 ("horizontal is discarded"), :16337, :16737.

The GPU's half therefore cannot be "set the scroll and reshape". It needs a mechanism that does not exist — a shifted text origin at paint time, adjusted clip bounds, or something else — interacting correctly with the gutter, the caret (code_byte_px), decoration geometry (push_glyph_extent_rects), and hit testing (gutter_aware_rel_x), each of which assumes x starts at text_left().

Answered in Q#HS1: the GPU is Stage 5.

1.4 view_top is persisted; a view_left would want to be

SavedLeaf carries path, cursor, and view_top at DESKTOP_VERSION = 1 (src/desktop.rs:33, :276-280); the restore path clamps view_top against the line count (:512). Q#HS5.

1.5 The cursor-follow hazard is already documented

scroll_window (src/editor.rs:3628) carries the cursor with a vertical scroll, and its comment says why:

The cursor must follow the scroll: the renderer has an "auto-scroll to keep cursor visible" pass that would otherwise snap view_top straight back to wherever the cursor sits, so the user's mouse-wheel scroll would feel stuck after one notch.

Under Q#HS2's answer (automatic-only) this pass is not a hazard but the entire mechanism — Stage 4 adds its horizontal component. The hazard returns with explicit commands, which is why Q#HS4 is deferred rather than closed.

1.6 goal_col exists and its relationship to view_left is unexamined

goal_col (src/window.rs:376) remembers a target column across vertical motion, cleared at seven sites in src/editor.rs. It is a column within the line; view_left is a viewport offset. Both are horizontal state on the same window, and a design ignoring the interaction produces a cursor that jumps on the first vertical motion after a horizontal scroll. Feeds Q#HS7.


2. The scope fact, stated before the answers

Under wrap, horizontal scroll is meaningless — nothing sits past the right edge. So Stage 4's entire surface is conditional on a buffer-local mode: one user-facing question ("how do I see the rest of this line?") gets two disjoint answers depending on a setting. Stated here because COHERENCE.md §20 requires it, and answered in Q#HS6.


3. Questions

Q#HS1 — is the GPU in scope? ANSWERED: no — Stage 5

Answered 2026-08-07 (user): split the GPU work into Stage 5. "This is a conscious, bounded divergence, not a repeat of Stage 3's accidental one. Make the time box concrete and keep wrap default until parity lands."

The distinction is the load-bearing part. Stage 3's defect was never "the frontends differ" — it was "the frontends differ and nobody chose that". A divergence that is decided, recorded, and bounded is a different object from one inherited from a library default.

The time box, concrete (the user's requirement, and the part that makes this a decision rather than a deferral):

  1. Stage 5 is the immediately-next QoL lane after Stage 4 merges — not backlogged behind another arc. If something displaces it, that displacement is itself a decision to record here.
  2. wrap stays the default until Stage 5 lands (independently reaffirmed in Q#HS6). This is what keeps the divergence invisible to anyone who has not opted in: a default-configuration user is never exposed to it.
  3. Stage 4's release notes must state the asymmetry — horizontal scroll works in the TUI and not yet the GUI — in the same way #221's had to state the word-wrap loss.
  4. While the gap exists, the truncate affordances must name it. §6's description amendment is where that lands, so a GUI user choosing truncate learns the limitation from the setting rather than from the behavior.

Q#HS2 — what moves the viewport? ANSWERED: automatic only

Answered 2026-08-07 (user): "Automatic-only first. It makes the report's text reachable with no new command surface."

The cursor-visibility pass gains a horizontal component, so moving the cursor past the edge scrolls the view. No new commands, no binding decisions, no new interaction island.

Explicit ui.scroll-left / ui.scroll-right are not in Stage 4. They can follow with evidence of use, and they are what re-opens Q#HS4.

Q#HS3 — per window or per buffer? NOT ACTUALLY OPEN

view_left is per window, unambiguously: two panes on one buffer must scroll independently, exactly as they already hold independent view_tops (src/desktop.rs:92). Stage 3's Q#LL2 recorded this and accepted the consequence — the mode is buffer-local while the offset is per-window, so one user-facing concept spans two scopes.

Listed so the accepted split is re-confirmed where it takes effect rather than inherited silently.

Q#HS4 — does the cursor follow an explicit scroll? DEFERRED, not answered

Not live under Q#HS2's answer: automatic-only means every viewport move already originates from a cursor move. §1.5 holds the precedent and the hazard for whenever explicit commands arrive.

Deferring rather than deleting, because the hazard is real and rediscovering it costs more than carrying the paragraph.

Q#HS5 — does view_left survive a restart? OPEN

view_top does (§1.4). Consistency argues yes; a defaulted field avoids a DESKTOP_VERSION bump.

My vote: yes, defaulted, no version bump — but confirm SavedLeaf's deserializer tolerates a missing field before relying on it. §1.4 cites the struct's shape, not serde's behavior on it, and that gap is exactly the kind §1.3 exists to warn about.

Q#HS6 — the coherence statement ANSWERED: keep wrap default

Answered 2026-08-07 (user): "Keep wrap as default for now. Revisit only after GPU parity and use evidence; changing to truncate before Stage 5 would make the default unreachable in the GUI."

That last clause is the argument revision 1 missed. I had framed this as "if scroll makes truncate good, the default deserves re-examination" — but with the GPU deferred to Stage 5, a truncate default would ship a mode that is navigable in the TUI and a dead end in the GUI, for every user who never opened the setting. Q#HS1 and Q#HS6 are therefore coupled: the split is only safe because the default does not move.

Revisit after Stage 5, on use evidence, not before.

Q#HS7 — what IS view_left? NEW, BLOCKING

Revision 1 decided what moves the viewport without ever saying what the viewport offset is. That is the same omission Stage 3 would have made had it shipped WrapMode without DisplayCoord: the mode is useless until the coordinate contract is written down, and the contract is where every sharp edge lives.

Revision 1's verification sketch named tabs and wide characters. It had no oracle for either, because nothing defined what a left edge is. Four things must be settled together:

(a) The unit. Candidates: a source byte offset within the line; a display column (cells from the line start, after tab expansion); or a cell boundary with an explicit validity rule.

My vote: display column. Tab expansion depends on the absolute column from the line start, so the walk must begin at column 0 and compute forward regardless of the offset — which makes a byte offset buy nothing and lose tab correctness. Starting at 0 and suppressing paint until col >= view_left preserves tab stops for free, and costs no more than paint_line already pays under wrapping.

(b) Which columns may be a left edge. A tab straddling the edge is unambiguous: its expansion is width-1 spaces, so the remaining ones paint. A wide (width-2) glyph is not — a grid cannot paint half of one.

My vote: a left edge may not fall inside a wide glyph's cells.

(c) The snap rule when an invalid edge is requested. Stage 3's coordinate contract is "identity on canonical inputs; otherwise projection to the contract's designated canonical representative" — designated, not nearest, because the direction differs per function and "nearest" hides that.

My vote: snap toward the line start. Snapping left can only reveal a character, never hide one that was visible; snapping right can hide the very glyph the user scrolled to reach. And snapping at the moment view_left is set — rather than clipping in the painter — keeps one canonical value that both painter and mapper read, instead of two that can disagree.

(d) The invariant rendering and coordinate mapping share. For a given view_left, byte_at_place must invert place_of_byte on every canonical input, and paint_line must place exactly the bytes place_of_byte claims. If the painter clips where the mapper does not, clicks land on the wrong character — silently, and only for lines wide enough to scroll.

This is the invariant the verification sketch needs as its oracle, and it is why Q#HS7 blocks: §4 cannot be written until it exists.


4. Verification sketch (depends on Q#HS7)

  • Cell-level tests at several window widths at non-zero offset — the case Stage 3's sketch explicitly refused, because "at non-zero offset" was a view_left requirement smuggled into a wrap lane.
  • A wrap control for every claim, asserting the wrap path is byte-identical with a horizontal offset present. Under wrap the offset must be inert, not merely harmless.
  • Round-trip identity for place_of_byte / byte_at_place at non-zero offset — the Q#HS7(d) invariant, walked exhaustively over a short line rather than sampled, as Stage 3 established.
  • The Q#HS7(b)/(c) cases, which have no oracle until it is answered: a wide glyph straddling the left edge; a tab whose expansion straddles it; a snap request landing inside each.
  • A PTY acceptance test for reachability, following tests/long_line_readable_acceptance.rs. That file's truncate control currently asserts the tail is absent — Stage 4 must update it, and that update is itself the proof the caveat is gone.
  • No GPU witness in Stage 4 (Q#HS1). Stage 5 owes one that the caret, a decoration, and a hit test all agree with the shifted origin — the three consumers §1.3 names.

5. Coherence impact (§20 requirement)

  • Journey step 4, "Understand interface — Partial." Stage 4 completes "a line that cannot be read in full" for truncate in the TUI only. The scorecard row should say so rather than reading as closed.
  • §16 Semantic Frontend Architecture. Q#HS1 widens frontend divergence for the duration of the Stage 4→5 gap. Per the answer, this is deliberate and time-boxed, and materially different from Stage 3's inherited accident — but the release notes must say which kind it is, or a reader cannot tell them apart.
  • No new interaction island — automatic-only adds no command surface (Q#HS2).
  • Config registry: no new settings expected. Stage 4 navigates the mode Stage 3 declared. If it needs a setting, that is a signal the design has drifted, not a feature.

6. A Stage 4 deliverable that is not scroll

Amend ui.line-wrap's description (builtin/runtime/linewrap.lua). Today it says only "…or truncate at the edge", which does not tell a user that the edge is a wall. The honest text depends on where Stage 4 lands:

  • Before Stage 4, truncate means unreachable in both frontends.
  • After Stage 4, it means reachable in the TUI and unreachable in the GUI until Stage 5 (Q#HS1's time box, item 4).
  • After Stage 5, the caveat is gone and the sentence should shrink back.

Carried here rather than filed elsewhere because it is the one place the arc's user-visible honesty is currently wrong, and revision 1 asserted it was already right.