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