diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md index 4a72e31..7b692f9 100644 --- a/docs/folding-stage2-framing.md +++ b/docs/folding-stage2-framing.md @@ -1,421 +1,434 @@ # Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6) -**Revision 1 — 2026-07-23. Status: DRAFT for review.** Parent architecture -(`docs/folding-framing.md`, rev 5) is APPROVED and Stage 1 (the headless fold -engine) is MERGED as **#142** (canonical `main` @ `c49a8c7`). This doc reframes -**Stage 2** in detail off that base, per the parent's §8/§14 ("each stage is -re-framed after the prior stage lands"). It carries the four Stage-2 obligations -the parent named, plus the one architectural fact the Stage-2 scout surfaced that -the parent only sketched. +**Revision 2 — 2026-07-23. Status: DRAFT for review (rev 1's five findings +addressed).** Parent architecture (`docs/folding-framing.md`, rev 5) is APPROVED +and Stage 1 (the headless fold engine) is MERGED as **#142** (canonical `main` @ +`c49a8c7`). This doc reframes **Stage 2** in detail off that base, per the +parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`. -Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`. +## 0. Revision history + +### Round 1 (rev 1 → rev 2) + +- **F1 (major) — nested folds resolved to the wrong head.** Rev 1's `head_of` + returned the *innermost* enclosing fold's head and assumed it visible; under + nesting an outer fold hides the inner head, so carets/diagnostics/relative + numbers would have clamped onto another *hidden* line. Rev 2 defines + **`visible_head_of(line)` = the head of the *outermost* enclosing fold** (the + only head guaranteed visible), used by every clamp. A hidden `view_top` clamps + **backward** to that head (not forward past the fold, which contradicted + acceptance 6). Relative/Hybrid numbering anchors on the **clamped visible + cursor**. New nested-fold / shared-cursor acceptance (§11). +- **F2 (major) — the mapping-consumer census was incomplete.** Rev 1 claimed all + painters route through `Viewport`. They do not: local selection + (`src/editor.rs:3241`) and the mode-line scroll indicator (`src/editor.rs:3803`) + paint from `paint_frame` with raw `view_top` arithmetic; peer cursor/selection + (`src/overlay_paint.rs:159`) runs *after* `paint_frame` and subtracts `view_top` + independently; the style overlay (`src/overlay.rs:385`), search wash + (`src/search.rs:492`), diagnostics (`src/diag.rs:563`), and the completion + anchor each derive `start_line + row_offset`. Rev 2 carries the **complete + consumer census** (§3.1), adds **TUI peer-presence fold behavior** (clamp a + hidden peer cursor to the visible head; drop/project hidden selection cells) as + explicit scope, and expands acceptance to pin local selection, peer presence, an + ordinary style/search overlay across a fold, completion anchoring, and the + scroll indicator. +- **F3 (major) — the fold glyph's sign cell does not exist by default.** Line + numbers default to `Off` ⇒ `gutter_width()==0` (`src/window.rs:216`); diagnostics + then fall back to a col-0 *background* on the first content cell + (`src/diag.rs:635`), so rev 1's "zero-width-change" gutter glyph had nowhere to + render. Rev 2 makes the fold glyph **conditional on a gutter existing** + (Q#FD20): gutter off ⇒ **ellipsis only**; gutter on ⇒ reuse the sign cell with + diagnostic priority. Acceptance covers both. The parent's unconditional + gutter-marker promise, if required, needs a **dedicated sign column** and an + acknowledged layout change (named alternative, §5/§9). +- **F4 (major) — a per-frame map instance cannot serve command-time motion, and + `Viewport` is `Copy`.** `move_up/down`, paging, wheel scroll, and clicks execute + **outside** the render frame, so they cannot reuse a map installed on that + frame's `Viewport`; and `Viewport` is compile-time-pinned `Copy` + (`src/view.rs:130`). Rev 2 reframes the map as **one shared derivation/query + primitive** with **separate short-lived instances** for rendering vs + command/event handling (Q#FD12); the render instance is threaded as + `Option<&'a VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** (a shared + ref is `Copy`, so `Viewport` stays `Copy`); the primitive's home is usable from + `EditorCore`, not render-only. +- **F5 (moderate) — tighter unfold seam + settled undo decision.** Rev 2 keys the + Lua-path widening on the existing **`InteractiveCommandOrigin`** + (`src/editor.rs:53`), not command-history inference; hooks the **common + `run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`) so interactive + `bypass_intercept` mutations do not escape; **requires the target to be the + invoking frontend's active-window buffer** (an explicit inactive-buffer Lua + mutation stays programmatic); keeps the Rust hook at `apply_active_edit` and the + `notify_buffer_edit` exclusion; and **explicitly defers undo/redo unfold** + (Q#FD19). + +### Rulings absorbed + +- **Q#FD17 = INCLUDE.** Vertical motion steps through *visible* lines; if motion + begins from a hidden logical cursor (shared fold or goto-line), it **first + normalizes to the visible head**, then steps to the preceding/following visible + line (§7). +- **#142 housekeeping** (retire the active-work.md folding lane + refresh handoff + §1) is a **separate docs PR**, kept out of `folding-tui`. ## 1. What Stage 2 ships -The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines -and paints a character grid it ships to the terminal client. Stage 1 built the +The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines and +paints a character grid it ships to the terminal client. Stage 1 built the instance-side fold store and produces `FoldState` for GPU sessions, but **no frontend renders a collapse yet** — the daemon grid path never consults the fold store. Stage 2 makes the **daemon grid renderer fold-aware**: -1. **Collapse** — omit each fold's hidden source lines; show the head line with a - trailing ellipsis; rows below shift up. -2. **Gutter fold marker** — a fold glyph on the head-line row (parent §7). -3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines, and - relative/hybrid distance is measured across the collapse (parent §8). +1. **Collapse** — omit each fold's hidden source lines; head line shows a trailing + ellipsis; rows below shift up. +2. **Gutter fold marker** — a fold glyph on the head-line row **when a gutter + exists** (Q#FD20); ellipsis-only otherwise. +3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines; + relative/hybrid distance is measured across the collapse, anchored on the + clamped visible cursor. 4. **Fold-aware diagnostic signs** — a sign on a hidden line clamps to the fold's - visible head-line row (parent §8, minor d). -5. **Fold-aware caret** — the caret never renders on a hidden line; it clamps to - the fold head (parent §8, Q#FD3 render-time invariant). -6. **Fold-aware viewport/scroll** — `view_top`, paging, and auto-scroll count - **visible** lines, not raw source lines (parent §8). + **visible head** row (most-severe merge). +5. **Fold-aware caret, local selection, and peer presence** — no caret, no + selection cell, and no peer cursor renders on a hidden line; each clamps to the + visible head or is projected/dropped. +6. **Fold-aware viewport/scroll/motion** — `view_top`, paging, wheel scroll, + clicks, auto-scroll, vertical line-motion, and the mode-line scroll indicator + all reckon in **visible** lines. 7. **Interactive-Lua-command unfold widening** — yank, query-replace, and - comment-toggle unfold a fold at their edit point before the edit is visible - (parent §8, R3-3), which is only observable once the TUI collapses. + comment-toggle unfold a fold at their edit point before the edit is visible. -**No wire schema changes and no protocol bump.** `FoldState` is already produced -(Stage 1) for semantic/GPU sessions; the TUI collapse is entirely daemon-side -(the vterm-Stage-2 shape). The GPU render path is untouched — that is Stage 3. -The store, the source, and the producer from Stage 1 are unchanged; Stage 2 only -*reads* the store in a new place (the daemon grid renderer) and *widens* one -pre-edit hook. +**No wire schema changes and no protocol bump.** `FoldState` production (Stage 1) +is untouched; the TUI collapse is entirely daemon-side (the vterm-Stage-2 shape); +the GPU render path is Stage 3. -## 2. Ground truth (scouted 2026-07-23, `main` @ `c49a8c7`) +## 2. Ground truth (scouted + review-verified 2026-07-23, `main` @ `c49a8c7`) -### 2.1 The grid render path is layered — and the collapse belongs deep in it +### 2.1 The grid render path is layered — collapse belongs deep in it -The parent framing (F1) located the collapse at "the daemon grid renderer, -`render_frame`." The scout refined this: `RenderState::render_frame` -(`src/instance_render.rs:98`) is a **diff shell** — it double-buffers cells and -emits `CellDelta`; it does **not** walk source lines. The source-line→grid-row -loop is two calls down: +`RenderState::render_frame` (`src/instance_render.rs:98`) is a **diff shell** (it +double-buffers cells, emits `CellDelta`); it does not walk source lines. The +source-line→grid-row loop is two calls down: ``` -RenderState::render_frame (src/instance_render.rs:98) — diff shell, no line walk +RenderState::render_frame (src/instance_render.rs:98) — diff shell → paint_frame (src/editor.rs:2796) — per-window composition; HAS state.fold_registry → window.text_view.render(buf, viewport, grid) (src/editor.rs:2948) - → TextView::render (src/text_view.rs:207) — THE line→row loop + → TextView::render (src/text_view.rs:207) — the line→row loop ``` -`TextView::render` is the primitive (`src/text_view.rs:213`): +`TextView::render` (`src/text_view.rs:213`) maps `line = start_line + row_offset` +— **strictly identity**, no skip, no wrap (long lines truncate). `Window.view_top` +(`src/window.rs:179`) is a **source-line index**. `DisplayCoord`'s doc +(`src/view.rs:108`) anticipates a non-identity map "once virtual lines, wrapping, +and inline expansions appear," but nothing implements one today — **folding is the +first**. -```rust -for row_offset in 0..max_rows { - let line = start_line + row_offset as usize; // source line (:214) - let cell_row = origin.row + row_offset; // grid row (:215) - ... -} -``` +### 2.2 The complete set of identity-assuming consumers (F2) -The mapping is **strictly identity** — `line = start_line + row_offset`, -contiguous +1, no skip, no wrap (long lines truncate at `max_cols`, they do not -wrap). This identity is repeated, un-abstracted, at **every** view_top-arithmetic -site: +Every site below assumes `display_row = source_line − view_top` (or the inverse) +and must consult the shared visible-line map. Rendering-frame sites, after-frame +sites, and command-time sites are distinguished because they need **different +instances** of the map (§3, F4): -| Site | file:line | assumes | -|---|---|---| -| render loop | `src/text_view.rs:214` | `line = view_top + row` | -| line-number gutter | `src/editor.rs:3215` | `buffer_line = view_top + r` | -| diagnostic signs | `src/diag.rs:563` | `row_offset = line − start_line` | -| caret grid row | `src/editor.rs:3044` | `grid_row = origin + (disp.row − view_top)` | -| click inverse | `src/editor.rs:2233` | `display_row = view_top + local_row` | -| auto-scroll clamp | `src/editor.rs:2866` | cursor row in `[view_top, view_top+rows)` | -| page/scroll | `src/editor_core.rs:1745`, `src/editor.rs:2264` | target = raw source row | +| # | Site | file:line | phase | +|---|---|---|---| +| 1 | text render loop | `src/text_view.rs:214` | render (Viewport) | +| 2 | line-number gutter | `src/editor.rs:3215` | render (paint_frame) | +| 3 | style/syntax overlay | `src/overlay.rs:385` | render (Viewport) | +| 4 | diagnostics overlay | `src/diag.rs:563` + `paint_line_markers` `:635` | render (Viewport) | +| 5 | search wash overlay | `src/search.rs:492` | render (Viewport) | +| 6 | completion popup anchor | completion overlay (byte→row) | render (Viewport) | +| 7 | caret grid row | `src/editor.rs:3044` | render (paint_frame) | +| 8 | local selection | `paint_local_selection`, `src/editor.rs:3241` | render (paint_frame) | +| 9 | mode-line scroll indicator | `format_scroll_indicator`, `src/editor.rs:3803` | render (paint_frame) | +| 10 | peer cursor + selection | `src/overlay_paint.rs:159` | **after** paint_frame | +| 11 | click inverse | `activate_and_position`, `src/editor.rs:2233` | command/event | +| 12 | auto-scroll clamp | `src/editor.rs:2866` | command/event | +| 13 | paging / wheel / vertical motion | `src/editor_core.rs:1745/1779`, `src/editor.rs:2264`, `move_up/down` | command/event | -`Window.view_top: usize` (`src/window.rs:179`) is a **source-line index**. -`DisplayCoord`'s own doc (`src/view.rs:108`) anticipates that row/col "diverge -once virtual lines, wrapping, and inline expansions appear," but **nothing -implements a non-identity map today** — `TextView` is the only base view and its -`pos_to_display`/`display_to_pos` are identity. **Folding is the first -non-identity source-line↔display-row mapping in the TUI.** +Overlays (1,3,4,5,6) already thread through `Viewport`; sites 2,7,8,9 run in +`paint_frame` directly; site 10 runs after `paint_frame` and re-derives +`gutter_w`/`view_top` itself (`src/overlay_paint.rs:143-160`); sites 11–13 run +during input dispatch, entirely outside any render frame. -**Consequence (the crux, Q#FD12).** Fold collapse cannot be a localized edit to -one loop, and it cannot be an overlay (overlays paint *on top of* laid-out rows; -they cannot delete rows or change the row count). It must be a **shared -fold-aware line map** that the render loop *and* all seven sites above consult. +### 2.3 The renderer can reach the fold store -### 2.2 The renderer can already reach the fold store +`fold_registry: SharedFoldRegistry` is a field on `EditorCore` +(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`). Both the +render path (`paint_frame`) and command-time code (`EditorCore` methods) reach it. +The read surface (`src/fold.rs`): `FoldRegistry::folds(buf) -> Vec` +(`:327`, whole-buffer, sorted); `FoldStore::containing(p)` (`:171`, byte-space, +`(start, end]`). **No line-space query exists** — Stage 2 adds it (§3). A fold's +`start` = end-of-head-line content byte, `end` = end-of-last-hidden-line content +byte, so `head_line = line_at_offset(start)`, hidden = `head_line+1 ..= +line_at_offset(end)`. Use the fold's `(start,end]` convention, never the +`ByteRange` struct doc's `[start,end)`. -`fold_registry: SharedFoldRegistry` is a field on both `EditorCore` -(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`). -`paint_frame` borrows `state`, so `state.fold_registry` + `window.buffer_id` are -in scope at the composition point (`src/editor.rs:2796`). The semantic producer -already reads it: `state.fold_registry.folds(buffer_id)` -(`src/semantic_render.rs:1433`). The **grid path reads it nowhere** yet — that is -the new consumer Stage 2 adds. +### 2.4 The gutter -The store's read surface (`src/fold.rs`): -- `FoldRegistry::folds(buf) -> Vec` (`:327`) — whole-buffer, sorted, - empty when no store. The one-call read a renderer wants. -- `FoldStore::containing(p) -> Vec` (`:171`) — byte-space containment, - `(start, end]` (start-exclusive, end-inclusive per `src/fold.rs:11`). -- **There is NO line-space query** ("is source line N hidden?"). Only byte-space - `containing` exists. Stage 2 must add the line-space view (§4). +- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; default **`Off`**) is + per-window (`Window.line_numbers`, `src/window.rs:188`). Number rule + `number_for(line, cursor_line)` (`:1197`) uses **raw-line `abs_diff`** for + relative distance. +- `paint_line_number_gutter` (`src/editor.rs:3177`): `buffer_line = view_top + r` + (`:3215`) → `number_for(...)`; `cursor_line = line_at_offset(window.cursor)` + (`:3189`). +- **Gutter width is 0 when line numbers are Off** (`Window::gutter_width`, + `src/window.rs:216`). When on, width = `decimal_digits(line_count)+2`, fixed to + the absolute count (no jitter; folding does not change `line_count`). +- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`) → + `paint_line_markers` (`:635`): `gutter_w>0` draws the sign glyph in the gutter's + leading cell; **`gutter_w==0` falls back to a col-0 background** on the first + content cell (the "fake gutter"). So a dedicated sign cell only exists with a + gutter on (F3). -A fold `ByteRange` is `start` = end-of-head-line content byte, `end` = -end-of-last-hidden-line content byte (parent §3/§5). So the byte→line conversion -is exact: `head_line = line_at_offset(start)`, hidden lines = -`head_line+1 ..= line_at_offset(end)`, head line and (for brace nodes) the closer -line stay visible. `ByteRange`'s struct doc (`pmacs-protocol/src/ids.rs:72`) calls -itself half-open `[start,end)`; the **fold engine overrides that to `(start, -end]`** — use the fold semantics, never the struct doc. +### 2.5 The interactive-edit unfold seam (F5, correcting rev 1) -### 2.3 The gutter +Stage 1's `unfold_before_point_edit` (`src/editor_core.rs:1847`, reads +`active_window().cursor`) is called at the top of the six `EditorCore` primitives; +the shared `apply_active_edit` (`:1266`) they call does not itself unfold. -- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; `Off`/`Absolute`/ - `Relative`/`Hybrid`; default `Off`) is per-window (`Window.line_numbers`, - `src/window.rs:188`). The number rule is - `LineNumberMode::number_for(line, cursor_line)` (`:1197`) — and it measures - relative distance as **`abs_diff` in raw source-line space**. -- The daemon computes the column in `paint_line_number_gutter` - (`src/editor.rs:3177`): `buffer_line = view_top + r` (`:3215`) → - `number_for(buffer_line, cursor_line)` (`:3224`); `cursor_line = - line_at_offset(window.cursor)` (`:3189`). -- Gutter **width** is `decimal_digits(line_count) + 2` (`src/window.rs:216`), - sized to the **absolute** line count and fixed per frame. Folding does not - change `line_count`, so **the gutter width is stable** (no jitter) — a fold does - not shrink the field. -- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`), `row_offset = - line − start_line_buf` (`:563`), most-severe-per-row into `line_markers` - (`:570`), painted by `paint_line_markers` (`:635`) with - `DiagnosticSeverity::gutter_glyph()` (`:75`) at window col 0 (the gutter's - leading cell). Diagnostics render **after** the number gutter (overlay order, - `src/editor.rs:2943-2954`), so a sign is not erased by the digits. -- Gutter column order across `[origin.col .. origin.col+gutter_w)`: - **[sign @ col 0 | right-aligned digits | content @ origin+gutter_w]**. Every - painter reads `Viewport.gutter_w` (`src/view.rs:144`) so it stays gutter-blind. +- **Yank / query-replace** are **`apply_active_edit` callers** (local; never the + remote path): yank → `clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit` + (`:2544/2570`); query-replace → `query_replace_apply_current`→`apply_active_edit` + (`:1129/1137`). They skip the six primitives, so they do not unfold today. +- **Comment-toggle / yank-pop** take the Lua mutator path. The common entry is + **`run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`), which dispatches to + `run_managed_edit` (`:1347`) *or* `run_bypass_edit` (`:1318`) on the + `bypass_intercept` flag; both call `apply_edit_skip_intercepts` then + `notify_buffer_edit_to_windows`. Hooking only `run_managed_edit` would let an + interactive `bypass_intercept` edit escape — hook `run_buffer_edit`. +- **`InteractiveCommandOrigin`** (`src/editor.rs:53`) is an ephemeral, Lua-app-data + authenticated origin: `.current() -> Option` is the frontend while an + interactive command runs; `.enter(fid)` returns a guard that restores on drop and + **clears even when a Lua command errors**. This is the scoped authority for the + Lua-path widening — no command-history inference needed. +- **Undo/redo** reach the buffer through `notify_buffer_edit_to_windows` directly + (`add_history_methods`, `src/lua_bindings/mod.rs`), not `apply_active_edit` nor + `run_buffer_edit`. -### 2.4 The interactive-edit unfold seam (correcting the parent's premise) +### 2.6 Scroll/viewport commands count raw lines; no recenter -Stage 1 wired the pre-edit unfold as `unfold_before_point_edit` -(`src/editor_core.rs:1847` — reads `active_window().cursor`, calls -`fold_registry.unfold_containing(id, point)`) called at the **top** of the six -`EditorCore` primitives (`insert_char` :1858, `insert_char_over_region` :1894, -`backspace` :1936, `delete_forward` :1958, `delete_word_backward` :1982, -`delete_word_forward` :2010). The shared `apply_active_edit` -(`src/editor_core.rs:1266`) they all call does **not** itself unfold. +`move_page_down/up` (`src/editor_core.rs:1745/1779`), `scroll_window` +(`src/editor.rs:2264`), `move_to_line` (`:708`), and the mode-line indicator all +work in **raw source-line** space. There is no `beginning/end-of-buffer` command +and **no recenter** (deferred, blocked on viewport facts; handoff §6). Stage 2 +inherits the recenter deferral. -The parent framing (R3-3) assumed yank/query-replace/comment-toggle all "mutate -through the Lua mutator path." **The scout corrected this:** +## 3. The shared visible-line map primitive (Q#FD12) -- **Yank (`C-y`)** and **query-replace** are **Rust `apply_active_edit` - callers**, not Lua-mutator callers: yank → - `clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit` - (`src/editor_core.rs:2544/2570`); query-replace → - `query_replace_apply_current`→`apply_active_edit` (`:1129/1137`). They skip the - six primitives, so they do **not** unfold today — but they are inherently - **local active-frontend** edits (`apply_active_edit` is never the path for a - remote/optimistic-CRDT apply). -- **Comment-toggle** (`comment.lua:172`, one `buf:replace`) and **yank-pop** - (`killring.lua:356`) take the **Lua mutator path**: `add_mutation_methods` - (`src/lua_bindings/mod.rs:1227`) → `run_managed_edit` (`:1347`) → - `Buffer::apply_edit_skip_intercepts` → `EditorCore::notify_buffer_edit` - (`src/editor_core.rs:1364`). This path has no unfold — **and it is shared with - remote/optimistic-CRDT applies**, which are the parent's named Stage 3 concern. +Stage 2's spine is **one derivation/query primitive** — a `VisibleLineMap` type +plus a builder — computed from `state.fold_registry.folds(buffer_id)` and the +buffer's line offsets. It is **not one instance pinned to a frame**: it is built as +**short-lived instances** wherever a source↔display mapping is needed (F4), and its +home is a module usable from **both** the render path and `EditorCore` (not +render-only). Candidate home: `src/fold_view.rs`, or a `FoldStore` +convenience that returns hidden-line intervals given the line-offset table (the +byte→line conversion then lives beside the `(start,end]` convention it must match). +The byte-range store in `src/fold.rs` stays the single source of truth; the map is +derived, never stored. -The dispatcher stamps `active_frontend = frontend_id` **before** any command body -runs (`src/editor.rs:807`; also `src/daemon.rs:999`), so the acting frontend and -its point (`active_window().cursor`) are available throughout an interactive -command — the exact pair `unfold_before_point_edit` already reads. There is a -narrower one-shot provenance record (`TypedEditRecord`/`take_typed_edit`, -`src/editor_core.rs:161/2477`) used by auto-pairing, but it is codepoint-scoped -self-insert only and cannot carry these multi-byte edits — Stage 2 needs a -position-keyed signal, not that record. +### 3.1 Queries -### 2.5 Scroll/viewport commands all count raw lines; there is no recenter +Folds may nest; the derivation unions their hidden intervals into a sorted, +non-overlapping hidden-line set `H` (a line is hidden regardless of which fold owns +it). The map answers, in **line space**: -`move_page_down`/`move_page_up` (`src/editor_core.rs:1745/1779`, step = -`last_visible_rows − 1`), `scroll_window` (mouse wheel, `src/editor.rs:2264`, -`SCROLL_LINES = 3`), and `move_to_line`/goto-line (`src/editor_core.rs:708`) all -compute targets in **raw source-line** space via `Window::text_view` and mutate a -raw-line `view_top`. There is **no `beginning/end-of-buffer` command** and **no -recenter** — recenter is explicitly deferred, blocked on viewport facts (the GPU -never consumes daemon `view_top`); confirmed in -`docs/editing-conveniences-framing.md:217` and the handoff §6. Stage 2 inherits -that deferral (recenter stays out). +- `is_hidden(line) -> bool` — `line ∈ H`. +- **`visible_head_of(line) -> line`** — for a hidden `line`, the head of the + **outermost** enclosing fold (resolve to the enclosing fold's head; if that head + is itself hidden, recurse; heads strictly decrease, so it terminates on the one + visible head). For a visible `line`, itself. **Every clamp uses this** — caret, + diagnostics, peer cursor, relative-number cursor anchor, and the `view_top` + backward clamp. (Rev 1's innermost `head_of` is removed — F1.) +- `next_visible(line)` / `prev_visible(line)` — the next/previous visible line, + skipping whole folds (for the render walk and vertical motion). +- `visible_between(a, b) -> isize` — signed count of visible lines from `a` to `b` + (relative/hybrid distance; paging). +- **`clamp_view_top(line) -> line`** — if `line ∈ H`, `visible_head_of(line)` + (**backward**, so a fold at the top shows its head — acceptance 6); else `line`. + This replaces rev 1's forward `first_visible_at_or_after`, which skipped past the + fold and hid the head. -## 3. The shared fold-aware line map (Q#FD12) +Build cost is O(folds) (folds are O(top-level blocks), parent B2), reusing the +render path's existing line-offset table — **Bet B4**. -Stage 2's spine is one primitive, derived per window per frame from -`state.fold_registry.folds(buffer_id)` and the buffer's line offsets (already -computed for rendering). It answers, in **line space**: +### 3.2 Instances per phase (F4) -- `is_hidden(line) -> bool` — is this source line inside some fold's hidden - interval `head+1 ..= last_hidden`? -- `head_of(line) -> Option` — if hidden, the visible head line of its - (innermost) enclosing fold. -- `next_visible(line)` / `prev_visible(line)` — walk to the next/previous - **visible** source line (skipping whole folds). -- `visible_between(a, b) -> usize` — count of visible lines in `[a, b)`, for - relative distance and paging. -- `first_visible_at_or_after(line)` — clamp a candidate `view_top` to a visible - line. +- **Render frame.** `paint_frame` (`src/editor.rs:2796`) builds one instance and + (a) threads it to the overlay painters (1,3,4,5,6) as `Option<&'a + VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** — a shared ref is + `Copy`, so `Viewport` stays `Copy` (F4); (b) passes the **same** instance + directly to the in-`paint_frame` sites (2,7,8,9). The `View::render` signature + becomes `Viewport<'_>`; every construction site gains the field (non-fold callers + pass `None`). This is a settled compile-time change across the `View` impls and + `Viewport` constructions. +- **After the frame.** The peer-presence pass (`src/overlay_paint.rs`, site 10) + runs after `paint_frame` and takes its own arguments; it receives (or builds) a + fresh instance for the same buffer and clamps peer cursors / projects peer + selection through it. +- **Command / event.** Click inverse, auto-scroll, paging, wheel, and vertical + motion (sites 11–13, in `EditorCore` / `EditorState`) each build a short-lived + instance from `state.fold_registry` + the buffer at call time. "The map is + already built" (rev 1) was false for these — they run outside any frame. -Implementation shape: convert `folds()` to a sorted, non-overlapping set of -**hidden-line intervals** (nested folds collapse to the union of their hidden -lines — a hidden line is hidden regardless of which fold owns it). Byte→line uses -the render path's existing line-offset table; the fold set is O(top-level blocks) -(parent B2), so the build is cheap (**Bet B4**). The map is a *derived per-frame -view*, not new stored state — the byte-range store in `src/fold.rs` stays the one -source of truth; nested/overlap normalization happens in the derivation. - -**`view_top` stays a source-line index** (Q#FD12, **Bet B5**): it is only ever -clamped to a *visible* line (`first_visible_at_or_after`). Keeping it in -source-line space avoids a second coordinate space and preserves the saveplace -contract (`view_top` is persisted, `saveplace.lua:60`) and the existing -`_view_top`/`set_view_top` Lua surface. The visible-line *ordinal* is derived -where needed (line numbers, paging), never stored. - -Home of the primitive: a Stage-2 render-side helper (e.g. `src/fold_view.rs` or a -function on the render path) that takes `&FoldStore`/`folds()` + line offsets and -yields the queries above. It is **not** put on `FoldStore` itself — the store is -byte-space and frontend-agnostic; the line-space view is a rendering concern. (A -thin convenience like `FoldStore::hidden_line_intervals(&line_offsets)` may live -in `fold.rs` if it keeps the byte→line conversion beside the containment -convention it must match; TBD in implementation, not load-bearing.) +`view_top` **stays a source-line index** (Q#FD12, **Bet B5**), only ever set via +`clamp_view_top` so it never rests on a hidden line — preserving the saveplace +(`saveplace.lua:60`) and `_view_top`/`set_view_top` contracts. Visible-line +*ordinals* are derived where needed, never stored. ## 4. Collapse rendering (Q#FD13) -**Threading.** `TextView::render` (`src/text_view.rs:207`) takes only `(buf, -viewport, cells)` and has no fold handle. The fold-aware line map is carried on -**`Viewport`** (widening it beside `gutter_w`) so that `TextView::render` *and* -every overlay painter (syntax, diagnostics, selection, search) see the **same** -map and their rows stay aligned. `paint_frame` builds the map (it holds -`state.fold_registry` + `window.buffer_id`) and installs it on the `Viewport` it -already constructs (`src/editor.rs:2932`). This mirrors how `gutter_w` is -threaded to keep painters gutter-blind (§2.3) — now they become fold-blind too, -consulting the map rather than each re-deriving it. - -**The loop.** `TextView::render` advances over **visible** lines: row `r` shows -the `r`-th visible source line at/after `view_top` (via `next_visible`). Hidden -lines are skipped; rows past the last visible line clear as today. - -**Head line + ellipsis.** A visible line that is a fold head renders its real -content, then a trailing ellipsis marker (` …`) in the **content area** after its -text (clipped like any long line). The ellipsis is the authoritative, -layout-neutral fold indicator. - -**Overlays.** Syntax spans, diagnostics, selection washes, and search hits all -paint per row through `Viewport`; because they now read the same visible map, a -span/wash on a hidden line is simply not painted (its row does not exist), and a -span on a visible line lands on the right row. No overlay tries to delete a row — -the row set is already fold-correct by the time overlays run. +`TextView::render` (`src/text_view.rs:207`) advances over **visible** lines via +`next_visible`: row `r` shows the `r`-th visible source line at/after +(already-visible) `view_top`; hidden lines are skipped; trailing rows clear as +today. A visible head line renders its real content then a trailing ellipsis +marker (` …`) in the **content area** (clipped like any long line) — the +authoritative, layout-neutral fold indicator. Overlays (1,3,4,5,6) read the same +map through `Viewport`, so a span/wash/sign/anchor on a hidden line is simply not +painted (its row does not exist) and one on a visible line lands on the right row. +Folding is **not** an overlay — overlays cannot delete rows. ## 5. Gutter: line numbers + fold glyph (Q#FD14, Q#FD20) -**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`) -walks visible lines (row `r` → the `r`-th visible line at/after `view_top`, same -map as §4): +**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`) walks +visible lines (row `r` → the `r`-th visible line). **Absolute** shows that line's +raw `line+1` (hidden numbers just do not appear — the column jumps from the head's +number to the first post-fold number). **Relative/Hybrid** measure distance in +**visible** lines: `visible_between(anchor, row_line)`, where the cursor **anchor** +is `visible_head_of(cursor_line)` (F1 — the cursor may be on a hidden shared-fold +line). Absolute still needs the raw line, so the walk carries both the raw source +line and the visible ordinal. Gutter width is unchanged (§2.4). -- **Absolute** shows that visible line's raw `line + 1` — unchanged rule; hidden - numbers simply do not appear, so the column jumps from the head's number to the - first post-fold number with no gap-filling. -- **Relative / Hybrid** measure distance in **visible** lines: - `visible_between(cursor_line, row_line)` (signed by direction), **not** - `number_for`'s raw `abs_diff`. Concretely, the caller feeds `number_for` a - *visible* distance (and, for Hybrid's cursor row, the raw `line + 1`). Absolute - keeps needing the raw line, so the walk carries both the raw source line and the - visible ordinal. +**Fold glyph (Q#FD20, F3).** Conditioned on a gutter existing: +- **Line numbers off (`gutter_w==0`, default):** no sign cell exists, so the fold + marker is the **content-area ellipsis only**; the diagnostic col-0 background + fallback is unchanged. +- **Line numbers on (`gutter_w>0`):** on a head-line row, draw a fold glyph in the + gutter's col-0 sign cell **unless a diagnostic clamps there** (Q#FD15) — + diagnostic wins (an error inside the fold is higher-signal). -Gutter **width** is unchanged (`decimal_digits(line_count)+2`, §2.3) — sized to -the absolute count, so it neither jitters nor shrinks when a fold collapses. - -**Fold glyph (Q#FD20).** Parent §7 promises a gutter fold marker. To honor it -with **zero layout change** and no contention: on a head-line row, draw a fold -marker in the gutter's **col-0 sign cell** *only when that row carries no -diagnostic sign*. A diagnostic clamped onto the head (Q#FD15) is higher-signal -("there is an error inside this fold") and wins col 0; otherwise the fold glyph -occupies it. This adds no gutter column and keeps the width rule intact. (A -dedicated fold column is deferred, §9 — it would widen the gutter and fight the -sign cell.) +This keeps the gutter width rule intact and adds no column. Making the gutter +marker **unconditional** would require a **dedicated fold sign column** and an +acknowledged layout change; deferred (§9). The parent §7's promise is thus honored +*when a gutter is present*, with the ellipsis as the universal indicator. ## 6. Diagnostic signs on hidden lines (Q#FD15) -`DiagnosticView::render` (`src/diag.rs:496`), before recording a marker for a -source `line` (`:563/:570`): if `is_hidden(line)`, **remap it to `head_of(line)`** -(clamp to the visible head-line row) rather than dropping it. The existing +`DiagnosticView::render` (`src/diag.rs:496`): before recording a marker for source +`line` (`:563/:570`), if `is_hidden(line)` **remap to `visible_head_of(line)`** +(clamp to the outermost visible head, F1), not drop. The existing most-severe-per-row merge (`:570`) then makes the head row show the most severe -sign among the head's own diagnostics and every hidden line under its fold. Clamp -(not drop) preserves the "there is a problem inside this collapsed region" signal -— the reason a fold marker exists. +sign among the head and every hidden line under its fold. Clamp (not drop) +preserves the "there is a problem inside this collapsed region" signal. +`DiagnosticView` reaches the map through `Viewport` (§3.2), needing no separate +handle. -`DiagnosticView` reaches the map the same way `TextView` does — through -`Viewport` (§4) — so it needs no separate fold-registry handle and every overlay -stays uniformly fold-aware. Its resolved head row then routes through the same -visible map as the numbers, so sign and number agree on the row. +## 7. Caret, selection, peer presence, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) -## 7. Caret, click, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) +**Caret clamp (Q#FD16).** Caret grid-row (`src/editor.rs:3033-3044`): if the +logical cursor's line `is_hidden`, render at `visible_head_of(line)`'s row — +satisfying the parent's per-cursor render-time invariant (Q#FD3), including the +shared-store case. -**Caret clamp (Q#FD16).** The caret grid-row (`src/editor.rs:3033-3044`): if the -logical cursor's source line `is_hidden`, render the caret at `head_of(line)`'s -row (then map through the visible map like any row). This satisfies the parent's -per-cursor render-time invariant (Q#FD3) — including the shared-store case where -another frontend folded a region around this frontend's cursor. +**Local selection (Q#FD16, F2).** `paint_local_selection` (`src/editor.rs:3241`): +selection cells on hidden lines are dropped; the visible portion projects through +the map (a selection spanning a fold paints on the visible head row and the visible +tail rows, contiguous on screen). -**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2228`): a -click on grid row `k` maps to the `k`-th visible line (via the map), so a click -can never land the cursor on a hidden line. +**Peer presence (Q#FD16, F2).** `src/overlay_paint.rs:159`: a peer cursor on a +hidden line clamps to `visible_head_of`; peer selection cells on hidden lines are +dropped/projected exactly like local selection. Folds are per-buffer/shared, so the +peer's map is the same buffer's map. -**Viewport / scroll (Q#FD18).** `view_top` is clamped to a visible line -(`first_visible_at_or_after`) wherever it is set. Paging and wheel scroll count -**visible** rows: `move_page_down`/`move_page_up` -(`src/editor_core.rs:1745/1779`) advance `view_top` and the cursor by a screenful -of *visible* lines (walk `next_visible`/`prev_visible` `page_step` times); -`scroll_window` (`src/editor.rs:2264`) advances by visible lines; the -auto-scroll-to-cursor clamp (`src/editor.rs:2866`) keeps the cursor's *visible* -row within the viewport. `move_to_line`/goto-line targets a raw line; if that -line is hidden, `view_top` is set so the target's **head** is visible (Stage 2 -does not auto-unfold on goto — revealing-by-unfold is the deferred search-reveal, -§9). Recenter stays deferred (§2.5). +**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2233`): grid +row `k` maps to the `k`-th visible line, so a click never lands on a hidden line. -**Cursor line-motion (Q#FD17) — SCOPE DECISION, recommendation: include.** -`next-line`/`prev-line` (and the arrow keys) today step to the same column on the -adjacent **source** line. Recommended for Stage 2: they step to the adjacent -**visible** line (`next_visible`/`prev_visible`), so a collapsed region is one -motion step and the cursor never *rests* on a hidden line — the caret clamp -(Q#FD16) becomes a backstop for the shared-fold case rather than the primary -guard, and the visible-line map is already built. This is small (it reuses the -map at the line-motion functions in `editor_core`) and makes the collapse feel -coherent. **Alternative (smaller Stage 2):** leave line-motion in raw source-line -space; rely solely on the render-time caret clamp; a down-arrow into a fold lands -the logical cursor on a hidden line (drawn clamped), and the next edit unfolds. -This is less code but a confusing "cursor entered the fold invisibly" UX. *This -is the one scope fork I most want your ruling on.* +**Viewport / scroll (Q#FD18).** `view_top` is set only via `clamp_view_top` +(backward to the head for a hidden candidate, F1). Paging +(`move_page_down/up`), wheel (`scroll_window`), and the auto-scroll-to-cursor clamp +(`src/editor.rs:2866`) advance and bound by **visible** lines +(`next_visible`/`prev_visible`, `visible_between`). `move_to_line`/goto-line targets +a raw line; if hidden, `view_top` clamps so the target's **head** is visible (no +auto-unfold — that is the deferred search-reveal, §9). The **mode-line scroll +indicator** (`format_scroll_indicator`, `src/editor.rs:3803`) computes All/Top/Bot/% +in **visible-line** space (visible total, `view_top`'s visible ordinal, cursor's +visible ordinal). Recenter stays deferred (§2.6). + +**Vertical line-motion (Q#FD17 — RULED: include).** `next-line`/`prev-line` (and +arrows) step to the adjacent **visible** line (`next_visible`/`prev_visible`), so a +collapsed region is one motion step and the cursor never rests hidden. **If motion +begins from a hidden logical cursor** (after a shared fold or a goto-line into a +fold), it **first normalizes to `visible_head_of(cursor)`**, then steps to the +preceding/following visible line. The render-time caret clamp (Q#FD16) remains the +backstop for the pre-motion shared-fold frame. These reuse the command-time map +instance (§3.2). ## 8. Interactive-Lua-command unfold widening (Q#FD19) -The parent's Stage 2 obligation (R3-3): widen the pre-edit unfold beyond the six -`dispatch_key` primitives to the interactive Lua commands. Per §2.4 the targets -split across two funnels, and the widening must stay **local-interactive only** — -it must NOT unfold on the remote/optimistic-CRDT apply path (parent's Stage 3 -"CRDT-origin unfold"). +Widen the pre-edit unfold beyond the six `dispatch_key` primitives to the +interactive Lua commands, **local-interactive only** (never the remote/optimistic +CRDT path — parent Stage 3). - **`apply_active_edit` funnel (yank, query-replace, and the six).** Move the pre-edit unfold to the **top of `apply_active_edit`** (`src/editor_core.rs:1266`), - keyed on the active frontend's point (as `unfold_before_point_edit` already is). - This subsumes the six primitives' individual calls (they are then **retired** — - one funnel) and covers yank (`clipboard_paste`→`insert_bytes_over_region`) and - query-replace (`query_replace_apply_current`) for free. `apply_active_edit` is - never the remote-apply path, so this funnel is inherently local — no CRDT - leakage. -- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** These reach - `run_managed_edit` (`src/lua_bindings/mod.rs:1347`). Unfold there **only when the - edit runs inside an interactive-command dispatch for the active frontend**, keyed - on the requested edit's start position (`edit.range.start`). Gating on the - interactive-command context is what keeps a plain programmatic `buf:insert` (a - plugin, or the Stage-1 data-API `pmacs.fold.fold`) from unfolding — matching - Stage 1's named data-API exemption ("programmatic, no invoking point"). The - dispatcher already stamps `active_frontend` and rotates command boundaries - around interactive commands (`this_command`/`invoke_interactive`), so the - "interactive command in flight for frontend F" context is available; Stage 2 - consults it, adding at most a small dispatch-scoped marker if none is directly - readable. -- **Explicitly EXCLUDED (Stage 3):** the shared `notify_buffer_edit` - (`src/editor_core.rs:1364`) remote/optimistic-CRDT apply path. A remote peer's - edit inside *my* fold must not unfold it; a GPU user's own optimistic-CRDT edit - at *their* point is the parent's Stage 3 obligation, wired when the GPU renders - folds. -- **Undo/redo (named minor).** `undo`/`redo` reach the invalidation funnel - directly (`src/editor_core.rs:2062/2102`), not `apply_active_edit`. They are - local active-frontend operations; Stage 2 **may** unfold at the edit position on - undo/redo (desirable — an undo that re-touches a folded region should reveal it) - or defer it. Not a blocker; decided in implementation. + keyed on the active frontend's point. This subsumes the six primitives' + individual calls (retired — one funnel) and covers yank + query-replace for free. + `apply_active_edit` is never the remote-apply path, so this funnel is inherently + local. +- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** Hook the **common + `run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`) — above both + `run_managed_edit` and `run_bypass_edit`, so an interactive `bypass_intercept` + edit does not escape (F5). Unfold **only when all** hold: (i) + `InteractiveCommandOrigin.current()` is `Some(f)` (`src/editor.rs:53`); (ii) the + edited buffer **is `f`'s active-window buffer** (an explicit inactive-buffer Lua + mutation stays programmatic — no unfold); (iii) `edit.range.start` is inside a + fold. Condition (i)+(ii) is what distinguishes an interactive command's edit at + the point from a plugin's/data-API's programmatic edit (matching Stage 1's + data-API exemption). +- **Excluded (Stage 3):** the remote/optimistic-CRDT apply path + (`notify_buffer_edit`, `src/editor_core.rs:1364`). A remote peer's edit inside my + fold must not unfold it; a GPU user's own optimistic edit is the parent's Stage 3 + obligation. +- **Undo/redo — DEFERRED (F5 ruling).** `undo`/`redo` reach + `notify_buffer_edit_to_windows` directly; their unfold behavior is **explicitly + deferred** (§9), not decided at implementation time. -Every path is keyed on the **edit position inside a fold**, and every widened -behavior is **bite-verified** (a test that fails without the widening). +Each widened behavior is **bite-verified** (a test failing without the widening). ## 9. Deferred (named) -- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity, - the `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin / - GPU-optimistic interactive unfold (parent R2-3). -- **Recenter** and any frontend scroll control — blocked on viewport facts (the - GPU not consuming `view_top`); Arc 8 adjacent (§2.5). -- **Search-reveal** — a match (isearch/query-replace) inside a fold auto-unfolds - to show the hit. Parent §11 marks it "Stage 2+"; deferred here to keep Stage 2 - focused (goto-line likewise does not auto-unfold, §7). -- **A dedicated gutter fold column** — Q#FD20 uses the existing col-0 sign cell; - a separate column (and its width recompute) is deferred. -- **Fold-aware horizontal/word motion** and screen-line editing semantics beyond - vertical line-motion. -- **Persisted folds, `hide-level N`, auto-fold-on-open** (parent §11). -- **Fold-store revalidation on revert/reload** (parent §11; v1 still drops it). -- If Q#FD17 is decided "alternative," fold-aware vertical line-motion moves here. +- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity, the + `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin / GPU-optimistic + interactive unfold (parent R2-3). +- **Undo/redo unfold** (F5 ruling) — deferred. +- **Recenter** and any frontend scroll control — blocked on viewport facts; Arc 8 + adjacent (§2.6). +- **Search-reveal** — a match inside a fold auto-unfolds; parent §11 "Stage 2+"; + goto-line likewise does not auto-unfold (§7). +- **A dedicated gutter fold column** (unconditional marker) — its width recompute / + layout change is deferred; Q#FD20 uses the conditional sign cell. +- **Fold-aware horizontal/word motion** and screen-line editing beyond vertical + line-motion. +- **Persisted folds, `hide-level N`, auto-fold-on-open**; fold-store revalidation on + revert/reload (parent §11; v1 still drops it). ## 10. Bets -- **B4** The per-frame visible-line map (derived from `folds()` + line offsets) is - cheap: folds are O(top-level blocks), byte→line reuses the render path's line - table. No incremental/stored map needed. FALSIFIABLE on a pathological - many-fold buffer (mitigation: the derivation is O(folds), not O(lines)). -- **B5** Keeping `view_top` a source-line index (clamped to visible) is less churn - and preserves the saveplace/`set_view_top` contracts, versus introducing a - visible-ordinal coordinate space. -- **B6** No wire schema or protocol change: `FoldState` production (Stage 1) is - untouched; the TUI collapse is purely daemon-side; a semantic GPU session still - gets `FoldState` and renders nothing new until Stage 3. +- **B4** The per-instance visible-line map (from `folds()` + line offsets) is cheap: + O(folds) build, reusing the render path's line table. Multiple short-lived + instances per interaction are still O(folds) each. FALSIFIABLE on a pathological + many-fold buffer (mitigation: derivation is O(folds), not O(lines)). +- **B5** `view_top` stays a source-line index (clamped via `clamp_view_top`) — + less churn than a visible-ordinal coordinate space, and preserves saveplace / + `set_view_top`. +- **B6** No wire/protocol change: `FoldState` (Stage 1) untouched; the TUI collapse + is daemon-side; a semantic GPU session still gets `FoldState` and renders nothing + new until Stage 3. +- **B7** Threading `Option<&'a VisibleLineMap>` on a lifetime-bearing `Viewport<'a>>` + keeps `Viewport: Copy`. FALSIFIABLE if a `View` impl or construction site cannot + satisfy the lifetime; fallback is to settle `Viewport` as non-`Copy` explicitly. ## 11. Acceptance — Stage 2 @@ -423,94 +436,107 @@ Tests assert on the **rendered cell grid** through a real-daemon / real-TUI grid harness (the vterm-Stage-2 real-PTY smoke and the UX-gutter daemon-acceptance are the precedents), sized for macOS startup + long temp-path width (handoff §5). -1. **Collapse.** Fold a region; the daemon frame omits the hidden source lines, - the head line shows its text + ellipsis, rows below shift up, and the content - row count equals the visible-line count. -2. **Head marker.** The head-line row shows the ellipsis (content area) and — when - no diagnostic clamps there — the gutter fold glyph; hidden rows are absent. -3. **Line numbers.** Absolute mode skips hidden numbers (head's number, then the - first post-fold number, no gap-fill). Relative/Hybrid distance is measured in - **visible** lines across a fold (a line two visible rows past the cursor shows - `2` even if a fold hides ten source lines between them). -4. **Diagnostic clamp.** A diagnostic on a hidden line surfaces as the sign on the - head-line row; most-severe wins across the head and all its hidden lines; no - sign on an absent row. -5. **Caret clamp.** With the logical cursor on a hidden line (folded via the - shared store from a second frontend), the caret renders on the head-line row; a - click on a grid row never selects a hidden line. -6. **Viewport / paging.** Page-down advances by a screenful of **visible** lines - across a fold; `view_top` never lands on a hidden line; scrolling with a fold at - the top clamps to its head; goto-line to a hidden line leaves the target's head - visible. -7. **Interactive unfold widening (Q#FD19).** A **yank** at a point inside a fold - unfolds it before the paste is visible; likewise a **query-replace** - replacement and a **comment-toggle** whose edit point is inside a fold. A - **programmatic** `pmacs.fold`-adjacent `buf:insert` inside a fold does **not** - unfold (it translates). Each assertion is **bite-verified**; the test documents - that CRDT-origin unfold remains the Stage 3 obligation. -8. **(If Q#FD17 = include)** `next-line`/`prev-line` step across a collapsed region - as one motion; the cursor never rests on a hidden line. -9. **No wire/protocol change.** A semantic session still receives `FoldState` - exactly as in Stage 1; the protocol version is unchanged; the GPU render path - is untouched. (Pin: the Stage-1 `FoldState` producer transitions test still - passes verbatim.) -10. **Shared store, independent viewports.** Two TUI windows on one buffer both - render the same fold collapsed, each with its own correct `view_top`, gutter, - and caret — folds are per-buffer/shared, viewports are per-window. +1. **Collapse.** Fold a region; the frame omits the hidden lines, the head shows + text + ellipsis, rows below shift up, content row count = visible-line count. +2. **Head marker, both gutter states (F3).** With line numbers **off**: the head + shows the ellipsis and **no gutter glyph** (width unchanged, 0). With a + line-number mode **on**: the head shows the ellipsis **and** the gutter fold + glyph (unless a diagnostic clamps there — then the diagnostic sign). +3. **Line numbers.** Absolute skips hidden numbers (head's number, then first + post-fold number, no gap-fill). Relative/Hybrid distance is measured in + **visible** lines across a fold, anchored on the visible cursor head. +4. **Diagnostic clamp (F1).** A diagnostic on a hidden line surfaces on the + **outermost visible head** row; most-severe wins across the head and all its + hidden lines (including a diagnostic on a *nested* inner-fold line clamping to + the outer head). +5. **Nested-fold / shared cursor (F1).** With a fold nested inside another and the + logical cursor on a deeply-hidden line (folded via the shared store from a + second frontend), the caret renders on the **outermost** visible head; relative + numbers anchor there; a `view_top` set into the nest clamps **backward** to that + head. +6. **Caret, local selection, peer presence (F2).** Caret on a hidden line → head + row. A local selection spanning a fold paints on the visible head + visible tail + rows, nothing on hidden rows. A peer cursor on a hidden line clamps to the head; + peer selection cells on hidden lines drop/project. A click never selects a hidden + line. +7. **Ordinary overlays across a fold (F2).** A style/syntax span and a search wash + on lines straddling a fold paint only on the visible rows, correctly aligned; + the completion popup anchored below a fold lands on the right visible row. +8. **Viewport / paging / indicator (F2).** Page-down advances by a screenful of + **visible** lines across a fold; `view_top` never rests hidden; a fold at the top + clamps to its head; goto-line to a hidden line leaves its head visible; the + mode-line indicator reports Top/Bot/% in visible-line space. +9. **Vertical motion (Q#FD17).** `next-line`/`prev-line` step across a collapsed + region as one motion; starting from a hidden logical cursor, motion first + normalizes to the visible head; the cursor never rests hidden. +10. **Interactive unfold widening (Q#FD19).** A **yank**, a **query-replace** + replacement, and a **comment-toggle** whose edit point is inside a fold each + unfold it before the edit is visible; a **bypass-intercept** interactive edit at + the point also unfolds (proving the `run_buffer_edit` seam). A **programmatic** + `buf:insert` — and an interactive command mutating an **inactive** buffer — do + **not** unfold. **Undo/redo do not unfold** (deferred). Each assertion is + **bite-verified**; the test documents CRDT-origin unfold as the Stage 3 + obligation. +11. **No wire/protocol change.** A semantic session still receives `FoldState` + exactly as in Stage 1; the protocol version is unchanged; the GPU render path is + untouched (the Stage-1 `FoldState` producer transitions test passes verbatim). +12. **Shared store, independent viewports.** Two TUI windows on one buffer both + render the same fold collapsed, each with its own correct `view_top`, gutter, and + caret. ## 12. Gates (Stage 2) `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (own step); `cargo test --lib`; `cargo test --lib --features crdt`; -`tests/folding_acceptance.rs` (Stage-1 suite, must stay green) + the new +`tests/folding_acceptance.rs` (Stage-1 suite, stays green) + new `tests/folding_stage2_acceptance.rs` (default + CRDT); `cargo test --test -m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p -pmacs-gpu` (must stay green — Stage 2 does not touch the GPU); the workspace -sweep as one invocation; `git diff --check`. New behavioral acceptance is -bite-verified with `scripts/bite`. Flaky-under-load timing tests are rerun -isolated before treating a sweep failure as a regression (handoff §3). +m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` +(stays green — Stage 2 does not touch the GPU); the workspace sweep as one +invocation; `git diff --check`. New behavioral acceptance is bite-verified with +`scripts/bite`; timing-flaky tests are rerun isolated before treating a sweep +failure as a regression (handoff §3). ## 13. Numbered decisions (continuing the parent's Q#FD scheme) -- **Q#FD12** Stage 2 introduces the first non-identity source-line↔display-row - map. It is centralized in one per-frame **visible-line map** (derived from - `folds()` + line offsets), consulted by the render loop and every - view_top-arithmetic site; `view_top` stays a source-line index clamped to a - visible line. (§3) -- **Q#FD13** Collapse lives in `TextView::render` (row `r` → `r`-th visible line), - with the map carried on `Viewport` so all overlay painters are uniformly - fold-aware; head line renders content + trailing ellipsis; folding is not an - overlay. (§4) -- **Q#FD14** Line numbers walk visible lines; Absolute uses the raw `line+1`, - Relative/Hybrid measure distance in **visible** lines (not raw `abs_diff`); - gutter width unchanged (sized to absolute count, no jitter). (§5) -- **Q#FD15** A diagnostic on a hidden line **clamps to the fold head row** - (most-severe merge across head + hidden lines), not dropped. (§6) -- **Q#FD16** Render-time caret clamp to the head for a cursor on a hidden line; - click inverse maps grid rows to visible lines. (§7) -- **Q#FD17** *(scope fork — needs your ruling)* Recommended: vertical line-motion - (`next-line`/`prev-line`/arrows) steps over folds by visible lines so the cursor - never rests hidden. Alternative: raw motion + render-time clamp only. (§7) -- **Q#FD18** Viewport/paging/auto-scroll count **visible** lines; `view_top` - clamped to visible; goto-line leaves a hidden target's head visible; recenter - stays deferred. (§7) -- **Q#FD19** Interactive-unfold widening hooks the **local** funnels only: - `apply_active_edit` (top, subsuming the six primitives; covers yank + - query-replace) and the interactive Lua-mutator path (comment-toggle, yank-pop) - gated on interactive-command context + edit position; the remote/optimistic-CRDT - `notify_buffer_edit` path is **excluded** (Stage 3); undo/redo unfold is a named - minor. (§8) -- **Q#FD20** Fold gutter glyph reuses the col-0 sign cell on the head row when no - diagnostic clamps there (diagnostic wins); no new gutter column. (§5) +- **Q#FD12** One shared visible-line map **primitive** (derivation + queries from + `folds()` + line offsets), instantiated as short-lived instances per phase + (render via `Option<&'a VisibleLineMap>` on `Viewport<'a>>` preserving `Copy`; + after-frame direct; command-time fresh), home usable from `EditorCore`. `view_top` + stays a source-line index, set only via `clamp_view_top`. Every consumer in the + §2.2 census routes through it. (§3) +- **Q#FD13** Collapse in `TextView::render` (row `r` → `r`-th visible line); head + renders content + trailing ellipsis; folding is not an overlay. (§4) +- **Q#FD14** Line numbers walk visible lines; Absolute uses raw `line+1`, + Relative/Hybrid measure **visible**-line distance anchored on `visible_head_of` + of the cursor; gutter width unchanged. (§5) +- **Q#FD15** A diagnostic on a hidden line clamps to `visible_head_of` (outermost + visible head), most-severe merge; not dropped. (§6) +- **Q#FD16** Render-time clamp to `visible_head_of` for caret, local selection, and + peer cursor on hidden lines; hidden selection cells drop/project; click inverse + maps grid rows to visible lines. (§7) +- **Q#FD17** *(ruled: include)* Vertical line-motion steps by visible lines; motion + from a hidden cursor first normalizes to the visible head. (§7) +- **Q#FD18** Viewport/paging/auto-scroll/indicator reckon in visible lines; + `view_top` set via `clamp_view_top` (backward to the head); goto-line leaves a + hidden target's head visible; recenter deferred. (§7) +- **Q#FD19** Interactive-unfold widening hooks **local** funnels only: + `apply_active_edit` (top; subsumes the six; covers yank + query-replace) and the + common `run_buffer_edit` gated on `InteractiveCommandOrigin.current()` **and** + the edit targeting the invoking frontend's active-window buffer **and** + `edit.range.start` inside a fold; the remote/optimistic-CRDT `notify_buffer_edit` + path is excluded (Stage 3); **undo/redo unfold is deferred**. (§8) +- **Q#FD20** Fold gutter glyph is **conditional on a gutter existing**: off ⇒ + ellipsis only; on ⇒ col-0 sign cell on the head row with diagnostic priority. A + dedicated fold column (unconditional marker + layout change) is deferred. (§5) ## 14. Branch and PR plan Branch **`folding-tui`**, worktree `../pmacs-folding-tui`, off canonical `main` @ -`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commit; -Stage 2 implements on this same branch and opens as the second folding PR. One -feature, one branch, one PR — Stage 3 (GPU) is a separate branch/PR off the main -resulting from this stage, re-framed after it lands. +`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commits (rev +1 → rev 2); Stage 2 implements on this same branch and opens as the second folding +PR. One feature, one branch, one PR — Stage 3 (GPU) is a separate branch/PR off the +resulting main. -Housekeeping owed from #142 (tracked separately, not folded into this PR): the -`docs/active-work.md` folding lane and `docs/agent-handoff.md` §1 still predate -the #142 merge and want a docs PR refresh (the #138–#140 convention). +Housekeeping from #142 (retire the `docs/active-work.md` folding lane + refresh +`docs/agent-handoff.md` §1) is a **separate docs PR**, kept out of `folding-tui` +(ruled).