From 4222ffae6edb9feb552d9ef0cd535c34c80beeeb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 17:25:55 -0400 Subject: [PATCH] =?UTF-8?q?docs(folding):=20Stage=202=20framing=20rev=204?= =?UTF-8?q?=20=E2=80=94=20address=20review=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route command-time visible-line maps through each operation's target window, while retaining the acting frontend as the projection-policy owner. Model nested and crossing folds as merged hidden components so row and byte clamps always resolve to one actually visible head. Also key projection on the negotiated render selection and correct the unmerged status of the separate Stage 1 housekeeping PR. --- docs/folding-stage2-framing.md | 176 ++++++++++++++++++++++----------- 1 file changed, 120 insertions(+), 56 deletions(-) diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md index 57974b6..8403b3d 100644 --- a/docs/folding-stage2-framing.md +++ b/docs/folding-stage2-framing.md @@ -1,6 +1,6 @@ # Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6) -**Revision 3 — 2026-07-23. Status: DRAFT for review (rounds 1–2 findings +**Revision 4 — 2026-07-23. Status: DRAFT for review (rounds 1–3 findings addressed).** 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 @@ -68,7 +68,8 @@ parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD1 normalizes to the visible head**, then steps to the preceding/following visible line (§7). - **#142 housekeeping** (retire the active-work.md folding lane + refresh handoff - §1) is a **separate docs PR**, kept out of `folding-tui` (landed as PR #147). + §1) is a **separate docs PR**, kept out of `folding-tui` (PR #147, ready for + merge but not yet landed). ### Round 2 (rev 2 → rev 3) @@ -94,7 +95,7 @@ parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD1 rendered window**, keyed on that window's `buffer_id` + its `TextView` line-offsets + `view_top`; peer presence derives/receives the **recipient window's** map (§3.2). New acceptance: a split with two **different** buffers, - only one folded — no active-buffer/map leakage (§11 acceptance 13). + only one folded — no active-buffer/map leakage (§11 acceptance 14). - **F3 (moderate) — hidden positions need column projection, not only row clamping.** Rev 2 clamped a hidden caret/peer cursor to `visible_head_of`'s row but left the column unspecified, which could paint a hidden point at an arbitrary @@ -108,6 +109,36 @@ parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD1 - **Nits.** Fixed the `Viewport<'a>` typo; stated the map build cost honestly as O(folds) with a byte→line lookup per fold (B4). +### Round 3 (rev 3 → rev 4) + +- **F1 (major) — command-time maps follow the operation's target window.** Rev 3 + said every command/event map came from the active window, but wheel events call + `scroll_window(win_id, …)` for the pane under the pointer **without activating + it** (`src/editor.rs:1919/2264`). In a split, scrolling inactive buffer B could + therefore derive folded buffer A's map. Rev 4 separates the two axes: the + **acting frontend** supplies the Q#FD21 projection-policy gate; the + **operation's target window** supplies `buffer_id` / line offsets / `view_top` + for the map. Motion, paging, and auto-scroll target the active window; click + inverse and wheel target their explicit `win_id`. Acceptance 14 now pins an + inactive-pane wheel with different fold state. +- **F2 (moderate) — byte projection must handle crossing overlaps, not only strict + nesting.** Stage 1's `FoldStore::insert` permits arbitrary normalized ranges and + rejects no crossing overlap. With A hiding lines 1–3 and B headed on line 2 and + hiding 3–5, a point on line 5 is directly inside only B; B's `range.start` is + nevertheless hidden by A. Rev 3's "outermost fold containing the original + point" could therefore return a hidden position. Rev 4 makes the derived map's + unit a **merged hidden component** retaining its one visible `head_line` and + exact `head_position`; both `visible_head_of` and `visible_position_of` resolve + through that component. This is equivalent to repeatedly projecting a hidden + fold head until visible and covers nesting, shared heads, and crossing overlap. + Acceptance 6 adds the crossing shape. +- **F3 (minor) — PR #147 is ready, not landed.** Corrected the two premature + "landed" claims; the housekeeping remains separate and unmerged. +- **Nit — selected render kind, not an advertisement.** Q#FD21 now keys on + `session_state.negotiated_capabilities.semantic_render`, the same attach-time + boolean that selects `RenderState` vs `SemanticRenderState`; LOCAL is explicitly + grid-projecting. + ## 1. What Stage 2 ships The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines and @@ -274,7 +305,10 @@ frontend's layout; distinct windows carry distinct `window.buffer_id` / show two different buffers with only one folded. The after-frame presence pass likewise iterates recipient windows by `buffer_id` (`src/overlay_paint.rs:124`). Any per-frame-singleton fold map therefore leaks; the map must be **per rendered -window** (F2). Terminal windows never fold (Q#FD9) and are skipped. +window** (F2). Command/event targeting matters too: wheel scrolling receives an +explicit `win_id` and does not activate that pane, so its map belongs to the +target window, not necessarily the active one (round-3 F1). Terminal windows +never fold (Q#FD9) and are skipped. ## 3. The shared visible-line map primitive (Q#FD12) @@ -291,24 +325,36 @@ derived, never stored. ### 3.1 Queries -Folds may nest; the derivation unions their hidden intervals into a sorted, -non-overlapping hidden-line set `H` (a line is hidden regardless of which fold owns -it). The map answers, in **line space**: +Folds may nest, share heads, or cross through arbitrary data-API ranges. The +derivation unions overlapping or adjacent hidden intervals into sorted, +non-overlapping **hidden components**. Each component +`C = [first_hidden, last_hidden]` retains: + +- `head_line = first_hidden - 1`, the one visible line immediately before `C`; +- `head_position`, the exact end-of-content byte of `head_line` (the + `range.start` that begins the earliest participating hidden interval). + +Adjacent intervals merge because the later fold's head is hidden by the earlier +interval; keeping them separate would preserve a head that cannot render. The +union of the components is the hidden-line set `H` (a line is hidden regardless +of which fold owns it). The map answers: - `is_hidden(line) -> bool` — `line ∈ H`. -- **`visible_head_of(line) -> line`** — for a hidden `line`, the head **line** of - the **outermost** enclosing fold (resolve to the enclosing fold's head; if that - head is itself hidden, recurse; heads strictly decrease, so it terminates on the - one visible head). For a visible `line`, itself. **Row-only** clamps use this: - diagnostic signs, the relative-number cursor anchor, and the `view_top` backward - clamp. (Rev 1's innermost `head_of` is removed — F1.) -- **`visible_position_of(pos) -> Position` (F3)** — for a byte `pos` on a hidden - line, the **outermost** enclosing fold's `range.start` (the end of the visible - head line — exactly where Stage 1 moves point on fold-at-cursor). For a visible - `pos`, itself. **Position** clamps — which carry a column, not just a row — use - this: the local caret, peer cursors, and selection **endpoints**. It resolves to - `visible_head_of(line(pos))`'s line at the head's end-of-content column, so a - hidden point never paints at an arbitrary column on the head. +- **`visible_head_of(line) -> line`** — for a hidden `line`, its component's + `head_line`; for a visible `line`, itself. This is the recursively resolved + **outermost visible head**, including when a crossing fold's own head is hidden + by an earlier fold. **Row-only** clamps use this: diagnostic signs, the + relative-number cursor anchor, and the `view_top` backward clamp. (Rev 1's + innermost `head_of` is removed — round-1 F1.) +- **`visible_position_of(pos) -> Position` (round-2 F3, round-3 F2)** — for a byte + `pos` on a hidden line, its component's exact `head_position`; for a visible + `pos`, itself. This is equivalent to projecting to a containing fold's + `range.start` and repeating while that head is hidden, so it cannot return + another hidden position under crossing overlap. **Position** clamps — which + carry a column, not just a row — use this: the local caret, peer cursors, and + selection **endpoints**. A hidden point therefore lands at + `visible_head_of(line(pos))`'s end-of-content column, never at an arbitrary + column on the head. - `next_visible(line)` / `prev_visible(line)` — the next/previous visible line, skipping whole folds (for the render walk and vertical motion). - `visible_between(a, b) -> isize` — signed count of visible lines from `a` to `b` @@ -346,13 +392,15 @@ window's `buffer_id`, its `TextView` line-offsets, and (for the render walk) its (`src/overlay_paint.rs`, site 10) iterates recipient windows by `buffer_id`; for each it derives/receives **that recipient window's** map and clamps peer cursors (`visible_position_of`) / projects peer selection endpoints through it. -- **Command / event — per acting window, gated on the frontend (F1).** Click - inverse, auto-scroll, paging, wheel, and vertical motion (sites 11–13, in - `EditorCore` / `EditorState`) build a short-lived instance from - `state.fold_registry` + the **active window's** buffer at call time — **only when - the acting frontend's `fold_projection_active` is set** (Q#FD21); otherwise they - keep raw source-line behavior. "The map is already built" (rev 1) was false — these - run outside any frame. +- **Command / event — per target window, gated on the acting frontend (round-3 + F1).** Click inverse, auto-scroll, paging, wheel, and vertical motion (sites + 11–13, in `EditorCore` / `EditorState`) build a short-lived instance from + `state.fold_registry` + the **operation's target window** at call time — **only + when the acting frontend's `fold_projection_active` is set** (Q#FD21). + Motion, paging, and auto-scroll target the active window; click inverse and + wheel use their explicit `win_id` (wheel does not activate the pane under the + pointer). Otherwise they keep raw source-line behavior. "The map is already + built" (rev 1) was false — these run outside any frame. `view_top` **stays a source-line index** (Q#FD12, **Bet B5**), only ever set via `clamp_view_top` so it never rests on a hidden line — preserving the saveplace @@ -414,16 +462,19 @@ semantic/GPU session never enters, so they are **already grid-only**. The **command/event-time** reckoning below (motion, paging, wheel, click, auto-scroll) runs in **shared** `EditorCore`/`EditorState` code, so each such site is **gated on the acting frontend's `fold_projection_active`** (a `FrontendView` flag, -`src/editor_core.rs:240`, set at attach from the frontend's advertised render kind: -grid ⇒ true in Stage 2; semantic/GPU ⇒ false until Stage 3, cleared at detach). +`src/editor_core.rs:240`, set at attach from +`session_state.negotiated_capabilities.semantic_render`, the same selected-render +bit that allocates `RenderState` vs `SemanticRenderState`: grid ⇒ true in Stage 2; +semantic/GPU ⇒ false until Stage 3; LOCAL ⇒ true; cleared with the view at detach). When the flag is false the site keeps its current raw-line behavior — so a GPU session on the same shared-fold buffer never skips a source line it still displays. **Caret clamp (Q#FD16, F3).** Caret grid position (`src/editor.rs:3033-3044`): if the logical cursor's line `is_hidden`, render at **`visible_position_of(cursor)`** -— the outermost fold's `range.start`, i.e. the visible head row at the head's -end-of-content column (not an arbitrary column, F3). Satisfies the parent's -per-cursor render-time invariant (Q#FD3), including the shared-store case. +— the hidden component's `head_position`, i.e. the visible head row at the head's +end-of-content column (not an arbitrary or still-hidden column, F3). Satisfies +the parent's per-cursor render-time invariant (Q#FD3), including the shared-store +case. **Local selection (Q#FD16, F2/F3).** `paint_local_selection` (`src/editor.rs:3241`): each selection **endpoint** on a hidden line projects via `visible_position_of`; @@ -436,13 +487,15 @@ same way and hidden interiors drop. The map is the **recipient window's** map (§3.2), built for that window's buffer. **Click inverse (Q#FD16, Q#FD21).** `activate_and_position` (`src/editor.rs:2233`): -on a fold-projecting frontend, grid row `k` maps to the `k`-th visible line, so a -click never lands on a hidden line. +on a fold-projecting frontend, build the map from the explicit target `win_id`; +grid row `k` maps to that window's `k`-th visible line, so a click never lands on +a hidden line. **Viewport / scroll (Q#FD18, Q#FD21).** On a fold-projecting frontend: `view_top` is set only via `clamp_view_top` (backward to the head for a hidden candidate, F1); -paging (`move_page_down/up`), wheel (`scroll_window`), and the auto-scroll-to-cursor -clamp (`src/editor.rs:2866`) advance and bound by **visible** lines +the active window's paging (`move_page_down/up`) and auto-scroll-to-cursor clamp +(`src/editor.rs:2866`), plus wheel `scroll_window(win_id)` on its explicit target +window, advance and bound by **visible** lines (`next_visible`/`prev_visible`, `visible_between`); `move_to_line`/goto-line targets a raw line and, if hidden, clamps `view_top` so the target's **head** is visible (no auto-unfold — deferred search-reveal, §9); the **mode-line scroll indicator** @@ -455,7 +508,7 @@ fold-projecting frontend, `next-line`/`prev-line` (and arrows) step to the adjac **visible** line (`next_visible`/`prev_visible`), so a collapsed region is one motion step and the cursor never rests hidden. **If motion begins from a hidden logical cursor** (a shared fold or a goto-line into a fold), it **first normalizes -to `visible_head_of(cursor)`**, then steps. The render-time caret clamp (Q#FD16) +to `visible_position_of(cursor)`**, then steps. The render-time caret clamp (Q#FD16) backstops the pre-motion shared-fold frame. On a non-fold-projecting frontend these retain raw-line motion. All reuse the command-time per-window map (§3.2). @@ -524,11 +577,12 @@ Each widened behavior is **bite-verified** (a test failing without the widening) - **B7** Threading `Option<&'a VisibleLineMap>` on a lifetime-bearing `Viewport<'a>` keeps `Viewport: Copy`. FALSIFIABLE if a `View` impl or construction site cannot satisfy the lifetime; fallback is to settle `Viewport` as non-`Copy` explicitly. -- **B8** A `fold_projection_active` flag on `FrontendView`, set from the frontend's - advertised render kind at attach, cleanly scopes command-time visible-line - reckoning to grid frontends. FALSIFIABLE if the render kind is not known at - `register_frontend_view` time; fallback is to derive it from the daemon's - grid-vs-semantic session map at dispatch. +- **B8** A `fold_projection_active` flag on `FrontendView`, set from + `session_state.negotiated_capabilities.semantic_render` in the same attach + transaction that selects the grid vs semantic render state (and initialized + true for LOCAL), cleanly scopes command-time visible-line reckoning to grid + frontends. Every non-daemon `FrontendView` construction must choose the + projection explicitly; it is never inferred from `FrontendId`. ## 11. Acceptance — Stage 2 @@ -561,6 +615,10 @@ the precedents), sized for macOS startup + long temp-path width (handoff §5). projects its endpoints and paints on the visible head + visible tail rows, nothing on hidden rows. A peer cursor on a hidden line clamps the same way; peer selection endpoints project, interiors drop. A click never selects a hidden line. + Repeat the caret/peer/endpoint assertions with **crossing folds** (A hides + lines 1–3; B's head is hidden on line 2 and B hides through line 5): a point on + line 5 resolves to A's visible head position, never B's hidden + `range.start`. 7. **Ordinary overlays across a fold (F2).** A style/syntax span and a search wash on lines straddling a fold paint only on the visible rows, correctly aligned; the completion popup anchored below a fold lands on the right visible row. @@ -594,7 +652,9 @@ the precedents), sized for macOS startup + long temp-path width (handoff §5). 14. **Split of different buffers, one folded (F2).** A vertical split shows buffer A (with a fold) and buffer B (no fold). A's window collapses; **B's window is byte-for-byte identical to the no-fold baseline** (no active-buffer/map leakage); - peer presence painted into B's window uses B's map. + peer presence painted into B's window uses B's map. Keep folded A active and + wheel-scroll over inactive B: focus stays on A, while B's `view_top` and + cursor advance through **B's** raw-visible lines using B's map, never A's. ## 12. Gates (Stage 2) @@ -613,8 +673,8 @@ failure as a regression (handoff §3). - **Q#FD12** One shared visible-line map **primitive** (derivation + queries from `folds()` + line offsets), instantiated as short-lived instances **per rendered window and per phase** (render via `Option<&'a VisibleLineMap>` on `Viewport<'a>` - preserving `Copy`; after-frame per recipient window; command-time per active - window), never a per-frame singleton (F2), home usable from `EditorCore`. + preserving `Copy`; after-frame per recipient window; command-time per operation + target window), never a per-frame singleton (F2), home usable from `EditorCore`. `view_top` stays a source-line index, set only via `clamp_view_top`. Every consumer in the §2.2 census routes through it. (§3) - **Q#FD13** Collapse in `TextView::render` (row `r` → `r`-th visible line); head @@ -624,9 +684,10 @@ failure as a regression (handoff §3). of the cursor; gutter width unchanged. (§5) - **Q#FD15** A diagnostic on a hidden line clamps to `visible_head_of` (outermost visible head), most-severe merge; not dropped. (§6) -- **Q#FD16** Render-time **position** clamp via `visible_position_of` (the outermost - fold's `range.start` — head row **and** head end-of-content column, F3) for the - caret, peer cursors, and selection endpoints; hidden selection interiors drop; +- **Q#FD16** Render-time **position** clamp via `visible_position_of` (the merged + hidden component's visible `head_position` — head row **and** head + end-of-content column, including crossing overlaps) for the caret, peer cursors, + and selection endpoints; hidden selection interiors drop; diagnostics and the relative-number anchor use the row-only `visible_head_of`; click inverse maps grid rows to visible lines. (§7) - **Q#FD17** *(ruled: include)* Vertical line-motion steps by visible lines; motion @@ -644,21 +705,24 @@ failure as a regression (handoff §3). ellipsis only; on ⇒ col-0 sign cell on the head row with diagnostic priority. A dedicated fold column (unconditional marker + layout change) is deferred. (§5) - **Q#FD21** Fold projection is **per-frontend**: a `fold_projection_active` flag on - `FrontendView`, set at attach from the advertised render kind (grid ⇒ true in - Stage 2; semantic/GPU ⇒ false until Stage 3), cleared at detach. All + `FrontendView`, set at attach from the negotiated selected-render bit (grid ⇒ + true in Stage 2; semantic/GPU ⇒ false until Stage 3; LOCAL ⇒ true), cleared at + detach. All command/event-time visible-line reckoning (motion, paging, wheel, click, - auto-scroll) is gated on the acting frontend's flag; render-time clamps are - already grid-path-only. Keeps a simultaneous unfolded GPU session's cursor from - skipping lines it still displays. (§7, §2.7) + auto-scroll) is gated on the acting frontend's flag, while the map derives from + the operation's target window (active for motion/paging/auto-scroll; explicit + `win_id` for click/wheel); render-time clamps are already grid-path-only. Keeps + a simultaneous unfolded GPU session's cursor from skipping lines it still + displays without leaking another pane's fold map. (§7, §2.7) ## 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 commits (rev -1 → rev 3); Stage 2 implements on this same branch and opens as the second folding +1 → rev 4); 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 resulting main. Housekeeping from #142 (retire the `docs/active-work.md` folding lane + refresh -`docs/agent-handoff.md` §1) **landed as the separate docs PR #147**, kept out of -`folding-tui` (ruled). +`docs/agent-handoff.md` §1) is **ready as the separate, unmerged docs PR #147**, +kept out of `folding-tui` (ruled).