docs: Stage 4 revision 4 — the tab rule was already there

Two small revisions, and every Stage 4 question is now answered.
Approval is NOT recorded; no implementation may begin.

Q#HS7(c″) — the tab-straddle mapping. Recorded as PRESERVED rather
than chosen, because it already exists: `byte_at_place`'s doc comment
says it rounds forward to the next character boundary, and the walk
accumulates past the tab byte and returns on the NEXT character's
start column, so every column inside a tab's expansion already yields
the post-tab offset (src/text_view.rs:224, :243-254). The requirement
on Stage 4 is therefore that horizontal scroll not PERTURB it — which
makes its witness a regression test, and one that should fail if the
walk is ever "optimized" to start at the effective edge instead of
column 0.

The obvious objection is that (c′) rounds backward and (c″) forward,
so the framing answers it. 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 exactly when it is what
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″) the (d) contract is total over visible cells:
ordinary character → its own start; bisected wide glyph → the glyph's
start; tab expansion → the byte after the tab.

Q#HS5 approved as stated, with both conditions written into the
approval rather than attached as advice: `#[serde(default)]` and a
literal v1 JSON fixture omitting the field, asserting restore at zero.

The handoff said "Stage 4 is the remainder". It now says Stages 4 AND
5 remain and the arc closes at Stage 5, carries the Q#HS1 time box,
and states explicitly that Rule 4 must not retire the long-lines lane
at Stage 4's merge. It also records that the unreachable caveat is
missing from the setting's description — the #221 gap — so that fact
lives in the durable doc and not only in a lane block that will
eventually be removed.

Ledger: the question list is consolidated (the accepted answers had
begun duplicating the blocking entries they resolved), keeping the
withdrawn (c)'s reasoning because the trap generalizes to any future
window-wide value derived from per-line content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-07 21:23:23 +02:00
parent baa71b607e
commit 403fb7fb07
No known key found for this signature in database
3 changed files with 117 additions and 53 deletions

View File

@ -457,8 +457,9 @@ tip — the ref, not a SHA, since any edit to this block advances past
whatever SHA it records. Recover: whatever SHA it records. Recover:
`git fetch githubsucks && git checkout horizontal-scroll`. `git fetch githubsucks && git checkout horizontal-scroll`.
**Status: framing, NOT approved.** `docs/horizontal-scroll-framing.md` **Status: `docs/horizontal-scroll-framing.md` revision 4 — every
revision 3. No implementation may begin. question answered, APPROVAL NOT YET RECORDED. No implementation may
begin until it is.**
**Answered by the user 2026-08-07:** **Answered by the user 2026-08-07:**
@ -476,43 +477,32 @@ revision 3. No implementation may begin.
the TUI and a **dead end in the GUI** for anyone who never opened the the TUI and a **dead end in the GUI** for anyone who never opened the
setting. Revisit after Stage 5, on use evidence. setting. Revisit after Stage 5, on use evidence.
**Still blocking:** - **Q#HS7 — ACCEPTED.** `view_left` is an unsnapped window display
column; the effective edge is derived **per line**; a bisected wide
glyph's trailing cell renders as styled blank and is designated to
the **glyph's start** byte; a straddling tab's surviving cells keep
the **existing** forward rounding to the byte after the tab
(`src/text_view.rs:224` — preserved, not chosen). Together these make
the mapping **total over visible cells**, which is the (d) invariant.
The discriminating witness is multi-line, with glyph widths differing
at the same column.
- **Q#HS5 — APPROVED: yes, persist, no `DESKTOP_VERSION` bump**
conditional on `#[serde(default)]` **and** a literal v1 JSON fixture
omitting the field, asserting restore at zero. Both conditions are
part of the approval.
- **Q#HS7 (NEW) — what IS `view_left`?** Revision 1 decided what moves
the viewport without saying what its offset *is* — the same omission
as shipping `WrapMode` with no `DisplayCoord`. Without part (d), **a
painter that clips where the mapper does not puts clicks on the wrong
character**, silently, only on lines wide enough to scroll.
**Revision 2's part (c) is WITHDRAWN.** It voted to snap `view_left`
to a valid boundary when set. That cannot exist: `view_left` is one
per-window display column, but *"does column N bisect a wide
glyph?"* is a **per-line** question — column 11 can be a wide glyph's
trailing cell on one line and ordinary ASCII on the next. No
setter-time value is canonical for every visible line, and snapping
per line instead would break vertical alignment.
Replaced by a **per-line effective edge**: `view_left` stored
unsnapped, each line deriving its own edge in the walk it already
performs from column 0. Where the edge bisects a wide glyph, that
glyph's trailing cell **paints blank and the mapping designates it to
the glyph's start byte** — which keeps `byte_at_place` total and
preserves the round trip. The invariant is therefore a property of
**`(view_left, line)`**, not of `view_left` alone, so the oracle must
sweep lines whose glyph widths differ at the same column.
- **Q#HS5 — does `view_left` survive a restart?** Vote: yes, no
`DESKTOP_VERSION` bump — **conditional on two things, now concrete.**
Verified: `SavedLeaf` carries **no `#[serde(default)]` anywhere in
`src/desktop.rs`**, so serde would **reject** a version-1 desktop
JSON omitting a new `view_left`. Sound only with (1)
`#[serde(default)]` on the field and (2) a regression fixture — a
literal v1 desktop JSON without `view_left`, asserting restore at
offset 0 rather than an error. The reverse direction already works:
an old binary meets an unknown field, which serde ignores absent
`deny_unknown_fields` (none in that file).
- **Q#HS3** is re-confirmed rather than open (per-window, per Q#LL2); - **Q#HS3** is re-confirmed rather than open (per-window, per Q#LL2);
**Q#HS4** is deferred, live only if explicit commands arrive. **Q#HS4** is deferred, live only if explicit commands arrive.
**The reasoning worth keeping from the withdrawn Q#HS7(c).** Revision
2 voted to snap `view_left` to a valid boundary when set. That cannot
exist, and the reason generalizes: `view_left` is ONE per-window
column, but *"does column N bisect a wide glyph?"* is a **per-line**
question. No setter-time value is canonical for every visible line,
and snapping per line instead would break the vertical alignment a
column-oriented view exists to provide. Recorded because the same trap
waits for any future window-wide value derived from per-line content.
**One correction carried into revision 2.** Revision 1 claimed the **One correction carried into revision 2.** Revision 1 claimed the
"unreachable past the edge" caveat is recorded in the setting's "unreachable past the edge" caveat is recorded in the setting's
description. It is not — `builtin/runtime/linewrap.lua:23` says only description. It is not — `builtin/runtime/linewrap.lua:23` says only

View File

@ -85,10 +85,10 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-08-07) ## 1. Where the project stands (2026-08-07)
- **QoL arc — Stages 1-3 merged (#219, #220, #221); Stage 4 is the - **QoL arc — Stages 1-3 merged (#219, #220, #221); Stages 4 AND 5
remainder.** From one daily-driver report: terminal zoom broke TUI remain, and the arc closes at Stage 5.** From one daily-driver
rendering and did nothing in the GUI, and a long line was unreadable report: terminal zoom broke TUI rendering and did nothing in the GUI,
past the edge. and a long line was unreadable past the edge.
- **#219** made the grid TUI honor `full_grid`, so a post-resize - **#219** made the grid TUI honor `full_grid`, so a post-resize
resync blanks the host before repainting. resync blanks the host before repainting.
- **#220** gave the GUI native zoom over the font preference that - **#220** gave the GUI native zoom over the font preference that
@ -125,10 +125,24 @@ commands, read `docs/active-work.md` immediately after this file.
drawable clip. Neither `view_range` (it carries drawable clip. Neither `view_range` (it carries
`SCROLL_OVERSCAN` past the window) nor `scroll_top` (it ignores `SCROLL_OVERSCAN` past the window) nor `scroll_top` (it ignores
`code_scroll_residual`) can answer it. `code_scroll_residual`) can answer it.
- **Stage 4 is horizontal scroll**, and `truncate` is incomplete - **Stage 4 is horizontal scroll in the TUI; Stage 5 is the GPU**, a
without it: text past the right edge is currently *unreachable*, split decided rather than inherited (framing Q#HS1) and time-boxed:
which is why `wrap` is the default and why the toggle says so. Stage 5 is the immediately-next QoL lane after Stage 4 merges, and
Framing in `docs/horizontal-scroll-framing.md`. `wrap` stays the default until it lands — which is what keeps the
divergence invisible to anyone who has not opted in.
`truncate` is incomplete without scroll: text past the right edge is
currently *unreachable*. **That caveat is NOT in the setting's
description** — `builtin/runtime/linewrap.lua:23` says only
"truncate at the edge", and the word appears in
`ui.toggle-line-wrap`'s status message and a source comment, which
a user who sets the mode in `init.lua` never sees. A small
user-facing gap shipped in #221; amending the description is a
Stage 4 deliverable.
**Rule 4 must not retire the long-lines lane when Stage 4 merges**
the arc closes at Stage 5. Framing in
`docs/horizontal-scroll-framing.md`.
- **`main` @ `db1bbe9`.** The **tree primitive #217**`listview` rows - **`main` @ `db1bbe9`.** The **tree primitive #217**`listview` rows
take optional `depth`/`id`, collapse is primitive-owned, folding is take optional `depth`/`id`, collapse is primitive-owned, folding is

View File

@ -1,10 +1,20 @@
# Horizontal scroll — QoL Stage 4 # Horizontal scroll — QoL Stage 4
**Status: revision 3 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the **Status: revision 4 — every question answered; APPROVAL NOT YET
user (2026-08-07). Q#HS7 remains BLOCKING and its part (c) is RECORDED. No implementation may begin until it is.**
**withdrawn and replaced** — a setter-time snap cannot exist for a
window-wide offset. Q#HS5's condition is now concrete. No | question | state |
implementation may begin.** |---|---|
| 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 45.
**Stage 4 does NOT close the QoL arc.** Revision 1 said it did, and **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. that was written before Q#HS1 moved GPU horizontal scroll to Stage 5.
@ -192,7 +202,13 @@ hazard for whenever explicit commands arrive.
Deferring rather than deleting, because the hazard is real and Deferring rather than deleting, because the hazard is real and
rediscovering it costs more than carrying the paragraph. rediscovering it costs more than carrying the paragraph.
### Q#HS5 — does `view_left` survive a restart? **OPEN, with the condition now concrete** ### 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. `view_top` does (§1.4). Consistency argues yes.
@ -240,7 +256,13 @@ default does not move.
Revisit after Stage 5, on use evidence, not before. Revisit after Stage 5, on use evidence, not before.
### Q#HS7 — what IS `view_left`? **NEW, BLOCKING** ### 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 **Revision 1 decided what moves the viewport without ever saying what
the viewport offset is.** That is the same omission Stage 3 would have the viewport offset is.** That is the same omission Stage 3 would have
@ -327,6 +349,37 @@ pushed to the next row **entirely** (`advance_wrapped`, with its
next row to push to, so the blank-plus-designation rule is what the next row to push to, so the blank-plus-designation rule is what the
same intent requires here. 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 **(d) The invariant rendering and coordinate mapping share.** For a
given `view_left` **and line**, `byte_at_place` must invert given `view_left` **and line**, `byte_at_place` must invert
`place_of_byte` on every canonical input, and `paint_line` must place `place_of_byte` on every canonical input, and `paint_line` must place
@ -357,9 +410,16 @@ and it is why Q#HS7 blocks: §4 cannot be written until it exists.
- Round-trip identity for `place_of_byte` / `byte_at_place` at non-zero - Round-trip identity for `place_of_byte` / `byte_at_place` at non-zero
offset — the Q#HS7(d) invariant, walked exhaustively over a short offset — the Q#HS7(d) invariant, walked exhaustively over a short
line rather than sampled, as Stage 3 established. line rather than sampled, as Stage 3 established.
- **The Q#HS7(b)/(c) cases, which have no oracle until it is - **The Q#HS7(c) case**: a wide glyph straddling the left edge — the
answered**: a wide glyph straddling the left edge; a tab whose trailing cell blank, and `byte_at_place` on it returning the glyph's
expansion straddles it. **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** - **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 — 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 reach. At one `view_left`, one line must take the straddle path and