30 KiB
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.
Numbering continues the parent's Q#FD… scheme from Q#FD12.
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
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:
- Collapse — omit each fold's hidden source lines; show the head line with a trailing ellipsis; rows below shift up.
- Gutter fold marker — a fold glyph on the head-line row (parent §7).
- Fold-aware line numbers — the
LineNumbersfamily skips hidden lines, and relative/hybrid distance is measured across the collapse (parent §8). - Fold-aware diagnostic signs — a sign on a hidden line clamps to the fold's visible head-line row (parent §8, minor d).
- Fold-aware caret — the caret never renders on a hidden line; it clamps to the fold head (parent §8, Q#FD3 render-time invariant).
- Fold-aware viewport/scroll —
view_top, paging, and auto-scroll count visible lines, not raw source lines (parent §8). - 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.
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.
2. Ground truth (scouted 2026-07-23, main @ c49a8c7)
2.1 The grid render path is layered — and the 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) — diff shell, no line walk
→ 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 is the primitive (src/text_view.rs:213):
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,
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:
| 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 |
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.
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.2 The renderer can already reach the fold store
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.
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 persrc/fold.rs:11).- There is NO line-space query ("is source line N hidden?"). Only byte-space
containingexists. Stage 2 must add the line-space view (§4).
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.3 The gutter
LineNumberMode(pmacs-protocol/src/message.rs:1173;Off/Absolute/Relative/Hybrid; defaultOff) is per-window (Window.line_numbers,src/window.rs:188). The number rule isLineNumberMode::number_for(line, cursor_line)(:1197) — and it measures relative distance asabs_diffin 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 changeline_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 intoline_markers(:570), painted bypaint_line_markers(:635) withDiagnosticSeverity::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 readsViewport.gutter_w(src/view.rs:144) so it stays gutter-blind.
2.4 The interactive-edit unfold seam (correcting the parent's premise)
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.
The parent framing (R3-3) assumed yank/query-replace/comment-toggle all "mutate through the Lua mutator path." The scout corrected this:
- Yank (
C-y) and query-replace are Rustapply_active_editcallers, 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_editis never the path for a remote/optimistic-CRDT apply). - Comment-toggle (
comment.lua:172, onebuf: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.
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.
2.5 Scroll/viewport commands all count raw lines; there is no recenter
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).
3. The shared fold-aware line map (Q#FD12)
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:
is_hidden(line) -> bool— is this source line inside some fold's hidden intervalhead+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 candidateview_topto a visible line.
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.)
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.
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):
- 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), notnumber_for's rawabs_diff. Concretely, the caller feedsnumber_fora visible distance (and, for Hybrid's cursor row, the rawline + 1). Absolute 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
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.)
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
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.
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, click, viewport, and motion (Q#FD16, Q#FD17, Q#FD18)
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.
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.
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).
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.
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").
apply_active_editfunnel (yank, query-replace, and the six). Move the pre-edit unfold to the top ofapply_active_edit(src/editor_core.rs:1266), keyed on the active frontend's point (asunfold_before_point_editalready 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_editis 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 programmaticbuf:insert(a plugin, or the Stage-1 data-APIpmacs.fold.fold) from unfolding — matching Stage 1's named data-API exemption ("programmatic, no invoking point"). The dispatcher already stampsactive_frontendand 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/redoreach the invalidation funnel directly (src/editor_core.rs:2062/2102), notapply_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 behavior is bite-verified (a test that fails without the widening).
9. Deferred (named)
- Stage 3 (GPU): GPU collapse, caret/hit-test fold-awareness at TUI parity,
the
BufferSnapshotfold-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.
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_topa source-line index (clamped to visible) is less churn and preserves the saveplace/set_view_topcontracts, versus introducing a visible-ordinal coordinate space. - B6 No wire schema or protocol change:
FoldStateproduction (Stage 1) is untouched; the TUI collapse is purely daemon-side; a semantic GPU session still getsFoldStateand renders nothing new until Stage 3.
11. Acceptance — Stage 2
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).
- 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.
- 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.
- 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
2even if a fold hides ten source lines between them). - 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.
- 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.
- Viewport / paging. Page-down advances by a screenful of visible lines
across a fold;
view_topnever 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. - 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-adjacentbuf:insertinside a fold does not unfold (it translates). Each assertion is bite-verified; the test documents that CRDT-origin unfold remains the Stage 3 obligation. - (If Q#FD17 = include)
next-line/prev-linestep across a collapsed region as one motion; the cursor never rests on a hidden line. - No wire/protocol change. A semantic session still receives
FoldStateexactly as in Stage 1; the protocol version is unchanged; the GPU render path is untouched. (Pin: the Stage-1FoldStateproducer transitions test still passes verbatim.) - 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.
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_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).
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_topstays a source-line index clamped to a visible line. (§3) - Q#FD13 Collapse lives in
TextView::render(rowr→r-th visible line), with the map carried onViewportso 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 rawabs_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_topclamped 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-CRDTnotify_buffer_editpath 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)
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.
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).