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)
**Status: revision 13 — AWAITING APPROVAL.** Revision 13 is §2a's
ground-truth re-measurement **for Stage 1b**, taken at the post-#240
tip. Every 1b line anchor was stale, which was expected. What was not
expected is that **three B-rows describe a field as empty when it is
occupied**, and that **B7 re-opens a question another framing
deliberately deferred** — so this revision asks for a ruling (Q#S1-11)
rather than only renumbering. **No B-row contract is changed here.**
**Status: revision 14 — AWAITING APPROVAL.** Revision 14 answers review
of revision 13. **Q#S1-11 is RULED (B), viewport only**, with the full
five-clause lifetime contract and its witnesses; (A) was not viable
because the GPU cursor is a daemon-owned mirror and carrying point
would need a wire operation 1b is not allowed. Two further holes review
named are closed: **B1's "surface" is now enumerated** as a
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
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),
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
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
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
@ -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
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
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`**,
not `:3628`.)
So a wheel-driven `view_left` that does **not** carry the cursor will be
snapped back by the next `horizontal_follow`, and horizontal wheel
scrolling will "feel stuck after one notch" — the identical bug, one
axis over, on **both** frontends, since the GPU's `horizontal_follow`
(`:7702`) has the same shape.
So a wheel-driven `view_left` that does **not** carry the cursor is
snapped back, and horizontal wheel scrolling "feels stuck after one
notch" — the identical bug, one axis over, on **both** frontends, since
the GPU's `horizontal_follow` (`:7702`) has the same shape.
**B7's stated contract does not mention the cursor at all**, and its
mutations therefore cannot detect this: a clamp row and a wrap row both
pass against a viewport that snaps back on the next caret event.
**And it happens on the next PAINT, not the next caret event.**
`prepare_window_cursor_visible` (`src/editor.rs:4518`) calls
`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
than closed precisely so this would be decided when explicit commands
arrived. Two candidate answers, both defensible:
#### Q#S1-11 — does a horizontal wheel scroll carry the cursor? **RULED: (B), viewport only**
- **(A) Carry it, mirroring `scroll_window`.** Consistent with the
vertical axis, and the snap-back cannot arise. Costs a cursor move
the user did not ask for.
- **(B) Do not carry it; suppress the follow while a wheel origin is
live.** Preserves the cursor, but needs a rule for when the origin
stops being authoritative, and that rule is new state on both
frontends.
**(A) is not viable in 1b, and the vertical precedent does not reach
it.** `scroll_window` carries point because it is **TUI-side**, where
the editor owns the cursor directly. The GPU has no such power:
`OwnCursor` (`pmacs-gpu/src/main.rs:2337`) is *"pmacs-gpu's own cursor
position, **mirrored** from `CursorByte`"* — a read-only reflection of
daemon state. The only wire operation that positions it is `Pointer`,
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:**
scroll sideways, then trigger a caret-follow, and assert the origin
behaves as ruled. Without it the row is green either way.
Note also that **GPU vertical scrolling already does not carry point**:
`apply_wheel` ends at `send_viewport` (`:3129`). (A) would therefore not
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
@ -446,10 +600,13 @@ The crate has **exactly one** executable `event_loop.exit()`, in
### 1b — pointer and scroll
> **Read §2a first.** Every line number in this table was measured
> before 1-pre and is stale. §2a re-measures them at `72da24a` and
> records three rows whose "nothing exists yet" is wrong, plus **Q#S1-11
> (open)**, which B7 cannot be implemented without. The contracts below
> are unchanged.
> before 1-pre and is stale. §2a re-measures them at `72da24a`, records
> three rows whose "nothing exists yet" is wrong (B3, B4, B5),
> enumerates B1's wheel targets, gives B3 B7's exact upper bound, and
> 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 |
|---|---|---|---|
@ -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),
`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?
Raised by §2a; **blocks B7 and B3 only**, and nothing else in 1b. It is
the question `docs/horizontal-scroll-framing.md` deferred as Q#HS4,
now live because B7 is the explicit horizontal scroll command whose
absence was that deferral's stated premise.
**Q#S1-11 — RULED: (B), viewport only** (§2a). A horizontal wheel
scroll never moves point or selection; the origin it sets is
authoritative under a **five-clause lifetime contract**, and B7 and B3
carry preservation, release and cross-axis witnesses at the real call
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