22 KiB
Horizontal scroll — QoL Stage 4
Status: revision 4 — every question answered; APPROVAL NOT YET RECORDED. No implementation may begin until it is.
| question | state |
|---|---|
| Q#HS1 — GPU in scope? | answered: no, Stage 5, time-boxed (§3) |
| Q#HS2 — what moves the viewport? | answered: automatic only |
| Q#HS3 — window or buffer? | re-confirmed: per window |
| Q#HS4 — cursor follows explicit scroll? | deferred — not live under HS2 |
Q#HS5 — persist view_left? |
approved: yes, no version bump, on two conditions |
| Q#HS6 — the default | answered: wrap stays |
Q#HS7 — what IS view_left? |
accepted: (a), (b), (c′), (c″), (d) |
Revision 4 adds only Q#HS7(c″) — the tab-straddle mapping — and fixes the handoff's "Stage 4 is the remainder" to name Stages 4–5.
Stage 4 does NOT close the QoL arc. Revision 1 said it did, and
that was written before Q#HS1 moved GPU horizontal scroll to Stage 5.
The claim is not merely stale — it is load-bearing in the wrong
direction: docs/active-work.md's Rule 4 removes a lane when its
ARC is done, so a framing asserting Stage 4 closes the arc would
license retiring this lane at the TUI merge, orphaning the very
Stage 5 that Q#HS1's time box exists to guarantee. The arc closes at
Stage 5.
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 TUI half of 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_topstraight 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
wrapdefault 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):
- 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.
wrapstays 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.- 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.
- While the gap exists, the
truncateaffordances must name it. §6's description amendment is where that lands, so a GUI user choosingtruncatelearns 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? APPROVED: yes
Approved 2026-08-07 (user): persist
view_leftwithout a desktop-version bump, provided implementation adds#[serde(default)]and a literal v1 JSON fixture omitting the field, asserting restoration at zero. Both conditions are part of the approval, not advice attached to it.
view_top does (§1.4). Consistency argues yes.
Verified, not assumed: SavedLeaf is a plain
#[derive(Serialize, Deserialize)] (src/desktop.rs:85) with no
#[serde(default)] on any field and none anywhere in the file. So
serde will reject a version-1 desktop JSON that omits a newly added
view_left — a missing field is a deserialization error, not a zero.
Revision 2 cited the struct's shape as though it settled serde's
behavior on it; it did not.
"Yes, persisted, no DESKTOP_VERSION bump" is sound only with both
of:
#[serde(default)]on the new field. This is the whole of the new-binary-reads-old-file direction.- A regression fixture: a literal version-1 desktop JSON with no
view_left, deserialized in a test, asserting it restores at offset 0 rather than erroring. Without this, (1) is an untested claim about a crate's behavior — which is precisely the failure this question was reopened for.
The other direction already works, and that is why no bump is
needed. An old binary reading a new file passes the version check
(version is still 1, src/desktop.rs:366) and then meets an unknown
view_left field — which serde ignores by default, and
src/desktop.rs sets no deny_unknown_fields anywhere (verified). So
both directions are safe at DESKTOP_VERSION = 1 given (1), and
neither is safe without it.
Q#HS6 — the coherence statement ANSWERED: keep wrap default
Answered 2026-08-07 (user): "Keep
wrapas default for now. Revisit only after GPU parity and use evidence; changing totruncatebefore 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? ACCEPTED (revision 4)
Accepted 2026-08-07 (user): "keep
view_leftas an unsnapped window display column; derive the effective edge per line; render a bisected wide glyph's trailing cell as styled blank and designate it to the glyph start. The multi-line discriminating witness is exactly right." Plus (c″) below, on the user's recommendation.
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) What happens at a left edge that bisects a glyph. 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.
(c) The snap rule when an invalid edge is requested. WITHDRAWN
(revision 3).
Revision 2 voted "a left edge may not fall inside a wide glyph" plus "snap toward the line start, at the moment
view_leftis set". That cannot hold, and the reason is structural rather than a detail to tune.
view_leftis one per-window display column. "Does column N bisect a wide glyph?" is a per-line question: column 11 can be a wide glyph's trailing cell on line 3 and an ordinary ASCII cell on line 4. No single setter-time value is canonical for every visible line, so a snap performed once is simply wrong for most of them — and the invariant in (d), which the whole question exists to serve, would stay undefined exactly where it matters.Snapping per line is the other way to read it, and it is worse: the same source column would then appear at different screen columns on different rows, destroying the vertical alignment that a column-oriented view exists to provide.
(c′) The per-line effective edge, which replaces it.
view_left is stored unsnapped — the requested display column,
constrained only to >= 0 and whatever maximum the design picks. Each
line derives its own effective edge during the walk it already
performs from column 0.
When the requested edge bisects a wide glyph on this line, that glyph's trailing cell is the leftmost visible cell. It cannot be painted as half a glyph, so:
- It paints as a space, carrying the glyph's own cell style.
- The mapping designates that cell to the wide glyph's START byte.
Both halves are load-bearing, and the second is the part the finding correctly says was missing:
- The cell visually belongs to that character, so a click there selecting it is what a user expects.
- It keeps
byte_at_placetotal over visible cells — every painted cell maps to some byte, with no hole at column 0. - It preserves the round trip:
place_of_byte(glyph_start)reports the straddle and designates cell 0, sobyte_at_place(0) == glyph_start.
And the direction rule place_of_byte needs at the left edge: a
byte whose cells lie entirely left of the effective edge is not
visible, and place_of_byte must report that rather than clamping to
column 0. Clamping would make arbitrarily many bytes share cell 0 and
destroy (d). Only the straddling glyph designates cell 0.
This is deliberately NOT the mirror of Stage 3's right-edge rule,
and the asymmetry should be stated so nobody "fixes" one to match the
other. Under wrap, a wide glyph that will not fit at the right edge is
pushed to the next row entirely (advance_wrapped, with its
max_cols >= 2 guard). At the left edge under truncate there is no
next row to push to, so the blank-plus-designation rule is what the
same intent requires here.
(c″) A tab whose expansion straddles the edge — PRESERVED, not chosen.
Each visible tab-expansion cell maps to the byte immediately after
the tab. This is not a new rule: it is what byte_at_place already
does, and its doc comment says so — "Rounds forward to the next
character boundary… matching the unwrapped display_to_pos"
(src/text_view.rs:224). The walk accumulates walked past the tab
byte and returns on the next character's start_col, so every column
inside the expansion already yields the post-tab offset
(src/text_view.rs:243-254).
So the requirement on Stage 4 is that horizontal scroll not perturb it: with the expansion's leading cells scrolled off, the surviving cells must still map post-tab, exactly as they do at offset 0.
Why this direction differs from (c′)'s, which is the obvious objection. A wide glyph's two cells belong to one character; forward-rounding its trailing cell would designate it to the next character and leave the straddling glyph with no visible cell mapping to it at all — unreachable by click precisely when it is the thing the user scrolled toward. A tab's expansion cells are whitespace between the tab byte and the next character, and forward-rounding them is already how clicking in indentation lands at the start of the text. Different directions, one principle: every visible cell is designated to the byte a user would mean by clicking it.
With (c′) and (c″) together, the (d) contract is total over visible cells: ordinary character → its own start byte; bisected wide glyph → the glyph's start byte; tab expansion → the byte after the tab.
(d) The invariant rendering and coordinate mapping share. For a
given view_left and line, 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.
"And line" is what (c′) forced, and it is the whole of that
finding: the invariant is not a property of view_left alone. It is a
property of (view_left, line), because the effective edge is derived
per line. A test that fixes one line and sweeps offsets will not see
the failure; the oracle has to sweep lines whose glyph widths differ
at the same column.
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_leftrequirement smuggled into a wrap lane. - A
wrapcontrol for every claim, asserting the wrap path is byte-identical with a horizontal offset present. Underwrapthe offset must be inert, not merely harmless. - Round-trip identity for
place_of_byte/byte_at_placeat non-zero offset — the Q#HS7(d) invariant, walked exhaustively over a short line rather than sampled, as Stage 3 established. - The Q#HS7(c′) case: a wide glyph straddling the left edge — the
trailing cell blank, and
byte_at_placeon it returning the glyph's start byte. - The Q#HS7(c″) case: a tab whose expansion straddles the edge,
with every surviving cell still mapping to the byte after the
tab. This one is a regression witness rather than a new claim —
byte_at_placealready behaves this way at offset 0 (src/text_view.rs:224), so the test asserts scroll did not perturb it, and it should fail if the walk is "optimized" to start at the effective edge instead of column 0. - A multi-line fixture whose glyph widths DIFFER at the same column
— the case (c′) exists for, and the one a single-line sweep cannot
reach. At one
view_left, one line must take the straddle path and another the ordinary path, with the (d) invariant holding on both. A test that fixes one line and sweeps offsets passes against the withdrawn setter-time snap, which is what makes this the discriminating fixture rather than an extra one. - A PTY acceptance test for reachability, following
tests/long_line_readable_acceptance.rs. That file'struncatecontrol 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
truncatein 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,
truncatemeans 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.