pmacs/docs/horizontal-scroll-framing.md

326 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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.