326 lines
14 KiB
Markdown
326 lines
14 KiB
Markdown
# Horizontal scroll — QoL Stage 4
|
||
|
||
**Status: revision 2 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the
|
||
user (2026-08-07); Q#HS3–HS5 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_top`s (`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.
|