31 KiB
Folding — framing (Arc 6)
Revision 5 — 2026-07-23. Status: APPROVED; Stage 1 implementing on branch
folding (rebased onto canonical main @ 96d0bae). Rev 1 passed a
ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round
2's five majors and four minors; rev 4 fixes round 3's one major, three
minors, and a nit; rev 5 records the settled keybinding decision (Q#FD4) and
approval. See §0 for the per-round changelog.
0. Revision history
Round 1 (rev 1 → rev 2)
- F1 the grid TUI is daemon-rendered and never receives
FoldState; its collapse is instance-side in the daemon grid renderer. Staging reworked. - F2 stored range pinned to a line-aligned interior.
- F3 the store's translation is the instance-side buffer-attached
View(BufferStyleSpanTranslatorpattern), not the frontendtranslate_byte_range. - F4 stale-tree fold creation refuses.
- F5 multi-frontend point / edit-vs-fold pinned.
- F6 explicit-buffer Lua API + validation.
- F7 authoritative-empty
FoldState.
Round 2 (rev 2 → rev 3)
- R2-1 (major). The block-kind heuristic picked a body line as the
fold head on indentation grammars — tree-sitter-python's
blockstarts on the first statement line, sodef foo():was left above a headless fold (verified in review; brace languages escaped only because{shares the introducer line). Fixed with a head-selection ascend rule (Q#FD1, §3), a no-op for brace languages. Acceptance 1 now tests both languages. - R2-2 (major). The interactive-vs-programmatic split cannot live in the
store's
View:View::on_edit(&Buffer, &Edit)(src/overlay.rs:248) andEdit(src/rope.rs) carry no source frontend and no "point was inside" signal — only optionalcrdt_op. TheViewdoes translate + drop only; the unfold is a pre-edit step at the dispatch/command layer that knows the authenticated frontend and its point (Q#FD5, §5). This is the handoff's deferred "origin-pinnedbuffer.after-editfan-out" gap. - R2-3 (major). "CRDT op = remote = translate" misclassifies GPU typing (a GPU user types via CRDT ops but is editing at their own point inside a rendered fold). The classifier is the authenticated source frontend's point, not the transport. Stage 1 implements the unfold for the command path; CRDT-origin unfold is a named Stage 3 obligation (that is when a GPU user can type into a rendered fold). Q#FD5, §5, §8.
- R2-4 (major). Q#FD8 recreated the #120 stale-mirror trap: revert drops
the store, emits
BufferSnapshot, and resets the producer baseline, so the now-empty store is suppressed as "initial empty" and a GPU keeps rendering pre-revert folds unless its snapshot arm clears the fold mirror. That frontend clear is load-bearing; named as a Stage 3 obligation and pinned in acceptance 7 (Q#FD8, §5). Same class as message-gating-on-active-state. - R2-5 (major). A line-aligned tail hid non-member text on shared-closer
lines (
} else {,}, [deps])). Fixed by keeping a closing-delimiter line visible (Q#FD3, §5); delimiter-less (indentation) nodes still hide through their last body line. Decided, not bet. - Minors. (a) unfold is plural — every fold containing the point.
(b) a shared head line (
foo(() => {) toggles innermost-first. (c) Q#FD9's rejection reason corrected:(0,0)is in-bounds; the reject comes from the ≥1-hidden-line rule. (d) Stage 2/3 re-framings must address three named interactions (§8): fold-awareLineNumbers, visible-line viewport/scroll accounting, and hidden-line signs/presence clamp-or-drop.
Round 3 (rev 3 → rev 4)
- R3-1 (major). Rev 3's head-selection ascend was not a no-op for
brace languages: rustfmt wraps long signatures (
fn foo(/a: u32,/) -> bool {) and puts{on its own line under awhereclause, soblock.start_line > parent.start_line, the ascend fired, and the fold hid the wrapped signature — the R2-5 defect class reintroduced one level up. Replaced by a derived head line: the interior comes from the body node alone (closer-aware tail unchanged) and the head is the line immediately above the first hidden line (§3) — Emacs hideshow / LSPfoldingRangeparity; wrapped introducer text now stays visible in both grammar shapes. The introducer↔body association survives for matching andclose-allonly. Acceptance 1 gains wrapped-signature cases. - R3-2 (minor). "Innermost-first" on a shared head line made the outer
fold unreachable via
fold.toggle(close inner, reopen inner, forever) and allowed zero-visible-change presses. Replaced by state-aware ordering with an org-TAB-style toggle cycle (§6); acceptance 9 updated. - R3-3 (minor). Stage 1's "command path" is
dispatch_keyself-insert/delete only; interactive Lua commands (yank, query-replace, comment-toggle) mutate through the Lua mutator path and classify programmatic, so their edits land inside a fold without unfolding. Now stated as the intended Stage 1 line, and widening the classifier to interactive Lua command contexts is a named Stage 2 obligation (Q#FD5, §5, §8), beside Stage 3's CRDT-origin unfold. - R3-4 (minor). The data API's normalization of an arbitrary range was
unstated; §6 now defines it (no node, so no introducer or closer
inference). Nit: stored-range containment pinned start-exclusive,
end-inclusive with the matching
Viewboundary bias, so typing at the end of a head line neither unfolds nor lands hidden (§5; acceptance 6).
Approval + keybindings (rev 4 → rev 5)
- Q#FD4 keybindings decided. rev 4 deferred the binding to the user; the
user chose Emacs hideshow parity, so Stage 1 ships the
C-c @prefix set (§6, §9) —C-c <letter>is fully taken by the LSP surface, and theC-c @hs-minor-mode prefix collides with nothing. This follows the M-;/M-% precedent of shipping faithful Emacs-idiom defaults. - Bet B1 accepted as framed (block-kind target heuristic; curated Tier-1
queries the named fallback). rev 4's architecture is approved; Stage 1
implements on this branch, rebased onto canonical
main@96d0bae.
1. Problem and what ships
Pmacs cannot fold. FoldState was declared in the M11.1 semantic-frontend
design but has never been produced — the producer says "pmacs has no
instance-side fold source yet," and a test pins it is never emitted.
Arc 6 gives pmacs a fold engine (instance-side fold store + a fold source +
Lua commands), renders collapsed regions with a gutter fold marker in both
frontends, and produces FoldState for semantic (GPU) sessions. It is the
roadmap's "keystone gutter rider."
FoldState needs no protocol bump — the variant has been in the encoding
since M11.1 and both frontends already decode it (the TUI drops it, the GPU
has a decode arm); Arc 6 only starts producing it.
Git gutter markers are a SIBLING rider, not this arc (§11).
2. Ground truth (scouted 2026-07-22, main @ cac4961; verified across three review rounds)
FoldState { buffer_id, folds: Vec<ByteRange> }—pmacs-protocol/src/message.rs:886, gated onsemantic_render, DECLARED-BUT-UNPRODUCED.semantic_render.rs:4002pins it is never emitted.BlockAdornments(also unproduced) is the declared home for folded-region placeholders. Arc 6 does not produce it (Q#FD7).- No fold source exists — the bundled grammars export
HIGHLIGHTS/INJECTIONS/LOCALS/TAGSonly, no fold query, nofolds.scm. Fold source is Q#FD1. tree-sitter-python'sblocknode starts on the first statement line, not thedefline (R2-1), and tree-sitter-rust'sblockstarts at{, which rustfmt places below thefnline for wrapped signatures and standalone underwhereclauses (R3-1) — the two facts the derived-head rule answers. - Two frontend render paths (F1). Grid TUI: daemon-rendered
(
render_states→render_state.render_frame,src/daemon.rs:1106), advertisessemantic_render: false(src/frontend.rs:385), never receivesFoldState. GPU: semantic session (semantic_states→sem.render_frame,:1091), does. Collapse is daemon-side for the TUI, wire-fed for the GPU. - Gutter signs are frontend-derived, not a wire channel — fold markers follow suit per path; no new wire type.
- Instance-side edit translation is the buffer-attached
View(BufferStyleSpanTranslator,src/overlay.rs:235; hookon_edit(&Buffer, &Edit)at:248), which sees every edit once, provenance-blind (R2-2):Editcarries onlycrdt_op, no source frontend. - Staleness is detectable —
ParseViewHandle::current()(src/syntax.rs:696) isNonebefore first settle;pending_edit_count()(:706) is nonzero while edits await settle. - Greenfield Lua/commands.
3. Fold source (Q#FD1) — structural node folding with derived head line and closer-aware tail
The grammars ship no fold queries, so pmacs defines "what is foldable." v1 is structural node folding for grammar-backed buffers (reuses the parse trees for every bundled grammar and injection layer, zero per-language authoring). Indentation folding (grammarless fallback) and curated per-language queries (quality pass) are DEFERRED (§11).
The source, at a point:
- Match the nearest enclosing NAMED node spanning ≥2 source lines
(source lines, not display rows — soft wrap is frontend-only and unknowable
instance-side), biased to block-like kinds (
block,body,*_list,declaration_list,statement_block, brace/bracket-delimited nodes). - Resolve introducer↔body (R2-1, R3-1). If the matched node is an
introducer — a
function_definition/if_statement/ … matched from its header lines, whose block-like body child (grammar fieldbody/consequence; feeds B1) starts at or below it — descend to that body child. The interior-defining nodeBis the body; otherwise it is the matched node itself.Bis introduced when its parent is such an introducer. The association exists for matching andclose-allenumeration only —fold.toggleondef foo():or on any wrapped-signature line resolves to the body below; it no longer selects the head line (rev 3's start-line ascend is removed, R3-1). - Head — the line immediately above the first hidden line (R3-1). The
head line is
B.start_line - 1whenBis an introduced, delimiter-less body (a Pythonblock: its introducer's header ends on the line above —def foo():, or the):line when the signature wraps). Otherwise it isB.start_line(a brace body's{line — normally the introducer's own line; the) -> bool {line when rustfmt wraps the signature; the standalone{under awhereclause). The first hidden line ishead_line + 1. Wrapped introducer text always stays visible — rev 3's ascend took the introducer's start line as the head and so hid wrapped signatures andwhereclauses, the R2-5 defect class one level up. This is Emacs hideshow / LSPfoldingRangeparity: the fold hides the body, nothing else. - Tail — closer-aware (R2-5). The last hidden line is:
- if
B's last line begins withB's closing-delimiter token (}/)/], andend-style closers later) — a brace/bracket node — thenB.last_line - 1, keeping the closer line visible. This is what keeps} else {and}, [deps])on screen with their trailing siblings. - else (a delimiter-less node, e.g. a Python
block) —B.last_line, hiding through the last body line.
- if
The stored range is the byte range [end of head_line, end of last-hidden line] (§5). A fold must have ≥1 hidden line: the ≥2-source-line gate is
a match condition on the matched node, foldability is the ≥1-hidden-line
rule on the normalized interior. So fn f() { / } (empty body — the
closer-aware tail leaves nothing between head and closer) is not
foldable, while a two-line def foo(): / x = 1 is: the matched
function_definition spans two lines and its one-line body is the interior.
Stale-tree rule (Q#FD10, F4). The source reads
ParseViewHandle::current(); if it is None (no settle yet) or
pending_edit_count() > 0 (settled coordinates are stale), the fold command
refuses with a status message and stores nothing — a fold is durable state
and must not be computed against stale coordinates. Settle is a main-thread
pump, so the window is sub-frame. Translate-through-pending is a §11
refinement.
The block-kind heuristic (step 1) and the body-field bias (step 2) remain a taste bet (Bet B1); steps 3–4 fixed the determinable defects (R2-1, R2-5, R3-1), which were not taste.
4. Where fold state lives (Q#FD2)
Instance-side, per the wire contract. A per-buffer fold store (a set of
byte ranges) lives beside the buffer, attached as a View (F3). Commands
mutate it; the daemon grid renderer reads it directly (Stage 2); the semantic
producer ships it as FoldState to GPU sessions (Stage 3). Nested folds are
allowed; the store is shared by every attached frontend (Emacs parity).
5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6, Q#FD8)
Stored range = line-aligned hidden interior (Q#FD3). A fold is identified
by its head line (the line immediately above the hidden interior, §3
step 3), which stays visible with a frontend-drawn ellipsis. The stored byte
range is [end of head line, end of the last hidden line], where the last
hidden line is chosen by §3 step 4 — so a closing-delimiter line stays
visible (fixing } else {), while a delimiter-less node hides through its
last body line. One normalized form is computed by the source and seen
identically by the store, the grid renderer, the wire, and folds().
Containment and boundary bias (R3-4 nit). The stored range is
start-exclusive, end-inclusive — (start, end]. A point at
range.start (the end of the head line) is outside the fold: typing
there does not trigger the pre-edit unfold, and the store View translates
an insert at exactly range.start by shifting the fold right (the
BufferStyleSpanTranslator at-or-after bias), so the typed character lands
visible on the head line. A point at range.end (the end of the last hidden
line) is inside: typing there unfolds. One convention covers both the
containment test and the translation bias.
Point and folds (Q#FD3, F5).
- Folding a range containing the invoking frontend's point moves that point to the head line.
- The store is shared, so another frontend's cursor may sit inside a newly folded range. "No cursor inside a fold" is a per-cursor, render-time invariant (its caret clamps to the head on that frontend's next frame — a Stage 2/3 concern). In Stage 1 there is no motion-awareness, so the invariant is creation-time-only; acceptance is scoped to that so it cannot self-contradict.
Edits and folds — two separated mechanisms (Q#FD5, Q#FD6, R2-2, R2-3).
- The store's buffer-attached
Viewdoes translation and drop only, and is provenance-blind: on every edit it translates each fold's range, and drops any fold whose head/tail the edit destroys or whose interior collapses below one line. It cannot unfold-on-typing becauseEditcarries no frontend and no point (R2-2). - Unfolding on an interactive edit is a pre-edit step at the dispatch layer
(which holds the authenticated frontend and its point): before applying an
edit that a frontend is making at its point, unfold every fold
containing that point (plural — minor a). The classifier is the
authenticated source frontend's point, not the transport (R2-3): a GPU
user's CRDT-op insert at a point inside a fold is interactive and must
unfold, even though it arrives as a CRDT op. Stage 1 implements this for
the command path — daemon
dispatch_keyself-insert/delete, the only point-anchored edits the daemon applies directly with the frontend + point in hand. Two widenings are named, each landing with the rendering that makes it user-visible (R3-3):- Interactive Lua command edits — Stage 2 obligation. Yank, query-replace, and comment-toggle mutate through the Lua mutator path, which this split classifies as programmatic: in Stage 1 such an edit inside a fold translates without unfolding. Invisible while headless, but a visible "the yank vanished into the fold" once the TUI collapses — Stage 2 widens the classifier to interactive Lua command contexts, keyed on the edit position.
- CRDT-origin unfold — Stage 3 obligation, wired when the GPU renders folds and a GPU user can type into one.
Store lifecycle vs producer baseline (Q#FD8, F3, R2-4). Three coupled resets, kept distinct:
- The per-session producer suppression baseline resets on
BufferSnapshotso the fold set is re-shipped to a (re)joining semantic session. - The per-buffer store is dropped on buffer content replacement (revert/reload) — its ranges name bytes that no longer exist (revalidation is a §11 refinement).
- The frontend fold mirror must clear on
BufferSnapshot(R2-4, Stage 3 obligation). Revert simultaneously drops the store, emits a snapshot, and resets the baseline, so the producer sees an empty store with a fresh baseline and correctly suppresses it as "initial empty" — which means the GPU keeps rendering pre-revert folds unless its snapshot arm clears fold state, exactly as it already clears spans/decorations. The empty-after-snapshot suppression is correct only because the snapshot clears the frontend mirror; this pairing is load-bearing and is pinned in acceptance 7.
6. Lua command surface and validation (Q#FD4, Q#FD11)
Interactive commands (resolve to the invoking frontend's active-window buffer — command context, not ambient resolution):
fold.toggle,fold.close,fold.open,fold.close-all,fold.open-all.close-allfolds top-level foldable regions only (Emacshs-hide-allparity — nested regions are not auto-folded; feeds B2).open-allclears.- Shared head lines — state-aware ordering (R3-2). On a head line shared
by more than one fold (
foo(() => {), plain "innermost-first" dead-loops: toggle closes the inner fold, then acts on it again and reopens it, forever — the outer fold is unreachable, and opening an inner fold while the outer is closed changes nothing on screen. Ordering is therefore keyed on fold state so every press has a visible effect:fold.closecloses the innermost open fold (repeated presses walk outward);fold.openopens the outermost closed fold (repeated presses walk inward);fold.togglecycles org-TAB-style — it closes the innermost open fold until every fold on the head is closed, then one more press opens them all.
Data API (Q#FD4, F6): explicit buffer, no ambient resolution (matching
#127): pmacs.fold.fold(buffer, range), unfold(buffer, range),
folds(buffer), toggle(buffer, pos).
Arbitrary-range normalization (R3-4). A data-API fold(buffer, range)
carries no node, so none of §3's introducer or closer inference applies —
the caller names exactly what to hide. The head line is the line containing
range.start; the hidden lines are the full lines strictly after it,
through the line containing range.end — or through the previous line
when range.end sits at a line start. The stored form is §5's; validation
then applies.
Validation (Q#FD11, F6). fold(buffer, range) rejects unless: the buffer
is a normal document buffer; both endpoints are UTF-8 boundaries; and the
range normalizes to ≥1 hidden line. Q#FD9 (terminals never fold) follows
from the last clause (minor c): (0,0) on an empty terminal identity buffer
is technically in-bounds, but it normalizes to zero hidden lines and is
rejected — no special case.
Default bindings (Q#FD4) — Emacs hideshow parity. Stage 1 ships the
C-c @ prefix set. C-c <letter> is fully taken by the LSP surface, so the
Emacs hs-minor-mode prefix C-c @ is the one faithful choice that collides
with nothing:
C-c @ C-c→fold.toggle(org-TAB-style cycle)C-c @ C-h→fold.closeC-c @ C-s→fold.openC-c @ C-M-h→fold.close-allC-c @ C-M-s→fold.open-all
These follow the M-;/M-% precedent of shipping faithful Emacs-idiom
defaults; users rebind through pmacs.keymap as usual.
7. Frontend collapse + gutter marker (Q#FD7)
- Grid TUI — daemon-rendered. The daemon grid renderer reads the store and omits each fold's hidden lines, showing the head line with an ellipsis and a gutter fold glyph. No wire (the vterm Stage 2 shape).
- Semantic GPU — wire-fed. The GPU receives
FoldState, excludes the hidden bytes from its shaped slice, shows the head + ellipsis + fold glyph, and makes caret/hit-test fold-aware. It also clears its fold mirror onBufferSnapshot(R2-4).
The placeholder is frontend-local (not a BlockAdornment); the gutter marker
is derived per path like the diagnostic sign bars — no new wire type.
8. Staging and scope
- Stage 1 — fold engine (instance), headless. The per-buffer store + its
translating/dropping
View; the structural source with derived head line, closer-aware tail, and the stale-tree rule; the Lua data API + interactive commands + validation; the command-path pre-edit unfold;FoldStateproduction (authoritative-empty, diff-suppressed); headless acceptance. No rendering. Approval-critical. - Stage 2 — grid (daemon-rendered) collapse + gutter marker. The daemon
grid renderer collapses folded interiors and draws the TUI gutter glyph +
head placeholder; caret clamps to the head. Must also make the
daemon-computed
LineNumbersfamily fold-aware (skipped lines; relative distance measured across a fold), count visible lines in viewport/scroll accounting, clamp-to-head-or-drop diagnostic signs on hidden lines (minor d), and widen the pre-edit interactive unfold to interactive Lua command edits (yank / query-replace / comment-toggle — R3-3). - Stage 3 — GPU collapse + gutter marker. The GPU consumes
FoldState, excludes folded bytes, draws the glyph, makes caret/hit-test fold-aware at TUI parity, clears the fold mirror onBufferSnapshot(R2-4), wires CRDT-origin interactive unfold (R2-3), and applies the same hidden-line rules to peer-presence rects and line numbers (minor d).
Stages 2–3 are sketched; each is re-framed in detail after the prior stage lands. This framing asks approval for the architecture and Stage 1's detail.
9. Numbered decisions
- Q#FD1 Structural node folding: match block-like node ≥2 source lines →
resolve introducer↔body (matching +
close-allonly) → head line = the line immediately above the first hidden line (wrapped introducer text always visible) → closer-aware tail (closing-delimiter line kept visible; delimiter-less nodes hide through the last body line); stale/absent tree refuses. Indentation and curated queries deferred. (§3) - Q#FD2 Fold state is instance-side, per-buffer, a set of ranges, shared by all frontends; nested allowed. (§4)
- Q#FD3 Stored range = line-aligned hidden interior, start-exclusive,
end-inclusive with the matching
Viewboundary bias; head line (the line above the interior) visible; closer line visible for closer-terminated nodes; invoking point moves to the head; no-cursor-inside is per-cursor render-time, creation-time-only in Stage 1. (§5) - Q#FD4 Interactive commands (invoking frontend's buffer; shared head
lines use state-aware ordering — close innermost-open, open
outermost-closed, toggle cycles); data API takes an explicit buffer, no
ambient resolution, with the §6 arbitrary-range normalization; default
bindings = the Emacs hideshow
C-c @prefix set (§6), rebindable. (§6) - Q#FD5 The store
Viewtranslates + drops only (provenance-blind); the pre-edit interactive unfold lives at the dispatch layer, keyed on the authenticated source frontend's point (not transport), unfolding every fold containing it; Stage 1 = command path (dispatch_keyself-insert/delete), interactive-Lua-command widening = Stage 2, CRDT-origin = Stage 3. (§5) - Q#FD6 A fold whose head/tail an edit destroys is dropped, not re-anchored. (§5)
- Q#FD7 Placeholder + gutter marker are frontend-local per path (TUI
daemon-rendered, GPU wire-fed);
BlockAdornmentsstays unproduced; no new wire type. (§7) - Q#FD8
FoldState(semantic sessions only) is whole-buffer, authoritative-empty (initial empty suppressed until a fold exists; unchanged suppressed; non-empty→empty emits exactly one empty frame); its per-session baseline resets onBufferSnapshot; the STORE drops on content replacement; the GPU fold mirror must clear onBufferSnapshotor empty-after-revert suppression leaves stale folds (#120 class). (§5, §8) - Q#FD9 Terminals never fold — from the ≥1-hidden-line validation, not from bounds. (§6)
- Q#FD10 Fold creation against a
None/stale parse tree refuses. (§3) - Q#FD11
fold(buffer, range)validates buffer kind, UTF-8 boundaries, and ≥1 hidden line after the §6 normalization; rejects otherwise. (§6)
10. Bets
- B1 The block-kind heuristic (§3 step 1) and body-field bias (step 2) pick a fold target users find natural. FALSIFIABLE on real Rust/Python; fallback is curated Tier-1 queries. (The derived head line and the closer-aware tail are decided, not bet.)
- B2 Whole-buffer
FoldStateis cheap: folds are a handful,close-allis top-level only, so the set is O(top-level blocks). No viewport scoping. - B3 Both collapse paths reuse existing machinery (daemon cell painting; the GPU's projected↔source map) — no new layout engine.
11. Deferred (named)
- Indentation folding for grammarless buffers (Q#FD1 (C)).
- Curated per-language fold queries (Q#FD1 (A)).
- Translate-a-node-range-through-pending-edits so creation need not refuse on a stale tree (Q#FD10 refinement).
- Fold-store revalidation against new content on revert/reload (v1 drops it).
- Persisted folds across sessions;
fold.hide-level N; auto-fold-on-open. BlockAdornmentsproduction (rich placeholders, diff zones, blame bands).- Git gutter markers — the sibling gutter rider; separate diff source.
- Search revealing folds (a match inside a fold auto-unfolds) — Stage 2+.
12. Acceptance — Stage 1 (engine)
- Head line, both grammar shapes, wrapped headers (R2-1, R3-1). In Rust
fn foo() { … }, a point in the body folds with head linefn foo() {; with a rustfmt-wrapped signature (fn foo(/a: u32,/) -> bool {) the head is the) -> bool {line and every signature line stays visible. In Pythondef foo(): / body, a point in the body folds with head linedef foo():— not a body line; with a wrapped signature the head is the):line and the signature stays visible.close-allon each yields the same heads. - Stale/absent tree (Q#FD10). With
current() == None, and withpending_edit_count() > 0after an edit before settle,fold.togglerefuses and stores nothing; after settle it succeeds. - Commands.
fold.togglefolds the enclosing region and unfolds on a head;close-allfolds top-level regions only (a nested inner region is not auto-folded);open-allclears. - Range semantics (Q#FD3, R2-5). For a brace node the closing-delimiter
line is outside the stored range (stays visible) and a
} else {case keepselse {visible; for a Python node the last body line is inside the range (hidden).folds(buffer)returns exactly the normalized ranges. - Point (Q#FD3). Folding a range containing the invoking point moves it to the head; Stage 1 does not prevent later motion into a fold.
- Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3, R3-3/4). The store
Viewtranslates a fold across a programmatic edit inside it and drops a fold whose head an edit deletes — with no knowledge of source. A command-path self-insert at a point inside a fold (or inside nested folds) unfolds all of them before the edit applies. A self-insert at the end of the head line (point == range.start) unfolds nothing and the fold shifts right — the character lands visible on the head line. An interactive Lua-command edit (e.g. a yank) inside a fold translates without unfolding in Stage 1; the test documents this as the named Stage 2 widening. (CRDT-origin unfold is asserted in Stage 3.) FoldStateproduction (Q#FD8, F7, R2-4). The flipped pin test asserts all three transitions to a semantic session — nothing until a fold exists, nothing when unchanged, exactly one empty frame afteropen-all— whileBlockAdornmentsis still never emitted; the per-session baseline resets onBufferSnapshot. The test documents that empty-after-snapshot suppression is correct only paired with the Stage 3 frontend-mirror clear.- Store lifecycle. Buffer content replacement (revert) drops the store.
- Nested folds (R3-2). Folding an inner then an outer region yields two
ranges;
open-allclears both. On a shared head line: repeatedfold.closecloses innermost-then-outer, repeatedfold.openopens outermost-then-inner, andfold.togglecycles close-inner → close-outer → open-all — the outer fold is reachable by every command. - Injected layer. A fold sourced inside an injected layer — a fenced code block in a markdown buffer — returns the inner block's range, proving the source walks injection layers, not just the root tree.
- Lua data API (Q#FD4/Q#FD11, R3-4).
pmacs.fold.*with an explicit buffer drives the above and round-tripsfolds(); an out-of-bounds, non-boundary, or sub-one-line range is rejected — including a range whoseendsits at the start of the line after its head, which normalizes to zero hidden lines; a fold on a terminal identity buffer is rejected via the ≥1-hidden-line rule (Q#FD9).
13. Gates (Stage 1)
cargo fmt --check; strict workspace Clippy; cargo test --lib and
--features crdt; tests/folding_acceptance.rs (default + CRDT); cargo test --test m4_acceptance -- --skip basedpyright; PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu; the workspace sweep; git diff --check. New behavioral
acceptance is bite-verified with scripts/bite.
14. Branch and PR plan
Branch folding, worktree ../pmacs-folding. This framing (rev 1 → rev 5)
is its opening commits; the branch was rebased onto canonical main @
96d0bae when implementation started (the earlier base cac4961 was
behind after the documentation + tab-width #137 housekeeping; Stage 1's
instance-side scope does not overlap that work). Stage 1 implements on this
same branch and opens as the first folding PR. Stages 2 and 3 are separate
branches/PRs off the main resulting from the prior stage, each with its own
detailed framing.