pmacs/docs/folding-framing.md

22 KiB
Raw Blame History

Folding — framing (Arc 6)

Revision 3 — 2026-07-22. Status: framing only, on branch folding (off canonical main @ cac4961); no implementation. Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round 2's five majors and four minors. 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 (BufferStyleSpanTranslator pattern), not the frontend translate_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 block starts on the first statement line, so def 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) and Edit (src/rope.rs) carry no source frontend and no "point was inside" signal — only optional crdt_op. The View does 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-pinned buffer.after-edit fan-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-aware LineNumbers, visible-line viewport/scroll accounting, and hidden-line signs/presence clamp-or-drop.

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 two review rounds)

  • FoldState { buffer_id, folds: Vec<ByteRange> }pmacs-protocol/src/message.rs:886, gated on semantic_render, DECLARED-BUT-UNPRODUCED. semantic_render.rs:4002 pins 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/TAGS only, no fold query, no folds.scm. Fold source is Q#FD1. tree-sitter-python's block node starts on the first statement line, not the def line (R2-1) — the reason the head-selection rule is required.
  • Two frontend render paths (F1). Grid TUI: daemon-rendered (render_statesrender_state.render_frame, src/daemon.rs:1106), advertises semantic_render: false (src/frontend.rs:385), never receives FoldState. GPU: semantic session (semantic_statessem.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; hook on_edit(&Buffer, &Edit) at :248), which sees every edit once, provenance-blind (R2-2): Edit carries only crdt_op, no source frontend.
  • Staleness is detectableParseViewHandle::current() (src/syntax.rs:696) is None before first settle; pending_edit_count() (:706) is nonzero while edits await settle.
  • Greenfield Lua/commands.

3. Fold source (Q#FD1) — structural node folding with head selection 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:

  1. Match the nearest enclosing NAMED node B 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).
  2. Head selection (R2-1). Ascend: while B's parent introduces B (a function_definition / if_statement / … whose block child is B) and parent.start_line < B.start_line, take the parent as the head node. This makes the introducer line the headdef foo(): on Python, where the block starts a line lower. It is a no-op for brace languages, where { shares the introducer's line (parent.start_line == B.start_line), so the head node stays B and the result is identical.
  3. Tail selection (R2-5). The hidden interior is a whole-line range. Its first hidden line is head_line + 1. Its last hidden line is:
    • if B's last line begins with B's closing-delimiter token (}/)/], and end-style closers later) — a brace/bracket node — then B.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.

The stored range is the byte range [end of head_line, end of last-hidden line] (§5). A node that yields zero hidden lines (e.g. fn f() {} on two lines, empty body) is not foldable.

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) remains a taste bet (Bet B1); step 2 fixed the determinable Python defect, which was 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 introducer, §3 step 2), 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 3 — 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().

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 View does 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 because Edit carries 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_key self-insert/delete, which has the frontend + point); CRDT-origin unfold is a 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 BufferSnapshot so 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). On a head line shared by more than one fold, they act innermost-first (minor b):

  • fold.toggle, fold.close, fold.open, fold.close-all, fold.open-all.
  • close-all folds top-level foldable regions only (Emacs hs-hide-all parity — nested regions are not auto-folded; feeds B2). open-all clears.

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).

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.

Bindings remain the user's call (Emacs has no single convention).

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 on BufferSnapshot (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 head selection, closer-aware tail, and the stale-tree rule; the Lua data API + interactive commands + validation; the command-path pre-edit unfold; FoldState production (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 LineNumbers family fold-aware (skipped lines; relative distance measured across a fold), count visible lines in viewport/scroll accounting, and clamp-to-head-or-drop diagnostic signs on hidden lines (minor d).
  • 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 on BufferSnapshot (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 23 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 → ascend to the introducer headcloser-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; head line 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, innermost-first on shared heads); data API takes an explicit buffer, no ambient resolution; bindings decided by the user. (§6)
  • Q#FD5 The store View translates + 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, 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); BlockAdornments stays 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 on BufferSnapshot; the STORE drops on content replacement; the GPU fold mirror must clear on BufferSnapshot or 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, ≥1 hidden line; rejects otherwise. (§6)

10. Bets

  • B1 The block-kind heuristic (§3 step 1) picks a fold target users find natural. FALSIFIABLE on real Rust/Python; fallback is curated Tier-1 queries. (Head selection and closer-aware tail are now decided, not bet.)
  • B2 Whole-buffer FoldState is cheap: folds are a handful, close-all is 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.
  • BlockAdornments production (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)

  1. Head selection, both grammar shapes (R2-1). In Rust fn foo() { … }, a point in the body folds with head line fn foo() {. In Python def foo(): / body, a point in the body folds with head line def foo():not a body line. close-all on each yields the introducer as head.
  2. Stale/absent tree (Q#FD10). With current() == None, and with pending_edit_count() > 0 after an edit before settle, fold.toggle refuses and stores nothing; after settle it succeeds.
  3. Commands. fold.toggle folds the enclosing region and unfolds on a head; close-all folds top-level regions only (a nested inner region is not auto-folded); open-all clears.
  4. 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 keeps else { visible; for a Python node the last body line is inside the range (hidden). folds(buffer) returns exactly the normalized ranges.
  5. 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.
  6. Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3). The store View translates 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. (CRDT-origin unfold is asserted in Stage 3.)
  7. FoldState production (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 after open-all — while BlockAdornments is still never emitted; the per-session baseline resets on BufferSnapshot. The test documents that empty-after-snapshot suppression is correct only paired with the Stage 3 frontend-mirror clear.
  8. Store lifecycle. Buffer content replacement (revert) drops the store.
  9. Nested folds. Folding an inner then an outer region yields two ranges; open-all clears both; a shared head line toggles innermost-first.
  10. 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.
  11. Lua data API (Q#FD4/Q#FD11). pmacs.fold.* with an explicit buffer drives the above and round-trips folds(); an out-of-bounds, non-boundary, or sub-one-line range is rejected; 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, off canonical main @ cac4961. This framing (rev 1 → rev 3) is its opening commits. After approval, 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.