# Horizontal scroll — QoL Stage 4 **Status: revision 4 — APPROVED 2026-08-07. Every question answered. Implementation may begin within this scope.** > *"Q#HS7's per-line effective edge and preserved tab-forward mapping > are coherent and fully specified. Q#HS5 is approved with the required > serde default and literal-v1-fixture conditions. The Stage 4/Stage 5 > split and Rule 4 protection are now durably reflected in the handoff > and ledger."* | 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_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? **APPROVED: yes** > **Approved 2026-08-07 (user):** persist `view_left` **without** 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:** 1. **`#[serde(default)]` on the new field.** This is the whole of the new-binary-reads-old-file direction. 2. **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 `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`? **ACCEPTED (revision 4)** > **Accepted 2026-08-07 (user):** *"keep `view_left` as 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_left` is set"*. > **That cannot hold, and the reason is structural rather than a detail > to tune.** > > `view_left` is **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_place` **total** 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, so `byte_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_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(c′) case**: a wide glyph straddling the left edge — the trailing cell blank, and `byte_at_place` on 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_place` already 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'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.