pmacs/docs/long-lines-framing.md

85 KiB
Raw Blame History

Long lines — QoL Stage 3

Status: revision 18 — branched as long-lines; Q#LL1LL7 settled; Q#LL8 awaiting approval, and §5d.6 (where the classifier lives) needs a decision. 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_colspos_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 anchorscroll_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 — 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.

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 sharedsrc/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 slicerebuild_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 — the current one. 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.

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:
    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_tops (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):

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:

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:

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:

    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 --- OPEN, needs a decision

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.

I lean (b) and will not take it unilaterally, because it widens pmacs-protocol from wire vocabulary toward presentation, which is a COHERENCE.md §16 layering question and not this lane's to settle alone. The narrow version --- share the ScrollPosition enum and classify, leave the string rendering per-frontend --- keeps the protocol crate holding a decision type rather than presentation, and panel.rs is arguably precedent for that.

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 --- 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 { 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.


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#.