docs(folding): framing rev 2 — address review round 1
Seven findings from the first review, all fixed: F1 (architectural): the grid TUI is daemon-rendered and never receives FoldState (advertises semantic_render:false), so its fold collapse is instance-side work in the daemon grid renderer reading the store directly — the vterm Stage 2 shape. FoldState on the wire serves only semantic (GPU) sessions. Staging reworked: Stage 2 = grid/daemon rendering, Stage 3 = wire-fed GPU. F2: stored range pinned to the line-aligned hidden interior (head line visible, closing-delimiter line hidden); one normalized form everywhere, resolving the R1 5/7 contradiction. F3: the store's edit-translation is the instance-side buffer-attached View (BufferStyleSpanTranslator pattern, overlay.rs:235), which sees every real edit — not the frontend-side translate_byte_range. Split the two resets: per-session producer baseline vs per-buffer store lifecycle on content replacement. F4: stale-tree fold creation refuses with a message when ParseViewHandle::current() is None or pending_edit_count() > 0. F5: multi-frontend point + edit-vs-fold pinned — invoking point moves to the head; interactive-point-inside edits unfold, programmatic/remote edits translate; the no-cursor-inside invariant is creation-time-only in Stage 1. F6: Lua data API takes an explicit buffer (no ambient resolution, per #127) with full range validation, which is also what makes terminals never fold. F7: FoldState follows the authoritative-empty discipline; open-all emits exactly one empty frame; the flipped pin test keeps asserting BlockAdornments is never emitted. Minors: source lines not display rows; close-all folds top-level only (hs-hide-all parity); an injected-layer (markdown fence) acceptance added; and an explicit note that FoldState needs no protocol bump.
This commit is contained in:
parent
55f5a2e6aa
commit
b7a865d120
|
|
@ -1,12 +1,48 @@
|
|||
# Folding — framing (Arc 6)
|
||||
|
||||
**Draft Revision 1 — 2026-07-22. Status: framing only, committed to the
|
||||
`folding` branch for review; no implementation yet.** Branch `folding` is cut
|
||||
from canonical `main` @ `cac4961` (Vterm Stage 3 #135 merged, atop tab-width
|
||||
parity #137 and locals-query #134), so Arc 5's terminal stage is complete and
|
||||
this scout reflects that tree. Expect one to three findings rounds on this
|
||||
document before implementation; revise the doc, then implement Stage 1 on this
|
||||
same branch.
|
||||
**Revision 2 — 2026-07-22. Status: framing only, on branch `folding`
|
||||
(off canonical `main` @ `cac4961`); no implementation.** Revision 1 passed
|
||||
a ground-truth review (every scouted claim verified against the tree) but the
|
||||
reviewer found one architectural mis-framing and six spec gaps. Revision 2
|
||||
fixes all of them; see §0 for the changelog.
|
||||
|
||||
## 0. Revision 2 — review round 1 resolutions
|
||||
|
||||
- **F1 (architectural).** R1's Stage 2 said "the TUI consumes `FoldState`."
|
||||
It cannot: the grid TUI advertises `semantic_render: false`
|
||||
(`src/frontend.rs:385`), so the per-session outgoing filter never sends the
|
||||
`FoldState` family to it, and the TUI has no layout of its own — the daemon
|
||||
renders its cell grid (`render_states` vs `semantic_states`,
|
||||
`src/daemon.rs:875`/`881`; the grid path is `render_state.render_frame(...)`
|
||||
at `:1106`). **The TUI collapse is instance-side rendering in the daemon
|
||||
grid renderer, reading the fold store directly — no wire.** `FoldState` on
|
||||
the wire serves ONLY semantic (GPU) sessions. Staging reworked accordingly
|
||||
(§8): Stage 2 is grid/daemon rendering; Stage 3 is the wire-fed GPU.
|
||||
- **F2.** Stored range semantics pinned: the store holds **line-aligned
|
||||
interior** ranges (Q#FD3, §5); the head line stays visible, the interior
|
||||
including the closing-delimiter line is hidden. §7 rewritten to match.
|
||||
- **F3.** The fold store's edit-translation is the **instance-side
|
||||
buffer-attached `View`** (`BufferStyleSpanTranslator` pattern,
|
||||
`src/overlay.rs:235`), which sees every real edit regardless of source — not
|
||||
the frontend-side `translate_byte_range`. The two "resets" are split:
|
||||
per-session producer baseline vs per-buffer store lifecycle (Q#FD8, §5).
|
||||
- **F4.** Stale-tree fold creation pinned: read `ParseViewHandle::current()`
|
||||
(`src/syntax.rs:696`); if it is `None` or `pending_edit_count() > 0`
|
||||
(`:706`), **refuse with a status message** in v1 (Q#FD10, §3).
|
||||
- **F5.** Multi-frontend point + edit-vs-fold rules pinned (Q#FD3, Q#FD5, §5):
|
||||
the invoking frontend's point moves to the head; interactive-point-inside
|
||||
edits unfold, programmatic/remote edits translate; the invariant is
|
||||
creation-time-only in Stage 1.
|
||||
- **F6.** Lua surface takes an **explicit buffer** (no ambient resolution,
|
||||
per #127), with full range **validation** (Q#FD4, Q#FD11, §6) — which is
|
||||
also what makes Q#FD9 (terminals never fold) hold.
|
||||
- **F7.** `FoldState` follows the authoritative-empty discipline; the
|
||||
non-empty→empty transition (open-all) emits exactly one empty frame
|
||||
(Q#FD8, acceptance 4).
|
||||
- **Minors.** "≥2 source lines" not display rows; `close-all` folds
|
||||
top-level only (Emacs `hs-hide-all` parity, feeds B2); an injected-layer
|
||||
fold acceptance added; and an explicit note that `FoldState` needs **no
|
||||
protocol bump**.
|
||||
|
||||
## 1. Problem and what ships
|
||||
|
||||
|
|
@ -16,45 +52,60 @@ so many words that "pmacs has no instance-side fold source yet," and a test
|
|||
pins that `FoldState` is never emitted.
|
||||
|
||||
Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source +
|
||||
Lua commands), produces `FoldState`, and renders collapsed regions with a
|
||||
gutter fold marker in both frontends. It is the roadmap's "keystone gutter
|
||||
rider": it lights up an already-declared wire family, adds the fold-marker
|
||||
rider beside the existing diagnostic signs, and is a visible feature.
|
||||
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": it lights up an already-declared wire
|
||||
family, adds the fold-marker rider beside the existing diagnostic signs, and
|
||||
is a visible feature.
|
||||
|
||||
**`FoldState` needs no protocol bump.** The variant has been in the wire
|
||||
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. No
|
||||
`PROTOCOL_VERSION` change, no `SUPPORTED` change.
|
||||
|
||||
**Git gutter markers are a SIBLING rider, not this arc.** They ride the same
|
||||
gutter but need a diff source, which is unrelated to folding. Named as a
|
||||
deferral (§11), framed separately.
|
||||
gutter but need a diff source, unrelated to folding. Named as a deferral
|
||||
(§11), framed separately.
|
||||
|
||||
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`)
|
||||
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified in review)
|
||||
|
||||
- **`FoldState { buffer_id, folds: Vec<ByteRange> }`** exists in
|
||||
`pmacs-protocol/src/message.rs`, gated on `semantic_render`,
|
||||
DECLARED-BUT-UNPRODUCED. Its doc: *"the instance's authoritative fold set as
|
||||
`pmacs-protocol/src/message.rs:886`, gated on `semantic_render`,
|
||||
DECLARED-BUT-UNPRODUCED. Doc: *"the instance's authoritative fold set as
|
||||
document facts. Folding is an instance command-semantics concern (Lua can
|
||||
fold); the visual collapse is a frontend layout concern — the frontend
|
||||
renders the placeholder and adjusts its own layout."* So the **fold set is
|
||||
instance-side state** and the **collapse is frontend layout**.
|
||||
- `semantic_render.rs::block_adornments_and_fold_state_still_never_emitted`
|
||||
asserts `FoldState`/`BlockAdornments` are never sent (not even empty). Stage
|
||||
1 flips this test.
|
||||
fold); the visual collapse is a frontend layout concern."*
|
||||
- `semantic_render.rs:4002`
|
||||
(`block_adornments_and_fold_state_still_never_emitted`) asserts
|
||||
`FoldState`/`BlockAdornments` are never sent. Stage 1 flips this to assert
|
||||
`FoldState` IS produced while `BlockAdornments` stays unemitted (F7).
|
||||
- **`BlockAdornments`** (also unproduced) is the declared home for
|
||||
"folded-region placeholders." Arc 6 does **not** produce it — the fold
|
||||
placeholder is frontend-local (Q#FD7).
|
||||
- **No fold source exists.** The bundled grammars export
|
||||
`HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — **no fold query and
|
||||
no `folds.scm`** in any grammar crate. The roadmap's "tree-sitter fold
|
||||
ranges" is therefore not free; the fold source is the load-bearing decision
|
||||
(Q#FD1).
|
||||
- **No frontend consumes `FoldState`.** The TUI drops it in `frontend.rs`'s
|
||||
ignored set; the GPU has only a debug label. Both must add consumption.
|
||||
`HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — no fold query and
|
||||
no `folds.scm` (`LanguageEntry` has exactly those fields). The roadmap's
|
||||
"tree-sitter fold ranges" is not free; the fold source is Q#FD1.
|
||||
- **Two frontend render paths, not one (F1).** The grid TUI is daemon-rendered
|
||||
(`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`) and
|
||||
advertises `semantic_render: false` (`src/frontend.rs:385`), so it never
|
||||
receives `FoldState`. The GPU is a semantic session (`semantic_states` →
|
||||
`sem.render_frame`, `:1091`) and does receive it. Fold collapse is therefore
|
||||
daemon-side for the TUI and wire-fed for the GPU.
|
||||
- **Gutter signs are frontend-derived, not a wire channel.** The GPU's
|
||||
`collect_gutter_sign_rects` computes diagnostic sign bars locally from the
|
||||
decorations it already holds. Fold markers follow the same model: derived
|
||||
frontend-locally from `FoldState`; **no new wire type**.
|
||||
- **Edit-translation is a solved discipline.** Style spans and decorations are
|
||||
already translated through `translate_byte_range` on every optimistic edit
|
||||
and reset on `BufferSnapshot`. Fold ranges reuse it, plus an invalidation
|
||||
rule when an edit destroys a fold's structure (Q#FD6).
|
||||
`collect_gutter_sign_rects` computes diagnostic sign bars locally; the TUI's
|
||||
are painted daemon-side. Fold markers follow the same model per path — no new
|
||||
wire type.
|
||||
- **Instance-side edit translation is a solved pattern (F3).** Compile-mode's
|
||||
`BufferStyleSpanTranslator` (`src/overlay.rs:235`) is a buffer-attached
|
||||
`View` that sees every real edit — commands, CRDT ops, LSP workspace edits,
|
||||
Lua — once per edit, fragment-preserving. The fold store attaches the same
|
||||
kind of `View`. The frontend-side `translate_byte_range`
|
||||
(`pmacs-gpu/src/main.rs`) is a *different* thing (the GPU translating its own
|
||||
received copies across optimistic edits) and is not the store's mechanism.
|
||||
- **Staleness is detectable (F4).** `ParseViewHandle::current()`
|
||||
(`src/syntax.rs:696`) returns `None` before the first settle and the latest
|
||||
settled bundle otherwise; `pending_edit_count()` (`:706`) is nonzero while
|
||||
edits await settle.
|
||||
- **Greenfield Lua/commands.** No existing fold surface or commands.
|
||||
|
||||
## 3. Fold source (Q#FD1) — the load-bearing decision
|
||||
|
|
@ -63,151 +114,225 @@ The grammars ship no fold queries, so "what is foldable" must be defined by
|
|||
pmacs. Three options:
|
||||
|
||||
- **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture
|
||||
model). Highest quality, matches the tree-sitter investment — but per
|
||||
language authoring plus ongoing maintenance, and it is exactly the work the
|
||||
grammars declined to ship.
|
||||
model). Highest quality — but per-language authoring plus ongoing
|
||||
maintenance, exactly the work the grammars declined to ship.
|
||||
- **(B) Structural node folding.** At a point, fold the nearest enclosing
|
||||
NAMED node that spans ≥2 display rows, biased to block-like kinds by a small
|
||||
shared heuristic on node-kind names (`block`, `body`, `*_list`,
|
||||
`declaration_list`, `statement_block`, brace/bracket-delimited nodes).
|
||||
Reuses the parse trees already present for every bundled grammar AND every
|
||||
injection layer. Zero per-language authoring.
|
||||
- **(C) Indentation folding.** Fold the maximal run of lines more-indented
|
||||
than a header line. Language-agnostic, predictable, works with **no grammar
|
||||
at all** (plain text, unknown languages), but ignores syntax.
|
||||
NAMED node that spans **≥2 source lines** (F-minor: source lines, not
|
||||
display rows — soft wrap is frontend layout and unknowable instance-side),
|
||||
biased to block-like kinds by a small shared heuristic on node-kind names
|
||||
(`block`, `body`, `*_list`, `declaration_list`, `statement_block`,
|
||||
brace/bracket-delimited nodes). Reuses the parse trees already present for
|
||||
every bundled grammar AND every injection layer. Zero per-language authoring.
|
||||
- **(C) Indentation folding.** Language-agnostic, works with no grammar,
|
||||
predictable, but ignores syntax.
|
||||
|
||||
**Recommendation: (B) structural node folding for grammar-backed buffers as
|
||||
the v1 engine.** It reuses tree-sitter, needs no per-language work, and folds
|
||||
the whole set of bundled grammars and injections on day one. (C) indentation
|
||||
folding is the right **grammarless fallback** but is DEFERRED so Stage 1 stays
|
||||
scoped to grammar buffers; (A) curated queries are a later **quality pass**,
|
||||
DEFERRED. This is the honest adjustment to the roadmap's premise: tree-sitter
|
||||
still drives folding, but via node structure rather than queries that do not
|
||||
exist.
|
||||
the v1 engine.** Reuses tree-sitter, no per-language work, covers all bundled
|
||||
grammars and injection layers day one. (C) is the grammarless fallback,
|
||||
DEFERRED so Stage 1 stays scoped to grammar buffers; (A) is a later quality
|
||||
pass, DEFERRED.
|
||||
|
||||
Open sub-question for review: the block-kind heuristic in (B) is the part most
|
||||
likely to feel wrong ("it folded the tiny inner block, not the function").
|
||||
The bet (§10) is that "nearest enclosing block-like node spanning ≥2 rows" is
|
||||
predictable enough for v1; the fallback if it isn't is (A) for the handful of
|
||||
Tier-1 languages.
|
||||
**Stale-tree rule (Q#FD10, F4).** The source reads
|
||||
`ParseViewHandle::current()`. If it is `None` (no settle yet) or
|
||||
`pending_edit_count() > 0` (the settled tree's coordinates are stale relative
|
||||
to the current buffer), a 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 sub-frame, so the refuse window is tiny.
|
||||
Translate-the-node-range-through-pending-edits is a named refinement (§11).
|
||||
|
||||
The block-kind heuristic (B) is the part most likely to feel wrong ("it folded
|
||||
the tiny inner block, not the function"); Bet B1 (§10) states it and names the
|
||||
fallback (curated Tier-1 queries).
|
||||
|
||||
## 4. Where fold state lives (Q#FD2)
|
||||
|
||||
Instance-side, per the wire contract. `EditorCore` (or a sibling store) owns a
|
||||
**per-buffer set of folded byte ranges**. Commands mutate it; the semantic
|
||||
producer ships it as `FoldState`; each frontend collapses the union of folded
|
||||
ranges in its own layout. Nested folds are allowed — the store is a set, and
|
||||
the frontend collapses the union, so a fold inside a fold is just two ranges.
|
||||
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 to collapse the TUI
|
||||
(Stage 2); the semantic producer ships it as `FoldState` to GPU sessions
|
||||
(Stage 3). Nested folds are allowed — the store is a set, consumers collapse
|
||||
the union.
|
||||
|
||||
## 5. Fold model semantics (Q#FD3)
|
||||
The store is **shared by every attached frontend** (Emacs parity): folds are a
|
||||
document-level view fact, not per-window. Per-cursor consequences of that
|
||||
sharing are pinned in §5 (F5).
|
||||
|
||||
- **Head-anchored, byte-range folds.** A fold is `[start, end)` where `start`
|
||||
is the byte at the fold-head line's content and `end` is one past the last
|
||||
folded byte. The head line stays visible; the interior collapses.
|
||||
- **Cursor cannot sit inside a fold.** Folding a range that contains point
|
||||
moves point to the fold head (Emacs `hs-minor-mode` behavior). Editing
|
||||
commands that would enter a fold either skip it or unfold it — Q#FD5.
|
||||
- **Edits translate folds** through the existing `translate_byte_range`, and a
|
||||
fold whose head or tail is destroyed by an edit (e.g. the head line deleted)
|
||||
is dropped rather than re-anchored (Q#FD6). Dropping is safe: a fold is a
|
||||
view convenience, never data.
|
||||
## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6)
|
||||
|
||||
## 6. Lua command surface (Q#FD4)
|
||||
**Stored range = line-aligned interior (Q#FD3, F2).** A fold is identified by
|
||||
its **head line** H. The stored/shipped byte range is the **hidden interior**:
|
||||
from the newline that terminates H through the end of the last source line the
|
||||
folded region spans. So:
|
||||
|
||||
Greenfield, mirroring the comment/kill-ring command style:
|
||||
- H (with its opener, e.g. `fn foo() {`) **stays visible**, with a
|
||||
frontend-drawn ellipsis at its end.
|
||||
- The interior lines **and the closing-delimiter line** (`}`) are **hidden**
|
||||
— the range ends at the end of the line containing the region's last byte.
|
||||
- The structural source yields a raw node span `[node.start, node.end)`; the
|
||||
store **normalizes** it to this line-aligned interior before anything else
|
||||
(renderer, wire, `folds()`) sees it. One normalized form, one meaning,
|
||||
everywhere — resolving the R1 §5/§7 contradiction.
|
||||
|
||||
**Point and folds (Q#FD3, F5).**
|
||||
- Folding a range that contains the **invoking frontend's** point moves that
|
||||
point to the head line H (Emacs `hs-minor-mode`).
|
||||
- The store is shared, so **another** frontend's cursor may already sit inside
|
||||
a newly folded range. "A cursor cannot sit inside a fold" is a **per-cursor,
|
||||
render-time** invariant: on that frontend's next frame the caret clamps to H
|
||||
(a Stage 2/3 rendering concern). In **Stage 1** there is no motion-awareness
|
||||
(deferred to Stage 2/3), so the invariant is **creation-time-only**: folding
|
||||
moves the invoking point out, but later motion re-entering a fold is not yet
|
||||
prevented. Acceptance is written to that scope so it cannot self-contradict.
|
||||
|
||||
**Edits and folds (Q#FD5, Q#FD6, F5).** The store's buffer-attached `View`
|
||||
(F3) sees every edit:
|
||||
- An **interactive edit at the invoking frontend whose point is inside a
|
||||
fold** unfolds that fold first — you cannot type into hidden text you cannot
|
||||
see.
|
||||
- A **programmatic or remote edit** (a peer CRDT op, an LSP workspace edit, a
|
||||
Lua buffer edit) **translates** the fold through the `View`, keeping it
|
||||
folded — it is not a person typing into the hidden region.
|
||||
- A fold whose head or tail an edit **destroys** (e.g. the head line deleted,
|
||||
or the range collapses below one hidden line) is **dropped**, not
|
||||
re-anchored — a fold is view state, never data.
|
||||
|
||||
**Store lifecycle vs producer baseline (Q#FD8, F3).** Two distinct resets,
|
||||
previously conflated:
|
||||
- The **per-session producer suppression baseline** resets on `BufferSnapshot`
|
||||
so the fold set is re-shipped to a (re)joining semantic session — the
|
||||
established producer discipline.
|
||||
- The **per-buffer fold store** is dropped or revalidated on buffer **content
|
||||
replacement** (revert/reload): the ranges describe bytes that no longer
|
||||
exist, so revert clears the store (revalidation against the new content is a
|
||||
§11 refinement).
|
||||
|
||||
## 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 the enclosing foldable region at point, or unfold if
|
||||
point's line is a fold head.
|
||||
- `fold.close` / `fold.open` — explicit fold/unfold at point.
|
||||
- `fold.close-all` / `fold.open-all` — fold every foldable region in the
|
||||
buffer / clear the fold set.
|
||||
- `pmacs.fold` Lua surface: `fold(range?)`, `unfold(range?)`, `folds()`,
|
||||
`toggle()` — so Lua can drive folding (the wire contract's "Lua can fold").
|
||||
- `fold.close-all` / `fold.open-all` — fold every **top-level** foldable
|
||||
region (Emacs `hs-hide-all` parity — nested regions are not auto-folded;
|
||||
see B2) / clear the fold set.
|
||||
|
||||
Bindings are deliberately left for the review round — Emacs uses `C-x C-z` /
|
||||
`hs-*` / outline `C-c @`; pmacs has no precedent, so the binding is a decision
|
||||
for the user, not a default I pick.
|
||||
**Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching
|
||||
#127's deliberate refusal of ambient-buffer lookup):
|
||||
|
||||
- `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`,
|
||||
`folds(buffer) -> {range,...}`, `toggle(buffer, pos)`.
|
||||
|
||||
**Validation (Q#FD11, F6).** `fold(buffer, range)` validates and rejects
|
||||
otherwise: the buffer exists and is a normal document buffer; the range is
|
||||
in-bounds; both endpoints are UTF-8 char boundaries; the range normalizes
|
||||
(§5) to **at least one hidden line**. This validation is what makes Q#FD9
|
||||
hold: a terminal identity buffer is empty, so every range is out-of-bounds and
|
||||
rejected — no fold can be stored on a terminal even from Lua, with no special
|
||||
case.
|
||||
|
||||
Bindings are left for this review round — Emacs uses `C-x C-z` / `hs-*` /
|
||||
outline `C-c @`; pmacs has no precedent, so the binding is the user's call.
|
||||
|
||||
## 7. Frontend collapse + gutter marker (Q#FD7)
|
||||
|
||||
- **The collapse is frontend-local layout.** The frontend receives the fold
|
||||
set and removes folded byte ranges from what it lays out: the TUI skips the
|
||||
folded display rows; the GPU excludes the folded bytes from its shaped code
|
||||
slice. The head line shows a **placeholder** (e.g. `⋯` or ` ⋯ N lines `) —
|
||||
frontend-drawn, **not** a `BlockAdornment` (Q#FD7 keeps `BlockAdornments`
|
||||
unproduced).
|
||||
- **The gutter marker is frontend-derived from `FoldState`**, exactly like the
|
||||
diagnostic sign bars: a fold-head line draws an open/closed fold glyph in the
|
||||
gutter. No new wire type.
|
||||
- **Caret/hit-test cross folds.** Clicking or arrowing across a fold-head skips
|
||||
the folded bytes; the existing GPU projected↔source maps gain a fold-aware
|
||||
step. This is the largest per-frontend cost and is why the frontends are
|
||||
separate stages.
|
||||
Two paths (F1):
|
||||
|
||||
- **Grid TUI — daemon-rendered.** The daemon grid renderer reads the fold
|
||||
store directly and omits each fold's hidden interior from the cells it
|
||||
paints, showing H with an ellipsis; it draws the gutter fold glyph on H.
|
||||
No wire, same shape as vterm Stage 2's daemon-painted terminal cells.
|
||||
- **Semantic GPU — wire-fed.** The GPU receives `FoldState`, excludes the
|
||||
hidden bytes from its shaped code slice, shows H with an ellipsis, and draws
|
||||
the fold glyph on H. Caret/hit-test gain a fold-aware step (the largest
|
||||
per-frontend cost, and why the GPU is its own stage).
|
||||
|
||||
In both paths the placeholder is **frontend-local** (an ellipsis / ` ⋯ N
|
||||
lines `), **not** a `BlockAdornment` — Q#FD7 keeps `BlockAdornments`
|
||||
unproduced. The gutter marker is derived from the fold set per path, like the
|
||||
diagnostic sign bars — no new wire type.
|
||||
|
||||
## 8. Staging and scope
|
||||
|
||||
Mirrors vterm: one useful, independently testable stage per PR.
|
||||
Mirrors vterm; reworked for F1 (the TUI path is daemon-side, not a wire
|
||||
consumer).
|
||||
|
||||
- **Stage 1 — fold engine (instance).** The fold model, the structural fold
|
||||
source, the Lua/command surface, `FoldState` production (whole-buffer,
|
||||
diff-suppressed, edit-translated, snapshot-reset), and headless acceptance.
|
||||
No frontend rendering — `FoldState` is asserted on the wire, not on screen.
|
||||
This is the approval-critical stage.
|
||||
- **Stage 2 — TUI collapse + gutter marker.** The TUI consumes `FoldState`,
|
||||
collapses folded rows, draws the gutter fold glyph and the head placeholder,
|
||||
and makes cursor motion fold-aware.
|
||||
- **Stage 1 — fold engine (instance), headless.** The per-buffer fold store +
|
||||
its buffer-attached translating `View`; the structural fold source with the
|
||||
stale-tree rule; the Lua data API + interactive commands + validation;
|
||||
`FoldState` production for semantic sessions (authoritative-empty,
|
||||
diff-suppressed); and headless acceptance. No rendering — folds are asserted
|
||||
in the store and on the wire, not on screen. **Approval-critical.**
|
||||
- **Stage 2 — grid (daemon-rendered) collapse + gutter marker.** The daemon
|
||||
grid renderer collapses folded interiors and draws the TUI gutter fold glyph
|
||||
+ head placeholder; caret handling clamps to H. Instance-side rendering
|
||||
work; no wire change.
|
||||
- **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`,
|
||||
excludes folded bytes from its shaped slice, draws the fold glyph + caret/hit
|
||||
fold-awareness, at TUI parity.
|
||||
excludes folded bytes from its shaped slice, draws the fold glyph and makes
|
||||
caret/hit-test fold-aware, at TUI parity.
|
||||
|
||||
Stages 2–3 are sketched here and **re-framed in detail after Stage 1 lands**,
|
||||
exactly as vterm did. This framing asks approval for the overall architecture
|
||||
and Stage 1's full detail.
|
||||
Stages 2–3 are sketched here and re-framed in detail after Stage 1 lands.
|
||||
This framing asks approval for the architecture and Stage 1's full detail.
|
||||
|
||||
## 9. Numbered decisions
|
||||
|
||||
- **Q#FD1** Fold source: structural tree-sitter node folding (v1); indentation
|
||||
fallback and curated queries deferred. (§3)
|
||||
- **Q#FD2** Fold state is instance-side, per-buffer, a set of byte ranges;
|
||||
nested folds allowed. (§4)
|
||||
- **Q#FD3** Head-anchored byte-range folds; point cannot sit inside a fold;
|
||||
edits translate, structure-destroying edits drop. (§5)
|
||||
- **Q#FD4** Command surface `fold.toggle/close/open/close-all/open-all` +
|
||||
`pmacs.fold`; bindings decided in review. (§6)
|
||||
- **Q#FD5** Entering a fold: motion skips it; an edit that targets inside an
|
||||
existing fold unfolds it first. (Detail deferred to Stage 2 framing.)
|
||||
- **Q#FD1** Fold source: structural tree-sitter node folding (v1);
|
||||
indentation fallback and curated queries deferred. (§3)
|
||||
- **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared
|
||||
by all frontends; nested folds allowed. (§4)
|
||||
- **Q#FD3** Stored range is the line-aligned hidden interior (head line
|
||||
visible, closing-delimiter line hidden); the invoking point moves to the
|
||||
head; "no cursor inside a fold" is a per-cursor render-time invariant,
|
||||
creation-time-only in Stage 1. (§5)
|
||||
- **Q#FD4** Interactive commands `fold.toggle/close/open/close-all/open-all`
|
||||
(invoking frontend's active buffer); data API `pmacs.fold.*` takes an
|
||||
explicit buffer, no ambient resolution; bindings decided in review. (§6)
|
||||
- **Q#FD5** Interactive edit with point inside a fold unfolds it first;
|
||||
programmatic/remote edits translate the fold. (§5)
|
||||
- **Q#FD6** A fold whose head/tail an edit destroys is dropped, not
|
||||
re-anchored — folds are view state, never data. (§5)
|
||||
- **Q#FD7** Placeholder + gutter marker are frontend-local, derived from
|
||||
`FoldState`; `BlockAdornments` stays unproduced; no new wire type. (§7)
|
||||
- **Q#FD8** `FoldState` is whole-buffer (folds are sparse and shift line
|
||||
numbers above the viewport), diff-suppressed (cached-compare), and reset on
|
||||
`BufferSnapshot` — the established producer discipline. (§8, Stage 1)
|
||||
- **Q#FD9** Terminal identity buffers (read-only, #135) never fold; the
|
||||
producer already suppresses the document family in terminal mode, so no
|
||||
special case is needed.
|
||||
re-anchored. (§5)
|
||||
- **Q#FD7** Placeholder + gutter marker are frontend-local per path; the TUI
|
||||
path is daemon-rendered, the GPU path wire-fed; `BlockAdornments` stays
|
||||
unproduced; no new wire type. (§7)
|
||||
- **Q#FD8** `FoldState` (to 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), and its
|
||||
per-session baseline resets on `BufferSnapshot`. The per-buffer STORE is a
|
||||
separate lifecycle, dropped on buffer content replacement. (§5, §8)
|
||||
- **Q#FD9** Terminal identity buffers never fold — guaranteed by validation
|
||||
(empty buffer ⇒ out-of-bounds ⇒ rejected), not a special case. (§6)
|
||||
- **Q#FD10** Fold creation against a `None` or stale
|
||||
(`pending_edit_count() > 0`) parse tree refuses with a message; no fold is
|
||||
stored. (§3)
|
||||
- **Q#FD11** Explicit-`fold(buffer, range)` validates buffer kind, bounds,
|
||||
UTF-8 boundaries, and ≥1 hidden line; rejects otherwise. (§6)
|
||||
|
||||
## 10. Bets
|
||||
|
||||
- **B1** "Nearest enclosing block-like node spanning ≥2 rows" is predictable
|
||||
enough for a v1 fold without curated queries. FALSIFIABLE: if the review
|
||||
finds the fold target surprising on real Rust/Python, fall back to curated
|
||||
queries for Tier-1 languages.
|
||||
- **B2** Whole-buffer `FoldState` is cheap enough (folds are a handful, not
|
||||
O(lines)); no viewport scoping needed. FALSIFIABLE by a fold-all on a huge
|
||||
file — but fold-all produces one range per block, still far below the style
|
||||
span volume the producer already ships.
|
||||
- **B3** The frontend collapse can reuse each renderer's existing
|
||||
projected↔source machinery (the GPU already has `translate_byte_range` and a
|
||||
projected-run map for adornments) rather than a new layout engine.
|
||||
- **B1** "Nearest enclosing block-like node spanning ≥2 source lines" is
|
||||
predictable enough for a v1 fold without curated queries. FALSIFIABLE: if the
|
||||
review finds the fold target surprising on real Rust/Python, fall back to
|
||||
curated queries for Tier-1 languages.
|
||||
- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, and
|
||||
`close-all` folds **top-level only** (Q#FD4), so the set is O(top-level
|
||||
blocks), far below the style-span volume the producer already ships. No
|
||||
viewport scoping.
|
||||
- **B3** The two collapse paths reuse existing machinery: the daemon grid
|
||||
renderer already paints cells from instance state (vterm Stage 2), and the
|
||||
GPU already has a projected↔source map for adornments — neither needs a new
|
||||
layout engine.
|
||||
|
||||
## 11. Deferred (named)
|
||||
|
||||
- Indentation folding for grammarless buffers (the Q#FD1 (C) fallback).
|
||||
- Curated per-language fold queries (the Q#FD1 (A) quality pass).
|
||||
- Indentation folding for grammarless buffers (Q#FD1 (C)).
|
||||
- Curated per-language fold queries (Q#FD1 (A)).
|
||||
- Translate-a-node-range-through-pending-edits so fold creation need not refuse
|
||||
on a stale tree (Q#FD10 refinement).
|
||||
- Fold-store revalidation against new content on revert/reload (Q#FD8: v1
|
||||
drops the store).
|
||||
- Persisted folds across sessions (saveplace-style).
|
||||
- `fold.hide-level N` / outline-style folding by depth.
|
||||
- Fold-on-open (auto-fold imports/license headers) — needs a policy.
|
||||
- `fold.hide-level N` / outline-style folding by depth; auto-fold-on-open.
|
||||
- `BlockAdornments` production (rich placeholders, diff zones, blame bands).
|
||||
- **Git gutter markers** — the sibling gutter rider; separate diff source,
|
||||
separate framing.
|
||||
|
|
@ -215,23 +340,41 @@ and Stage 1's full detail.
|
|||
|
||||
## 12. Acceptance — Stage 1 (engine)
|
||||
|
||||
1. Structural fold source: at a point inside a multi-line block, the fold
|
||||
source returns the enclosing block-like node's byte range; at top level it
|
||||
returns the enclosing item; in a grammarless buffer it returns nothing
|
||||
(fallback deferred).
|
||||
2. `fold.toggle` folds the enclosing region, and toggling on a fold head
|
||||
unfolds it; `close-all` folds every block-like region, `open-all` clears.
|
||||
3. Folding a range containing point moves point to the fold head.
|
||||
4. `FoldState` is produced (the pinned "never emitted" test is replaced by a
|
||||
"emitted with the current fold set" test), whole-buffer, diff-suppressed
|
||||
(an unchanged fold set sends nothing), and reset on `BufferSnapshot`.
|
||||
5. An edit inside a folded range translates the fold; an edit deleting the
|
||||
fold head drops the fold; both leave a consistent set.
|
||||
6. Nested folds: folding an inner then an outer region yields two ranges;
|
||||
1. **Structural source.** At a point inside a multi-line block, the source
|
||||
returns the enclosing block-like node normalized to its line-aligned
|
||||
interior; at top level it returns the enclosing item; in a grammarless
|
||||
buffer it returns nothing (fallback deferred).
|
||||
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
|
||||
fold head; `close-all` folds every top-level block-like region (nested not
|
||||
auto-folded); `open-all` clears.
|
||||
4. **Range semantics (Q#FD3).** The stored/shipped range is the line-aligned
|
||||
interior: the head line's bytes are outside it, the closing-delimiter line
|
||||
is inside it; `folds(buffer)` returns exactly the normalized ranges.
|
||||
5. **Point (Q#FD3).** Folding a range containing the invoking point moves it
|
||||
to the head; the creation-time-only scope holds (Stage 1 does not prevent
|
||||
later motion into a fold).
|
||||
6. **Edits (Q#FD5/Q#FD6).** An interactive edit at a point inside a fold
|
||||
unfolds it; a programmatic edit inside a fold translates it; an edit
|
||||
deleting the head drops it; each leaves a consistent set.
|
||||
7. **`FoldState` production (Q#FD8, F7).** 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`.
|
||||
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.
|
||||
7. The `pmacs.fold` Lua surface drives all of the above and round-trips
|
||||
`folds()`.
|
||||
8. A terminal identity buffer never produces `FoldState` (Q#FD9).
|
||||
10. **Injected layer (§3 injection-coverage claim).** 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.fold/unfold/folds/toggle`
|
||||
with an explicit buffer drive all of the above and round-trip `folds()`;
|
||||
an out-of-bounds, non-boundary, or sub-one-line range is rejected; a fold
|
||||
on a terminal identity buffer is rejected (Q#FD9).
|
||||
|
||||
## 13. Gates (Stage 1)
|
||||
|
||||
|
|
@ -244,7 +387,7 @@ CRDT); `cargo test --test m4_acceptance -- --skip basedpyright`;
|
|||
## 14. Branch and PR plan
|
||||
|
||||
Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @
|
||||
`cac4961`. This framing is its first commit. After the framing is approved,
|
||||
Stage 1 is implemented on this same branch and opened as the first folding PR.
|
||||
`cac4961`. This framing (rev 1 → rev 2) 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue