docs(framing): Stage 1 revision 14 --- Q#S1-11 ruled B, and two holes closed

Answers review of revision 13. Still framing only; no code.

**Q#S1-11 IS RULED (B), VIEWPORT ONLY.** (A) is not viable in 1b and
the vertical precedent does not reach it. `scroll_window` can carry
point because it is TUI-side, where the editor owns the cursor.
`OwnCursor` is "pmacs-gpu's own cursor position, MIRRORED from
`CursorByte`" --- a read-only reflection --- and the only wire operation
that positions it, `dispatch_pointer`, sets `active_frontend`, calls
`break_command_chain`, and by its own comment moves point or changes
selection on every kind. Carrying point from a wheel therefore needs a
NEW wire operation, which contradicts 1b's non-protocol scope. (A)
would not even be self-consistent: GPU vertical scrolling already does
not carry point.

**And "do not carry" alone is not a ruling** --- it leaves the origin's
lifetime undefined, which is the part that decides whether the feature
works. Five clauses, all of them the ruling: viewport only; an
EFFECTIVE move arms authority; repaint, same-cursor follow, resize and
vertical wheel preserve it while geometry and content changes CLAMP
rather than release; a genuine cursor-position change releases it; wrap
and buffer replacement clear it and pin zero.

**THE HAZARD LANDS ON THE NEXT PAINT, NOT THE NEXT CARET EVENT.**
Revision 13 said caret event and understated the exposure:
`horizontal_follow` is the FIRST act of `prepare_window_cursor_visible`,
which `paint_frame` runs every frame. The origin is overwritten by a
redraw with no input at all. Witnesses must therefore drive real call
sites on both frontends --- a helper unit test cannot see a follow that
runs inside `paint_frame` --- and a TUI cross-axis row is required
because vertical wheel DOES carry point there. Both mutations named:
follow ignores authority, and authority never releases.

**B1'S "SURFACE" IS NOW ENUMERATED.** Two facts made the gap real:
quantization and the zero return happen at `:3074`, BEFORE the panel
(`:3090`) and terminal (`:3112`) branches, so a sub-tick delta is
discarded before anything knows where it was going; and
`PointerSurface::Elsewhere` conflates document, terminal, minimap and
chrome, so the existing classifier cannot name what B1 needs. Six
targets are tabulated with a residual owner and a horizontal answer
each. The horizontal gap is NOT a wire gap --- `MouseKind::ScrollLeft`
and `ScrollRight` already exist and round-trip --- it is a missing
handler, so each target must be ruled emit-and-handle or explicitly
inert. Without the table, one global accumulator passes every
per-surface row that tests one surface.

Recorded while measuring: a wheel over the panel divider or the band's
background scrolls the DOCUMENT today, though the enum says the band
owns the pixel.

**B3 TAKES B7'S EXACT BOUND.** "Content bounds" was vaguer than B7's
saturated widest-line-minus-viewport rule, for the same bound on the
same rule, and B7 is exact precisely because the loose version blanks
the viewport. Stated in the GPU's column grid, since
`horizontal_follow` already reckons there and re-multiplies to snap the
offset --- a pixel clamp would break the snap the shared rule depends
on. Narrow-buffer and final-column-visible rows added; a clamp at full
content width must fail.

Ledger: the minimal 1b recovery/checkpoint entry, plus three lane
headers that were lying --- #240 and #239 were still marked OPEN, and
the arc header still said 1a was next. The #239/#240 absorption stays
deferred and is a separate lane.

Gates: all nine green under `env -u TMPDIR`, log 20260813T211645Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-13 23:21:28 +02:00
parent ce982e6701
commit e94ce85be1
No known key found for this signature in database
1 changed files with 201 additions and 39 deletions

View File

@ -1,12 +1,24 @@
# GUI arc, Stage 1 — input foundation (framing) # GUI arc, Stage 1 — input foundation (framing)
**Status: revision 13 — AWAITING APPROVAL.** Revision 13 is §2a's **Status: revision 14 — AWAITING APPROVAL.** Revision 14 answers review
ground-truth re-measurement **for Stage 1b**, taken at the post-#240 of revision 13. **Q#S1-11 is RULED (B), viewport only**, with the full
tip. Every 1b line anchor was stale, which was expected. What was not five-clause lifetime contract and its witnesses; (A) was not viable
expected is that **three B-rows describe a field as empty when it is because the GPU cursor is a daemon-owned mirror and carrying point
occupied**, and that **B7 re-opens a question another framing would need a wire operation 1b is not allowed. Two further holes review
deliberately deferred** — so this revision asks for a ruling (Q#S1-11) named are closed: **B1's "surface" is now enumerated** as a
rather than only renumbering. **No B-row contract is changed here.** wheel-target table with a residual owner and a horizontal answer per
target, and **B3 takes B7's exact saturated upper bound** in the GPU's
column grid instead of "content bounds". Revision 13's claim that the
snap-back lands on the next *caret event* is **corrected**: it lands on
the next **paint**.
**Previously, revision 13 — SUPERSEDED.** §2a's ground-truth
re-measurement for Stage 1b at the post-#240 tip. Every 1b anchor was
stale, as expected; what was not was that **three B-rows describe a
field as empty when it is occupied**, and that **B7 re-opens a question
another framing deliberately deferred**. **No B-row contract is changed
by either revision** — 13 and 14 add ground truth, a ruling, and
witnesses.
**Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a **Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a
revision 16.** Revision 13 changes no previously ruled outcome. It revision 16.** Revision 13 changes no previously ruled outcome. It
@ -36,7 +48,8 @@ Protocol slices stay serialized; one was inserted in front.
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12), **Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
the tip after 1-pre; it was originally taken at `a994f37`. **§2a is the tip after 1-pre; it was originally taken at `a994f37`. **§2a is
measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it measured at `72da24a`** (2026-08-13), the tip after 1a and #240, and it
is the base for **1b only**. Sections other than §2/§2a were written is the base for **1b only**; it carries Q#S1-11's ruling. Sections
other than §2/§2a were written
against `a994f37` and their *rulings* are unaffected by 1-pre, which against `a994f37` and their *rulings* are unaffected by 1-pre, which
changed no behaviour — but **any line number outside §2 and §2a changed no behaviour — but **any line number outside §2 and §2a
predates 1-pre and should be re-checked before it is relied on.** The predates 1-pre and should be re-checked before it is relied on.** The
@ -170,6 +183,57 @@ accumulator is what both need. **There is no wheel accumulator today.**
pixel residual, buffer-scoped, cleared at `:5928`. **Reusing it would pixel residual, buffer-scoped, cleared at `:5928`. **Reusing it would
be a defect**, not a shortcut. be a defect**, not a shortcut.
### CORRECTION 5 — B1's "surface" is not enumerated, and the classifier cannot enumerate it
B1 says "residual per **axis and surface**" and the table leaves
"surface" undefined. Two facts make that a hole rather than a detail.
**Quantization happens BEFORE routing.** The rounding and the
`lines == 0` return are at `:3074`–`:3084`; the panel branch is `:3090`
and the terminal branch `:3112`. So a sub-tick delta bound for the
panel or the terminal is **discarded before anything knows where it was
going**. An accumulator added after the routing decision would fix the
document and leave the wire targets exactly as broken as they are now.
**And `PointerSurface` cannot name the surfaces B1 needs.**
`classify_pointer_surface` (`:7192`) resolves panel geometry only;
`Elsewhere` (`:2067`) is *"the document, the terminal, the minimap, or
the chrome"* — four wheel targets under one name, three of which B1 and
B6 must distinguish. **B1 needs a wheel-target enumeration; it does not
get one for free from the existing classifier.**
Note also a live consequence of `:3090` matching `PanelCell(_)` alone:
**a wheel over the panel DIVIDER or the band's BACKGROUND scrolls the
document today**, though the enum's own doc says the band "still owns
the pixel."
The enumeration B1 must carry, measured:
| wheel target | classified today | vertical wheel today | residual owner | horizontal |
|---|---|---|---|---|
| Panel cell | `PanelCell` | wire `ScrollUp`/`Down` (`:3102`) | per panel | **must be ruled** — wire has the kinds, daemon has no arm |
| Panel divider / background | `PanelDivider` / `PanelBackground` | **falls through to the document** | — | inert |
| Terminal | `Elsewhere` + `terminal.is_some()` (`:3112`) | wire terminal `ScrollUp`/`Down` (`:3122`) | per terminal | **must be ruled** — same gap |
| Minimap | `Elsewhere` | document `scroll_by_lines` | **its own** (B6) | inert |
| Document | `Elsewhere` | `scroll_by_lines` (`:3126`) | document | `code_scroll_left` (B3) |
| Chrome | `Elsewhere` | document | none | inert |
**The horizontal gap is not a wire gap.** `MouseKind::ScrollLeft` and
`ScrollRight` already exist (`pmacs-protocol/src/message.rs:245`,
`:247`) and round-trip (`src/protocol.rs:720`), so **emitting them
needs no protocol bump** — 1b stays non-protocol-bearing either way.
What is missing is a *handler*: the terminal path has arms for
`ScrollUp`/`ScrollDown` only (`src/editor.rs:3576`, `:3579`), and the
panel path only `ScrollUp` (`src/daemon.rs:6683`). So for each wire
target 1b must choose **emit-and-handle** or **explicitly inert**, and
say which. Silence here is what lets a horizontal tick vanish.
**Without this table the rows are satisfiable by an implementation that
is wrong**: one global accumulator passes every per-surface row that
only ever tests one surface, and a document-and-minimap-only
implementation passes B1 and B6 while panel and terminal traffic still
carries residue across surfaces or drops sub-tick deltas silently.
### CORRECTION 3 — three rows say "nothing exists" where something does ### CORRECTION 3 — three rows say "nothing exists" where something does
Each of these reads as an empty field in the table and is not one. The Each of these reads as an empty field in the table and is not one. The
@ -222,33 +286,123 @@ scroll for exactly this reason:
(That citation is itself stale: `scroll_window` is **`src/editor.rs:3845`**, (That citation is itself stale: `scroll_window` is **`src/editor.rs:3845`**,
not `:3628`.) not `:3628`.)
So a wheel-driven `view_left` that does **not** carry the cursor will be So a wheel-driven `view_left` that does **not** carry the cursor is
snapped back by the next `horizontal_follow`, and horizontal wheel snapped back, and horizontal wheel scrolling "feels stuck after one
scrolling will "feel stuck after one notch" — the identical bug, one notch" — the identical bug, one axis over, on **both** frontends, since
axis over, on **both** frontends, since the GPU's `horizontal_follow` the GPU's `horizontal_follow` (`:7702`) has the same shape.
(`:7702`) has the same shape.
**B7's stated contract does not mention the cursor at all**, and its **And it happens on the next PAINT, not the next caret event.**
mutations therefore cannot detect this: a clamp row and a wrap row both `prepare_window_cursor_visible` (`src/editor.rs:4518`) calls
pass against a viewport that snaps back on the next caret event. `horizontal_follow` unconditionally as its **first** act (`:4539`), and
`paint_frame` (`:4747`) calls it every frame (`:4852`, and `:2572` for
the panel). Revision 13 said "the next caret event"; that was wrong and
understated the exposure — the origin is overwritten by a redraw with
no input at all.
#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **OPEN** **B7's stated contract does not mention the cursor**, so its mutations
cannot detect this: a clamp row and a wrap row both pass against a
viewport that is overwritten on the next frame.
Not answerable from the table as written; Q#HS4 was deferred rather #### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **RULED: (B), viewport only**
than closed precisely so this would be decided when explicit commands
arrived. Two candidate answers, both defensible:
- **(A) Carry it, mirroring `scroll_window`.** Consistent with the **(A) is not viable in 1b, and the vertical precedent does not reach
vertical axis, and the snap-back cannot arise. Costs a cursor move it.** `scroll_window` carries point because it is **TUI-side**, where
the user did not ask for. the editor owns the cursor directly. The GPU has no such power:
- **(B) Do not carry it; suppress the follow while a wheel origin is `OwnCursor` (`pmacs-gpu/src/main.rs:2337`) is *"pmacs-gpu's own cursor
live.** Preserves the cursor, but needs a rule for when the origin position, **mirrored** from `CursorByte`"* — a read-only reflection of
stops being authoritative, and that rule is new state on both daemon state. The only wire operation that positions it is `Pointer`,
frontends. and `dispatch_pointer` (`src/editor.rs:3638`) sets `active_frontend`,
calls `break_command_chain`, and — by its own comment — *"Every
`PointerKind` moves point or changes the selection."* Carrying point
from a wheel would mean **a new wire operation**, which contradicts
1b's non-protocol scope outright.
**Whichever is ruled, B7 needs a witness the current table lacks:** Note also that **GPU vertical scrolling already does not carry point**:
scroll sideways, then trigger a caret-follow, and assert the origin `apply_wheel` ends at `send_viewport` (`:3129`). (A) would therefore not
behaves as ruled. Without it the row is green either way. even be internally consistent — it would make the horizontal axis carry
point on a frontend where the vertical axis does not.
##### The lifetime contract (B)
A ruling that only says "do not carry" leaves the origin's lifetime
undefined, which is the part that decides whether the feature works.
**All five clauses are the ruling**, not commentary on it:
1. **Horizontal wheel changes the VIEWPORT only** — never point, never
selection, on either frontend.
2. **An effective wheel move makes that origin authoritative**, and it
stays authoritative while the cursor position is unchanged. "An
effective move" means one that actually changed the origin; a move
fully absorbed by the clamp arms nothing.
3. **Preserved by** repaint, a same-cursor follow, resize, and vertical
wheel. **Clamped by** geometry and content changes — clamped, not
released: a narrower window must not silently discard the gesture.
4. **A genuine cursor-position change releases it**, and normal follow
resumes on that same event. Release is driven by the cursor
*changing*, never by elapsed time or by the follow running.
5. **Wrap and buffer replacement clear it and pin the origin to zero.**
This is the existing rule (`horizontal_follow`'s wrap branch, and
the GPU's at `:7703`); authority must not survive either.
##### What B7 and B3 must witness
Rows against the **real call sites**, on **both** frontends — a unit
test of a helper cannot see a follow that runs inside `paint_frame`:
- **Preservation:** wheel sideways, then drive a real paint with the
cursor unchanged; the origin holds.
- **Release:** wheel sideways, then a genuine cursor-position change;
the origin follows the cursor on that event.
- **Cross-axis, TUI only:** wheel sideways, then wheel **vertically**.
Vertical wheel carries point in the TUI (`scroll_window`), so this
path *does* move the cursor and would release a naive
authority-on-any-cursor-write implementation. Clause 3 says the
horizontal origin survives it.
Two mutations, and both must fail their own rows:
| mutation | must fail |
|---|---|
| follow ignores manual authority (always overwrites) | preservation |
| manual authority never releases | release |
Clause 5 is already implemented for the caret path; the mutation for it
is the existing wrap-guard removal named in the B7 row.
### CORRECTION 6 — B3's right bound is vaguer than B7's, for the same bound
B7 states its upper bound exactly — *"(widest display-line width − text
viewport width), SATURATING AT ZERO"* — and pins that the final display
column stays visible. **B3 says only "content bounds"** and witnesses
just the negative-origin end. That asymmetry is not defensible: it is
the same bound on the same rule, and B7's exactness exists because the
loose version *blanks the viewport*.
**B3 takes B7's rule verbatim, in the GPU's column-grid units.** The
GPU already reckons in that grid — `horizontal_follow` (`:7702`)
derives `cols = (width / advance).floor()` and `left_col =
(code_scroll_left / advance).round()`, then re-multiplies to snap the
offset back onto the grid, deliberately, so both frontends put the same
first character on screen. The clamp is therefore stated in **columns**
and applied to `code_scroll_left` through the same conversion — not in
pixels, which would break the snap the shared rule depends on.
B3's rows, matching B7's:
- **Lower bound:** the origin never goes negative (already named).
- **Upper bound:** clamped at *widest display-line width − text
viewport width*, **saturating at zero** — a buffer narrower than the
viewport clamps to 0, not to a negative.
- **Narrow-buffer row:** every line shorter than the viewport → the
origin stays 0 however far the wheel is pushed.
- **Final-column-visible row:** at the upper bound, the widest line's
**last display column is still on screen**. This is the row that
distinguishes the correct bound from the plausible one.
**Mutation: clamp at the full content width.** It must fail the
final-column-visible row — that clamp lets the origin advance past
every glyph and leaves the viewport blank, which is exactly the defect
B7's revision found and B3 currently has no row to catch.
## 3. PR topology ## 3. PR topology
@ -446,10 +600,13 @@ The crate has **exactly one** executable `event_loop.exit()`, in
### 1b — pointer and scroll ### 1b — pointer and scroll
> **Read §2a first.** Every line number in this table was measured > **Read §2a first.** Every line number in this table was measured
> before 1-pre and is stale. §2a re-measures them at `72da24a` and > before 1-pre and is stale. §2a re-measures them at `72da24a`, records
> records three rows whose "nothing exists yet" is wrong, plus **Q#S1-11 > three rows whose "nothing exists yet" is wrong (B3, B4, B5),
> (open)**, which B7 cannot be implemented without. The contracts below > enumerates B1's wheel targets, gives B3 B7's exact upper bound, and
> are unchanged. > rules **Q#S1-11 (B, viewport only)** with the lifetime contract and
> witnesses B7 and B3 both depend on. The contracts below are
> unchanged, but **none of B1, B3 or B7 is implementable from this
> table alone.**
| # | Contract | Witness | Mutation | | # | Contract | Witness | Mutation |
|---|---|---|---| |---|---|---|---|
@ -759,11 +916,16 @@ and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e`
and the backlog amended by 1-pre's first PR · **Q#S1-8** (A), and the backlog amended by 1-pre's first PR · **Q#S1-8** (A),
`SEMANTIC_BOOTSTRAP_GRID` · **Q#S1-9** precedence per §5 · **Q#S1-10** terminal `OpenTargetResult`. `SEMANTIC_BOOTSTRAP_GRID` · **Q#S1-9** precedence per §5 · **Q#S1-10** terminal `OpenTargetResult`.
**OPEN: Q#S1-11** — does a horizontal wheel scroll carry the cursor? **Q#S1-11 — RULED: (B), viewport only** (§2a). A horizontal wheel
Raised by §2a; **blocks B7 and B3 only**, and nothing else in 1b. It is scroll never moves point or selection; the origin it sets is
the question `docs/horizontal-scroll-framing.md` deferred as Q#HS4, authoritative under a **five-clause lifetime contract**, and B7 and B3
now live because B7 is the explicit horizontal scroll command whose carry preservation, release and cross-axis witnesses at the real call
absence was that deferral's stated premise. sites. (A) — carrying point, as `scroll_window` does vertically — is
**not viable in 1b**: the GPU cursor is a mirror of daemon state, the
only wire operation that positions it also breaks the command chain and
changes selection, and a new one would break 1b's non-protocol scope.
This settles `docs/horizontal-scroll-framing.md`'s **Q#HS4** for the
wheel case, which was deferred against exactly this arrival.
## 11. Gates ## 11. Gates