diff --git a/docs/active-work.md b/docs/active-work.md index 38955a9..7346389 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -196,7 +196,7 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. -## Docs absorption after #217 — PR #218 OPEN +## Docs absorption after #217 — MERGED as #218 (2026-08-06 09:59Z) **PR #218** — https://github.com/levineuwirth/pmacs/pull/218. **This block was written with the lane's first commit, before the PR @@ -225,7 +225,7 @@ are the lanes this one creates, not work it does. Retiring the CI-CRDT, Distribution, or reap-ledger lanes: each still owns undone work and rule 4 does not apply to them. -## Honoring `full_grid` (QoL Stage 1) — PR #219 OPEN +## Honoring `full_grid` (QoL Stage 1) — MERGED as #219 (2026-08-06 13:41Z) **PR #219** — https://github.com/levineuwirth/pmacs/pull/219. **This block was written with the lane's first commit, before the PR @@ -296,6 +296,80 @@ change to *when* `needs_full_grid` is set — the producer's triggers were verified correct, along with per-frame geometry sync and `view_top` reconciliation on shrink. +## Long lines (QoL Stage 3) — BRANCHED, no PR yet + +**Branch `long-lines`**, base `githubsucks/main` @ `218d2e7` (the #219 +merge). `githubsucks/long-lines` is the authoritative tip — the ref, +not a SHA, since any edit to this block advances past whatever SHA it +records. Recover: `git fetch githubsucks && git checkout long-lines`. + +**This block was written with the lane's first commit, before any PR +exists** — the standing correction from #171, #215 and #220. + +- **Framing `docs/long-lines-framing.md` revision 14, APPROVED**, after + **eleven** revisions of review. All six questions answered. +- **Independent of #220.** Stage 2 (GUI zoom) and Stage 3 share no + code, so this branch does not wait on that merge — which matters, + because #220 is blocked on a GitHub Actions outage, not on itself. + +### The defect + +A line wider than the window is unreadable past the edge **in the +TUI**. The GPU is not in that state: it already wraps. So the +cross-frontend defect is **not** unreadability — it is that *neither +behavior was chosen*. The TUI truncates because a cell walk breaks at +`max_cols`; the GPU wraps because cosmic-text's `Wrap::WordOrGlyph` +default was never overridden. Two accidents that disagree, with no way +for the user to express a preference in either. + +### The six answers + +- **Q#LL1** — ships `wrap` + `truncate`, **default `wrap`**; horizontal + scroll is **Stage 4**. No default preserves both frontends, so this + knowingly changes the TUI's behavior and leaves the GPU's alone. +- **Q#LL2** — the mode is **buffer-local**; `Viewport` carries the + *resolved* mode as it already carries `folds`, so `TextView` stays + config-agnostic. +- **Q#LL4** — do **not** adopt `editing.fill-column`; it is orphaned + because its consumer (`M-q` / auto-fill) does not exist. Sharpen its + description, and name ours `ui.line-wrap` (`ConfigKind::Enum`). +- **Q#LL5** — **character wrap in both frontends**; the GPU document + buffer gets its first explicit `set_wrap`, `Wrap::Glyph`. +- **Q#LL6** — no global map (every vertical consumer is local, so + layout is per-line); `view_top`'s sub-line component is a **byte**; + `DisplayCoord` gains `sub_row` rather than redefining `row`. + +### The two decisions most likely to be questioned later + +- **GUI users lose word wrap.** Character-wrap parity is cheap and + Emacs-consistent, but the GPU has word-wrapped since it existed. + Accepted deliberately (user, 2026-08-06). **Must appear in the PR + description and release notes**, not only in the framing. +- **The audit strategy is asymmetric on purpose.** The coordinate + functions gain a required context parameter so the **compiler + enumerates** every call site; `DisplayCoord` gains an **additive** + field so untouched consumers stay *correct* rather than merely + findable. Compiler-enforced where possible, correct-by-default where + not. + +### Verification (planned, from framing §7) + +Cell-level tests at several **window widths** (not offsets — offsets +are Stage 4). Wrapped visual-row mapping with identity on cursor +boundaries and projection elsewhere. The **wrap-point** case: the +position at a soft break belongs to `(k+1, 0)`, while a **hard** line +end filling the row exactly keeps `(k, max_cols)` — a control that the +wrap work did not move it. A `truncate` control for every mapping +claim. A GPU witness that `truncate` is *honored*, since the existing +`wrapped_caret_survives_size_changes` passes against a wrap nobody +configured. + +### Not in scope + +Horizontal scroll in full (Stage 4). `M-q` / auto-fill / reflow. Word +wrap as a mode value — a named future third choice, not this stage. +Bidi/RTL. Soft-wrap gutter indicators. + ## Tree primitive (P5) — MERGED as #217; adoption is the open work **The lane is gone, not the work.** Rule 4 removes a lane after merge, diff --git a/docs/long-lines-framing.md b/docs/long-lines-framing.md new file mode 100644 index 0000000..f36d5b1 --- /dev/null +++ b/docs/long-lines-framing.md @@ -0,0 +1,1217 @@ +# Long lines — QoL Stage 3 + +**Status: revision 14 — APPROVED to branch (2026-08-06). All six +questions answered. Not yet implemented.** + +**Revision 2** corrected a load-bearing error in revision 1: it claimed +both frontends render from the same `CellGrid`. They do not — the GPU +ignores the grid variants and lays out locally, and it **already wraps +long lines**. See §1.2. The correction reverses the cost profile and +therefore the recommendation in §3. + +**Revision 3** fixed two things review caught downstream of that +correction. The opening still called the defect "total ... in either +frontend," contradicting §1.2's own table — corrected below, along with +what the cross-frontend defect actually is. And §7's GPU witness +described the non-wrap mode as "scroll/truncate," conflating a deferred +Stage 4 capability with a Stage 3 mode; **§3.1 now states the Stage 3 +surface explicitly** rather than letting a test mode imply a product +one. + +Writing §3.1 surfaced a third thing neither review nor revision 2 had +named: agreeing on the mode is not agreeing on the wrap. **New Q#LL5 +(§5a)** — the GPU wraps at `Wrap::WordOrGlyph` and a grid walk would +naturally wrap at the character, so both frontends could honor `wrap` +and still break lines in different places. It was briefly buried in +"not in scope"; it is the same class of defect this lane exists to +close, so it is a question now. + +**Revision 4** — the verification sketch still asked +for `pos_to_display` / `display_to_pos` round trips "at non-zero +offset", and its first bullet for cell tests "at several offsets". +Both are `view_left` requirements, and `view_left` is Stage 4. +Deferring scope in §3.1 and §9 while test lines quietly assumed it is +exactly how deferred work creeps back in. Replaced with the wrapped +visual-row mapping witness `wrap` actually needs — including the +wrap-point boundary case and a `truncate` control — and with "at +several window widths" (§7). + +**Revision 5** — revision 4's replacement then asked +for round-trip identity "for every position", which the existing +coordinate contract makes impossible: `pos_to_display` canonicalizes a +byte inside a multi-byte codepoint to that codepoint's column, and +`display_to_pos` returns the codepoint start. Restated as identity on +valid cursor boundaries plus projection elsewhere, with the +interior-byte canonicalization preserved under its own separate witness +(§7). A witness that cannot be satisfied gets weakened until it passes, +which would have cost exactly the discriminating power §7 exists for. + +**Revision 6** — the wrap-point case still said the +last position on row *k* and the first on row *k+1* "must not +collide". They are **one** source position with two candidate +coordinates, so that assertion has no content. §7 now *decides* the +ownership — the wrap position is column 0 of the next row, because the +alternative coordinate is off-grid on a row that is full by +construction — states affinity as a deliberate non-goal, and separately +names the requirement revision 5 was actually reaching for: the two +*distinct* adjacent codepoint starts across the break must map +distinctly. + +**Revision 7** — revision 6's justification was wrong +twice. A hard line ending at exactly `max_cols` also has column +`max_cols` — `pos_to_display` clamps nothing — so "off-grid" never +distinguished the soft-wrap case; and `pos_to_display` takes no +viewport at all, so it has no grid to be off. The decision stands, on a +rule that subsumes both cases: **a position maps to the cell of the +glyph that follows it when one exists, otherwise just past the last +glyph** — which resolves the soft wrap and leaves hard ends, including +full-row ones, exactly as they are today (now a control in §7). + +Chasing that also surfaced a structural cost no earlier revision had: +under `wrap`, `pos_to_display` **cannot compute a visual row from its +current arguments**, so the wrap width has to reach it — a trait +signature change across ~35 call sites, though only `TextView` +overrides the method. + +**Revision 8** — that cost was still framed too +narrowly: a signature is not a model. `display_to_pos` has the same +missing inputs and treats `coord.row` as a raw source-line index, so it +fails *silently* into the wrong line; and `view_top`, vertical motion, +paging, wheel scroll, gutters and overlays all operate in source-line +space today. `move_down` alone treats a display row as a source line. +**New Q#LL6 (§5b)** makes the +authoritative logical-to-visual row map a design item — both +directions, its width/mode inputs, how it *composes with* the existing +fold map rather than bypassing it, what becomes of `view_top` (a +persisted value, via `saveplace`), and `truncate` as the identity case. +§5b.7 restates the cost as an audit of both mapping APIs and every +source-row assumption. + +**Revision 9** — revision 8's `view_top` question was +a **false binary**: "source line" and "visual row" are both +unworkable. A source line cannot name a viewport starting partway down +a wrapped line, so a line taller than the viewport could never scroll +to its second visual row — the exact buffers this lane is for. The +representation must be composite (anchor line + row-within-line), +composed with folds. + +The persistence consequence is also sharper than "a format change". +`saveplace` has **no version marker** and stores a bare integer, so +redefining `view_top` silently reinterprets every existing record; and +its path field is the whitespace-split remainder, so appending a field +is not backward-compatible either. A visual row is additionally +**width-dependent** — saved at 120 columns, restored at 80, it denotes +a different place. §5b.6 is new, recommends persisting only the +width-independent anchor line (no migration needed, by construction), +and requires Q#LL6 to settle a **resize-restore policy** for the +row-within-line offset, which live resizes need regardless of what is +stored. + +Also: revisions 4 through 8 each ended up labelled "the current one". +Only the Status line above is authoritative; the stale markers are +removed. + +**Revision 10** — revision 9 described +`VisibleLineMap` as mapping source lines to a renumbered *visible-line* +space. It does not: `next_visible`, `prev_visible`, `visible_head_of` +and `clamp_view_top` all take and return **source-line indices**, +constrained to visible heads (`src/fold_view.rs:223`). Folds project +onto visible anchors; they do not renumber. The error mattered in the one place +this section exists to protect — a reader who believed folds renumber +would add a *second* renumbering for wrap and misindex every fold +consumer. §5b.2, §5b.3 and §5b.4 are corrected, and the same loose +wording is fixed in §2.1, §5 and §7, where it had also mislabelled +`pos_to_display`'s current return (a **source** line index, from +`line_at_offset`). + +§5b.6 additionally turns the anchor-persistence recommendation into an +explicit **public API contract**: `pmacs.editor.view_top()` keeps +returning the source anchor and `set_view_top(n)` sets it with +`row_within_line = 0`, so `saveplace` needs no change and existing +records keep working by contract rather than by luck. + +**Revision 11** — revision 10 replaced "renumbers" +with "restricts the source-line domain to visible heads". The second +half is also wrong, and in a way that inverts a contract: +`clamp_view_top` **deliberately accepts a hidden line** and projects it +to its visible head — that is why it exists (`src/fold_view.rs:215`), +and `text_view::render` depends on it. "Restricted domain" would make +the supported case read as a caller error. §5b.3 now describes the +first step as a **source-index-preserving projection onto a visible +source-line anchor**: total, idempotent, same index space — which is +the same shape as §7's coordinate rule one level up. The load-bearing +conclusion is unchanged: no dense middle coordinate space exists. + +**Revision 12** — revision 11 stated that shared rule +as "projection to the **nearest** canonical value". It is not +proximity-based: a hidden line maps to its fold head even when the next +visible line is closer, and an interior UTF-8 byte maps to its +codepoint start rather than the nearer boundary. Nor is it uniformly +backward — `pos_to_display` projects an interior byte back to the +codepoint start while `display_to_pos` rounds forward to the next +boundary. The accurate rule is **identity on canonical inputs; +otherwise projection to the contract's designated canonical +representative**, with total-and-idempotent as the genuinely shared +algebra (§5b.3). + +**Revision 13** — records decisions from the +2026-08-06 design discussion rather than correcting an error. + +**Q#LL1 is answered** (§3): `wrap` + `truncate`, **default `wrap`**, +scroll deferred to Stage 4. The default is a knowing behavior change to +the TUI — no default can preserve both frontends, because they +currently disagree. + +**Q#LL6 item 3 is answered** (§5b.4): `view_top`'s sub-line component +is a **byte**, not a row index — width-independent, exactly reversible +across resizes, and it **dissolves** the resize-restore policy §5b.6 +demanded rather than answering it. Two further structural decisions are +recorded in the new §5b.5: **no global map is needed** (every vertical +consumer is local, so layout is per-line — which removes the +`open_100mb_under_200ms` risk §5b.7's framing invited), and +**`DisplayCoord` gains a `sub_row` rather than redefining `row`**, so +untouched consumers stay correct instead of merely findable. + +Found while answering: **the GPU already carries this composite +anchor** — `scroll_top` plus `code_scroll_residual`, renormalized by +`normalize_code_scroll` (framing Q#F6) when reflow pushes the residual +across source lines. The shape is precedent, not invention. + +**Revision 14 — the current one.** Answers the remaining four questions +and moves the document to APPROVED. + +**Q#LL2** (§4): buffer-local, with `Viewport` carrying the *resolved* +mode as it already carries `folds`, so `TextView` stays +config-agnostic. **Q#LL4** (§6): do not adopt `editing.fill-column` — +it is orphaned because its consumer (`M-q` / auto-fill) does not exist, +which is a different defect from `full_grid`'s and §1.1 should not be +read as equating them; sharpen its description and name ours +`ui.line-wrap`. **Q#LL5** (§5a): character wrap in **both** frontends, +accepting that GUI users lose word wrap — the analysis changed on +discovering that a whitespace-based grid wrap would give only +*approximate* parity against cosmic-text's UAX #14 line breaking, which +is worse than honest divergence. **Q#LL6 items 1-2** (§5b.4): +`TextView` methods, no cache initially, one `Copy` context parameter — +breaking on the input side so the compiler enumerates the audit, +additive on the output side so untouched consumers stay correct. + +Drafted while GitHub Actions was in a major outage and #220 could not +merge. Nothing here depends on #220 landing; the two lanes touch no +common code. + +The user's report, from daily-driver use: + +> long lines need to either wrap somehow or be scrollable. Haven't +> tried this in GUI, but in TUI, a line that extends off screen cannot +> be read in full in any way. This should also be something that the +> user can configure, whether to wrap or scrollable. + +**The report is accurate where it makes a claim, and it is careful to +limit that claim to the TUI.** In the TUI a line wider than the window +is unreadable past the edge by any means. The GPU is not in that state: +it already wraps, so the text is readable there --- see §1.2, which +also records that revision 1 of this document asserted "in either +frontend" and was wrong to. + +**The cross-frontend defect is not unreadability. It is that neither +behavior was chosen.** The TUI truncates because a cell walk breaks at +`max_cols`; the GPU wraps because a library default was never +overridden. Two accidents that disagree, and no way for the user to +express a preference in either --- which is the part of the report that +applies to both frontends: *"should also be something that the user can +configure."* + +--- + +## 1. What is already built + +Almost nothing, and that is the honest headline. Stage 1 and Stage 2 +both drove machinery that already existed --- Stage 1 honored a flag +with a documented contract, Stage 2 drove a font preference with a +whole wire message behind it. **Stage 3 has no such seam.** It builds a +capability the codebase does not have. + +What exists and helps: + +- **`text_view::render` is the single place a source line becomes + cells** (`src/text_view.rs:211`). One walk, one truncation site --- + **for the grid path only.** See §1.2: that is one of two renderers, + not the renderer. +- **The fold precedent.** Arc 6 already broke the row-to-source-line + identity: `VisibleLineMap` + `Viewport.folds` let row `r` show the + `r`-th *visible* line. The framing for it recorded why folding is not + an overlay --- *"overlays repaint cells, they cannot delete rows"* + (`src/view.rs`). Wrapping is the exact dual: it **adds** rows for one + source line. The same argument forbids it being an overlay, for the + same reason. +- **The config registry already supports buffer-local overrides.** + `Registry::get(name, Option)` consults a per-buffer layer + before global (`src/config_registry.rs:843`), with an explicit note + that there is no ambient current buffer --- a caller wanting + buffer-aware behavior must pass the `BufferId`. So "wrap in prose, + scroll in logs" needs no new machinery. + +What does **not** exist: + +- **No horizontal offset anywhere.** `Viewport` (`src/view.rs:131`) has + `buffer_start`, `buffer_end`, `cell_origin`, `cell_size`, `gutter_w`, + `folds` --- and no column offset. `Window` has `view_top` + (`src/desktop.rs:92`) and no `view_left`. +- **The truncation is one line of code with no alternative path.** + `src/text_view.rs:251`: + ```rust + if col >= max_cols { + break; + } + ``` + The walk always starts at the line's first character. There is no + mode, no flag, and no caller that can ask for anything else. + +### 1.1 An orphaned setting, of the exact shape Stage 1 just fixed + +`editing.fill-column` is defined in the registry --- *"Preferred wrap +column."*, `ConfigKind::Number`, min 1, max 1000 +(`src/config_registry.rs:1191`) --- and is **read by nothing**. It +appears only in its own definition and in tests of the registry and the +Lua binding. + +This is the same shape as the `full_grid` defect Stage 1 closed: a +declared contract with full definition-side coverage and zero +consumers. **Stage 3 must either give it a consumer or explicitly say +why it does not deserve one.** Leaving it orphaned a second time, in +the very lane about line width, would be the worse outcome. + +My reading: `fill-column` is a *fill* concept (where `M-q` reflows +text, editing the buffer), not a *display wrap* concept (where a long +line is shown across rows, buffer unchanged). Conflating them is a +known Emacs papercut. That argues for a separate display setting and a +note here --- but it is Q#LL4 below, not my call. + +### 1.2 The two frontends already disagree, and that is the defect + +**Revision 1 of this document got this wrong and the error inverted the +lane's cost.** It claimed both frontends consume the same `CellGrid`, +so one change in `text_view::render` would reach both for free. That is +false, and `pmacs-gpu` says so in its own words +(`pmacs-gpu/src/main.rs:4502`): + +> The grid variants (`CellDelta`, `Cursor`, `CursorByte`) are +> **ignored** --- pmacs-gpu **lays out locally** and tracks the cursor +> via `PresenceUpdate`. + +The `terminal.rs:772` comment revision 1 cited --- "one run per row, +never wrapped together" --- is the **vterm** path, where a run must +occupy exactly the cells the child gave it. It says nothing about +document text. + +What the GPU actually does with a long line: **it wraps it.** Every +explicit `set_wrap` in `pmacs-gpu` is `Wrap::None` and every one is +chrome --- status, status-left, menu, minibuffer, completion --- or the +terminal run path (`:3930`, `:3940`, `:3956`, `:3966`, `:3978`, +`:5313`, `:5618`, `:8215-8221`). **The document buffer never sets a +wrap mode at all**, so it keeps the one `cosmic-text`'s `Buffer` +constructor installs --- `Wrap::WordOrGlyph` (`buffer.rs:262` in +0.18.2) --- which is what `sync_buffer_dimensions`'s comment assumes: +*"so cosmic-text wraps at the final clip"* (`:4338`). + +It is tested, if incidentally: `wrapped_caret_survives_size_changes` +(`:15853`) puts a 180-character line in a 320px window with the caret +at byte 180 and asserts the caret paints inside the code clip. A +truncating renderer would have that byte off-screen. + +**So the real state of the product is:** + +| | long line | horizontal scroll | +|---|---|---| +| TUI (grid) | **truncated, unreadable** | none | +| GPU (local layout) | **wrapped** --- readable | none | + +This reframes the lane. The user wrote *"Haven't tried this in GUI, but +in TUI, a line that extends off screen cannot be read in full"* --- and +that instinct was exactly right. The GUI half most likely already +works. What is broken there is different: **the wrap is implicit, +unconfigurable, and was never a decision** --- it is cosmic-text's +default leaking through as product behavior. + +**Stage 3 is therefore not "build wrap and scroll." It is "make the two +frontends agree on a mode the user chose."** That is a §16 Semantic +Frontend Architecture concern, and it is a larger lane than revision 1 +implied: the work lands in `text_view::render` *and* in the GPU's local +layout, with a shared setting deciding both. + + +--- + +## 2. The central question: wrap and scroll are not one mechanism + +The user's phrasing --- "either wrap somehow or be scrollable ... +whether to wrap or scrollable" --- reads as one setting with two +values. **Internally they are not two settings on one mechanism; they +are two different mechanisms**, and the framing has to say so before +anything is built. + +- **Horizontal scroll** is a *viewport offset*. Row `r` still shows + exactly one source line. The walk starts at display column + `view_left` instead of 0. The row-to-line relation is untouched. +- **Wrap** is a *row-multiplying line map*. One source line occupies + ceil(width / cols) rows. The row-to-line relation **breaks**, in the + same way folding broke it --- and in the opposite direction. + +They cost very different amounts. Scroll is close to the cheap change +it looks like. Wrap is not. + +### 2.1 What wrap breaks that scroll does not + +`TextView::pos_to_display` (`src/text_view.rs:156`) returns +`DisplayCoord::new(row_idx, col)` where `row_idx` is the **source** +line index (`line_at_offset(pos)`) and `col` is the display width of +the line's prefix. Under wrap +neither half survives: one source line has many rows, and `col` is +width-modulo-cols rather than the prefix width. + +That function is not a detail. Per `src/view.rs`, **cursor placement +and scrolling use the base text view's `pos_to_display` only.** And +`overlay_paint.rs` maps every overlay's display row through `view_top` +plus the fold map (`src/overlay_paint.rs:178`, `:318`). So a new row +mapping that does not go through the same place puts **every overlay +--- diagnostics, highlights, inlay hints --- on the wrong row** for any +buffer containing a wrapped line. + +This is the real cost of wrap, and it is why I am not proposing to +build both at once. + +--- + +## 3. Q#LL1 --- scope: one mechanism or two? **ANSWERED** + +> **Answered 2026-08-06.** Stage 3 ships `wrap` and `truncate`, with +> **`wrap` as the default**; horizontal scroll is Stage 4. The default +> was decided knowingly: no default preserves both frontends, because +> they currently disagree (§1.2), so this one **changes the TUI's +> current behavior** and leaves the GPU's alone. `wrap` wins because it +> is the readable value and the one the reported defect asks for. +> +> Corroborating, though not the reason: **Emacs also wraps by +> default** --- `truncate-lines` is `nil` --- so the choice matches +> what an Emacs-shaped editor's users expect. See §5a for the part of +> that comparison which does *not* transfer. + + +**(a) Horizontal scroll only.** Closes the reported defect --- the line +becomes readable --- at the lowest risk. `view_left` on the window, an +offset in the walk, commands to move it, and cursor-follow. Does not +touch `pos_to_display`'s contract beyond a column shift. + +**(b) Wrap only.** Matches what many users reach for first, but pays +the whole row-mapping cost immediately and puts every overlay's +correctness in the blast radius. + +**(c) Both, in one lane.** Two mechanisms, one review. Against the +project's one-feature-one-branch rule in spirit even if it is one +"feature" in the user's words. + +**(d) Scroll now (Stage 3), wrap as Stage 4.** Ships readability +quickly; leaves the mode setting's second value unimplemented for a +while, which is a discoverability wart --- a setting that names a value +it does not honor is its own coherence defect. + +**Revision 2 changes this recommendation.** Revision 1 recommended (a) +or (d) --- scroll first, wrap later --- on the belief that one renderer +served both frontends. §1.2 shows that is false, and it undermines the +recommendation: with the GPU **already wrapping**, shipping +scroll-only would leave the TUI scrolling while the GUI wraps. The +frontends would still disagree, and the user would still have no say +--- which is the actual complaint. + +**Revised recommendation: (b) wrap first**, as the mode both frontends +can already almost honor, then scroll as Stage 4. + +The reasoning inverts cleanly. Wrap is the expensive one in the grid +renderer and **free in the GPU, where it already happens**; picking it +first means Stage 3 ends with both frontends doing the same declared +thing. Scroll is cheap in the grid renderer and **entirely new in the +GPU** --- which has `scroll_top` but no horizontal counterpart +anywhere (a search for `scroll_left|hscroll|x_offset` in +`pmacs-gpu` returns nothing). + +I still do not recommend (c). But note the cost profile is now the +mirror image of what revision 1 claimed, so please read that +recommendation as withdrawn rather than merely amended. + +### 3.1 The Stage 3 surface, stated + +Wrap-first leaves an obvious hole: a setting with one legal value is +not a setting, and the user asked for a choice. Revision 2 left this +implicit and let a *test* mode stand in for a *product* mode, which is +how §7's witness ended up describing "scroll/truncate" as if they were +one thing. They are not, and scroll is deferred. + +**Decision: Stage 3 ships two values, `wrap` (default) and +`truncate`.** Scroll is Stage 4. + +- **`wrap`** --- the default, because it is the only value that leaves + all text reachable with Stage 3's machinery alone. It is also what + the GPU does today, so the default is not a behavior change there. +- **`truncate`** --- one source line per row, clipped at the edge. This + is exactly what the TUI does today, named and made deliberate, and + made available in the GPU where it currently is not. + +**`truncate` is not a placeholder and not a test-only mode, but it is +incomplete until Stage 4.** The honest description: truncate is the +*mode*, horizontal scroll is the *navigation* that makes the clipped +remainder reachable. "Scrollable" in the user's request decomposes into +exactly those two, and Stage 3 ships the first. Until Stage 4 lands, +selecting `truncate` means accepting that text past the edge cannot be +read --- which is why it must not be the default, and why its +description string has to say so rather than implying a complete +feature. + +The alternative --- ship `wrap` alone with no setting and defer the +whole config surface to Stage 4 --- is defensible and cheaper, and I +rejected it for one reason: it would leave the TUI's *current* +truncation reachable only by not-yet-existing configuration, so the +TUI's existing behavior would become unavailable the moment wrap +landed. Users reading logs want one line per row. Removing that, +even temporarily, is a regression dressed as a fix. + +**If you would rather ship wrap-only and take that regression as +acceptable for one stage, say so and I will cut `truncate` --- but the +framing should not pretend the choice is free either way.** + +--- + +## 4. Q#LL2 --- per-buffer or per-window? **ANSWERED** + +> **Answered 2026-08-06: buffer-local.** Free in the existing registry, +> and it matches Emacs, where `truncate-lines` is a buffer-local +> variable shared by every window on the buffer. A window-local layer +> would be a **third** config layer built for a need nobody has +> reported. +> +> **How the mode reaches the renderer**, which is the part that needed +> deciding: `Viewport` carries the **resolved** mode, exactly as it +> already carries `folds`. The render driver in `editor.rs` holds both +> the registry and the `BufferId`, resolves once per window per frame, +> and `TextView` stays config-agnostic --- respecting the registry's +> "no ambient current buffer" rule (`src/config_registry.rs:837`) +> rather than working around it. +> +> This does put the **mode** per-buffer and the **byte anchor** +> per-window. That split is correct rather than merely tolerable: the +> mode is a property of the content, the anchor is a property of the +> viewport looking at it. + + +The registry supports **buffer-local** overrides today, for free. + +But line display is arguably a **window** property: the same buffer in +a split could reasonably wrap in one pane and truncate in the other. + +**Scoped to Stage 3, this question is only about the mode**, and +buffer-local answers it for free. The follow-on --- that `view_left` +would be unambiguously per-window, since two panes on one buffer must +scroll independently exactly as they already hold independent +`view_top`s (`src/desktop.rs:92`) --- **is Stage 4's, not this +lane's.** + +Recording it here anyway, because the choice made now constrains it: if +Stage 3 makes the mode buffer-local and Stage 4 then needs a +per-window offset, the two halves of one user-facing concept end up +living at different scopes. That is what Emacs effectively does and it +is survivable, but it should be a decision rather than a discovery. +**Q#LL2 asks whether to accept that split now.** + +--- + +## 5. Q#LL3 --- what does the cursor do? + +**Under `wrap`, this is a Stage 3 question and it is not optional.** +`pos_to_display` returns `(source line index, prefix width)`, and +§2.1 shows both halves stop being true once one source line owns +several rows. Cursor placement uses that function exclusively, so a +wrapped buffer with an unrepaired mapping puts the caret on the wrong +row --- in the grid renderer, which is the half that has to be built. +Whatever answers it must also serve `overlay_paint`, or diagnostics +land on the wrong row too. + +**Under `truncate`, the cursor question is trivial** --- one row per +line, the existing mapping holds --- **but only until Stage 4.** Moving +the cursor past the right edge in a truncating view is precisely what +horizontal scroll exists to handle, and Stage 3 has no answer for it: +the caret goes off-screen. That is a real, if minor, sharp edge of +shipping `truncate` without scroll, and §3.1's description string +should own it. + +Deferred to Stage 4, recorded here so it is not rediscovered: whether +explicit horizontal scroll drags the cursor with it (as the wheel does +vertically) or leaves it for the next motion to snap back. The existing +"auto-scroll to keep cursor visible" pass that `scroll_window` +deliberately works around (`src/editor.rs:3624-3628`) is where a +horizontal analog would live, and its comment already records the +hazard --- an unconditional snap-back makes explicit scrolling feel +stuck. + +--- + +## 5a. Q#LL5 --- agreeing on the mode is not agreeing on the wrap **ANSWERED** + +> **Answered 2026-08-06: character wrap in BOTH frontends.** The GPU +> document buffer gets an explicit `Wrap::Glyph` --- its first explicit +> `set_wrap` --- and the grid walk wraps at the character. +> +> **The option analysis changed while deciding, and the change is the +> reason.** "Teach the grid word wrap" looked like the high-effort, +> high-parity option. It is not: cosmic-text performs **Unicode line +> breaking (UAX #14)**, so a grid walk breaking on whitespace would +> diverge on hyphens, CJK and non-breaking spaces. That buys +> *approximate* parity, which is worse than honest divergence because +> it looks unified until it is not. True parity that way requires a +> UAX #14 dependency. +> +> Character wrap in both is the only option that is **true parity, +> cheap, and Emacs-consistent** (Emacs's default wrap is a character +> wrap; word wrap is opt-in `visual-line-mode` / `word-wrap`). It also +> completes the lane's thesis: one declared mode, one declared wrap +> style, two frontends that agree. +> +> **Word wrap becomes a declared third value later** --- +> `ui.line-wrap = "word"` honored by both frontends --- rather than an +> inherited library default that only one frontend has. `ConfigKind::Enum` +> makes adding a choice a clean additive change. +> +> **The regression is real and must be stated where users see it, not +> only here.** GUI users have had word wrap since the GPU frontend +> existed and never opted into losing it. This belongs in the PR +> description and the release notes. + + +Raised by writing §9 and noticing I had put it in "not in scope" as if +it were a detail. It is not. + +The lane's thesis is that two frontends should stop disagreeing. But +choosing `wrap` in both only makes them agree on *whether* to wrap, not +*how*. + +The GPU's value is exact and worth naming: `cosmic-text` 0.18.2 +constructs every `Buffer` with **`Wrap::WordOrGlyph`** +(`buffer.rs:262`) --- word wrap, falling back to glyph wrap for a word +that cannot fit a line by itself. It is not a trait `Default`; the +constructor sets it, which is why the GPU document gets it without ever +asking. + +The natural grid implementation --- keep walking the cell row and +continue on the next --- is a plain **character** wrap. Ship both and +the same buffer at the same width breaks lines in different places in +the two frontends. + +Worth noting for whoever writes the tests: the existing +`wrapped_caret_survives_size_changes` uses `"x".repeat(180)`, a line +with **no word boundary at all**, so it exercises only +`WordOrGlyph`'s glyph fallback. It would pass identically under +`Wrap::Glyph`, and therefore cannot detect the divergence this question +is about. A prose line is needed to see it. + +That is a smaller defect than today's truncate-vs-wrap split, and it +may be an acceptable one. But it is the same *kind* of defect this lane +exists to close, so it should be decided rather than inherited --- +which is precisely the mistake §1.2 documents the GPU already making +once. + +Options: match `WordOrGlyph` in the grid walk (most work, genuine +parity); accept character wrap in the grid and document the divergence; +or set `Wrap::Glyph` on the GPU document buffer so both are character +wraps (cheapest parity --- one line, and it would be the document +buffer's *first* explicit `set_wrap` --- but a visible downgrade for +GUI users who have had word wrap all along without anyone deciding they +should). + +No recommendation yet --- I would rather know Q#LL1's answer first, +since this question only exists if `wrap` ships. + +--- + +## 5b. Q#LL6 --- the visual-row map is the actual design problem + +Revision 7 recorded that `pos_to_display` cannot compute a visual row +from its arguments, and framed that as a signature change. **Review was +right that this understates it: a signature is not a model.** + +### 5b.1 The inverse has the same hole, and it is worse + +`display_to_pos` takes `(&self, buf, coord)` (`src/view.rs:278`) and +opens by treating the row as a **source line index** +(`src/text_view.rs:188`): + +```rust +let row = coord.row as usize; +if row >= self.line_count() { return None; } +``` + +Without width and mode it cannot invert a wrapped visual row at all --- +and unlike the forward direction, it will not fail loudly. It will +return a position from the wrong source line. + +### 5b.2 The vertical stack is in source-line space, end to end + +- **`Window::view_top` is documented as "First buffer line shown at the + top of this window's viewport"** (`src/window.rs:374`). Not a visual + row. +- **Rendering converts it as a source line:** + `window.text_view.line_offset(window.view_top)` (`src/editor.rs:4334`). +- **`move_down` conflates display rows with source lines** + (`src/editor_core.rs:2145`). It reads `coord.row` from + `pos_to_display` --- a **display** row --- then treats it as a source + line: bounding `next_row >= aw.text_view.line_count()` against the + source line count and passing it through `map.next_visible()`. This + is correct today only because display row and source line coincide. + Under `wrap` they diverge, and a one-source-line buffer wrapping to + two visual rows would refuse to move to the second row. + + **Revision 9 called `next_visible`'s argument a "visible-line index", + as though folds introduced a third renumbered space. They do not** + --- see §5b.3. That mistake matters here specifically: if a reader + believed folds renumber, the natural fix to `move_down` would be to + renumber again for wrap, which is precisely the fold-composition + error §5b.3 exists to prevent. + +The same source-row assumption runs through `goal_col` +(`src/window.rs:376`, "sticky display column for vertical motion"), +`visible_rows` and therefore `cursor.page-down` (`src/window.rs:377`), +`scroll_window` (`src/editor.rs:3629`), the gutter's line numbers, and +`overlay_paint`'s row arithmetic (`:178`, `:318`). + +### 5b.3 It must compose with folds, not replace them + +This is the constraint that makes it a design item rather than a +utility --- and getting the existing model right is the first half of +it, because **revision 9 got it wrong in the direction that would cause +the very bug this section prevents.** + +Revision 9 said `VisibleLineMap` "mediates source line to **visible** +line" and drew: + +> ~~source line --(folds)--> visible line --(wrap)--> visual row~~ + +**There is no renumbered visible-line space.** `next_visible(line)` +computes `line + 1` and then jumps past a collapsed component, +returning a **source-line index** (`src/fold_view.rs:223`); so do +`prev_visible`, `visible_head_of` and `clamp_view_top`. + +Folds do not renumber lines. Nor --- a second-order correction, from +review of revision 10 --- do they **restrict the domain**, which is how +this paragraph first put it. `clamp_view_top` *deliberately accepts a +hidden line* and projects it to its visible head; that is the reason it +exists (`src/fold_view.rs:215`), and `text_view::render` relies on it, +noting that "a caller that hands us a hidden start still gets its +head". Calling the domain restricted would imply passing a hidden line +is a caller error. It is the supported case. + +**The first step is a source-index-preserving projection onto a +visible source-line anchor.** Total --- every source line is a legal +input --- idempotent, and staying inside the same index space +throughout. (`visible_rows_between` does return a dense count, but that +is a *distance*, not a coordinate, and nothing indexes with it.) + +That is the same shape as the coordinate rule in §7, one level up: +**identity on canonical inputs; otherwise projection to the contract's +designated canonical representative.** + +"Designated", not "nearest" --- the distinction is the whole content of +the rule. A hidden line maps to its **fold head** even when the next +visible line is closer (`visible_head_of`, not "whichever visible line +is fewest lines away"), and an interior UTF-8 byte maps to its +**codepoint start**, which is not necessarily the nearer boundary. Each +contract names its representative; proximity never selects it. + +The direction is not shared either, which is why the rule has to be +stated in terms of designation rather than of going backward. +`pos_to_display` projects an interior byte **back** to its codepoint +start, while `display_to_pos` rounds a column landing inside a wide +character **forward** to the next codepoint boundary +(`display_to_pos_jumps_over_wide_chars`, +`display_to_pos_inside_tab_rounds_to_next_codepoint`). Two directions, +one rule --- each names its own representative. + +What the two levels genuinely share is the algebra: both are **total** +(every input is legal) and **idempotent** (projecting twice equals +projecting once). That is the property the wrap map must also hold, in +both directions. + +The accurate model, and the one the wrap map must be built against: + +> source line *(projected by folds onto a visible source-line anchor)* +> --(wrap)--> visual row + +So there are exactly **two** coordinate spaces after this lane, not +three: source lines, and visual rows. Wrap is the only renumbering +step, and it consumes a source line the fold map has already projected +onto a visible anchor. + +Why the distinction is load-bearing rather than pedantic: a reader who +believes folds renumber will reach for a second renumbering to layer +wrap on top, ending with a source-to-visible-to-visual chain in which +the middle space has no definition and every fold consumer is +subtly misindexed. A wrap map that instead goes straight from source +line to visual row *without consulting the fold map* bypasses folding +and silently breaks it; one that replaces `VisibleLineMap` +re-implements a merged and reviewed arc. **The map takes a +fold-vouched source line and returns a visual row**, and both +directions have to survive that composition. + +### 5b.4 What Q#LL6 asks --- **ANSWERED** + +> **Answered 2026-08-06**, in discussion. Item 3 (byte-anchored +> `view_top`) is above; two structural decisions are in §5b.5 --- **no +> global map**, and **`DisplayCoord` gains a sub-row rather than +> redefining `row`**. The resize-restore policy §5b.6 demanded is +> **dissolved** rather than answered: a byte anchor makes it +> unnecessary. +> +> **Item 1 --- where it lives: `TextView`, no new type, no cache.** It +> already owns the line offsets and the character walk. Two methods +> taking width and mode. No cache initially: a viewport is ~50 lines, +> so that is at most ~50 single-line layouts per frame, the same order +> as rendering, which already walks every visible line. A per-window +> `(line, width)` cache is a profiling response, not a design premise. +> +> **Item 2 --- signatures.** One `Copy` context value rather than two +> loose parameters: +> +> ```text +> pos_to_display(buf, pos, ctx) -> Option // ctx: { width, mode } +> display_to_pos(buf, coord, ctx) -> Option +> ``` +> +> The asymmetry with the `DisplayCoord` decision (§5b.5) is +> deliberate, and it is the whole audit strategy: +> +> - **The input change is breaking on purpose.** A required parameter +> makes the compiler enumerate every call site, so the §5b.7 audit is +> mechanical rather than a grep. +> - **The output change is additive on purpose.** `sub_row` defaults to +> 0, so a consumer that does not know about wrap stays *correct*, not +> merely *findable*. +> +> Compiler-enforced where enforcement is possible; correct-by-default +> where it is not. + + +1. **Where the map lives and who owns it.** Width is a *window* + property, so a per-window map is the obvious home --- which then + interacts with Q#LL2's buffer-local mode. A per-window map keyed by + a buffer-local mode is coherent but should be stated. +2. **Both directions, explicitly.** Fold-vouched source line to first + visual row, and visual row back to (source line, row-within-line). §7's + witnesses test the second; nothing currently tests the first + because it does not exist. +3. **What `view_top` becomes --- and revision 8 posed this as a false + binary.** It offered "stays a source line" or "becomes a visual + row". **Neither works.** + + A source line **cannot represent a viewport that begins partway + down a wrapped line.** A line taller than the viewport must be + scrollable from its visual row 0 to its visual row 1, and + "convert the source line" always yields row 0 --- so the second + half of a tall line would be unreachable by scrolling. That is not + an edge case; it is the exact situation this lane exists for, since + the motivating buffers are the ones with very long lines. + + A bare visual row fails differently --- see §5b.6. + + **The representation has to be composite: an anchor line plus a + sub-line offset**, composed with folds (§5b.3). This is load-bearing + for cursor visibility, wheel scrolling and paging, not a storage + detail. + + **ANSWERED 2026-08-06: the sub-line component is a BYTE, not a row + index.** `view_top` is the byte offset of the first visible + character (equivalently, anchor line plus byte-within-line). + + A row index is width-dependent and lossy: narrowing then widening + then narrowing again does not return the viewport where it started, + and every width change needs a clamp-or-reset policy. **A byte is + width-independent**, so resize needs no policy at all --- recompute + which row that byte falls on at the new width, exactly and + reversibly. It is the same property that made anchor-line + persistence safe in §5b.6. + + It also composes with the established algebra rather than adding a + rule: an arbitrary byte is not necessarily a row start, so it + projects onto the row start containing it --- total, idempotent, + designated representative, exactly as folds and `pos_to_display` do + (§5b.3). + + And it satisfies the pinned API contract by construction: + `view_top()` returns the line containing that byte; + `set_view_top(n)` sets the byte to line `n`'s start, which *is* + sub-row zero. + + **Precedent, found while answering this:** the GPU already carries a + composite anchor --- `scroll_top` (a source line index into + `current_line_starts`) plus `code_scroll_residual` (a sub-line + offset) --- and `normalize_code_scroll` (`pmacs-gpu/src/main.rs:7955`, + framing Q#F6) already handles reflow pushing that residual across + source lines, by **renormalizing rather than clamping**. So the + composite shape is not novel here. The grid can do better than the + GPU on the sub-component only because it owns its own layout: the + GPU's residual is in pixels because cosmic-text owns layout there, + which is why it needs a renormalization loop that byte-anchoring + does not. +4. **Whether `truncate` is the identity case.** It should be: under + `truncate` the map is the identity and every current behavior holds + unchanged, which is what makes the whole change additive and + testable against today's suite. + +### 5b.5 Two structural decisions taken with Q#LL6 --- **ANSWERED** + +Both from the 2026-08-06 discussion, both load-bearing for cost. + +**1. There is no global logical-to-visual map, and none is needed.** + +A materialized "visual row of every line" prefix sum would be `O(N)` +memory and an `O(N)` rebuild on every width change --- against an M1 +gate that includes `open_100mb_under_200ms`. That is a real perf risk +and §5b.7's "authoritative map" framing invited it. + +It is not required, because every vertical consumer is **local**: +rendering walks forward from `view_top` bounded by viewport height; +`move_down`/`move_up` need one step; paging needs viewport-height rows; +the wheel needs *n* rows from `view_top`. Nothing asks for the absolute +visual row of line 40,000, and nothing indexes by one. + +So "the map" is two per-line functions --- how many rows this line +occupies at this width, and which row a given byte falls on --- plus +incremental walks. **Layout is needed one line at a time.** + +**2. `DisplayCoord` gains a sub-row; `row` keeps its meaning.** + +`DisplayCoord { row, col }` (`src/view.rs:110`) is core-internal, 53 +references, 39 inside `text_view.rs`'s own tests --- so roughly eight +real external uses. Either approach is tractable in size; they are not +equivalent in risk. + +**Redefining `row` from source line to absolute visual row would +silently break every existing consumer** --- `overlay_paint`'s +`disp.row - view_top` (`:189`, `:321`), `move_down`'s bounds check +(`src/editor_core.rs:2145`) --- with no compile error, which is the +exact failure class this framing has been catching all along. + +Adding a `sub_row` makes it **additive**: `sub_row == 0` under +`truncate` and for every unwrapped line, so existing consumers stay +correct by default and wrap-aware ones opt in explicitly. That is what +bounds the §5b.7 audit: the compiler cannot find these call sites for +us, so the design has to make the untouched ones *right* rather than +merely *findable*. + +### 5b.6 `saveplace` persistence, and why a bare visual row is unsafe + +`view_top` is written to disk. `saveplace` stores one +` ` line per file and parses it with +`^(%d+)%s+(%d+)%s+(.+)$` (`builtin/runtime/saveplace.lua:5`, `:37`), +restoring via `pmacs.editor.set_view_top` (`:76`). Two properties make +this sharper than "a format change": + +- **There is no version marker.** Nothing distinguishes a record + written before this lane from one written after. Redefine what the + second integer means and **every existing record is silently + reinterpreted** --- an old source line 500 becomes visual row 500, + which in any wrapped buffer is a different place entirely. No error, + no migration prompt, just a wrong viewport. +- **The path is the whitespace-split remainder.** So simply appending a + fourth field is not backward-compatible either: an older pmacs + reading a newer file parses the new sub-index as the head of the + path and loses the entry. + +There is a second, independent problem: **a visual row is +width-dependent.** Saved at 120 columns and restored at 80, the same +number denotes a different source location --- and windows legitimately +change width between sessions, which is precisely what QoL Stage 1 was +about. + +**My recommendation, offered as the cheapest correct option rather than +a decision:** persist only the **anchor line**, never the +row-within-line offset. Then the stored value keeps its current +meaning, every existing record stays valid **by construction**, no +migration or version marker is needed, and the persisted number is +width-independent again. The cost is bounded and small: reopening a +file restores to the top of the anchor line rather than partway down +it --- at most one line's height of drift, and only for files closed +mid-wrapped-line. + +**That recommendation is only real if it is an API contract, so state +it as one.** `saveplace` does not touch a field; it calls public Lua +(`builtin/runtime/saveplace.lua:60`, `:76`), and those bindings are +documented today as source lines --- *"view_top(): the active window's +first visible source line"* and *"set_view_top(line): set the first +visible source line"* (`src/lua_bindings/mod.rs:13714`). So the +contract Stage 3 must preserve is: + +- **`pmacs.editor.view_top()` continues to return the source anchor + line**, not a visual row, whatever the internal representation + becomes. +- **`pmacs.editor.set_view_top(n)` sets that anchor with + `row_within_line = 0`.** + +With both held, `saveplace` needs **no change at all** and existing +records keep working --- the compatibility comes from the API contract, +not from `saveplace` being careful. Any future call that needs the +sub-row is a **new** binding, additive, and not what `saveplace` +writes. + +This also decides a question Q#LL6 would otherwise leave open: the +composite `view_top` is an *internal* window representation, and the +Lua surface exposes only its anchor component. Widening the public +getter to return a pair would be the change that breaks records +silently, and it is exactly what "just make `view_top` composite" +invites if the API is not pinned here. + +**Q#LL6 must also settle the resize-restore policy**, which the +composite representation does not escape: when the width changes, a +row-within-line offset may exceed the line's row count at the new +width. Clamp to the last row, or reset to 0? This applies to live +resizes as well as restores, so it is needed regardless of what is +persisted. + +### 5b.7 The cost, restated honestly + +Revision 7 costed this as "~35 `pos_to_display` call sites". **That was +the wrong unit.** The real work is an audit of *both* mapping APIs plus +every place that assumes a display row is a source line --- vertical +motion, paging, wheel scroll, `view_top` handling, gutter numbering, +overlay placement, and the fold interaction above. + +Sizing that audit is itself part of Q#LL6, and it is a strong argument +for `wrap` and `truncate` shipping as one lane with `truncate` as the +identity case: it gives every one of those consumers a mode in which +its current behavior is provably unchanged. + +--- + +## 6. Q#LL4 --- `editing.fill-column` **ANSWERED** + +> **Answered 2026-08-06: do not adopt it** --- but the reason is +> sharper than "a different concept", and §1.1 overstated the finding. +> +> `editing.fill-column` is orphaned because **its consumer does not +> exist yet**: there is no `M-q`, no auto-fill, and no reflow command +> anywhere in this codebase. It is a setting ahead of its feature. +> That is a *different* defect from `full_grid`'s, which was a flag +> with a live consumer that ignored it, and §1.1 should not be read as +> equating them. +> +> Two things Stage 3 does owe it: +> +> - **Sharpen its description.** Once a wrap setting ships, +> `"Preferred wrap column."` actively invites the wrong conclusion. +> It must say it governs reflow commands, not display. +> - **Name ours so confusion is impossible: `ui.line-wrap`**, +> `ConfigKind::Enum { choices: ["wrap", "truncate"] }`, default +> `"wrap"`. `editing.*` is buffer-editing behavior, `ui.*` is +> display; both existing `ui.*` settings carry a `gpu-` prefix to +> mark frontend-specificity, so its **absence** here is what signals +> "both frontends". + + +Give it a consumer, or state why display wrap is a separate concept and +leave it orphaned with that reasoning recorded. See §1.1. + +--- + +## 7. Verification sketch + +Not final --- it depends on Q#LL1. + +- Unit tests on `text_view::render` at the cell level, **at several + window widths** --- not "at several offsets", which was the same + `view_left` assumption in the very first bullet. A line longer than + `max_cols`; a wide character straddling the wrap/clip column; a tab + expanded across it (the walk's tab path at `src/text_view.rs:254` has + its own `col >= max_cols` break, so wrap has to be taught there too, + not only in the main character path). +- **Wrapped visual-row mapping**, which is Stage 3's version of this. + Revision 3 asked for round trips "at non-zero offset" --- that is a + `view_left` requirement and `view_left` is Stage 4, so the sketch was + quietly re-importing deferred scope. What `wrap` actually needs + witnessed: + - For a source line occupying N visual rows, `pos_to_display` returns + the **visual** row and the column *within* that row --- not the + source-line index and the whole prefix width, which is what it + returns today (`src/text_view.rs:184`). + - `display_to_pos` inverts it: a click on visual row *k* of a wrapped + line lands in that row's byte range, not the source line's head. + - Round trip is identity for **every valid cursor boundary** in a + wrapped line, walked exhaustively rather than sampled --- the line + is short enough to make that cheap, and sampling is what would miss + the next case. + + **"Every position" would be an impossible invariant, and revision 4 + asked for it.** `pos_to_display` accepts a byte offset *inside* a + multi-byte codepoint and deliberately canonicalizes it: continuation + bytes outside a complete codepoint are trimmed and the codepoint's + own column is the answer (`src/text_view.rs:163-167`), while + `display_to_pos` returns the codepoint **start** + (`src/text_view.rs:185`, and the existing + `display_to_pos_jumps_over_wide_chars` / + `display_to_pos_inside_tab_rounds_to_next_codepoint` pin it). An + interior byte therefore cannot round-trip to itself today, and + demanding it would have made the witness unsatisfiable rather than + discriminating --- the test would have been "fixed" by weakening it, + which is the failure mode this whole sketch is trying to avoid. + + The accurate contract is: **identity on boundaries, projection + elsewhere.** For an interior byte the round trip must land on the + containing codepoint's start, and applying it twice must equal + applying it once. + - **That canonicalization is pre-existing behavior, and this lane + preserves it unless it says otherwise.** It gets its own witness, + separate from the wrap tests, so that "wrap changed the interior-byte + rule" cannot hide inside a wrap failure — or vice versa. If wrap + turns out to need a different rule at a wrap point that also splits a + codepoint, that is a deliberate change with its own Q#, not a quiet + consequence. + - **The wrap point itself**, which is the case worth designing the + test around --- and which revision 5 described in a way that + collapsed two different requirements into one incoherent sentence. + + Take `abcdef` soft-wrapping after `abc`. Buffer positions are + `0=a 1=b 2=c 3=d 4=e 5=f`. **Position 3 is a single source position + with two defensible display coordinates**: `(k, 3)` --- just past + the last glyph of row *k* --- and `(k+1, 0)` --- just before the + first glyph of row *k+1*. Revision 5 called these "the last + position on row *k* and the first on row *k+1*" and demanded they + "not collide". They are the same position. Nothing can be asserted + about their collision. + + **Decision: the wrap position belongs to column 0 of row *k+1*.** + + Revision 6 justified this by calling the alternative "off-grid", + and parenthetically claimed a hard line end is "within the row". + **Both halves are wrong.** A hard line ending at exactly `max_cols` + gets column `max_cols` --- `pos_to_display` sums the prefix width + and clamps nothing (`src/text_view.rs:184`) --- so it is *also* + just past the last cell, and that is existing, accepted behavior. + Off-gridness therefore does not distinguish the two cases at all. + + Worse, the argument was incoherent on its own terms: + **`pos_to_display` does not know the grid.** Its signature is + `(&self, buf, pos)` (`src/view.rs:271`, `src/text_view.rs:156`) --- + no viewport, no `max_cols`. A function with no notion of the grid + cannot be reasoned about as producing coordinates "off" it. + + The rule that actually decides it, and subsumes both cases: + + > **A position maps to the cell of the glyph that follows it when + > one exists on some row; otherwise to the column just past the + > last glyph.** + + - Soft wrap: position 3 is followed by `d` at `(k+1, 0)`. A + following glyph exists, so that is the answer. There is a genuine + choice here, and this resolves it. + - Hard line end: no glyph follows on any row, so the coordinate is + the column just past the last glyph --- `(k, width)`, **including + `(k, max_cols)` when the line fills the row exactly.** No choice + exists, and **this preserves current behavior unchanged**, which + is the point: the wrap work must not quietly move hard-end + coordinates. + + So the two cases differ because one has an alternative and the other + does not --- not because one is off-grid. + + **A consequence the earlier revisions missed entirely:** under + `wrap`, `pos_to_display` **cannot compute a visual row from its + current arguments.** The wrap width has to reach it. Revision 7 + treated that as a trait signature change and costed it at ~35 call + sites; **that framing was too narrow, and §5b (Q#LL6) replaces it** + --- the inverse has the same hole, and `view_top`, vertical motion, + paging, wheel scroll, gutters and overlays all currently work in + source-line space. Read §5b before costing this. + + Consequences to witness, and they are the discriminating ones: + - `pos_to_display(3)` is `(k+1, 0)`, never `(k, 3)`. + - **A hard line end that exactly fills the row still maps to + `(k, max_cols)`** --- a control asserting the wrap work left the + existing hard-end coordinate alone, since the soft-wrap rule + superficially resembles a rule that would have moved it. + - `display_to_pos` on the trailing cells of row *k* --- which exist + when a wide character forced an early break and left the row's + last cell blank --- must land on the wrap position, not on the + last glyph's start. This is the case a naive "clamp to row width" + gets wrong. + - **Affinity is explicitly not implemented.** Editors that let + `End` on row *k* and `Home` on row *k+1* sit visually apart at + one buffer position carry an upstream/downstream bit to do it. + Stage 3 carries a single canonical coordinate instead. If that + distinction is wanted later it is a feature with its own state, + not a bug in this mapping --- named here so it is a decision + rather than a discovery. + - **Distinct adjacent codepoints across the break must map + distinctly**, which is the requirement revision 5 was reaching for. + The start of the last codepoint on row *k* (position 2, `c`) and + the start of the first on row *k+1* (position 3, `d`) are two + different positions; they must give `(k, 2)` and `(k+1, 0)`, and + the round trip must return each unchanged. + - A `truncate` **control** asserting the mapping is unchanged from + today, so the wrap work cannot silently alter the non-wrapped path. +- If wrap is in scope: an overlay-placement test with a wrapped line + above the overlay's row, which is the regression §2.1 predicts. +- A PTY acceptance test for the user-visible report: with `wrap`, a + line longer than the terminal is readable in full --- following + `full_grid_resync_acceptance.rs`'s content-anchored pattern rather + than any time-based settle. (Not "scrolled past the edge" --- there + is no scrolling in Stage 3. Revision 2 wrote it that way and was + describing Stage 4.) +- **A GPU-side witness that the mode is honored rather than inherited.** + `wrapped_caret_survives_size_changes` (`pmacs-gpu/src/main.rs:15853`) + passes today against a wrap nobody configured, so it cannot + distinguish "honors the setting" from "cosmic-text's default happens + to match." The discriminating case is the other value: **with the + mode set to `truncate`, an overlong line must NOT occupy a second + row.** Without it this lane ships the defect Stage 1 just fixed --- a + declared setting nothing enforces. + + This test is a **negative control for mode enforcement**, and it is + worth being exact about what it does *not* stand for: it is not a + witness for horizontal scroll, and `truncate` is not the user's + "scrollable." Scroll is Stage 4 (§3.1). A reader who takes this test + as evidence that the scroll alternative works would be reading it + backwards --- it proves only that an explicit non-wrap mode reaches + the GPU's layout. + +--- + +## 8. Coherence impact (§20 requirement) + +- **Scorecard row 11, "Config layering + provenance --- Partial + (foundation only), 5 settings live in it."** This lane adds registry + settings against that row, and either adopts or explicitly declines + the orphaned `editing.fill-column`. +- **Journey step 4, "Understand interface --- Partial."** A line that + cannot be read in full is a direct hit on this step; the scorecard + does not currently name it, and should. +- **§16 Semantic Frontend Architecture --- this is the lane's primary + coherence citation, per §1.2.** Two frontends currently render the + same buffer's long lines differently, and neither behavior was + chosen: the TUI truncates because the cell walk breaks at `max_cols`, + the GPU wraps because cosmic-text's default was never overridden. + Stage 3 replaces two accidents with one declared mode. +- **No new interaction island.** The mode command goes in the ordinary + command registry and the global keymap. Per Q#Z3's finding in Stage 2, + `keymap_stack::Scope` carries no frontend identity --- and unlike + zoom, that is not a constraint here, because the setting is + frontend-independent by design: both renderers read the same value + and each honors it in its own layout. +- **No background-work attribution.** Nothing async. + +--- + +## 9. Not in scope + +**Horizontal scroll, in full: `view_left` on the window, the commands +that move it, and the cursor-follow pass. That is Stage 4** (§3.1, +§5). Stage 3 ships the `truncate` mode that Stage 4 makes navigable, +and ships it knowing text past the edge is unreachable in the +meantime. + +Reflow/fill commands that *edit* the buffer (`M-q`). Bidi or RTL. A +minimap. Soft-wrap indicators in the gutter --- worth doing, but they +are a gutter-arc concern and would need their own Q#.