pmacs/docs/long-lines-framing.md

1839 lines
91 KiB
Markdown

# Long lines — QoL Stage 3
**Status: revision 20 — APPROVED (2026-08-06). All eight questions
answered; §5d.6 resolved. Implementation complete; PR pending.**
**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** — 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`;
name ours `ui.line-wrap`. *(Revision 20 corrects the stated reason:
the setting is a `#[cfg(test)]` fixture name, not an orphaned
definition — see §1.1, withdrawn. The answer is unchanged, the
"sharpen its description" deliverable is void, and naming the fixtures
as fixtures replaces it.)* **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.
**Revision 15** — review of `bd752f2` found two holes
and one notation hazard.
**Q#LL7 (§5c) — the GPU had no wire.** §4 resolves the mode into
`Viewport`, which reaches the *grid*. The GPU is not a grid consumer:
it lays out locally, `BufferSnapshot` carries only CRDT bytes, and no
message expresses a wrap mode. So `truncate` would have changed the TUI
and left the GPU wrapping — the exact disagreement this lane closes.
Specified as an additive variant at v22 (advertised baseline unmoved),
carrying `buffer_id` because the mode is buffer-local, resent on
attach, on config change, **and on buffer switch** — the third being
the one a `FontFacts`-shaped design misses, since font size is global
while wrap mode is per buffer.
**Q#LL8 (§5d) — "every vertical consumer is local" was false.** The
scroll indicator needs a total: a one-line buffer wrapping to fifty
rows has `total_lines == 1`, so `format_scroll_indicator` returns
`All` while forty-nine rows sit off-screen. §5b.5 is narrowed
accordingly, keeping the distinction that bounds the cost — a *total*
is one lazily-computed number, an *index* is `O(N)` resident. `All`,
`Top` and `Bot` need no aggregate at all; only `NN%` does.
**Notation (§7).** Revision 14 said `pos_to_display` returns "the
visual row", which reads as redefining `row` — the thing §5b.5
forbids. Every wrap-point example is now the explicit triple
`{row, sub_row, col}`, and both coordinates at a soft break share the
same `row`, which is the information a redefinition would have
destroyed.
**Revision 16** — review of `1c9ff6a` found two more,
both in Q#LL8, and both the same shape: a fix that looked complete
because it was correct in one of two places.
**The GPU has its own indicator** (§5d.3). `format_scroll_indicator` is
**duplicated, not shared**`src/editor.rs:5509` and
`pmacs-gpu/src/main.rs:10114` — and the GPU passes
`current_line_starts.len()`, a source-line count. So revision 15 would
have fixed the indicator in the TUI and left the GPU reporting `All`
for a one-line wrapped buffer: **this lane's own defect, reproduced by
the section meant to close it.** Both copies keep their signature; what
changes is what the callers pass, so every existing formatter test
stays valid.
**The lazy total's cache key omitted fold state** (§5d.2). Folds are
per rendered window and can change with no edit, no resize and no mode
change, so all three of revision 15's key components stay put while the
projection moves. Corrected to **(buffer generation, content width,
mode, fold projection)** — keyed on the projection's own `components`,
which is `O(folds)` to compare and **cannot be forgotten**, rather than
a maintained revision counter that can. Same principle as byte-anchoring
and additive `sub_row`: self-validating over maintained. And *content*
width, not window width, because the gutter changes at the line-count
digit boundary.
**Revision 17** — review of `b95506f` falsified the
premise revision 16 gave the GPU: it said cosmic-text "already knows
each line's visual height". It does not. The GPU shapes **only the
viewport slice** — `rebuild_code_slice` feeds cosmic-text
`current_text[vstart..vend]` because Session S1 found the whole rope
made large-file editing **O(file) per keystroke**
(`pmacs-gpu/src/main.rs:7912`, `:1710`). Its layout cannot produce a
total, and re-shaping the document to get one would reintroduce exactly
that cost — for a status-line readout.
**So the aggregate is abandoned rather than relocated (§5d.4): `NN%` is
byte-based in both frontends**, with `truncate` keeping today's
visible-line percentage. Computing rows arithmetically would only
*approximate* what cosmic-text actually renders — the same trap Q#LL5
rejected — and letting the two frontends use different rules would be
this lane's own defect a third time. `All`/`Top`/`Bot` are unaffected:
they are local predicates and stay exact, and they are what users
actually read.
**This makes revision 16's cache — and its fold-key correction —
unnecessary.** That correction was right for the design as it stood;
the design moved under it. §5d.2 is marked superseded rather than
deleted, so a later reader can tell "the key was fixed" from "there is
no key". §5d.7 adds the **large-file guard witnesses**, because "no
whole-document work happens" must be enforced, not merely intended.
**Revision 18** — review of `d6b5285` found the
interface contradiction the byte fallback left behind. Revision 16 had
claimed the formatter could keep its signature and change only its
arguments; it cannot. **Every branch of `format_scroll_indicator`
derives from `total_lines`** — including `Bot` via
`view_top + visible >= total_lines`, which with a byte total would
compare **rows against bytes** — and any stand-in small enough to pass
restores the false `All`. "Local predicates" is not something that
signature can express, because it has no parameter for them.
**Resolved by not asking it to (§5d.5).** `truncate` calls the existing
formatter **untouched**, so its output is byte-identical *by
construction* and every existing formatter test stays valid; `wrap`
calls a new `classify(first_visible, last_visible, byte_pos, byte_len)`
returning `All`/`Top`/`Bot`/`Percent`, which never sees a row count —
so the unit mixing is not avoided but **unrepresentable**. Trying to
serve two genuinely different contracts from one four-count signature
was the mistake; it could only do so by making units implicit, which is
how the contradiction arose.
**§5d.6 is a new open question**: `pmacs-gpu` depends on
`pmacs-protocol` only, never on the `pmacs` lib, so the duplication is
**structural**. Duplicating the classifier preserves exactly the
condition that produced §5d.3's defect; sharing it via
`pmacs-protocol` makes agreement structural but widens that crate
toward presentation — a §16 layering call I am not taking alone.
**Revision 20 — the current one.** Written *during* implementation
rather than before it, because that is when the error surfaced: §1.1's
"orphaned setting" does not exist. `editing.fill-column` is a
`#[cfg(test)]` fixture name at both cited sites, so the Q#LL4
deliverable "sharpen its description" had no object. §1.1 is withdrawn
in place with the original text and the reasoning that produced it;
§6's answer is unchanged and its premise corrected.
The approval is not reopened by this. Q#LL4's answer was *do not adopt
it*, and a setting that turns out not to exist is a stronger reason not
to adopt it than the one recorded. Nothing else in the document
depended on §1.1 — it argued for a display setting separate from
`fill-column`, which is what shipped.
**Revision 19.** §5d.6 answered as **(b)**:
`ScrollPosition` and `classify` go in `pmacs-protocol`, string
rendering stays per-frontend. The §16 reading that decides it — each
frontend computes its own local layout facts, the shared crate owns the
semantic decision they feed — is a sharper split than "shared
vocabulary", and it adds **no wire message and no version bump**.
Q#LL8 is approved. **The framing is approved; implementation begins.**
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<BufferId>)` 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~~ **WITHDRAWN (revision 20)**
> **This section was wrong, and the error survived nineteen revisions
> and three rounds of review.** It is kept rather than deleted because
> the failure mode is reusable: a grep hit that *reads* like production
> code.
>
> `editing.fill-column` is **not defined in the registry**. Both
> occurrences are inside `#[cfg(test)] mod tests` ---
> `src/config_registry.rs:1191` and `src/lua_bindings/config.rs:898`
> --- where they are fixture names in round-trip tests that exercise
> one setting per `ConfigKind`. Two of the five names in those fixtures
> are real (`editing.auto-pair`, `autosave.interval-ms`); the other
> three, including this one, are defined nowhere else in the tree.
>
> So there is no shipped setting. No user can get it, set it, or
> discover it, and **there is no description to sharpen** --- which was
> the one Q#LL4 deliverable this document still owed. What Stage 3 does
> instead is name the fixtures as fixtures at both sites, so the next
> reader is not sent down the same path.
>
> The original claim, for the record: *"`editing.fill-column` is
> defined in the registry --- 'Preferred wrap column.',
> `ConfigKind::Number`, min 1, max 1000 --- and is read by nothing.
> This is the same shape as the `full_grid` defect Stage 1 closed."*
>
> **How it happened.** I grepped for prior art on line width, found the
> name at a `src/` path, read the `r.define(...)` call --- which is
> genuine API usage, not a mock --- and never checked the enclosing
> module. `#[cfg(test)]` sat about fifty lines above. Every later
> revision inherited the conclusion instead of the evidence, and each
> round of review reasoned about the *consequences* of an orphaned
> setting rather than re-checking that it existed. A file:line citation
> is not a substitute for reading the scope it sits in.
The surviving observation, which needed none of the above: `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, and it is why
this lane's setting is `ui.line-wrap` rather than a reuse of that name
--- see Q#LL4 (§6).
### 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<DisplayCoord> // ctx: { width, mode }
> display_to_pos(buf, coord, ctx) -> Option<Position>
> ```
>
> 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.
No **index** is required, because every *positioning* 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.
**Revision 14 overstated this as "every vertical consumer is local",
and that is false.** Review of `bd752f2` found the counterexample: the
scroll indicator needs a **total**. See §5d --- and note the
distinction that survives, because it is what keeps the cost bounded: a
*total* is one number, computable lazily and cacheable; a *prefix-sum
index* is `O(N)` resident storage. Stage 3 needs the former and still
does not need the latter.
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
`<cursor> <view_top> <path>` 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.
---
## 5c. Q#LL7 --- the GPU needs a wire message, and revision 14 had none
**Raised in review of `bd752f2`, and it is a hole in the lane's central
claim.** §4 resolves `ui.line-wrap` into `Viewport`, which reaches the
**grid** renderer. The GPU is not a grid consumer (§1.2): it lays out
locally and ignores `CellDelta`. `BufferSnapshot` carries only CRDT
bytes (`pmacs-protocol/src/message.rs:777`), and no `InstanceMessage`
variant expresses a wrap mode.
So as framed through revision 14, `ui.line-wrap = "truncate"` would
change the TUI and **leave the GPU wrapping** --- the two frontends
still disagreeing, which is the exact defect this lane exists to close.
Q#LL5's "character wrap in both" is likewise unreachable without a
wire: setting `Wrap::Glyph` at GPU startup is not the same as honoring
a mode that can change.
### 5c.1 The message
**Additive variant, appended after the current final `InstanceMessage`
variant; `PROTOCOL_VERSION` 21 -> 22; `ADVERTISED_PROTOCOL_VERSION`
stays 20.** This is the path `FontFacts` took at v17 and the panel
shapes took at v21, and the constant's own doc reserves moving the
advertised baseline for changes "that cannot be expressed additively"
--- this one can.
It carries `buffer_id` alongside the mode. **Not optional: the mode is
buffer-local (§4)**, so "the current mode" is meaningless without
naming the buffer it belongs to, and the GPU tracks
`current_buffer_id` already.
### 5c.2 Resend semantics --- the part most likely to be got wrong
The mode must reach the GPU on **all three** of:
1. **Attach**, for the initially-shown buffer, as part of the same
initial-state burst that establishes font facts. A frontend that
attaches to an existing session must not have to wait for a change
to learn the current mode.
2. **Config change**, via the registry's `on_change` --- for every
attached frontend showing that buffer.
3. **Buffer switch.** This is the one a `FontFacts`-shaped design
misses. Font size is global; **wrap mode is per buffer**, so
switching from a buffer set to `truncate` to one left at `wrap`
changes the effective mode with **no config event at all**. A
design that only listens to `on_change` is silently wrong here, and
would look correct in every single-buffer test.
### 5c.3 GPU behavior on receipt
Set `Wrap::Glyph` (mode `wrap`) or `Wrap::None` (mode `truncate`) on
the **document** buffer --- its first explicit `set_wrap` either way
(§1.2) --- then reshape and **renormalize the scroll anchor** through
`normalize_code_scroll` (`pmacs-gpu/src/main.rs:7955`). That path
already exists for exactly this situation: reflow moving the retained
residual across source lines. Changing wrap mode reflows the whole
document, so it is the same event class as a font-size change, and must
reuse that repair rather than reimplement it.
An out-of-range or unknown mode value is **rejected as a whole
message**, matching `apply_font_facts` rather than clamping --- the
convention Stage 2 followed (`docs/gui-zoom-framing.md`).
### 5c.4 Older frontends
A v21-or-older frontend never receives the variant and keeps wrapping.
That is a **documented divergence**, not a silent one: the guarantee
"both frontends agree" holds for peers that negotiated v22, and the
release notes must say so alongside the word-wrap regression (§5a).
---
## 5d. Q#LL8 --- the scroll indicator, which falsifies "everything is local"
**Raised in review of `bd752f2`.** `format_scroll_indicator`
(`src/editor.rs:5509`) reckons `All`/`Top`/`Bot`/`NN%` from
`total_lines`, fed in visible-line space (`src/editor.rs:4336`, Arc 6
Q#FD18) so a collapsed remainder correctly reads `All`.
Under `wrap` that is wrong in a way a user sees immediately. **A
one-line buffer wrapping to fifty screen rows has `total_lines == 1`,
so the very first branch --- `if total_lines <= 1 { return "All" }` ---
reports `All` while forty-nine rows sit below the viewport.** The
indicator claims the whole buffer is on screen when almost none of it
is.
### 5d.1 The contract
The indicator is reckoned in **visual rows** whenever the mode is
`wrap`, and in visible lines under `truncate` --- where the two
coincide, so `truncate` remains exactly today's behavior, consistent
with §5b.5's identity-case strategy.
- `All` --- every visual row of the buffer is on screen.
- `Top` --- the first visual row is on screen and `All` does not hold.
- `Bot` --- the last visual row is on screen and `All` does not hold.
- `NN%` --- **byte position**, not a visual-row ordinal. See §5d.4:
a true row ordinal is unobtainable in the GPU without violating its
large-file design, and approximating it would diverge from what is
actually rendered.
### 5d.2 What must be computed, and what must not
**`All` / `Top` / `Bot` need no aggregate.** Each is a local predicate:
is the first visual row on screen (`view_top` byte == first visible
byte), and is the last one (does the forward walk from `view_top` reach
the buffer end within the viewport)? Both fall out of the render walk
that already happens. **Only `NN%` needs a total**, which matters
because `All`/`Top`/`Bot` are the states a user reads most and the
common cases stay `O(viewport)`.
> **SUPERSEDED by §5d.4 (revision 17).** There is no total and no
> cache: `NN%` is byte-based in both frontends. Everything below was
> correct for the design as it stood in revision 16 and is kept because
> the reasoning still applies to any future aggregate --- and because a
> reader should be able to tell "the key was fixed" from "there is no
> key". Skip to §5d.4 for what is built.
The total may be computed **lazily and cached** --- but revision 15's
key was wrong, and review of `1c9ff6a` caught it. It said "buffer
generation, width and mode". Two corrections:
**Fold state must be in the key.** `Viewport.folds` is built **per
rendered window** (`src/view.rs:146`) and a fold can be collapsed or
expanded with **no edit, no width change and no mode change** --- so
all three key components are unchanged while the projection underneath
them is not. Compute `NN%`, collapse a fold, and the stale total is
served for the new projection.
**Prefer a content-derived key over a maintained one.**
`VisibleLineMap` is `{ components: Vec<HiddenComponent> }`
(`src/fold_view.rs:104`) with no revision field, and
`fold_map_for_window` rebuilds it per call. Two ways to key on it:
- A revision counter on the fold registry, bumped by every mutation.
Cheap to compare, and it carries a *did-you-remember-to-bump* hazard
on every present and future mutation path --- the same failure shape
as Q#LL7's buffer-switch trigger.
- **The projection's own contents.** `components` holds one entry per
collapsed region, so hashing or comparing it is `O(folds)`, not
`O(N)` --- negligible per frame, and it **cannot be forgotten**,
because the key *is* the thing it guards.
**Take the second**, for the same reason byte-anchoring beat a row
index (§5b.4) and an additive `sub_row` beat redefining `row`
(§5b.5): a key that derives from the state is self-validating, while
one maintained alongside it is a standing invitation to drift.
**And it is the CONTENT width, not the window width.** Wrapping happens
in the text area, so the gutter is already subtracted --- and the
gutter's width changes with the line-count digit boundary (9 -> 10,
99 -> 100), which the GPU's `sync_buffer_dimensions` comment already
records for its own shaping (`pmacs-gpu/src/main.rs:4338`). Keying on
window width would serve a stale total across a digit boundary.
So: **(buffer generation, content width, mode, fold projection)**.
It composes with folds by counting rows only for lines the fold map
vouches as visible (§5b.3).
**It must not become a resident prefix-sum index** --- that is the
`O(N)` storage §5b.5 rules out, and the distinction is exactly one
number versus one number per line.
**The `open_100mb_under_200ms` gate (M1) constrains this.** Computing
total visual rows means laying out every line, so it must not happen on
open, on every frame, or on any path the gate measures --- only on
first `NN%` paint after an invalidation. If that proves too slow on
large buffers, the fallback is to report a **byte-based** percentage
under `wrap` and say so; what is not acceptable is today's silent
`All`.
### 5d.3 The GPU has its own indicator, and revision 15 missed it
**Raised in review of `1c9ff6a`.** §5d as written specified only the
TUI path. `format_scroll_indicator` is **duplicated, not shared** ---
`src/editor.rs:5509` and `pmacs-gpu/src/main.rs:10114`, each with its
own tests --- and the GPU calls its copy with
`self.current_line_starts.len()`, a **source-line** count
(`pmacs-gpu/src/main.rs:7199`).
So a one-line wrapped buffer reports `All` in the GPU too, by an
entirely independent path. **Stage 3 as framed through revision 15
would have fixed the indicator in one frontend and left it wrong in the
other** --- which is this lane's own defect, reproduced by the lane
meant to close it.
The GPU's `visible` argument is wrong under `wrap` for the same reason:
`estimated_visible_lines(...)` counts **lines**, and visible *rows* is
what the indicator needs once one line owns several.
**Revision 16 said both copies could keep their signature and change
only what callers pass. That is not sufficient, and review of
`d6b5285` was right to reject it.** See §5d.5 --- every branch of the
formatter derives from `total_lines`, which revision 17 removed.
**Revision 16 said the GPU could derive its total locally because
"cosmic-text already knows each line's visual height". That is false**,
and review of `b95506f` caught it. The GPU's cosmic-text buffer holds
**only the viewport slice**: `rebuild_code_slice` shapes
`current_text[vstart..vend]` and nothing else
(`pmacs-gpu/src/main.rs:7912`), because Session S1 found that feeding
the whole rope "made large-file editing **O(file) per keystroke**".
`scroll_top`'s own doc says the same (`:1710`). Its layout cannot yield
total visual rows, nor the cursor's or top's visual-row ordinal.
Re-shaping the whole document to get them would reintroduce exactly the
cost Session S1 exists to prevent. That is not a tradeoff worth
reopening for a status-line readout.
### 5d.4 The aggregate is abandoned: byte percentage, both frontends
**Decision: under `wrap`, `NN%` is computed from BYTE POSITION, in both
frontends. No aggregate, no cache, no invalidation.** Under `truncate`,
both keep today's visible-line percentage unchanged.
This is the fallback §5d.2 named as a contingency, promoted to the
plan. The reasoning:
- **The GPU cannot produce a true total** without violating Session S1.
- **Arithmetic would only approximate it.** Rows-per-line could be
computed as `ceil(width / cols)` without shaping --- but cosmic-text
decides the real break points, so the number could disagree with what
is actually on screen. That is the same *approximate parity* trap
Q#LL5 rejected for whitespace wrapping, and it should be rejected
here for the same reason.
- **A divergent choice would be worse than either.** Visual-row `NN%`
in the TUI and byte `NN%` in the GPU means the same buffer shows two
different percentages --- this lane's own defect, for a third time
(§5d.3). One rule in both frontends is the point.
- **`All`/`Top`/`Bot` are unaffected and stay exact**, because they are
local predicates (§5d.2). Those are the states a user actually reads;
`NN%` is a coarse readout, and a byte-based one is honest rather than
wrong.
- Emacs computes its percentage from buffer position too.
**This makes §5d.2's cache unnecessary, including the fold-key
correction from revision 16.** That correction was right for the design
as it then stood, and the design has since changed underneath it ---
recorded rather than quietly deleted, because "we fixed the key" and
"there is no key" are different states and a later reader should be
able to tell which happened.
**Known imprecision, stated rather than discovered:** under folds, a
byte percentage counts hidden bytes. Folding is TUI-only today (the GPU
fold stage is unstarted), so this is currently a single-frontend
nuance, and it matches Emacs. It should be revisited **by the GPU
folding lane**, not by this one.
### 5d.5 The formatter cannot express the new contract --- so it is not asked to
**The contradiction, stated plainly.** `format_scroll_indicator`
(`src/editor.rs:5509`) derives **every** branch from `total_lines`:
```rust
if total_lines <= 1 { return "All" }
if visible >= total_lines { return "All" }
if view_top == 0 { return "Top" }
if view_top + visible >= total_lines { return "Bot" }
let pct = (cursor_row + 1) * 100 / total_lines;
```
Revision 17 removed the total. So:
- **Passing byte counts mixes units.** `view_top` and `visible` are
rows; a byte `total_lines` makes `view_top + visible >= total_lines`
compare rows against bytes. It would return plausible strings and be
meaningless.
- **Passing a fake total restores the bug.** Any stand-in that is
`<= 1`, or `<= visible`, returns the false `All` this section exists
to remove.
"Local predicates" is therefore not something the retained formatter
can evaluate --- it has no parameter for them.
**Resolution: `truncate` keeps the existing formatter untouched;
`wrap` gets a new classifier.**
This is §5b.5's identity-case strategy applied to the indicator itself,
and it is stronger than adapting one function to two contracts:
- **`truncate` calls `format_scroll_indicator` exactly as today**, with
the same arguments in the same units. Byte-identical output is
guaranteed **by construction**, not by a test --- and every existing
formatter test stays valid unchanged, including the GPU's
`format_scroll_indicator(0, 10, 1, 0) == "All"` (`:13091`), which
correctly pins line-space behavior.
- **`wrap` calls a new classifier** that never sees a row total:
```text
enum ScrollPosition { All, Top, Bot, Percent(u8) }
classify(first_visible: bool, // is the buffer's first row on screen?
last_visible: bool, // is the buffer's last row on screen?
byte_pos: u64, // cursor byte
byte_len: u64) -> ScrollPosition
```
`All = first && last`; `Top = first && !last`; `Bot = last && !first`;
otherwise `Percent` from bytes. **No count of rows enters it**, so the
unit mixing above is not merely avoided, it is unrepresentable.
Attempting one signature for both modes was the actual mistake: the two
contracts genuinely differ, and a shared four-count signature can only
serve them by making units implicit --- which is how this contradiction
arose.
### 5d.6 Where the classifier lives --- **ANSWERED: `pmacs-protocol`**
`pmacs-gpu` depends on **`pmacs-protocol` only**, never on the `pmacs`
lib (`pmacs-gpu/Cargo.toml:65`). So `format_scroll_indicator` is
duplicated **structurally**, not by oversight, and a new classifier
faces the same fork:
- **(a) Duplicate it too.** Matches what is there, adds nothing to any
crate's remit --- and preserves exactly the condition that produced
§5d.3's defect, where one copy was fixed and the other was not.
- **(b) Put it in `pmacs-protocol`.** The only crate both sides
already share. Agreement becomes **structural rather than
maintained** --- the principle that chose byte-anchoring, additive
`sub_row`, and a content-derived cache key.
**Answered 2026-08-06: (b), in the narrow form.** `ScrollPosition` and
the pure `classify(first_visible, last_visible, byte_pos, byte_len)`
live in `pmacs-protocol`; **string rendering stays in each frontend.**
The §16 reading that settles it, and it is a better statement of the
split than "shared vocabulary": **each frontend computes its own local
layout facts --- which rows are on screen is a question only it can
answer --- while the shared crate owns the common semantic decision
those facts feed.** That is not presentation moving into the protocol
crate; it is the *decision* being placed where both frontends are
structurally unable to disagree about it, with the rendering left where
it belongs.
Two properties worth recording because they bound the change:
- **No wire message and no protocol-version bump.** `classify` is a
pure function over values each side already has. Q#LL7's v22 variant
is unrelated and unaffected.
- **The §5d.3 defect becomes unrepresentable**, rather than guarded by
a reviewer noticing the second copy. A frontend cannot classify
differently, because there is only one classifier.
### 5d.7 The large-file guard
Because the whole point is that no whole-document work happens, that
must be a **witness, not an intention**:
- Painting the indicator on a large buffer with `wrap` active must
perform **no whole-document layout**. In the GPU this is observable
directly --- `view_range` and `shaped_top` must be unchanged by an
indicator paint --- and in the TUI by bounding the lines laid out to
the viewport.
- The existing `open_100mb_under_200ms` gate (M1) must still pass with
`wrap` as the default mode, which is the end-to-end version of the
same claim.
Without these, the byte-percentage decision is an unenforced comment,
and a later "improvement" to a real row count would silently reintroduce
`O(file)` work.
**The duplication is itself the hazard worth naming.** Two copies means
two call sites must change, and nothing in the type system connects
them. That is the same shape as Q#LL7's three resend triggers: a
correct fix in one place that looks complete.
### 5d.8 Verification
- **The reported case, as a direct witness, IN BOTH FRONTENDS:** one
source line, viewport shorter than its wrapped height, mode `wrap`
--- the indicator must **not** be `All`. This fails against revision
14's design in the TUI and revision 15's in the GPU, which is what
makes it worth writing first, twice.
- **The large-file guards of §5d.7**, in both frontends --- an
indicator paint must leave the GPU's `view_range` / `shaped_top`
untouched, and must not lay out beyond the viewport in the TUI.
- **`open_100mb_under_200ms` (M1) with `wrap` as the default mode.**
- **A `truncate` output-identity witness against the retained
formatter** (§5d.5): same buffer, same viewport, byte-identical
string. Cheap, and it is the assertion that the identity case is real
rather than asserted.
- **Classifier unit-safety**, which is what the old signature could not
give: `classify` takes two booleans and a byte pair, so a
rows-versus-bytes comparison is unrepresentable. Witness the four
outcomes directly --- `first && last` is `All`, `first && !last` is
`Top`, `last && !first` is `Bot`, neither is `Percent` --- including
the one-line-wrapped case, where `first && !last` must yield `Top`
and **not** `All`.
- The fold witnesses revision 16 asked for are **withdrawn with the
cache they guarded** (§5d.2, §5d.4). What survives from that round is
the `truncate` control below, which still pins the identity case.
- `Top` at the buffer start, `Bot` at the end, `All` only when every
visual row fits --- each with a wrapped line present.
- **A `truncate` control** asserting the indicator is byte-identical to
today's output for the same buffer and viewport.
- A **folded + wrapped** case, since §5b.3's composition applies here
too and the Q#FD18 contract must survive.
---
## 6. Q#LL4 --- `editing.fill-column` **ANSWERED**
> **Answered 2026-08-06: do not adopt it.** *(Revised 2026-08-07 --- the
> premise was wrong; the answer was not.)*
>
> The 2026-08-06 answer said the setting was orphaned because **its
> consumer does not exist yet**: no `M-q`, no auto-fill, no reflow
> command anywhere in the tree. That much is true. But the setting does
> not exist either --- it is a **test fixture name**, not a definition
> (§1.1, withdrawn). So the framing of "a setting ahead of its feature"
> described nothing real, and the `full_grid` comparison it was already
> walking back was doubly inapt: `full_grid` was a wire flag with a
> live consumer that ignored it, and this is a string in two
> `#[cfg(test)]` blocks.
>
> **What changed in the deliverables.** One of the two evaporated:
>
> - ~~**Sharpen its description.**~~ There is no shipped description.
> Replaced by: **name the fixtures as fixtures** at
> `src/config_registry.rs` and `src/lua_bindings/config.rs`, so the
> next reader does not repeat §1.1. That is the entire remaining cost,
> and it is a comment.
> - **Name ours so confusion is impossible: `ui.line-wrap`**,
> `ConfigKind::Enum { choices: ["wrap", "truncate"] }`, default
> `"wrap"`. Unchanged, and it never depended on the bad premise ---
> `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". The naming discipline stands on the concept split, which
> a real `fill-column` would only have made more urgent, not less.
>
> **The one place this could have bitten.** Had the premise gone
> unchecked into implementation, Stage 3 would have shipped an edit to a
> unit-test fixture believing it was rewording a user-visible setting
> --- a no-op change with a misleading commit message, and a lane
> closing on a deliverable it had not delivered.
---
## 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
`{ row: source_line, sub_row, col }` --- the **same `row` it
returns today**, plus which visual row *within* that line and the
column within *that* row, rather than the whole prefix width
(`src/text_view.rs:184`).
**Notation matters here and revision 14 got it wrong.** It said
`pos_to_display` returns "the visual row", which reads as a
redefinition of `row` --- exactly what §5b.5 forbids. Every example
below is therefore written as the explicit triple
`{row, sub_row, col}`; a bare pair anywhere in this section is a
bug in the document, not a shorthand.
- `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**:
`{row: L, sub_row: k, col: 3}` --- just past the last glyph of
visual row *k* of line *L* --- and `{row: L, sub_row: k+1, col: 0}`
--- just before the first glyph of visual row *k+1* of the **same
source line**. Note both share `row: L`: the wrap point does not
cross a source line, which is precisely why redefining `row` would
have destroyed the information this case turns on. 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
`{row: L, sub_row: k+1, col: 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
`{row: L, sub_row: last, col: max_cols}` when the line fills its
final visual 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 `{row: L, sub_row: k+1, col: 0}`, never
`{row: L, sub_row: k, col: 3}`.
- **A hard line end that exactly fills the row still maps to
`{row: L, sub_row: 0, col: max_cols}` with `sub_row` still 0** ---
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 `{row: L, sub_row: k, col: 2}`
and `{row: L, sub_row: k+1, col: 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.
### 7.1 What the sketch did not anticipate (revision 20)
Three witnesses exist that §7 never asked for, each because review or
implementation found a defect the sketch had no reason to predict.
Recorded so the gap between sketch and suite is deliberate:
- **Rendered-output witnesses**
(`the_default_actually_wraps_the_painted_text` and its `truncate`
control, `tests/line_wrap_acceptance.rs`). Every test §7 sketched
asks a *component* a question. The defect review found lived in the
**driver**: `src/editor.rs` resolved the mode, recorded it on the
window, and fed it to coordinate mapping and the indicator --- while
the `Viewport` literal it built still carried a hard-coded
`Truncate`. A test constructing its own viewport passes against that.
These read the grid reconstructed from emitted `CellDelta` spans
instead.
- **Two-buffer toggle witnesses** (same file). `ui.line-wrap` is
buffer-local, so the toggle's failure mode --- reading and writing
the *global* layer --- is invisible in any single-buffer test, and
wrong in both directions at once: it leaves a pinned buffer alone
while silently moving every unpinned one.
- **GPU scroll-endpoint witnesses** (`pmacs-gpu/src/main.rs`). §5d.6
settled *what* the classifier consumes; it did not settle how the GPU
decides `first_visible` / `last_visible`, and the two cheap answers
are both wrong (`view_range` includes overscan; `scroll_top` ignores
the sub-line residual). The trailing-empty-line case
(`an_empty_final_line_still_counts_as_bot`) was found *by* these
tests, not confirmed by them --- it made every newline-terminated
file report a percentage instead of `Bot` at the bottom.
---
## 8. Coherence impact (§20 requirement)
- **Scorecard row 11, "Config layering + provenance --- Partial
(foundation only), 5 settings live in it."** This lane adds
`ui.line-wrap` against that row --- registry-defined, enum-validated,
buffer-local, and consumed by both frontends. *(Revision 20: this
bullet previously also promised to adopt or decline "the orphaned
`editing.fill-column`". It is not orphaned and not a setting; see
§1.1, withdrawn. The count in that scorecard row was never affected
by it either way.)*
- **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#.