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
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;
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
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`
(the #221 merge). `githubsucks/horizontal-scroll` is the authoritative
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`.
**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:**
@ -472,17 +480,36 @@ revision 2. No implementation may begin.
- **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`. Four coupled parts:
the unit; which columns may be a left edge; the snap rule for an
invalid one; and the invariant rendering and coordinate mapping
share. Without the last, **a painter that clips where the mapper does
not puts clicks on the wrong character**, silently, only on lines
wide enough to scroll. §4's sketch has no oracle until this is
answered.
- **Q#HS5 — does `view_left` survive a restart?** Vote: yes, defaulted,
no `DESKTOP_VERSION` bump — *after* confirming `SavedLeaf`'s
deserializer tolerates a missing field, which is a claim about serde
and not about the struct shape §1.4 cites.
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#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
decision*, and Stage 4 is where the bill arrives.
- **`truncate` is the mode Stage 4 makes navigable.** Today text past
the right edge is not merely off-screen but **unreachable**; that is
stated in the setting's description and in `ui.toggle-line-wrap`'s
status message, both of which should be revisited when scroll lands.
the right edge is not merely off-screen but **unreachable** — and
that is stated **only** in `ui.toggle-line-wrap`'s status message and
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
is therefore conditional on the mode, which is a coherence question
(one concept, two behaviors) and not only an implementation one.

View File

@ -1,13 +1,24 @@
# 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
**Status: revision 3 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the
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.**
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:
**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
@ -181,15 +192,36 @@ 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**
### 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
avoids a `DESKTOP_VERSION` bump.
`view_top` does (§1.4). Consistency argues yes.
**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.
**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**
@ -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
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.
**(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.
*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
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.
> 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.
*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.
**(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.
**(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.
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.
@ -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
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
- **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.
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
`tests/long_line_readable_acceptance.rs`. That file's `truncate`
control currently asserts the tail is **absent** — Stage 4 must