docs(folding): Stage 2 framing rev 2 — address review round 1

Round 1's five findings + two rulings, all verified against c49a8c7:

- F1 (major): nested folds. `head_of`(innermost) could clamp onto a
  still-hidden inner head. Replace with `visible_head_of` (outermost
  visible head); `view_top` clamps BACKWARD to the head, not forward
  past the fold; relative numbers anchor on the clamped visible cursor.
- F2 (major): the consumer census was incomplete. Add the full §2.2
  table — local selection (editor.rs:3241), peer presence
  (overlay_paint.rs:159, after paint_frame), mode-line indicator
  (editor.rs:3803), style/search/completion overlays — and make TUI
  peer-presence fold behavior explicit scope.
- F3 (major): line numbers default Off => gutter_w==0 => no sign cell.
  Make the fold glyph conditional: off => ellipsis only; on => sign
  cell with diagnostic priority. Dedicated column (unconditional) named
  as a deferred layout change.
- F4 (major): a frame-pinned map can't serve command-time motion, and
  Viewport is Copy. Reframe as one derivation primitive with per-phase
  short-lived instances (render via Option<&VisibleLineMap> on a
  lifetime-bearing Viewport, preserving Copy; after-frame direct;
  command-time fresh); home usable from EditorCore.
- F5 (moderate): key the Lua-path widening on InteractiveCommandOrigin
  (editor.rs:53), hook the common run_buffer_edit (not only
  run_managed_edit) so bypass_intercept edits don't escape, require the
  target to be the invoking frontend's active-window buffer, and
  explicitly DEFER undo/redo unfold.

Rulings: Q#FD17 include (normalize a hidden cursor to the visible head
before stepping); #142 housekeeping stays a separate docs PR.

Acceptance expanded to pin nested-fold/shared-cursor, local selection,
peer presence, an ordinary overlay across a fold, completion anchoring,
the scroll indicator, and both gutter-off/gutter-on fold-marker cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
Levi Neuwirth 2026-07-23 16:08:22 -04:00
parent 59410c004d
commit e221f131f0
1 changed files with 444 additions and 418 deletions

View File

@ -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 1113 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<ByteRange>`
(`: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<ByteRange>` (`:327`) — whole-buffer, sorted,
empty when no store. The one-call read a renderer wants.
- `FoldStore::containing(p) -> Vec<ByteRange>` (`: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<FrontendId>` 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<line>` — 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 1113, 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).