docs: Stage 4 revision 3 — a setter-time snap cannot exist

P1 — Q#HS7(c) WITHDRAWN, and my vote was wrong rather than vague.
Revision 2 voted to snap `view_left` to a valid boundary at the moment
it is 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 line 3 and
ordinary ASCII on line 4. No setter-time value is canonical for every
visible line. Snapping per line instead is worse — the same source
column would appear at different screen columns on different rows,
destroying the alignment a column-oriented view exists to provide.

Replaced by (c′), a per-line effective edge. `view_left` is stored
unsnapped; each line derives its own edge during the walk it already
performs from column 0. Where the requested edge bisects a wide glyph
on THAT line, the finding's actual question — what occupies the
leftmost cell — is answered: it paints as a space carrying the glyph's
style, and the mapping DESIGNATES that cell to the wide glyph's start
byte. That keeps `byte_at_place` total over visible cells, preserves
the round trip (`place_of_byte(start)` reports the straddle and
designates cell 0), and gives a click there the character a user would
expect. Bytes lying entirely left of the edge are reported not-visible
rather than clamped to column 0, because clamping would make
arbitrarily many bytes share cell 0 and destroy (d).

Recorded as deliberately NOT the mirror of Stage 3's right-edge rule:
under wrap a too-wide glyph is pushed to the next row entirely, and at
the left edge under truncate there is no next row, so the same intent
requires a different rule. Stated so nobody "fixes" one to match the
other.

(d) is amended accordingly: the invariant is a property of
`(view_left, line)`, not of `view_left` alone — which is what makes a
multi-line fixture with differing glyph widths at the same column the
DISCRIMINATING test rather than an extra one. A single-line sweep
passes against the withdrawn design.

P1 — Stage 4 no longer closes the arc, and the stale claim was
load-bearing in the wrong direction. Rule 4 removes a lane when its ARC
is done, so a framing asserting Stage 4 closes it would license
retiring the lane at the TUI merge — orphaning the very Stage 5 that
Q#HS1's time box exists to guarantee, while `truncate` is still a dead
end in the GUI. Both the framing opening and the lane header now say
the arc closes at Stage 5, and the lane carries an explicit "Rule 4
does not apply at Stage 4's merge".

P2 — the stale Stage 3 residue in the ledger claimed the unreachable
caveat is in the setting description, contradicting revision 2 forty
lines above it. Corrected in place: the caveat lives only in the
toggle's status message and a source comment, and a user who sets the
mode in init.lua is told nothing.

Q#HS5 now states the concrete condition rather than an instruction to
check one. 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`. "Yes, no version bump" is sound only with the
annotation AND a regression fixture holding literal v1 JSON without the
field. The reverse direction already works — an old binary meets an
unknown field, which serde ignores absent deny_unknown_fields, and
there is none in that file.

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:14:58 +02:00
parent 5d041a4f17
commit baa71b607e
No known key found for this signature in database
2 changed files with 173 additions and 52 deletions

View File

@ -435,7 +435,7 @@ Whether `docs/ci-red-signatures.md` should grow a short non-row section
for this class is an open question for its owner, not something this for this class is an open question for its owner, not something this
lane decided. lane decided.
## Long lines (QoL arc) — Stage 3 MERGED as #221; Stage 4 is framing ## Long lines (QoL arc) — Stage 3 MERGED as #221; Stages 4 AND 5 ahead
**Rewritten, not removed.** Rule 4 removes a lane when its ARC is done; **Rewritten, not removed.** Rule 4 removes a lane when its ARC is done;
this one has Stage 4 ahead. Stage 3's durable facts are absorbed into this one has Stage 4 ahead. Stage 3's durable facts are absorbed into
@ -443,6 +443,14 @@ this one has Stage 4 ahead. Stage 3's durable facts are absorbed into
than deferred — so what remains here is the Stage 4 plan and only the than deferred — so what remains here is the Stage 4 plan and only the
Stage 3 residue that constrains it. Stage 3 residue that constrains it.
> **RULE 4 DOES NOT APPLY AT STAGE 4's MERGE.** The arc closes at
> **Stage 5** (GPU horizontal scroll), not Stage 4. Q#HS1 split the GPU
> out deliberately and time-boxed it; retiring this lane when the TUI
> half merges would orphan exactly the half the time box exists to
> guarantee, and would do so while `truncate` is still a dead end in
> the GUI. **Do not remove this block until Stage 5 has merged**, and
> read Q#HS1's four time-box items before concluding otherwise.
**Branch `horizontal-scroll`**, based on `githubsucks/main` @ `02f3ec3` **Branch `horizontal-scroll`**, based on `githubsucks/main` @ `02f3ec3`
(the #221 merge). `githubsucks/horizontal-scroll` is the authoritative (the #221 merge). `githubsucks/horizontal-scroll` is the authoritative
tip — the ref, not a SHA, since any edit to this block advances past tip — the ref, not a SHA, since any edit to this block advances past
@ -450,7 +458,7 @@ 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: framing, NOT approved.** `docs/horizontal-scroll-framing.md`
revision 2. No implementation may begin. revision 3. No implementation may begin.
**Answered by the user 2026-08-07:** **Answered by the user 2026-08-07:**
@ -472,17 +480,36 @@ revision 2. No implementation may begin.
- **Q#HS7 (NEW) — what IS `view_left`?** Revision 1 decided what moves - **Q#HS7 (NEW) — what IS `view_left`?** Revision 1 decided what moves
the viewport without saying what its offset *is* — the same omission the viewport without saying what its offset *is* — the same omission
as shipping `WrapMode` with no `DisplayCoord`. Four coupled parts: as shipping `WrapMode` with no `DisplayCoord`. Without part (d), **a
the unit; which columns may be a left edge; the snap rule for an painter that clips where the mapper does not puts clicks on the wrong
invalid one; and the invariant rendering and coordinate mapping character**, silently, only on lines wide enough to scroll.
share. Without the last, **a painter that clips where the mapper does
not puts clicks on the wrong character**, silently, only on lines **Revision 2's part (c) is WITHDRAWN.** It voted to snap `view_left`
wide enough to scroll. §4's sketch has no oracle until this is to a valid boundary when set. That cannot exist: `view_left` is one
answered. per-window display column, but *"does column N bisect a wide
- **Q#HS5 — does `view_left` survive a restart?** Vote: yes, defaulted, glyph?"* is a **per-line** question — column 11 can be a wide glyph's
no `DESKTOP_VERSION` bump — *after* confirming `SavedLeaf`'s trailing cell on one line and ordinary ASCII on the next. No
deserializer tolerates a missing field, which is a claim about serde setter-time value is canonical for every visible line, and snapping
and not about the struct shape §1.4 cites. 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.
@ -506,9 +533,13 @@ amending the description is now a Stage 4 deliverable (framing §6).
this and it is survivable — but Stage 3 signed up for it as *a this and it is survivable — but Stage 3 signed up for it as *a
decision*, and Stage 4 is where the bill arrives. decision*, and Stage 4 is where the bill arrives.
- **`truncate` is the mode Stage 4 makes navigable.** Today text past - **`truncate` is the mode Stage 4 makes navigable.** Today text past
the right edge is not merely off-screen but **unreachable**; that is the right edge is not merely off-screen but **unreachable** — and
stated in the setting's description and in `ui.toggle-line-wrap`'s that is stated **only** in `ui.toggle-line-wrap`'s status message and
status message, both of which should be revisited when scroll lands. a source comment, **not** in the setting's description
(`builtin/runtime/linewrap.lua:23` says just "truncate at the edge").
A user who sets the mode in `init.lua` and never invokes the toggle
is told nothing. Amending the description is a Stage 4 deliverable
(framing §6); the status message is revisited when scroll lands.
- **Under `wrap`, horizontal scroll is meaningless.** Stage 4's surface - **Under `wrap`, horizontal scroll is meaningless.** Stage 4's surface
is therefore conditional on the mode, which is a coherence question is therefore conditional on the mode, which is a coherence question
(one concept, two behaviors) and not only an implementation one. (one concept, two behaviors) and not only an implementation one.

View File

@ -1,13 +1,24 @@
# Horizontal scroll — QoL Stage 4 # Horizontal scroll — QoL Stage 4
**Status: revision 2 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the **Status: revision 3 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the
user (2026-08-07); Q#HS3HS5 stand; Q#HS7 is NEW and BLOCKING. No user (2026-08-07). Q#HS7 remains BLOCKING and its part (c) is
**withdrawn and replaced** — a setter-time snap cannot exist for a
window-wide offset. Q#HS5's condition is now concrete. No
implementation may begin.** implementation may begin.**
This closes the QoL arc opened by one daily-driver report. Stage 1 **Stage 4 does NOT close the QoL arc.** Revision 1 said it did, and
(#219) made the TUI survive terminal zoom; Stage 2 (#220) gave the GUI that was written before Q#HS1 moved GPU horizontal scroll to Stage 5.
native zoom; Stage 3 (#221) added `ui.line-wrap` and made `wrap` the The claim is not merely stale — it is load-bearing in the wrong
default. Stage 4 is the other half of the user's own sentence: 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 > long lines need to either **wrap somehow or be scrollable**. […] This
> should also be something that the user can configure, whether to wrap > should also be something that the user can configure, whether to wrap
@ -181,15 +192,36 @@ 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** ### Q#HS5 — does `view_left` survive a restart? **OPEN, with the condition now concrete**
`view_top` does (§1.4). Consistency argues yes; a defaulted field `view_top` does (§1.4). Consistency argues yes.
avoids a `DESKTOP_VERSION` bump.
**My vote: yes, defaulted, no version bump** — but **confirm **Verified, not assumed:** `SavedLeaf` is a plain
`SavedLeaf`'s deserializer tolerates a missing field before relying on `#[derive(Serialize, Deserialize)]` (`src/desktop.rs:85`) with **no
it.** §1.4 cites the struct's *shape*, not serde's behavior on it, and `#[serde(default)]` on any field and none anywhere in the file**. So
that gap is exactly the kind §1.3 exists to warn about. 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** ### Q#HS6 — the coherence statement **ANSWERED: keep `wrap` default**
@ -231,32 +263,83 @@ nothing and lose tab correctness. Starting at 0 and suppressing paint
until `col >= view_left` preserves tab stops **for free**, and costs no until `col >= view_left` preserves tab stops **for free**, and costs no
more than `paint_line` already pays under wrapping. more than `paint_line` already pays under wrapping.
**(b) Which columns may be a left edge.** A tab straddling the edge is **(b) What happens at a left edge that bisects a glyph.** A tab
unambiguous: its expansion is width-1 spaces, so the remaining ones straddling the edge is unambiguous: its expansion is width-1 spaces, so
paint. **A wide (width-2) glyph is not** — a grid cannot paint half of the remaining ones paint. **A wide (width-2) glyph is not** — a grid
one. 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.~~ WITHDRAWN
(revision 3).**
**(c) The snap rule when an invalid edge is requested.** Stage 3's > Revision 2 voted *"a left edge may not fall inside a wide glyph"* plus
coordinate contract is *"identity on canonical inputs; otherwise > *"snap toward the line start, at the moment `view_left` is set"*.
projection to the contract's **designated** canonical representative"* — > **That cannot hold, and the reason is structural rather than a detail
designated, not nearest, because the direction differs per function and > to tune.**
"nearest" hides that. >
> `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.
*My vote: snap toward the line start.* Snapping left can only reveal a **(c) The per-line effective edge, which replaces it.**
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 stored **unsnapped** — the requested display column,
`view_left` is **set** — rather than clipping in the painter — keeps constrained only to `>= 0` and whatever maximum the design picks. Each
one canonical value that both painter and mapper read, instead of two line derives its own **effective edge** during the walk it already
that can disagree. 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.
**(d) The invariant rendering and coordinate mapping share.** For a **(d) The invariant rendering and coordinate mapping share.** For a
given `view_left`, `byte_at_place` must invert `place_of_byte` on every given `view_left` **and line**, `byte_at_place` must invert
canonical input, and `paint_line` must place exactly the bytes `place_of_byte` on every canonical input, and `paint_line` must place
`place_of_byte` claims. If the painter clips where the mapper does not, exactly the bytes `place_of_byte` claims. If the painter clips where the
**clicks land on the wrong character** — silently, and only for lines mapper does not, **clicks land on the wrong character** — silently, and
wide enough to scroll. 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*, *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. and it is why Q#HS7 blocks: §4 cannot be written until it exists.
@ -274,9 +357,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(b)/(c) cases, which have no oracle until it is
answered**: a wide glyph straddling the left edge; a tab whose answered**: a wide glyph straddling the left edge; a tab whose
expansion straddles it; a snap request landing inside each. expansion straddles it.
- **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 - **A PTY acceptance test for reachability**, following
`tests/long_line_readable_acceptance.rs`. That file's `truncate` `tests/long_line_readable_acceptance.rs`. That file's `truncate`
control currently asserts the tail is **absent** — Stage 4 must control currently asserts the tail is **absent** — Stage 4 must