11 KiB
Horizontal scroll — QoL Stage 4
Status: revision 1 — NOT APPROVED. Six questions open (Q#HS1–HS6). 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, and said
so: under truncate, text past the right edge is not merely off-screen
but unreachable. That is recorded in the setting's description and
in ui.toggle-line-wrap's status message. Stage 4 removes that caveat
or the caveat stands permanently.
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/.
This is greenfield. The window carries view_top, cursor, and
goal_col (src/window.rs:374-376) and nothing horizontal.
That matters for estimating: this is not "extend the vertical mechanism sideways". 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 it did 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. 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
This is the finding most likely to invert the cost estimate, and it is the Stage 4 analog of revision 1's error — 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.
So the GPU's half of Stage 4 cannot be "set the scroll and reshape". It
needs a different mechanism — a shifted text origin at paint time,
adjusted clip bounds, or something else — and that mechanism has to
interact 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 currently assumes x starts at
text_left().
Q#HS1 asks whether the GPU is in scope for Stage 4 at all.
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).
A horizontal offset that does not survive restart is defensible; one that does needs a defaulted field or a version bump. 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 exactly 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.
A horizontal analog hits the identical problem. Stage 3's Q#LL3 deferred the choice here deliberately: does explicit horizontal scroll drag the cursor, or does the next motion snap back? That is Q#HS4.
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 and is cleared at seven sites in src/editor.rs. It is
a column within the line, not a viewport offset — but both are
"horizontal position" state on the same window, and a design that
ignores the interaction will produce a cursor that jumps on the first
vertical motion after a horizontal scroll. Called out so it is designed
rather than discovered.
2. The scope question, stated before the answers
Stage 3 ended with wrap as the default. Under wrap, horizontal
scroll is meaningless — there is nothing past the right edge. So
Stage 4's entire surface is conditional on a buffer-local mode.
That is a coherence fact, not only an implementation one: one
user-facing concept ("how do I see the rest of this line?") now has two
disjoint answers depending on a setting, and the commands, key
bindings, and status affordances for the truncate half do not exist
under wrap. COHERENCE.md §20 requires this be stated. Q#HS6.
3. Open questions
Q#HS1 — is the GPU in scope for Stage 4?
The strongest argument for yes: Stage 3's entire thesis was that
the two frontends should stop disagreeing by accident. Shipping
horizontal scroll in the TUI only would recreate exactly the divergence
ui.line-wrap was built to close — a truncate buffer would be
navigable in one frontend and not the other.
The strongest argument for no, name it Stage 5: §1.3. The GPU needs a mechanism that does not exist yet, touching caret placement, decoration geometry, and hit testing. That is plausibly larger than the TUI half, and bundling them makes one reviewable change into two unreviewable ones.
My vote: split it, and say so in the setting's description. Ship the TUI half as Stage 4 and the GPU half as Stage 5, with the divergence documented and time-boxed rather than accidental — which is the distinction Stage 3 actually drew. Stage 3's defect was never "the frontends differ"; it was "the frontends differ and nobody chose that". But this is a product call about shipping a known asymmetry, and it is not mine to make.
Q#HS2 — what moves the viewport?
Options, not mutually exclusive:
- Automatic only — the cursor-visibility pass gains a horizontal component, so moving the cursor past the edge scrolls the view. No new commands, no new bindings. Smallest surface; makes a long line readable by arrowing along it.
- Explicit commands —
ui.scroll-left/ui.scroll-right, bound or not, plus thegoal_coland cursor-follow questions. - Both, which is what every editor with this feature ships.
My vote: automatic first, as its own stage-within-a-stage. It is the smallest change that makes the reported text reachable, and it needs no binding decisions. Explicit commands can follow with the evidence of use.
Q#HS3 — per window or per buffer?
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 already 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.
This is not really open; it is listed so the accepted split is re-confirmed at the moment it takes effect rather than inherited silently.
Q#HS4 — does the cursor follow an explicit scroll?
Only live if Q#HS2 includes explicit commands. §1.5 has the precedent
and the hazard. My vote: follow, matching scroll_window — the
existing snap-back pass makes the alternative feel broken, and the
vertical behavior is already the answer users have been trained on
in this editor.
Q#HS5 — does view_left survive a restart?
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 that
SavedLeaf's deserializer tolerates a missing field before relying on
it, because §1.4 is a citation of the shape, not of serde's
behavior on it.
Q#HS6 — what does the coherence statement say?
Per COHERENCE.md §20 this framing must state its coherence impact.
The honest version is uncomfortable: Stage 4 adds capability that
exists only under a non-default mode, which is a new conditional
surface rather than a uniform improvement. Journey step 4 ("Understand
interface") is the row it serves.
Q#HS6 is whether that is acceptable, or whether the arc should
instead reconsider the default. Naming the alternative honestly: if
horizontal scroll makes truncate genuinely good, wrap being the
default is a choice worth re-examining rather than treating as settled
— and Stage 3 chose it partly because scroll did not exist.
4. Verification sketch (not final — depends on Q#HS1/HS2)
- Cell-level tests at several window widths with a non-zero offset —
which is the Stage 4 case Stage 3's sketch explicitly refused to
write, 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, since underwrapthe offset must be inert. - Round-trip identity for
place_of_byte/byte_at_placeat non-zero offset, including the wide-character and tab cases Stage 3 settled at offset 0. - A PTY acceptance test for reachability, following
tests/long_line_readable_acceptance.rs: intruncate, the tail of a long line must reach the terminal after whatever Q#HS2 chooses moves the view. 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. - If the GPU is in scope: a headless witness 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 3's
framing said the scorecard should name "a line that cannot be read in
full" against this step. Stage 4 completes that only for
truncate. - §16 Semantic Frontend Architecture. Q#HS1 decides whether this lane narrows or widens frontend divergence. If the GPU is deferred, the divergence is deliberate and time-boxed — which is materially different from Stage 3's inherited accident, and the release notes must say which kind it is.
- No new interaction island if Q#HS2 lands automatic-only. Explicit commands would go in the ordinary command registry, not a new surface.
- Config registry adoption: none new expected. Stage 4 navigates the mode Stage 3 declared; if it needs a setting, that is a signal the design has drifted.