249 lines
11 KiB
Markdown
249 lines
11 KiB
Markdown
# 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_top`
|
||
> straight 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 the `goal_col` and 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_top`s (`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_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, since under `wrap`
|
||
the offset must be inert.
|
||
- Round-trip identity for `place_of_byte` / `byte_at_place` at 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`: in `truncate`, the tail of
|
||
a long line must reach the terminal *after* whatever Q#HS2 chooses
|
||
moves the view. 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.
|
||
- 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.
|