From 59410c004d3dad76b0bec1923f9836fc8a680cf9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 15:41:11 -0400 Subject: [PATCH 01/12] =?UTF-8?q?docs(folding):=20frame=20Stage=202=20?= =?UTF-8?q?=E2=80=94=20grid=20(daemon)=20collapse=20(rev=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe Arc 6 folding Stage 2 in detail off canonical main @ c49a8c7 (Stage 1 / #142 merged), per the parent framing's §8/§14. Continues the Q#FD scheme from Q#FD12. Stage 2 makes the daemon grid renderer fold-aware: collapse hidden lines, head-line ellipsis + gutter fold glyph, fold-aware line numbers (visible-line relative distance), diagnostic-sign clamp-to-head, caret clamp, visible-line viewport/scroll accounting, and the interactive-Lua unfold widening (yank/query-replace/comment). No wire schema or protocol change — FoldState production (Stage 1) is untouched; the GPU path is Stage 3. Scout findings that shaped the framing: - The TUI has NO non-identity source-line->display-row map today; the identity `view_top + row` is baked into ~7 sites. Folding is the first such map, so Stage 2's spine is one shared per-frame visible-line map (Q#FD12) that the render loop and every view_top-arithmetic site consult; collapse lives in TextView::render, not the diff shell. - Correcting the parent's premise: yank + query-replace are apply_active_edit callers (local), not Lua-mutator callers; only comment-toggle/yank-pop take the Lua path (shared with the remote/optimistic-CRDT apply that stays deferred to Stage 3). The widening hooks the local funnels only (Q#FD19). One open scope fork flagged for the user: Q#FD17 (fold-aware vertical line-motion vs render-time caret clamp only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- docs/folding-stage2-framing.md | 516 +++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 docs/folding-stage2-framing.md diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md new file mode 100644 index 0000000..4a72e31 --- /dev/null +++ b/docs/folding-stage2-framing.md @@ -0,0 +1,516 @@ +# Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6) + +**Revision 1 — 2026-07-23. Status: DRAFT for review.** Parent architecture +(`docs/folding-framing.md`, rev 5) is APPROVED and Stage 1 (the headless fold +engine) is MERGED as **#142** (canonical `main` @ `c49a8c7`). This doc reframes +**Stage 2** in detail off that base, per the parent's §8/§14 ("each stage is +re-framed after the prior stage lands"). It carries the four Stage-2 obligations +the parent named, plus the one architectural fact the Stage-2 scout surfaced that +the parent only sketched. + +Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`. + +## 1. What Stage 2 ships + +The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines +and paints a character grid it ships to the terminal client. Stage 1 built the +instance-side fold store and produces `FoldState` for GPU sessions, but **no +frontend renders a collapse yet** — the daemon grid path never consults the fold +store. + +Stage 2 makes the **daemon grid renderer fold-aware**: + +1. **Collapse** — omit each fold's hidden source lines; show the head line with a + trailing ellipsis; rows below shift up. +2. **Gutter fold marker** — a fold glyph on the head-line row (parent §7). +3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines, and + relative/hybrid distance is measured across the collapse (parent §8). +4. **Fold-aware diagnostic signs** — a sign on a hidden line clamps to the fold's + visible head-line row (parent §8, minor d). +5. **Fold-aware caret** — the caret never renders on a hidden line; it clamps to + the fold head (parent §8, Q#FD3 render-time invariant). +6. **Fold-aware viewport/scroll** — `view_top`, paging, and auto-scroll count + **visible** lines, not raw source lines (parent §8). +7. **Interactive-Lua-command unfold widening** — yank, query-replace, and + comment-toggle unfold a fold at their edit point before the edit is visible + (parent §8, R3-3), which is only observable once the TUI collapses. + +**No wire schema changes and no protocol bump.** `FoldState` is already produced +(Stage 1) for semantic/GPU sessions; the TUI collapse is entirely daemon-side +(the vterm-Stage-2 shape). The GPU render path is untouched — that is Stage 3. +The store, the source, and the producer from Stage 1 are unchanged; Stage 2 only +*reads* the store in a new place (the daemon grid renderer) and *widens* one +pre-edit hook. + +## 2. Ground truth (scouted 2026-07-23, `main` @ `c49a8c7`) + +### 2.1 The grid render path is layered — and the collapse belongs deep in it + +The parent framing (F1) located the collapse at "the daemon grid renderer, +`render_frame`." The scout refined this: `RenderState::render_frame` +(`src/instance_render.rs:98`) is a **diff shell** — it double-buffers cells and +emits `CellDelta`; it does **not** walk source lines. The source-line→grid-row +loop is two calls down: + +``` +RenderState::render_frame (src/instance_render.rs:98) — diff shell, no line walk + → paint_frame (src/editor.rs:2796) — per-window composition; HAS state.fold_registry + → window.text_view.render(buf, viewport, grid) (src/editor.rs:2948) + → TextView::render (src/text_view.rs:207) — THE line→row loop +``` + +`TextView::render` is the primitive (`src/text_view.rs:213`): + +```rust +for row_offset in 0..max_rows { + let line = start_line + row_offset as usize; // source line (:214) + let cell_row = origin.row + row_offset; // grid row (:215) + ... +} +``` + +The mapping is **strictly identity** — `line = start_line + row_offset`, +contiguous +1, no skip, no wrap (long lines truncate at `max_cols`, they do not +wrap). This identity is repeated, un-abstracted, at **every** view_top-arithmetic +site: + +| Site | file:line | assumes | +|---|---|---| +| render loop | `src/text_view.rs:214` | `line = view_top + row` | +| line-number gutter | `src/editor.rs:3215` | `buffer_line = view_top + r` | +| diagnostic signs | `src/diag.rs:563` | `row_offset = line − start_line` | +| caret grid row | `src/editor.rs:3044` | `grid_row = origin + (disp.row − view_top)` | +| click inverse | `src/editor.rs:2233` | `display_row = view_top + local_row` | +| auto-scroll clamp | `src/editor.rs:2866` | cursor row in `[view_top, view_top+rows)` | +| page/scroll | `src/editor_core.rs:1745`, `src/editor.rs:2264` | target = raw source row | + +`Window.view_top: usize` (`src/window.rs:179`) is a **source-line index**. +`DisplayCoord`'s own doc (`src/view.rs:108`) anticipates that row/col "diverge +once virtual lines, wrapping, and inline expansions appear," but **nothing +implements a non-identity map today** — `TextView` is the only base view and its +`pos_to_display`/`display_to_pos` are identity. **Folding is the first +non-identity source-line↔display-row mapping in the TUI.** + +**Consequence (the crux, Q#FD12).** Fold collapse cannot be a localized edit to +one loop, and it cannot be an overlay (overlays paint *on top of* laid-out rows; +they cannot delete rows or change the row count). It must be a **shared +fold-aware line map** that the render loop *and* all seven sites above consult. + +### 2.2 The renderer can already reach the fold store + +`fold_registry: SharedFoldRegistry` is a field on both `EditorCore` +(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`). +`paint_frame` borrows `state`, so `state.fold_registry` + `window.buffer_id` are +in scope at the composition point (`src/editor.rs:2796`). The semantic producer +already reads it: `state.fold_registry.folds(buffer_id)` +(`src/semantic_render.rs:1433`). The **grid path reads it nowhere** yet — that is +the new consumer Stage 2 adds. + +The store's read surface (`src/fold.rs`): +- `FoldRegistry::folds(buf) -> Vec` (`:327`) — whole-buffer, sorted, + empty when no store. The one-call read a renderer wants. +- `FoldStore::containing(p) -> Vec` (`:171`) — byte-space containment, + `(start, end]` (start-exclusive, end-inclusive per `src/fold.rs:11`). +- **There is NO line-space query** ("is source line N hidden?"). Only byte-space + `containing` exists. Stage 2 must add the line-space view (§4). + +A fold `ByteRange` is `start` = end-of-head-line content byte, `end` = +end-of-last-hidden-line content byte (parent §3/§5). So the byte→line conversion +is exact: `head_line = line_at_offset(start)`, hidden lines = +`head_line+1 ..= line_at_offset(end)`, head line and (for brace nodes) the closer +line stay visible. `ByteRange`'s struct doc (`pmacs-protocol/src/ids.rs:72`) calls +itself half-open `[start,end)`; the **fold engine overrides that to `(start, +end]`** — use the fold semantics, never the struct doc. + +### 2.3 The gutter + +- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; `Off`/`Absolute`/ + `Relative`/`Hybrid`; default `Off`) is per-window (`Window.line_numbers`, + `src/window.rs:188`). The number rule is + `LineNumberMode::number_for(line, cursor_line)` (`:1197`) — and it measures + relative distance as **`abs_diff` in raw source-line space**. +- The daemon computes the column in `paint_line_number_gutter` + (`src/editor.rs:3177`): `buffer_line = view_top + r` (`:3215`) → + `number_for(buffer_line, cursor_line)` (`:3224`); `cursor_line = + line_at_offset(window.cursor)` (`:3189`). +- Gutter **width** is `decimal_digits(line_count) + 2` (`src/window.rs:216`), + sized to the **absolute** line count and fixed per frame. Folding does not + change `line_count`, so **the gutter width is stable** (no jitter) — a fold does + not shrink the field. +- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`), `row_offset = + line − start_line_buf` (`:563`), most-severe-per-row into `line_markers` + (`:570`), painted by `paint_line_markers` (`:635`) with + `DiagnosticSeverity::gutter_glyph()` (`:75`) at window col 0 (the gutter's + leading cell). Diagnostics render **after** the number gutter (overlay order, + `src/editor.rs:2943-2954`), so a sign is not erased by the digits. +- Gutter column order across `[origin.col .. origin.col+gutter_w)`: + **[sign @ col 0 | right-aligned digits | content @ origin+gutter_w]**. Every + painter reads `Viewport.gutter_w` (`src/view.rs:144`) so it stays gutter-blind. + +### 2.4 The interactive-edit unfold seam (correcting the parent's premise) + +Stage 1 wired the pre-edit unfold as `unfold_before_point_edit` +(`src/editor_core.rs:1847` — reads `active_window().cursor`, calls +`fold_registry.unfold_containing(id, point)`) called at the **top** of the six +`EditorCore` primitives (`insert_char` :1858, `insert_char_over_region` :1894, +`backspace` :1936, `delete_forward` :1958, `delete_word_backward` :1982, +`delete_word_forward` :2010). The shared `apply_active_edit` +(`src/editor_core.rs:1266`) they all call does **not** itself unfold. + +The parent framing (R3-3) assumed yank/query-replace/comment-toggle all "mutate +through the Lua mutator path." **The scout corrected this:** + +- **Yank (`C-y`)** and **query-replace** are **Rust `apply_active_edit` + callers**, not Lua-mutator callers: yank → + `clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit` + (`src/editor_core.rs:2544/2570`); query-replace → + `query_replace_apply_current`→`apply_active_edit` (`:1129/1137`). They skip the + six primitives, so they do **not** unfold today — but they are inherently + **local active-frontend** edits (`apply_active_edit` is never the path for a + remote/optimistic-CRDT apply). +- **Comment-toggle** (`comment.lua:172`, one `buf:replace`) and **yank-pop** + (`killring.lua:356`) take the **Lua mutator path**: `add_mutation_methods` + (`src/lua_bindings/mod.rs:1227`) → `run_managed_edit` (`:1347`) → + `Buffer::apply_edit_skip_intercepts` → `EditorCore::notify_buffer_edit` + (`src/editor_core.rs:1364`). This path has no unfold — **and it is shared with + remote/optimistic-CRDT applies**, which are the parent's named Stage 3 concern. + +The dispatcher stamps `active_frontend = frontend_id` **before** any command body +runs (`src/editor.rs:807`; also `src/daemon.rs:999`), so the acting frontend and +its point (`active_window().cursor`) are available throughout an interactive +command — the exact pair `unfold_before_point_edit` already reads. There is a +narrower one-shot provenance record (`TypedEditRecord`/`take_typed_edit`, +`src/editor_core.rs:161/2477`) used by auto-pairing, but it is codepoint-scoped +self-insert only and cannot carry these multi-byte edits — Stage 2 needs a +position-keyed signal, not that record. + +### 2.5 Scroll/viewport commands all count raw lines; there is no recenter + +`move_page_down`/`move_page_up` (`src/editor_core.rs:1745/1779`, step = +`last_visible_rows − 1`), `scroll_window` (mouse wheel, `src/editor.rs:2264`, +`SCROLL_LINES = 3`), and `move_to_line`/goto-line (`src/editor_core.rs:708`) all +compute targets in **raw source-line** space via `Window::text_view` and mutate a +raw-line `view_top`. There is **no `beginning/end-of-buffer` command** and **no +recenter** — recenter is explicitly deferred, blocked on viewport facts (the GPU +never consumes daemon `view_top`); confirmed in +`docs/editing-conveniences-framing.md:217` and the handoff §6. Stage 2 inherits +that deferral (recenter stays out). + +## 3. The shared fold-aware line map (Q#FD12) + +Stage 2's spine is one primitive, derived per window per frame from +`state.fold_registry.folds(buffer_id)` and the buffer's line offsets (already +computed for rendering). It answers, in **line space**: + +- `is_hidden(line) -> bool` — is this source line inside some fold's hidden + interval `head+1 ..= last_hidden`? +- `head_of(line) -> Option` — if hidden, the visible head line of its + (innermost) enclosing fold. +- `next_visible(line)` / `prev_visible(line)` — walk to the next/previous + **visible** source line (skipping whole folds). +- `visible_between(a, b) -> usize` — count of visible lines in `[a, b)`, for + relative distance and paging. +- `first_visible_at_or_after(line)` — clamp a candidate `view_top` to a visible + line. + +Implementation shape: convert `folds()` to a sorted, non-overlapping set of +**hidden-line intervals** (nested folds collapse to the union of their hidden +lines — a hidden line is hidden regardless of which fold owns it). Byte→line uses +the render path's existing line-offset table; the fold set is O(top-level blocks) +(parent B2), so the build is cheap (**Bet B4**). The map is a *derived per-frame +view*, not new stored state — the byte-range store in `src/fold.rs` stays the one +source of truth; nested/overlap normalization happens in the derivation. + +**`view_top` stays a source-line index** (Q#FD12, **Bet B5**): it is only ever +clamped to a *visible* line (`first_visible_at_or_after`). Keeping it in +source-line space avoids a second coordinate space and preserves the saveplace +contract (`view_top` is persisted, `saveplace.lua:60`) and the existing +`_view_top`/`set_view_top` Lua surface. The visible-line *ordinal* is derived +where needed (line numbers, paging), never stored. + +Home of the primitive: a Stage-2 render-side helper (e.g. `src/fold_view.rs` or a +function on the render path) that takes `&FoldStore`/`folds()` + line offsets and +yields the queries above. It is **not** put on `FoldStore` itself — the store is +byte-space and frontend-agnostic; the line-space view is a rendering concern. (A +thin convenience like `FoldStore::hidden_line_intervals(&line_offsets)` may live +in `fold.rs` if it keeps the byte→line conversion beside the containment +convention it must match; TBD in implementation, not load-bearing.) + +## 4. Collapse rendering (Q#FD13) + +**Threading.** `TextView::render` (`src/text_view.rs:207`) takes only `(buf, +viewport, cells)` and has no fold handle. The fold-aware line map is carried on +**`Viewport`** (widening it beside `gutter_w`) so that `TextView::render` *and* +every overlay painter (syntax, diagnostics, selection, search) see the **same** +map and their rows stay aligned. `paint_frame` builds the map (it holds +`state.fold_registry` + `window.buffer_id`) and installs it on the `Viewport` it +already constructs (`src/editor.rs:2932`). This mirrors how `gutter_w` is +threaded to keep painters gutter-blind (§2.3) — now they become fold-blind too, +consulting the map rather than each re-deriving it. + +**The loop.** `TextView::render` advances over **visible** lines: row `r` shows +the `r`-th visible source line at/after `view_top` (via `next_visible`). Hidden +lines are skipped; rows past the last visible line clear as today. + +**Head line + ellipsis.** A visible line that is a fold head renders its real +content, then a trailing ellipsis marker (` …`) in the **content area** after its +text (clipped like any long line). The ellipsis is the authoritative, +layout-neutral fold indicator. + +**Overlays.** Syntax spans, diagnostics, selection washes, and search hits all +paint per row through `Viewport`; because they now read the same visible map, a +span/wash on a hidden line is simply not painted (its row does not exist), and a +span on a visible line lands on the right row. No overlay tries to delete a row — +the row set is already fold-correct by the time overlays run. + +## 5. Gutter: line numbers + fold glyph (Q#FD14, Q#FD20) + +**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`) +walks visible lines (row `r` → the `r`-th visible line at/after `view_top`, same +map as §4): + +- **Absolute** shows that visible line's raw `line + 1` — unchanged rule; hidden + numbers simply do not appear, so the column jumps from the head's number to the + first post-fold number with no gap-filling. +- **Relative / Hybrid** measure distance in **visible** lines: + `visible_between(cursor_line, row_line)` (signed by direction), **not** + `number_for`'s raw `abs_diff`. Concretely, the caller feeds `number_for` a + *visible* distance (and, for Hybrid's cursor row, the raw `line + 1`). Absolute + keeps needing the raw line, so the walk carries both the raw source line and the + visible ordinal. + +Gutter **width** is unchanged (`decimal_digits(line_count)+2`, §2.3) — sized to +the absolute count, so it neither jitters nor shrinks when a fold collapses. + +**Fold glyph (Q#FD20).** Parent §7 promises a gutter fold marker. To honor it +with **zero layout change** and no contention: on a head-line row, draw a fold +marker in the gutter's **col-0 sign cell** *only when that row carries no +diagnostic sign*. A diagnostic clamped onto the head (Q#FD15) is higher-signal +("there is an error inside this fold") and wins col 0; otherwise the fold glyph +occupies it. This adds no gutter column and keeps the width rule intact. (A +dedicated fold column is deferred, §9 — it would widen the gutter and fight the +sign cell.) + +## 6. Diagnostic signs on hidden lines (Q#FD15) + +`DiagnosticView::render` (`src/diag.rs:496`), before recording a marker for a +source `line` (`:563/:570`): if `is_hidden(line)`, **remap it to `head_of(line)`** +(clamp to the visible head-line row) rather than dropping it. The existing +most-severe-per-row merge (`:570`) then makes the head row show the most severe +sign among the head's own diagnostics and every hidden line under its fold. Clamp +(not drop) preserves the "there is a problem inside this collapsed region" signal +— the reason a fold marker exists. + +`DiagnosticView` reaches the map the same way `TextView` does — through +`Viewport` (§4) — so it needs no separate fold-registry handle and every overlay +stays uniformly fold-aware. Its resolved head row then routes through the same +visible map as the numbers, so sign and number agree on the row. + +## 7. Caret, click, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) + +**Caret clamp (Q#FD16).** The caret grid-row (`src/editor.rs:3033-3044`): if the +logical cursor's source line `is_hidden`, render the caret at `head_of(line)`'s +row (then map through the visible map like any row). This satisfies the parent's +per-cursor render-time invariant (Q#FD3) — including the shared-store case where +another frontend folded a region around this frontend's cursor. + +**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2228`): a +click on grid row `k` maps to the `k`-th visible line (via the map), so a click +can never land the cursor on a hidden line. + +**Viewport / scroll (Q#FD18).** `view_top` is clamped to a visible line +(`first_visible_at_or_after`) wherever it is set. Paging and wheel scroll count +**visible** rows: `move_page_down`/`move_page_up` +(`src/editor_core.rs:1745/1779`) advance `view_top` and the cursor by a screenful +of *visible* lines (walk `next_visible`/`prev_visible` `page_step` times); +`scroll_window` (`src/editor.rs:2264`) advances by visible lines; the +auto-scroll-to-cursor clamp (`src/editor.rs:2866`) keeps the cursor's *visible* +row within the viewport. `move_to_line`/goto-line targets a raw line; if that +line is hidden, `view_top` is set so the target's **head** is visible (Stage 2 +does not auto-unfold on goto — revealing-by-unfold is the deferred search-reveal, +§9). Recenter stays deferred (§2.5). + +**Cursor line-motion (Q#FD17) — SCOPE DECISION, recommendation: include.** +`next-line`/`prev-line` (and the arrow keys) today step to the same column on the +adjacent **source** line. Recommended for Stage 2: they step to the adjacent +**visible** line (`next_visible`/`prev_visible`), so a collapsed region is one +motion step and the cursor never *rests* on a hidden line — the caret clamp +(Q#FD16) becomes a backstop for the shared-fold case rather than the primary +guard, and the visible-line map is already built. This is small (it reuses the +map at the line-motion functions in `editor_core`) and makes the collapse feel +coherent. **Alternative (smaller Stage 2):** leave line-motion in raw source-line +space; rely solely on the render-time caret clamp; a down-arrow into a fold lands +the logical cursor on a hidden line (drawn clamped), and the next edit unfolds. +This is less code but a confusing "cursor entered the fold invisibly" UX. *This +is the one scope fork I most want your ruling on.* + +## 8. Interactive-Lua-command unfold widening (Q#FD19) + +The parent's Stage 2 obligation (R3-3): widen the pre-edit unfold beyond the six +`dispatch_key` primitives to the interactive Lua commands. Per §2.4 the targets +split across two funnels, and the widening must stay **local-interactive only** — +it must NOT unfold on the remote/optimistic-CRDT apply path (parent's Stage 3 +"CRDT-origin unfold"). + +- **`apply_active_edit` funnel (yank, query-replace, and the six).** Move the + pre-edit unfold to the **top of `apply_active_edit`** (`src/editor_core.rs:1266`), + keyed on the active frontend's point (as `unfold_before_point_edit` already is). + This subsumes the six primitives' individual calls (they are then **retired** — + one funnel) and covers yank (`clipboard_paste`→`insert_bytes_over_region`) and + query-replace (`query_replace_apply_current`) for free. `apply_active_edit` is + never the remote-apply path, so this funnel is inherently local — no CRDT + leakage. +- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** These reach + `run_managed_edit` (`src/lua_bindings/mod.rs:1347`). Unfold there **only when the + edit runs inside an interactive-command dispatch for the active frontend**, keyed + on the requested edit's start position (`edit.range.start`). Gating on the + interactive-command context is what keeps a plain programmatic `buf:insert` (a + plugin, or the Stage-1 data-API `pmacs.fold.fold`) from unfolding — matching + Stage 1's named data-API exemption ("programmatic, no invoking point"). The + dispatcher already stamps `active_frontend` and rotates command boundaries + around interactive commands (`this_command`/`invoke_interactive`), so the + "interactive command in flight for frontend F" context is available; Stage 2 + consults it, adding at most a small dispatch-scoped marker if none is directly + readable. +- **Explicitly EXCLUDED (Stage 3):** the shared `notify_buffer_edit` + (`src/editor_core.rs:1364`) remote/optimistic-CRDT apply path. A remote peer's + edit inside *my* fold must not unfold it; a GPU user's own optimistic-CRDT edit + at *their* point is the parent's Stage 3 obligation, wired when the GPU renders + folds. +- **Undo/redo (named minor).** `undo`/`redo` reach the invalidation funnel + directly (`src/editor_core.rs:2062/2102`), not `apply_active_edit`. They are + local active-frontend operations; Stage 2 **may** unfold at the edit position on + undo/redo (desirable — an undo that re-touches a folded region should reveal it) + or defer it. Not a blocker; decided in implementation. + +Every path is keyed on the **edit position inside a fold**, and every widened +behavior is **bite-verified** (a test that fails without the widening). + +## 9. Deferred (named) + +- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity, + the `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin / + GPU-optimistic interactive unfold (parent R2-3). +- **Recenter** and any frontend scroll control — blocked on viewport facts (the + GPU not consuming `view_top`); Arc 8 adjacent (§2.5). +- **Search-reveal** — a match (isearch/query-replace) inside a fold auto-unfolds + to show the hit. Parent §11 marks it "Stage 2+"; deferred here to keep Stage 2 + focused (goto-line likewise does not auto-unfold, §7). +- **A dedicated gutter fold column** — Q#FD20 uses the existing col-0 sign cell; + a separate column (and its width recompute) is deferred. +- **Fold-aware horizontal/word motion** and screen-line editing semantics beyond + vertical line-motion. +- **Persisted folds, `hide-level N`, auto-fold-on-open** (parent §11). +- **Fold-store revalidation on revert/reload** (parent §11; v1 still drops it). +- If Q#FD17 is decided "alternative," fold-aware vertical line-motion moves here. + +## 10. Bets + +- **B4** The per-frame visible-line map (derived from `folds()` + line offsets) is + cheap: folds are O(top-level blocks), byte→line reuses the render path's line + table. No incremental/stored map needed. FALSIFIABLE on a pathological + many-fold buffer (mitigation: the derivation is O(folds), not O(lines)). +- **B5** Keeping `view_top` a source-line index (clamped to visible) is less churn + and preserves the saveplace/`set_view_top` contracts, versus introducing a + visible-ordinal coordinate space. +- **B6** No wire schema or protocol change: `FoldState` production (Stage 1) is + untouched; the TUI collapse is purely daemon-side; a semantic GPU session still + gets `FoldState` and renders nothing new until Stage 3. + +## 11. Acceptance — Stage 2 + +Tests assert on the **rendered cell grid** through a real-daemon / real-TUI grid +harness (the vterm-Stage-2 real-PTY smoke and the UX-gutter daemon-acceptance are +the precedents), sized for macOS startup + long temp-path width (handoff §5). + +1. **Collapse.** Fold a region; the daemon frame omits the hidden source lines, + the head line shows its text + ellipsis, rows below shift up, and the content + row count equals the visible-line count. +2. **Head marker.** The head-line row shows the ellipsis (content area) and — when + no diagnostic clamps there — the gutter fold glyph; hidden rows are absent. +3. **Line numbers.** Absolute mode skips hidden numbers (head's number, then the + first post-fold number, no gap-fill). Relative/Hybrid distance is measured in + **visible** lines across a fold (a line two visible rows past the cursor shows + `2` even if a fold hides ten source lines between them). +4. **Diagnostic clamp.** A diagnostic on a hidden line surfaces as the sign on the + head-line row; most-severe wins across the head and all its hidden lines; no + sign on an absent row. +5. **Caret clamp.** With the logical cursor on a hidden line (folded via the + shared store from a second frontend), the caret renders on the head-line row; a + click on a grid row never selects a hidden line. +6. **Viewport / paging.** Page-down advances by a screenful of **visible** lines + across a fold; `view_top` never lands on a hidden line; scrolling with a fold at + the top clamps to its head; goto-line to a hidden line leaves the target's head + visible. +7. **Interactive unfold widening (Q#FD19).** A **yank** at a point inside a fold + unfolds it before the paste is visible; likewise a **query-replace** + replacement and a **comment-toggle** whose edit point is inside a fold. A + **programmatic** `pmacs.fold`-adjacent `buf:insert` inside a fold does **not** + unfold (it translates). Each assertion is **bite-verified**; the test documents + that CRDT-origin unfold remains the Stage 3 obligation. +8. **(If Q#FD17 = include)** `next-line`/`prev-line` step across a collapsed region + as one motion; the cursor never rests on a hidden line. +9. **No wire/protocol change.** A semantic session still receives `FoldState` + exactly as in Stage 1; the protocol version is unchanged; the GPU render path + is untouched. (Pin: the Stage-1 `FoldState` producer transitions test still + passes verbatim.) +10. **Shared store, independent viewports.** Two TUI windows on one buffer both + render the same fold collapsed, each with its own correct `view_top`, gutter, + and caret — folds are per-buffer/shared, viewports are per-window. + +## 12. Gates (Stage 2) + +`cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (own +step); `cargo test --lib`; `cargo test --lib --features crdt`; +`tests/folding_acceptance.rs` (Stage-1 suite, must stay green) + the new +`tests/folding_stage2_acceptance.rs` (default + CRDT); `cargo test --test +m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p +pmacs-gpu` (must stay green — Stage 2 does not touch the GPU); the workspace +sweep as one invocation; `git diff --check`. New behavioral acceptance is +bite-verified with `scripts/bite`. Flaky-under-load timing tests are rerun +isolated before treating a sweep failure as a regression (handoff §3). + +## 13. Numbered decisions (continuing the parent's Q#FD scheme) + +- **Q#FD12** Stage 2 introduces the first non-identity source-line↔display-row + map. It is centralized in one per-frame **visible-line map** (derived from + `folds()` + line offsets), consulted by the render loop and every + view_top-arithmetic site; `view_top` stays a source-line index clamped to a + visible line. (§3) +- **Q#FD13** Collapse lives in `TextView::render` (row `r` → `r`-th visible line), + with the map carried on `Viewport` so all overlay painters are uniformly + fold-aware; head line renders content + trailing ellipsis; folding is not an + overlay. (§4) +- **Q#FD14** Line numbers walk visible lines; Absolute uses the raw `line+1`, + Relative/Hybrid measure distance in **visible** lines (not raw `abs_diff`); + gutter width unchanged (sized to absolute count, no jitter). (§5) +- **Q#FD15** A diagnostic on a hidden line **clamps to the fold head row** + (most-severe merge across head + hidden lines), not dropped. (§6) +- **Q#FD16** Render-time caret clamp to the head for a cursor on a hidden line; + click inverse maps grid rows to visible lines. (§7) +- **Q#FD17** *(scope fork — needs your ruling)* Recommended: vertical line-motion + (`next-line`/`prev-line`/arrows) steps over folds by visible lines so the cursor + never rests hidden. Alternative: raw motion + render-time clamp only. (§7) +- **Q#FD18** Viewport/paging/auto-scroll count **visible** lines; `view_top` + clamped to visible; goto-line leaves a hidden target's head visible; recenter + stays deferred. (§7) +- **Q#FD19** Interactive-unfold widening hooks the **local** funnels only: + `apply_active_edit` (top, subsuming the six primitives; covers yank + + query-replace) and the interactive Lua-mutator path (comment-toggle, yank-pop) + gated on interactive-command context + edit position; the remote/optimistic-CRDT + `notify_buffer_edit` path is **excluded** (Stage 3); undo/redo unfold is a named + minor. (§8) +- **Q#FD20** Fold gutter glyph reuses the col-0 sign cell on the head row when no + diagnostic clamps there (diagnostic wins); no new gutter column. (§5) + +## 14. Branch and PR plan + +Branch **`folding-tui`**, worktree `../pmacs-folding-tui`, off canonical `main` @ +`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commit; +Stage 2 implements on this same branch and opens as the second folding PR. One +feature, one branch, one PR — Stage 3 (GPU) is a separate branch/PR off the main +resulting from this stage, re-framed after it lands. + +Housekeeping owed from #142 (tracked separately, not folded into this PR): the +`docs/active-work.md` folding lane and `docs/agent-handoff.md` §1 still predate +the #142 merge and want a docs PR refresh (the #138–#140 convention). From e221f131f0af09ecfcaa0fad51570306acbee34f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 16:08:22 -0400 Subject: [PATCH 02/12] =?UTF-8?q?docs(folding):=20Stage=202=20framing=20re?= =?UTF-8?q?v=202=20=E2=80=94=20address=20review=20round=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1's five findings + two rulings, all verified against c49a8c7: - F1 (major): nested folds. `head_of`(innermost) could clamp onto a still-hidden inner head. Replace with `visible_head_of` (outermost visible head); `view_top` clamps BACKWARD to the head, not forward past the fold; relative numbers anchor on the clamped visible cursor. - F2 (major): the consumer census was incomplete. Add the full §2.2 table — local selection (editor.rs:3241), peer presence (overlay_paint.rs:159, after paint_frame), mode-line indicator (editor.rs:3803), style/search/completion overlays — and make TUI peer-presence fold behavior explicit scope. - F3 (major): line numbers default Off => gutter_w==0 => no sign cell. Make the fold glyph conditional: off => ellipsis only; on => sign cell with diagnostic priority. Dedicated column (unconditional) named as a deferred layout change. - F4 (major): a frame-pinned map can't serve command-time motion, and Viewport is Copy. Reframe as one derivation primitive with per-phase short-lived instances (render via Option<&VisibleLineMap> on a lifetime-bearing Viewport, preserving Copy; after-frame direct; command-time fresh); home usable from EditorCore. - F5 (moderate): key the Lua-path widening on InteractiveCommandOrigin (editor.rs:53), hook the common run_buffer_edit (not only run_managed_edit) so bypass_intercept edits don't escape, require the target to be the invoking frontend's active-window buffer, and explicitly DEFER undo/redo unfold. Rulings: Q#FD17 include (normalize a hidden cursor to the visible head before stepping); #142 housekeeping stays a separate docs PR. Acceptance expanded to pin nested-fold/shared-cursor, local selection, peer presence, an ordinary overlay across a fold, completion anchoring, the scroll indicator, and both gutter-off/gutter-on fold-marker cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- docs/folding-stage2-framing.md | 862 +++++++++++++++++---------------- 1 file changed, 444 insertions(+), 418 deletions(-) diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md index 4a72e31..7b692f9 100644 --- a/docs/folding-stage2-framing.md +++ b/docs/folding-stage2-framing.md @@ -1,421 +1,434 @@ # Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6) -**Revision 1 — 2026-07-23. Status: DRAFT for review.** Parent architecture -(`docs/folding-framing.md`, rev 5) is APPROVED and Stage 1 (the headless fold -engine) is MERGED as **#142** (canonical `main` @ `c49a8c7`). This doc reframes -**Stage 2** in detail off that base, per the parent's §8/§14 ("each stage is -re-framed after the prior stage lands"). It carries the four Stage-2 obligations -the parent named, plus the one architectural fact the Stage-2 scout surfaced that -the parent only sketched. +**Revision 2 — 2026-07-23. Status: DRAFT for review (rev 1's five 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 +parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`. -Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`. +## 0. Revision history + +### Round 1 (rev 1 → rev 2) + +- **F1 (major) — nested folds resolved to the wrong head.** Rev 1's `head_of` + returned the *innermost* enclosing fold's head and assumed it visible; under + nesting an outer fold hides the inner head, so carets/diagnostics/relative + numbers would have clamped onto another *hidden* line. Rev 2 defines + **`visible_head_of(line)` = the head of the *outermost* enclosing fold** (the + only head guaranteed visible), used by every clamp. A hidden `view_top` clamps + **backward** to that head (not forward past the fold, which contradicted + acceptance 6). Relative/Hybrid numbering anchors on the **clamped visible + cursor**. New nested-fold / shared-cursor acceptance (§11). +- **F2 (major) — the mapping-consumer census was incomplete.** Rev 1 claimed all + painters route through `Viewport`. They do not: local selection + (`src/editor.rs:3241`) and the mode-line scroll indicator (`src/editor.rs:3803`) + paint from `paint_frame` with raw `view_top` arithmetic; peer cursor/selection + (`src/overlay_paint.rs:159`) runs *after* `paint_frame` and subtracts `view_top` + independently; the style overlay (`src/overlay.rs:385`), search wash + (`src/search.rs:492`), diagnostics (`src/diag.rs:563`), and the completion + anchor each derive `start_line + row_offset`. Rev 2 carries the **complete + consumer census** (§3.1), adds **TUI peer-presence fold behavior** (clamp a + hidden peer cursor to the visible head; drop/project hidden selection cells) as + explicit scope, and expands acceptance to pin local selection, peer presence, an + ordinary style/search overlay across a fold, completion anchoring, and the + scroll indicator. +- **F3 (major) — the fold glyph's sign cell does not exist by default.** Line + numbers default to `Off` ⇒ `gutter_width()==0` (`src/window.rs:216`); diagnostics + then fall back to a col-0 *background* on the first content cell + (`src/diag.rs:635`), so rev 1's "zero-width-change" gutter glyph had nowhere to + render. Rev 2 makes the fold glyph **conditional on a gutter existing** + (Q#FD20): gutter off ⇒ **ellipsis only**; gutter on ⇒ reuse the sign cell with + diagnostic priority. Acceptance covers both. The parent's unconditional + gutter-marker promise, if required, needs a **dedicated sign column** and an + acknowledged layout change (named alternative, §5/§9). +- **F4 (major) — a per-frame map instance cannot serve command-time motion, and + `Viewport` is `Copy`.** `move_up/down`, paging, wheel scroll, and clicks execute + **outside** the render frame, so they cannot reuse a map installed on that + frame's `Viewport`; and `Viewport` is compile-time-pinned `Copy` + (`src/view.rs:130`). Rev 2 reframes the map as **one shared derivation/query + primitive** with **separate short-lived instances** for rendering vs + command/event handling (Q#FD12); the render instance is threaded as + `Option<&'a VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** (a shared + ref is `Copy`, so `Viewport` stays `Copy`); the primitive's home is usable from + `EditorCore`, not render-only. +- **F5 (moderate) — tighter unfold seam + settled undo decision.** Rev 2 keys the + Lua-path widening on the existing **`InteractiveCommandOrigin`** + (`src/editor.rs:53`), not command-history inference; hooks the **common + `run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`) so interactive + `bypass_intercept` mutations do not escape; **requires the target to be the + invoking frontend's active-window buffer** (an explicit inactive-buffer Lua + mutation stays programmatic); keeps the Rust hook at `apply_active_edit` and the + `notify_buffer_edit` exclusion; and **explicitly defers undo/redo unfold** + (Q#FD19). + +### Rulings absorbed + +- **Q#FD17 = INCLUDE.** Vertical motion steps through *visible* lines; if motion + begins from a hidden logical cursor (shared fold or goto-line), it **first + 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`. ## 1. What Stage 2 ships -The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines -and paints a character grid it ships to the terminal client. Stage 1 built the +The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines and +paints a character grid it ships to the terminal client. Stage 1 built the instance-side fold store and produces `FoldState` for GPU sessions, but **no frontend renders a collapse yet** — the daemon grid path never consults the fold store. Stage 2 makes the **daemon grid renderer fold-aware**: -1. **Collapse** — omit each fold's hidden source lines; show the head line with a - trailing ellipsis; rows below shift up. -2. **Gutter fold marker** — a fold glyph on the head-line row (parent §7). -3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines, and - relative/hybrid distance is measured across the collapse (parent §8). +1. **Collapse** — omit each fold's hidden source lines; head line shows a trailing + ellipsis; rows below shift up. +2. **Gutter fold marker** — a fold glyph on the head-line row **when a gutter + exists** (Q#FD20); ellipsis-only otherwise. +3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines; + relative/hybrid distance is measured across the collapse, anchored on the + clamped visible cursor. 4. **Fold-aware diagnostic signs** — a sign on a hidden line clamps to the fold's - visible head-line row (parent §8, minor d). -5. **Fold-aware caret** — the caret never renders on a hidden line; it clamps to - the fold head (parent §8, Q#FD3 render-time invariant). -6. **Fold-aware viewport/scroll** — `view_top`, paging, and auto-scroll count - **visible** lines, not raw source lines (parent §8). + **visible head** row (most-severe merge). +5. **Fold-aware caret, local selection, and peer presence** — no caret, no + selection cell, and no peer cursor renders on a hidden line; each clamps to the + visible head or is projected/dropped. +6. **Fold-aware viewport/scroll/motion** — `view_top`, paging, wheel scroll, + clicks, auto-scroll, vertical line-motion, and the mode-line scroll indicator + all reckon in **visible** lines. 7. **Interactive-Lua-command unfold widening** — yank, query-replace, and - comment-toggle unfold a fold at their edit point before the edit is visible - (parent §8, R3-3), which is only observable once the TUI collapses. + comment-toggle unfold a fold at their edit point before the edit is visible. -**No wire schema changes and no protocol bump.** `FoldState` is already produced -(Stage 1) for semantic/GPU sessions; the TUI collapse is entirely daemon-side -(the vterm-Stage-2 shape). The GPU render path is untouched — that is Stage 3. -The store, the source, and the producer from Stage 1 are unchanged; Stage 2 only -*reads* the store in a new place (the daemon grid renderer) and *widens* one -pre-edit hook. +**No wire schema changes and no protocol bump.** `FoldState` production (Stage 1) +is untouched; the TUI collapse is entirely daemon-side (the vterm-Stage-2 shape); +the GPU render path is Stage 3. -## 2. Ground truth (scouted 2026-07-23, `main` @ `c49a8c7`) +## 2. Ground truth (scouted + review-verified 2026-07-23, `main` @ `c49a8c7`) -### 2.1 The grid render path is layered — and the collapse belongs deep in it +### 2.1 The grid render path is layered — collapse belongs deep in it -The parent framing (F1) located the collapse at "the daemon grid renderer, -`render_frame`." The scout refined this: `RenderState::render_frame` -(`src/instance_render.rs:98`) is a **diff shell** — it double-buffers cells and -emits `CellDelta`; it does **not** walk source lines. The source-line→grid-row -loop is two calls down: +`RenderState::render_frame` (`src/instance_render.rs:98`) is a **diff shell** (it +double-buffers cells, emits `CellDelta`); it does not walk source lines. The +source-line→grid-row loop is two calls down: ``` -RenderState::render_frame (src/instance_render.rs:98) — diff shell, no line walk +RenderState::render_frame (src/instance_render.rs:98) — diff shell → paint_frame (src/editor.rs:2796) — per-window composition; HAS state.fold_registry → window.text_view.render(buf, viewport, grid) (src/editor.rs:2948) - → TextView::render (src/text_view.rs:207) — THE line→row loop + → TextView::render (src/text_view.rs:207) — the line→row loop ``` -`TextView::render` is the primitive (`src/text_view.rs:213`): +`TextView::render` (`src/text_view.rs:213`) maps `line = start_line + row_offset` +— **strictly identity**, no skip, no wrap (long lines truncate). `Window.view_top` +(`src/window.rs:179`) is a **source-line index**. `DisplayCoord`'s doc +(`src/view.rs:108`) anticipates a non-identity map "once virtual lines, wrapping, +and inline expansions appear," but nothing implements one today — **folding is the +first**. -```rust -for row_offset in 0..max_rows { - let line = start_line + row_offset as usize; // source line (:214) - let cell_row = origin.row + row_offset; // grid row (:215) - ... -} -``` +### 2.2 The complete set of identity-assuming consumers (F2) -The mapping is **strictly identity** — `line = start_line + row_offset`, -contiguous +1, no skip, no wrap (long lines truncate at `max_cols`, they do not -wrap). This identity is repeated, un-abstracted, at **every** view_top-arithmetic -site: +Every site below assumes `display_row = source_line − view_top` (or the inverse) +and must consult the shared visible-line map. Rendering-frame sites, after-frame +sites, and command-time sites are distinguished because they need **different +instances** of the map (§3, F4): -| Site | file:line | assumes | -|---|---|---| -| render loop | `src/text_view.rs:214` | `line = view_top + row` | -| line-number gutter | `src/editor.rs:3215` | `buffer_line = view_top + r` | -| diagnostic signs | `src/diag.rs:563` | `row_offset = line − start_line` | -| caret grid row | `src/editor.rs:3044` | `grid_row = origin + (disp.row − view_top)` | -| click inverse | `src/editor.rs:2233` | `display_row = view_top + local_row` | -| auto-scroll clamp | `src/editor.rs:2866` | cursor row in `[view_top, view_top+rows)` | -| page/scroll | `src/editor_core.rs:1745`, `src/editor.rs:2264` | target = raw source row | +| # | Site | file:line | phase | +|---|---|---|---| +| 1 | text render loop | `src/text_view.rs:214` | render (Viewport) | +| 2 | line-number gutter | `src/editor.rs:3215` | render (paint_frame) | +| 3 | style/syntax overlay | `src/overlay.rs:385` | render (Viewport) | +| 4 | diagnostics overlay | `src/diag.rs:563` + `paint_line_markers` `:635` | render (Viewport) | +| 5 | search wash overlay | `src/search.rs:492` | render (Viewport) | +| 6 | completion popup anchor | completion overlay (byte→row) | render (Viewport) | +| 7 | caret grid row | `src/editor.rs:3044` | render (paint_frame) | +| 8 | local selection | `paint_local_selection`, `src/editor.rs:3241` | render (paint_frame) | +| 9 | mode-line scroll indicator | `format_scroll_indicator`, `src/editor.rs:3803` | render (paint_frame) | +| 10 | peer cursor + selection | `src/overlay_paint.rs:159` | **after** paint_frame | +| 11 | click inverse | `activate_and_position`, `src/editor.rs:2233` | command/event | +| 12 | auto-scroll clamp | `src/editor.rs:2866` | command/event | +| 13 | paging / wheel / vertical motion | `src/editor_core.rs:1745/1779`, `src/editor.rs:2264`, `move_up/down` | command/event | -`Window.view_top: usize` (`src/window.rs:179`) is a **source-line index**. -`DisplayCoord`'s own doc (`src/view.rs:108`) anticipates that row/col "diverge -once virtual lines, wrapping, and inline expansions appear," but **nothing -implements a non-identity map today** — `TextView` is the only base view and its -`pos_to_display`/`display_to_pos` are identity. **Folding is the first -non-identity source-line↔display-row mapping in the TUI.** +Overlays (1,3,4,5,6) already thread through `Viewport`; sites 2,7,8,9 run in +`paint_frame` directly; site 10 runs after `paint_frame` and re-derives +`gutter_w`/`view_top` itself (`src/overlay_paint.rs:143-160`); sites 11–13 run +during input dispatch, entirely outside any render frame. -**Consequence (the crux, Q#FD12).** Fold collapse cannot be a localized edit to -one loop, and it cannot be an overlay (overlays paint *on top of* laid-out rows; -they cannot delete rows or change the row count). It must be a **shared -fold-aware line map** that the render loop *and* all seven sites above consult. +### 2.3 The renderer can reach the fold store -### 2.2 The renderer can already reach the fold store +`fold_registry: SharedFoldRegistry` is a field on `EditorCore` +(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`). Both the +render path (`paint_frame`) and command-time code (`EditorCore` methods) reach it. +The read surface (`src/fold.rs`): `FoldRegistry::folds(buf) -> Vec` +(`:327`, whole-buffer, sorted); `FoldStore::containing(p)` (`:171`, byte-space, +`(start, end]`). **No line-space query exists** — Stage 2 adds it (§3). A fold's +`start` = end-of-head-line content byte, `end` = end-of-last-hidden-line content +byte, so `head_line = line_at_offset(start)`, hidden = `head_line+1 ..= +line_at_offset(end)`. Use the fold's `(start,end]` convention, never the +`ByteRange` struct doc's `[start,end)`. -`fold_registry: SharedFoldRegistry` is a field on both `EditorCore` -(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`). -`paint_frame` borrows `state`, so `state.fold_registry` + `window.buffer_id` are -in scope at the composition point (`src/editor.rs:2796`). The semantic producer -already reads it: `state.fold_registry.folds(buffer_id)` -(`src/semantic_render.rs:1433`). The **grid path reads it nowhere** yet — that is -the new consumer Stage 2 adds. +### 2.4 The gutter -The store's read surface (`src/fold.rs`): -- `FoldRegistry::folds(buf) -> Vec` (`:327`) — whole-buffer, sorted, - empty when no store. The one-call read a renderer wants. -- `FoldStore::containing(p) -> Vec` (`:171`) — byte-space containment, - `(start, end]` (start-exclusive, end-inclusive per `src/fold.rs:11`). -- **There is NO line-space query** ("is source line N hidden?"). Only byte-space - `containing` exists. Stage 2 must add the line-space view (§4). +- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; default **`Off`**) is + per-window (`Window.line_numbers`, `src/window.rs:188`). Number rule + `number_for(line, cursor_line)` (`:1197`) uses **raw-line `abs_diff`** for + relative distance. +- `paint_line_number_gutter` (`src/editor.rs:3177`): `buffer_line = view_top + r` + (`:3215`) → `number_for(...)`; `cursor_line = line_at_offset(window.cursor)` + (`:3189`). +- **Gutter width is 0 when line numbers are Off** (`Window::gutter_width`, + `src/window.rs:216`). When on, width = `decimal_digits(line_count)+2`, fixed to + the absolute count (no jitter; folding does not change `line_count`). +- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`) → + `paint_line_markers` (`:635`): `gutter_w>0` draws the sign glyph in the gutter's + leading cell; **`gutter_w==0` falls back to a col-0 background** on the first + content cell (the "fake gutter"). So a dedicated sign cell only exists with a + gutter on (F3). -A fold `ByteRange` is `start` = end-of-head-line content byte, `end` = -end-of-last-hidden-line content byte (parent §3/§5). So the byte→line conversion -is exact: `head_line = line_at_offset(start)`, hidden lines = -`head_line+1 ..= line_at_offset(end)`, head line and (for brace nodes) the closer -line stay visible. `ByteRange`'s struct doc (`pmacs-protocol/src/ids.rs:72`) calls -itself half-open `[start,end)`; the **fold engine overrides that to `(start, -end]`** — use the fold semantics, never the struct doc. +### 2.5 The interactive-edit unfold seam (F5, correcting rev 1) -### 2.3 The gutter +Stage 1's `unfold_before_point_edit` (`src/editor_core.rs:1847`, reads +`active_window().cursor`) is called at the top of the six `EditorCore` primitives; +the shared `apply_active_edit` (`:1266`) they call does not itself unfold. -- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; `Off`/`Absolute`/ - `Relative`/`Hybrid`; default `Off`) is per-window (`Window.line_numbers`, - `src/window.rs:188`). The number rule is - `LineNumberMode::number_for(line, cursor_line)` (`:1197`) — and it measures - relative distance as **`abs_diff` in raw source-line space**. -- The daemon computes the column in `paint_line_number_gutter` - (`src/editor.rs:3177`): `buffer_line = view_top + r` (`:3215`) → - `number_for(buffer_line, cursor_line)` (`:3224`); `cursor_line = - line_at_offset(window.cursor)` (`:3189`). -- Gutter **width** is `decimal_digits(line_count) + 2` (`src/window.rs:216`), - sized to the **absolute** line count and fixed per frame. Folding does not - change `line_count`, so **the gutter width is stable** (no jitter) — a fold does - not shrink the field. -- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`), `row_offset = - line − start_line_buf` (`:563`), most-severe-per-row into `line_markers` - (`:570`), painted by `paint_line_markers` (`:635`) with - `DiagnosticSeverity::gutter_glyph()` (`:75`) at window col 0 (the gutter's - leading cell). Diagnostics render **after** the number gutter (overlay order, - `src/editor.rs:2943-2954`), so a sign is not erased by the digits. -- Gutter column order across `[origin.col .. origin.col+gutter_w)`: - **[sign @ col 0 | right-aligned digits | content @ origin+gutter_w]**. Every - painter reads `Viewport.gutter_w` (`src/view.rs:144`) so it stays gutter-blind. +- **Yank / query-replace** are **`apply_active_edit` callers** (local; never the + remote path): yank → `clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit` + (`:2544/2570`); query-replace → `query_replace_apply_current`→`apply_active_edit` + (`:1129/1137`). They skip the six primitives, so they do not unfold today. +- **Comment-toggle / yank-pop** take the Lua mutator path. The common entry is + **`run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`), which dispatches to + `run_managed_edit` (`:1347`) *or* `run_bypass_edit` (`:1318`) on the + `bypass_intercept` flag; both call `apply_edit_skip_intercepts` then + `notify_buffer_edit_to_windows`. Hooking only `run_managed_edit` would let an + interactive `bypass_intercept` edit escape — hook `run_buffer_edit`. +- **`InteractiveCommandOrigin`** (`src/editor.rs:53`) is an ephemeral, Lua-app-data + authenticated origin: `.current() -> Option` is the frontend while an + interactive command runs; `.enter(fid)` returns a guard that restores on drop and + **clears even when a Lua command errors**. This is the scoped authority for the + Lua-path widening — no command-history inference needed. +- **Undo/redo** reach the buffer through `notify_buffer_edit_to_windows` directly + (`add_history_methods`, `src/lua_bindings/mod.rs`), not `apply_active_edit` nor + `run_buffer_edit`. -### 2.4 The interactive-edit unfold seam (correcting the parent's premise) +### 2.6 Scroll/viewport commands count raw lines; no recenter -Stage 1 wired the pre-edit unfold as `unfold_before_point_edit` -(`src/editor_core.rs:1847` — reads `active_window().cursor`, calls -`fold_registry.unfold_containing(id, point)`) called at the **top** of the six -`EditorCore` primitives (`insert_char` :1858, `insert_char_over_region` :1894, -`backspace` :1936, `delete_forward` :1958, `delete_word_backward` :1982, -`delete_word_forward` :2010). The shared `apply_active_edit` -(`src/editor_core.rs:1266`) they all call does **not** itself unfold. +`move_page_down/up` (`src/editor_core.rs:1745/1779`), `scroll_window` +(`src/editor.rs:2264`), `move_to_line` (`:708`), and the mode-line indicator all +work in **raw source-line** space. There is no `beginning/end-of-buffer` command +and **no recenter** (deferred, blocked on viewport facts; handoff §6). Stage 2 +inherits the recenter deferral. -The parent framing (R3-3) assumed yank/query-replace/comment-toggle all "mutate -through the Lua mutator path." **The scout corrected this:** +## 3. The shared visible-line map primitive (Q#FD12) -- **Yank (`C-y`)** and **query-replace** are **Rust `apply_active_edit` - callers**, not Lua-mutator callers: yank → - `clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit` - (`src/editor_core.rs:2544/2570`); query-replace → - `query_replace_apply_current`→`apply_active_edit` (`:1129/1137`). They skip the - six primitives, so they do **not** unfold today — but they are inherently - **local active-frontend** edits (`apply_active_edit` is never the path for a - remote/optimistic-CRDT apply). -- **Comment-toggle** (`comment.lua:172`, one `buf:replace`) and **yank-pop** - (`killring.lua:356`) take the **Lua mutator path**: `add_mutation_methods` - (`src/lua_bindings/mod.rs:1227`) → `run_managed_edit` (`:1347`) → - `Buffer::apply_edit_skip_intercepts` → `EditorCore::notify_buffer_edit` - (`src/editor_core.rs:1364`). This path has no unfold — **and it is shared with - remote/optimistic-CRDT applies**, which are the parent's named Stage 3 concern. +Stage 2's spine is **one derivation/query primitive** — a `VisibleLineMap` type +plus a builder — computed from `state.fold_registry.folds(buffer_id)` and the +buffer's line offsets. It is **not one instance pinned to a frame**: it is built as +**short-lived instances** wherever a source↔display mapping is needed (F4), and its +home is a module usable from **both** the render path and `EditorCore` (not +render-only). Candidate home: `src/fold_view.rs`, or a `FoldStore` +convenience that returns hidden-line intervals given the line-offset table (the +byte→line conversion then lives beside the `(start,end]` convention it must match). +The byte-range store in `src/fold.rs` stays the single source of truth; the map is +derived, never stored. -The dispatcher stamps `active_frontend = frontend_id` **before** any command body -runs (`src/editor.rs:807`; also `src/daemon.rs:999`), so the acting frontend and -its point (`active_window().cursor`) are available throughout an interactive -command — the exact pair `unfold_before_point_edit` already reads. There is a -narrower one-shot provenance record (`TypedEditRecord`/`take_typed_edit`, -`src/editor_core.rs:161/2477`) used by auto-pairing, but it is codepoint-scoped -self-insert only and cannot carry these multi-byte edits — Stage 2 needs a -position-keyed signal, not that record. +### 3.1 Queries -### 2.5 Scroll/viewport commands all count raw lines; there is no recenter +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**: -`move_page_down`/`move_page_up` (`src/editor_core.rs:1745/1779`, step = -`last_visible_rows − 1`), `scroll_window` (mouse wheel, `src/editor.rs:2264`, -`SCROLL_LINES = 3`), and `move_to_line`/goto-line (`src/editor_core.rs:708`) all -compute targets in **raw source-line** space via `Window::text_view` and mutate a -raw-line `view_top`. There is **no `beginning/end-of-buffer` command** and **no -recenter** — recenter is explicitly deferred, blocked on viewport facts (the GPU -never consumes daemon `view_top`); confirmed in -`docs/editing-conveniences-framing.md:217` and the handoff §6. Stage 2 inherits -that deferral (recenter stays out). +- `is_hidden(line) -> bool` — `line ∈ H`. +- **`visible_head_of(line) -> line`** — for a hidden `line`, the head 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. **Every clamp uses this** — caret, + diagnostics, peer cursor, relative-number cursor anchor, and the `view_top` + backward clamp. (Rev 1's innermost `head_of` is removed — F1.) +- `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` + (relative/hybrid distance; paging). +- **`clamp_view_top(line) -> line`** — if `line ∈ H`, `visible_head_of(line)` + (**backward**, so a fold at the top shows its head — acceptance 6); else `line`. + This replaces rev 1's forward `first_visible_at_or_after`, which skipped past the + fold and hid the head. -## 3. The shared fold-aware line map (Q#FD12) +Build cost is O(folds) (folds are O(top-level blocks), parent B2), reusing the +render path's existing line-offset table — **Bet B4**. -Stage 2's spine is one primitive, derived per window per frame from -`state.fold_registry.folds(buffer_id)` and the buffer's line offsets (already -computed for rendering). It answers, in **line space**: +### 3.2 Instances per phase (F4) -- `is_hidden(line) -> bool` — is this source line inside some fold's hidden - interval `head+1 ..= last_hidden`? -- `head_of(line) -> Option` — if hidden, the visible head line of its - (innermost) enclosing fold. -- `next_visible(line)` / `prev_visible(line)` — walk to the next/previous - **visible** source line (skipping whole folds). -- `visible_between(a, b) -> usize` — count of visible lines in `[a, b)`, for - relative distance and paging. -- `first_visible_at_or_after(line)` — clamp a candidate `view_top` to a visible - line. +- **Render frame.** `paint_frame` (`src/editor.rs:2796`) builds one instance and + (a) threads it to the overlay painters (1,3,4,5,6) as `Option<&'a + VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** — a shared ref is + `Copy`, so `Viewport` stays `Copy` (F4); (b) passes the **same** instance + directly to the in-`paint_frame` sites (2,7,8,9). The `View::render` signature + becomes `Viewport<'_>`; every construction site gains the field (non-fold callers + pass `None`). This is a settled compile-time change across the `View` impls and + `Viewport` constructions. +- **After the frame.** The peer-presence pass (`src/overlay_paint.rs`, site 10) + runs after `paint_frame` and takes its own arguments; it receives (or builds) a + fresh instance for the same buffer and clamps peer cursors / projects peer + selection through it. +- **Command / event.** Click inverse, auto-scroll, paging, wheel, and vertical + motion (sites 11–13, in `EditorCore` / `EditorState`) each build a short-lived + instance from `state.fold_registry` + the buffer at call time. "The map is + already built" (rev 1) was false for these — they run outside any frame. -Implementation shape: convert `folds()` to a sorted, non-overlapping set of -**hidden-line intervals** (nested folds collapse to the union of their hidden -lines — a hidden line is hidden regardless of which fold owns it). Byte→line uses -the render path's existing line-offset table; the fold set is O(top-level blocks) -(parent B2), so the build is cheap (**Bet B4**). The map is a *derived per-frame -view*, not new stored state — the byte-range store in `src/fold.rs` stays the one -source of truth; nested/overlap normalization happens in the derivation. - -**`view_top` stays a source-line index** (Q#FD12, **Bet B5**): it is only ever -clamped to a *visible* line (`first_visible_at_or_after`). Keeping it in -source-line space avoids a second coordinate space and preserves the saveplace -contract (`view_top` is persisted, `saveplace.lua:60`) and the existing -`_view_top`/`set_view_top` Lua surface. The visible-line *ordinal* is derived -where needed (line numbers, paging), never stored. - -Home of the primitive: a Stage-2 render-side helper (e.g. `src/fold_view.rs` or a -function on the render path) that takes `&FoldStore`/`folds()` + line offsets and -yields the queries above. It is **not** put on `FoldStore` itself — the store is -byte-space and frontend-agnostic; the line-space view is a rendering concern. (A -thin convenience like `FoldStore::hidden_line_intervals(&line_offsets)` may live -in `fold.rs` if it keeps the byte→line conversion beside the containment -convention it must match; TBD in implementation, not load-bearing.) +`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 +(`saveplace.lua:60`) and `_view_top`/`set_view_top` contracts. Visible-line +*ordinals* are derived where needed, never stored. ## 4. Collapse rendering (Q#FD13) -**Threading.** `TextView::render` (`src/text_view.rs:207`) takes only `(buf, -viewport, cells)` and has no fold handle. The fold-aware line map is carried on -**`Viewport`** (widening it beside `gutter_w`) so that `TextView::render` *and* -every overlay painter (syntax, diagnostics, selection, search) see the **same** -map and their rows stay aligned. `paint_frame` builds the map (it holds -`state.fold_registry` + `window.buffer_id`) and installs it on the `Viewport` it -already constructs (`src/editor.rs:2932`). This mirrors how `gutter_w` is -threaded to keep painters gutter-blind (§2.3) — now they become fold-blind too, -consulting the map rather than each re-deriving it. - -**The loop.** `TextView::render` advances over **visible** lines: row `r` shows -the `r`-th visible source line at/after `view_top` (via `next_visible`). Hidden -lines are skipped; rows past the last visible line clear as today. - -**Head line + ellipsis.** A visible line that is a fold head renders its real -content, then a trailing ellipsis marker (` …`) in the **content area** after its -text (clipped like any long line). The ellipsis is the authoritative, -layout-neutral fold indicator. - -**Overlays.** Syntax spans, diagnostics, selection washes, and search hits all -paint per row through `Viewport`; because they now read the same visible map, a -span/wash on a hidden line is simply not painted (its row does not exist), and a -span on a visible line lands on the right row. No overlay tries to delete a row — -the row set is already fold-correct by the time overlays run. +`TextView::render` (`src/text_view.rs:207`) advances over **visible** lines via +`next_visible`: row `r` shows the `r`-th visible source line at/after +(already-visible) `view_top`; hidden lines are skipped; trailing rows clear as +today. A visible head line renders its real content then a trailing ellipsis +marker (` …`) in the **content area** (clipped like any long line) — the +authoritative, layout-neutral fold indicator. Overlays (1,3,4,5,6) read the same +map through `Viewport`, so a span/wash/sign/anchor on a hidden line is simply not +painted (its row does not exist) and one on a visible line lands on the right row. +Folding is **not** an overlay — overlays cannot delete rows. ## 5. Gutter: line numbers + fold glyph (Q#FD14, Q#FD20) -**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`) -walks visible lines (row `r` → the `r`-th visible line at/after `view_top`, same -map as §4): +**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`) walks +visible lines (row `r` → the `r`-th visible line). **Absolute** shows that line's +raw `line+1` (hidden numbers just do not appear — the column jumps from the head's +number to the first post-fold number). **Relative/Hybrid** measure distance in +**visible** lines: `visible_between(anchor, row_line)`, where the cursor **anchor** +is `visible_head_of(cursor_line)` (F1 — the cursor may be on a hidden shared-fold +line). Absolute still needs the raw line, so the walk carries both the raw source +line and the visible ordinal. Gutter width is unchanged (§2.4). -- **Absolute** shows that visible line's raw `line + 1` — unchanged rule; hidden - numbers simply do not appear, so the column jumps from the head's number to the - first post-fold number with no gap-filling. -- **Relative / Hybrid** measure distance in **visible** lines: - `visible_between(cursor_line, row_line)` (signed by direction), **not** - `number_for`'s raw `abs_diff`. Concretely, the caller feeds `number_for` a - *visible* distance (and, for Hybrid's cursor row, the raw `line + 1`). Absolute - keeps needing the raw line, so the walk carries both the raw source line and the - visible ordinal. +**Fold glyph (Q#FD20, F3).** Conditioned on a gutter existing: +- **Line numbers off (`gutter_w==0`, default):** no sign cell exists, so the fold + marker is the **content-area ellipsis only**; the diagnostic col-0 background + fallback is unchanged. +- **Line numbers on (`gutter_w>0`):** on a head-line row, draw a fold glyph in the + gutter's col-0 sign cell **unless a diagnostic clamps there** (Q#FD15) — + diagnostic wins (an error inside the fold is higher-signal). -Gutter **width** is unchanged (`decimal_digits(line_count)+2`, §2.3) — sized to -the absolute count, so it neither jitters nor shrinks when a fold collapses. - -**Fold glyph (Q#FD20).** Parent §7 promises a gutter fold marker. To honor it -with **zero layout change** and no contention: on a head-line row, draw a fold -marker in the gutter's **col-0 sign cell** *only when that row carries no -diagnostic sign*. A diagnostic clamped onto the head (Q#FD15) is higher-signal -("there is an error inside this fold") and wins col 0; otherwise the fold glyph -occupies it. This adds no gutter column and keeps the width rule intact. (A -dedicated fold column is deferred, §9 — it would widen the gutter and fight the -sign cell.) +This keeps the gutter width rule intact and adds no column. Making the gutter +marker **unconditional** would require a **dedicated fold sign column** and an +acknowledged layout change; deferred (§9). The parent §7's promise is thus honored +*when a gutter is present*, with the ellipsis as the universal indicator. ## 6. Diagnostic signs on hidden lines (Q#FD15) -`DiagnosticView::render` (`src/diag.rs:496`), before recording a marker for a -source `line` (`:563/:570`): if `is_hidden(line)`, **remap it to `head_of(line)`** -(clamp to the visible head-line row) rather than dropping it. The existing +`DiagnosticView::render` (`src/diag.rs:496`): before recording a marker for source +`line` (`:563/:570`), if `is_hidden(line)` **remap to `visible_head_of(line)`** +(clamp to the outermost visible head, F1), not drop. The existing most-severe-per-row merge (`:570`) then makes the head row show the most severe -sign among the head's own diagnostics and every hidden line under its fold. Clamp -(not drop) preserves the "there is a problem inside this collapsed region" signal -— the reason a fold marker exists. +sign among the head and every hidden line under its fold. Clamp (not drop) +preserves the "there is a problem inside this collapsed region" signal. +`DiagnosticView` reaches the map through `Viewport` (§3.2), needing no separate +handle. -`DiagnosticView` reaches the map the same way `TextView` does — through -`Viewport` (§4) — so it needs no separate fold-registry handle and every overlay -stays uniformly fold-aware. Its resolved head row then routes through the same -visible map as the numbers, so sign and number agree on the row. +## 7. Caret, selection, peer presence, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) -## 7. Caret, click, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) +**Caret clamp (Q#FD16).** Caret grid-row (`src/editor.rs:3033-3044`): if the +logical cursor's line `is_hidden`, render at `visible_head_of(line)`'s row — +satisfying the parent's per-cursor render-time invariant (Q#FD3), including the +shared-store case. -**Caret clamp (Q#FD16).** The caret grid-row (`src/editor.rs:3033-3044`): if the -logical cursor's source line `is_hidden`, render the caret at `head_of(line)`'s -row (then map through the visible map like any row). This satisfies the parent's -per-cursor render-time invariant (Q#FD3) — including the shared-store case where -another frontend folded a region around this frontend's cursor. +**Local selection (Q#FD16, F2).** `paint_local_selection` (`src/editor.rs:3241`): +selection cells on hidden lines are dropped; the visible portion projects through +the map (a selection spanning a fold paints on the visible head row and the visible +tail rows, contiguous on screen). -**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2228`): a -click on grid row `k` maps to the `k`-th visible line (via the map), so a click -can never land the cursor on a hidden line. +**Peer presence (Q#FD16, F2).** `src/overlay_paint.rs:159`: a peer cursor on a +hidden line clamps to `visible_head_of`; peer selection cells on hidden lines are +dropped/projected exactly like local selection. Folds are per-buffer/shared, so the +peer's map is the same buffer's map. -**Viewport / scroll (Q#FD18).** `view_top` is clamped to a visible line -(`first_visible_at_or_after`) wherever it is set. Paging and wheel scroll count -**visible** rows: `move_page_down`/`move_page_up` -(`src/editor_core.rs:1745/1779`) advance `view_top` and the cursor by a screenful -of *visible* lines (walk `next_visible`/`prev_visible` `page_step` times); -`scroll_window` (`src/editor.rs:2264`) advances by visible lines; the -auto-scroll-to-cursor clamp (`src/editor.rs:2866`) keeps the cursor's *visible* -row within the viewport. `move_to_line`/goto-line targets a raw line; if that -line is hidden, `view_top` is set so the target's **head** is visible (Stage 2 -does not auto-unfold on goto — revealing-by-unfold is the deferred search-reveal, -§9). Recenter stays deferred (§2.5). +**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2233`): grid +row `k` maps to the `k`-th visible line, so a click never lands on a hidden line. -**Cursor line-motion (Q#FD17) — SCOPE DECISION, recommendation: include.** -`next-line`/`prev-line` (and the arrow keys) today step to the same column on the -adjacent **source** line. Recommended for Stage 2: they step to the adjacent -**visible** line (`next_visible`/`prev_visible`), so a collapsed region is one -motion step and the cursor never *rests* on a hidden line — the caret clamp -(Q#FD16) becomes a backstop for the shared-fold case rather than the primary -guard, and the visible-line map is already built. This is small (it reuses the -map at the line-motion functions in `editor_core`) and makes the collapse feel -coherent. **Alternative (smaller Stage 2):** leave line-motion in raw source-line -space; rely solely on the render-time caret clamp; a down-arrow into a fold lands -the logical cursor on a hidden line (drawn clamped), and the next edit unfolds. -This is less code but a confusing "cursor entered the fold invisibly" UX. *This -is the one scope fork I most want your ruling on.* +**Viewport / scroll (Q#FD18).** `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 +(`next_visible`/`prev_visible`, `visible_between`). `move_to_line`/goto-line targets +a raw line; if hidden, `view_top` clamps so the target's **head** is visible (no +auto-unfold — that is the deferred search-reveal, §9). The **mode-line scroll +indicator** (`format_scroll_indicator`, `src/editor.rs:3803`) computes All/Top/Bot/% +in **visible-line** space (visible total, `view_top`'s visible ordinal, cursor's +visible ordinal). Recenter stays deferred (§2.6). + +**Vertical line-motion (Q#FD17 — RULED: include).** `next-line`/`prev-line` (and +arrows) step to the adjacent **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** (after a shared fold or a goto-line into a +fold), it **first normalizes to `visible_head_of(cursor)`**, then steps to the +preceding/following visible line. The render-time caret clamp (Q#FD16) remains the +backstop for the pre-motion shared-fold frame. These reuse the command-time map +instance (§3.2). ## 8. Interactive-Lua-command unfold widening (Q#FD19) -The parent's Stage 2 obligation (R3-3): widen the pre-edit unfold beyond the six -`dispatch_key` primitives to the interactive Lua commands. Per §2.4 the targets -split across two funnels, and the widening must stay **local-interactive only** — -it must NOT unfold on the remote/optimistic-CRDT apply path (parent's Stage 3 -"CRDT-origin unfold"). +Widen the pre-edit unfold beyond the six `dispatch_key` primitives to the +interactive Lua commands, **local-interactive only** (never the remote/optimistic +CRDT path — parent Stage 3). - **`apply_active_edit` funnel (yank, query-replace, and the six).** Move the pre-edit unfold to the **top of `apply_active_edit`** (`src/editor_core.rs:1266`), - keyed on the active frontend's point (as `unfold_before_point_edit` already is). - This subsumes the six primitives' individual calls (they are then **retired** — - one funnel) and covers yank (`clipboard_paste`→`insert_bytes_over_region`) and - query-replace (`query_replace_apply_current`) for free. `apply_active_edit` is - never the remote-apply path, so this funnel is inherently local — no CRDT - leakage. -- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** These reach - `run_managed_edit` (`src/lua_bindings/mod.rs:1347`). Unfold there **only when the - edit runs inside an interactive-command dispatch for the active frontend**, keyed - on the requested edit's start position (`edit.range.start`). Gating on the - interactive-command context is what keeps a plain programmatic `buf:insert` (a - plugin, or the Stage-1 data-API `pmacs.fold.fold`) from unfolding — matching - Stage 1's named data-API exemption ("programmatic, no invoking point"). The - dispatcher already stamps `active_frontend` and rotates command boundaries - around interactive commands (`this_command`/`invoke_interactive`), so the - "interactive command in flight for frontend F" context is available; Stage 2 - consults it, adding at most a small dispatch-scoped marker if none is directly - readable. -- **Explicitly EXCLUDED (Stage 3):** the shared `notify_buffer_edit` - (`src/editor_core.rs:1364`) remote/optimistic-CRDT apply path. A remote peer's - edit inside *my* fold must not unfold it; a GPU user's own optimistic-CRDT edit - at *their* point is the parent's Stage 3 obligation, wired when the GPU renders - folds. -- **Undo/redo (named minor).** `undo`/`redo` reach the invalidation funnel - directly (`src/editor_core.rs:2062/2102`), not `apply_active_edit`. They are - local active-frontend operations; Stage 2 **may** unfold at the edit position on - undo/redo (desirable — an undo that re-touches a folded region should reveal it) - or defer it. Not a blocker; decided in implementation. + keyed on the active frontend's point. This subsumes the six primitives' + individual calls (retired — one funnel) and covers yank + query-replace for free. + `apply_active_edit` is never the remote-apply path, so this funnel is inherently + local. +- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** Hook the **common + `run_buffer_edit`** (`src/lua_bindings/mod.rs:1305`) — above both + `run_managed_edit` and `run_bypass_edit`, so an interactive `bypass_intercept` + edit does not escape (F5). Unfold **only when all** hold: (i) + `InteractiveCommandOrigin.current()` is `Some(f)` (`src/editor.rs:53`); (ii) the + edited buffer **is `f`'s active-window buffer** (an explicit inactive-buffer Lua + mutation stays programmatic — no unfold); (iii) `edit.range.start` is inside a + fold. Condition (i)+(ii) is what distinguishes an interactive command's edit at + the point from a plugin's/data-API's programmatic edit (matching Stage 1's + data-API exemption). +- **Excluded (Stage 3):** the remote/optimistic-CRDT apply path + (`notify_buffer_edit`, `src/editor_core.rs:1364`). A remote peer's edit inside my + fold must not unfold it; a GPU user's own optimistic edit is the parent's Stage 3 + obligation. +- **Undo/redo — DEFERRED (F5 ruling).** `undo`/`redo` reach + `notify_buffer_edit_to_windows` directly; their unfold behavior is **explicitly + deferred** (§9), not decided at implementation time. -Every path is keyed on the **edit position inside a fold**, and every widened -behavior is **bite-verified** (a test that fails without the widening). +Each widened behavior is **bite-verified** (a test failing without the widening). ## 9. Deferred (named) -- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity, - the `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin / - GPU-optimistic interactive unfold (parent R2-3). -- **Recenter** and any frontend scroll control — blocked on viewport facts (the - GPU not consuming `view_top`); Arc 8 adjacent (§2.5). -- **Search-reveal** — a match (isearch/query-replace) inside a fold auto-unfolds - to show the hit. Parent §11 marks it "Stage 2+"; deferred here to keep Stage 2 - focused (goto-line likewise does not auto-unfold, §7). -- **A dedicated gutter fold column** — Q#FD20 uses the existing col-0 sign cell; - a separate column (and its width recompute) is deferred. -- **Fold-aware horizontal/word motion** and screen-line editing semantics beyond - vertical line-motion. -- **Persisted folds, `hide-level N`, auto-fold-on-open** (parent §11). -- **Fold-store revalidation on revert/reload** (parent §11; v1 still drops it). -- If Q#FD17 is decided "alternative," fold-aware vertical line-motion moves here. +- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity, the + `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin / GPU-optimistic + interactive unfold (parent R2-3). +- **Undo/redo unfold** (F5 ruling) — deferred. +- **Recenter** and any frontend scroll control — blocked on viewport facts; Arc 8 + adjacent (§2.6). +- **Search-reveal** — a match inside a fold auto-unfolds; parent §11 "Stage 2+"; + goto-line likewise does not auto-unfold (§7). +- **A dedicated gutter fold column** (unconditional marker) — its width recompute / + layout change is deferred; Q#FD20 uses the conditional sign cell. +- **Fold-aware horizontal/word motion** and screen-line editing beyond vertical + line-motion. +- **Persisted folds, `hide-level N`, auto-fold-on-open**; fold-store revalidation on + revert/reload (parent §11; v1 still drops it). ## 10. Bets -- **B4** The per-frame visible-line map (derived from `folds()` + line offsets) is - cheap: folds are O(top-level blocks), byte→line reuses the render path's line - table. No incremental/stored map needed. FALSIFIABLE on a pathological - many-fold buffer (mitigation: the derivation is O(folds), not O(lines)). -- **B5** Keeping `view_top` a source-line index (clamped to visible) is less churn - and preserves the saveplace/`set_view_top` contracts, versus introducing a - visible-ordinal coordinate space. -- **B6** No wire schema or protocol change: `FoldState` production (Stage 1) is - untouched; the TUI collapse is purely daemon-side; a semantic GPU session still - gets `FoldState` and renders nothing new until Stage 3. +- **B4** The per-instance visible-line map (from `folds()` + line offsets) is cheap: + O(folds) build, reusing the render path's line table. Multiple short-lived + instances per interaction are still O(folds) each. FALSIFIABLE on a pathological + many-fold buffer (mitigation: derivation is O(folds), not O(lines)). +- **B5** `view_top` stays a source-line index (clamped via `clamp_view_top`) — + less churn than a visible-ordinal coordinate space, and preserves saveplace / + `set_view_top`. +- **B6** No wire/protocol change: `FoldState` (Stage 1) untouched; the TUI collapse + is daemon-side; a semantic GPU session still gets `FoldState` and renders nothing + new until Stage 3. +- **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. ## 11. Acceptance — Stage 2 @@ -423,94 +436,107 @@ Tests assert on the **rendered cell grid** through a real-daemon / real-TUI grid harness (the vterm-Stage-2 real-PTY smoke and the UX-gutter daemon-acceptance are the precedents), sized for macOS startup + long temp-path width (handoff §5). -1. **Collapse.** Fold a region; the daemon frame omits the hidden source lines, - the head line shows its text + ellipsis, rows below shift up, and the content - row count equals the visible-line count. -2. **Head marker.** The head-line row shows the ellipsis (content area) and — when - no diagnostic clamps there — the gutter fold glyph; hidden rows are absent. -3. **Line numbers.** Absolute mode skips hidden numbers (head's number, then the - first post-fold number, no gap-fill). Relative/Hybrid distance is measured in - **visible** lines across a fold (a line two visible rows past the cursor shows - `2` even if a fold hides ten source lines between them). -4. **Diagnostic clamp.** A diagnostic on a hidden line surfaces as the sign on the - head-line row; most-severe wins across the head and all its hidden lines; no - sign on an absent row. -5. **Caret clamp.** With the logical cursor on a hidden line (folded via the - shared store from a second frontend), the caret renders on the head-line row; a - click on a grid row never selects a hidden line. -6. **Viewport / paging.** Page-down advances by a screenful of **visible** lines - across a fold; `view_top` never lands on a hidden line; scrolling with a fold at - the top clamps to its head; goto-line to a hidden line leaves the target's head - visible. -7. **Interactive unfold widening (Q#FD19).** A **yank** at a point inside a fold - unfolds it before the paste is visible; likewise a **query-replace** - replacement and a **comment-toggle** whose edit point is inside a fold. A - **programmatic** `pmacs.fold`-adjacent `buf:insert` inside a fold does **not** - unfold (it translates). Each assertion is **bite-verified**; the test documents - that CRDT-origin unfold remains the Stage 3 obligation. -8. **(If Q#FD17 = include)** `next-line`/`prev-line` step across a collapsed region - as one motion; the cursor never rests on a hidden line. -9. **No wire/protocol change.** A semantic session still receives `FoldState` - exactly as in Stage 1; the protocol version is unchanged; the GPU render path - is untouched. (Pin: the Stage-1 `FoldState` producer transitions test still - passes verbatim.) -10. **Shared store, independent viewports.** Two TUI windows on one buffer both - render the same fold collapsed, each with its own correct `view_top`, gutter, - and caret — folds are per-buffer/shared, viewports are per-window. +1. **Collapse.** Fold a region; the frame omits the hidden lines, the head shows + text + ellipsis, rows below shift up, content row count = visible-line count. +2. **Head marker, both gutter states (F3).** With line numbers **off**: the head + shows the ellipsis and **no gutter glyph** (width unchanged, 0). With a + line-number mode **on**: the head shows the ellipsis **and** the gutter fold + glyph (unless a diagnostic clamps there — then the diagnostic sign). +3. **Line numbers.** Absolute skips hidden numbers (head's number, then first + post-fold number, no gap-fill). Relative/Hybrid distance is measured in + **visible** lines across a fold, anchored on the visible cursor head. +4. **Diagnostic clamp (F1).** A diagnostic on a hidden line surfaces on the + **outermost visible head** row; most-severe wins across the head and all its + hidden lines (including a diagnostic on a *nested* inner-fold line clamping to + the outer head). +5. **Nested-fold / shared cursor (F1).** With a fold nested inside another and the + logical cursor on a deeply-hidden line (folded via the shared store from a + second frontend), the caret renders on the **outermost** visible head; relative + numbers anchor there; a `view_top` set into the nest clamps **backward** to that + head. +6. **Caret, local selection, peer presence (F2).** Caret on a hidden line → head + row. A local selection spanning a fold paints on the visible head + visible tail + rows, nothing on hidden rows. A peer cursor on a hidden line clamps to the head; + peer selection cells on hidden lines drop/project. A click never selects a hidden + line. +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. +8. **Viewport / paging / indicator (F2).** Page-down advances by a screenful of + **visible** lines across a fold; `view_top` never rests hidden; a fold at the top + clamps to its head; goto-line to a hidden line leaves its head visible; the + mode-line indicator reports Top/Bot/% in visible-line space. +9. **Vertical motion (Q#FD17).** `next-line`/`prev-line` step across a collapsed + region as one motion; starting from a hidden logical cursor, motion first + normalizes to the visible head; the cursor never rests hidden. +10. **Interactive unfold widening (Q#FD19).** A **yank**, a **query-replace** + replacement, and a **comment-toggle** whose edit point is inside a fold each + unfold it before the edit is visible; a **bypass-intercept** interactive edit at + the point also unfolds (proving the `run_buffer_edit` seam). A **programmatic** + `buf:insert` — and an interactive command mutating an **inactive** buffer — do + **not** unfold. **Undo/redo do not unfold** (deferred). Each assertion is + **bite-verified**; the test documents CRDT-origin unfold as the Stage 3 + obligation. +11. **No wire/protocol change.** A semantic session still receives `FoldState` + exactly as in Stage 1; the protocol version is unchanged; the GPU render path is + untouched (the Stage-1 `FoldState` producer transitions test passes verbatim). +12. **Shared store, independent viewports.** Two TUI windows on one buffer both + render the same fold collapsed, each with its own correct `view_top`, gutter, and + caret. ## 12. Gates (Stage 2) `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (own step); `cargo test --lib`; `cargo test --lib --features crdt`; -`tests/folding_acceptance.rs` (Stage-1 suite, must stay green) + the new +`tests/folding_acceptance.rs` (Stage-1 suite, stays green) + new `tests/folding_stage2_acceptance.rs` (default + CRDT); `cargo test --test -m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p -pmacs-gpu` (must stay green — Stage 2 does not touch the GPU); the workspace -sweep as one invocation; `git diff --check`. New behavioral acceptance is -bite-verified with `scripts/bite`. Flaky-under-load timing tests are rerun -isolated before treating a sweep failure as a regression (handoff §3). +m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` +(stays green — Stage 2 does not touch the GPU); the workspace sweep as one +invocation; `git diff --check`. New behavioral acceptance is bite-verified with +`scripts/bite`; timing-flaky tests are rerun isolated before treating a sweep +failure as a regression (handoff §3). ## 13. Numbered decisions (continuing the parent's Q#FD scheme) -- **Q#FD12** Stage 2 introduces the first non-identity source-line↔display-row - map. It is centralized in one per-frame **visible-line map** (derived from - `folds()` + line offsets), consulted by the render loop and every - view_top-arithmetic site; `view_top` stays a source-line index clamped to a - visible line. (§3) -- **Q#FD13** Collapse lives in `TextView::render` (row `r` → `r`-th visible line), - with the map carried on `Viewport` so all overlay painters are uniformly - fold-aware; head line renders content + trailing ellipsis; folding is not an - overlay. (§4) -- **Q#FD14** Line numbers walk visible lines; Absolute uses the raw `line+1`, - Relative/Hybrid measure distance in **visible** lines (not raw `abs_diff`); - gutter width unchanged (sized to absolute count, no jitter). (§5) -- **Q#FD15** A diagnostic on a hidden line **clamps to the fold head row** - (most-severe merge across head + hidden lines), not dropped. (§6) -- **Q#FD16** Render-time caret clamp to the head for a cursor on a hidden line; - click inverse maps grid rows to visible lines. (§7) -- **Q#FD17** *(scope fork — needs your ruling)* Recommended: vertical line-motion - (`next-line`/`prev-line`/arrows) steps over folds by visible lines so the cursor - never rests hidden. Alternative: raw motion + render-time clamp only. (§7) -- **Q#FD18** Viewport/paging/auto-scroll count **visible** lines; `view_top` - clamped to visible; goto-line leaves a hidden target's head visible; recenter - stays deferred. (§7) -- **Q#FD19** Interactive-unfold widening hooks the **local** funnels only: - `apply_active_edit` (top, subsuming the six primitives; covers yank + - query-replace) and the interactive Lua-mutator path (comment-toggle, yank-pop) - gated on interactive-command context + edit position; the remote/optimistic-CRDT - `notify_buffer_edit` path is **excluded** (Stage 3); undo/redo unfold is a named - minor. (§8) -- **Q#FD20** Fold gutter glyph reuses the col-0 sign cell on the head row when no - diagnostic clamps there (diagnostic wins); no new gutter column. (§5) +- **Q#FD12** One shared visible-line map **primitive** (derivation + queries from + `folds()` + line offsets), instantiated as short-lived instances per phase + (render via `Option<&'a VisibleLineMap>` on `Viewport<'a>>` preserving `Copy`; + after-frame direct; command-time fresh), 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 + renders content + trailing ellipsis; folding is not an overlay. (§4) +- **Q#FD14** Line numbers walk visible lines; Absolute uses raw `line+1`, + Relative/Hybrid measure **visible**-line distance anchored on `visible_head_of` + 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 clamp to `visible_head_of` for caret, local selection, and + peer cursor on hidden lines; hidden selection cells drop/project; click inverse + maps grid rows to visible lines. (§7) +- **Q#FD17** *(ruled: include)* Vertical line-motion steps by visible lines; motion + from a hidden cursor first normalizes to the visible head. (§7) +- **Q#FD18** Viewport/paging/auto-scroll/indicator reckon in visible lines; + `view_top` set via `clamp_view_top` (backward to the head); goto-line leaves a + hidden target's head visible; recenter deferred. (§7) +- **Q#FD19** Interactive-unfold widening hooks **local** funnels only: + `apply_active_edit` (top; subsumes the six; covers yank + query-replace) and the + common `run_buffer_edit` gated on `InteractiveCommandOrigin.current()` **and** + the edit targeting the invoking frontend's active-window buffer **and** + `edit.range.start` inside a fold; the remote/optimistic-CRDT `notify_buffer_edit` + path is excluded (Stage 3); **undo/redo unfold is deferred**. (§8) +- **Q#FD20** Fold gutter glyph is **conditional on a gutter existing**: off ⇒ + 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) ## 14. Branch and PR plan Branch **`folding-tui`**, worktree `../pmacs-folding-tui`, off canonical `main` @ -`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commit; -Stage 2 implements on this same branch and opens as the second folding PR. One -feature, one branch, one PR — Stage 3 (GPU) is a separate branch/PR off the main -resulting from this stage, re-framed after it lands. +`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commits (rev +1 → rev 2); 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 owed from #142 (tracked separately, not folded into this PR): the -`docs/active-work.md` folding lane and `docs/agent-handoff.md` §1 still predate -the #142 merge and want a docs PR refresh (the #138–#140 convention). +Housekeeping from #142 (retire the `docs/active-work.md` folding lane + refresh +`docs/agent-handoff.md` §1) is a **separate docs PR**, kept out of `folding-tui` +(ruled). From 8160d660a075b53895bef6ddcbf5311d129cee2a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 16:55:25 -0400 Subject: [PATCH 03/12] =?UTF-8?q?docs(folding):=20Stage=202=20framing=20re?= =?UTF-8?q?v=203=20=E2=80=94=20address=20review=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2's three findings + two nits, all verified against c49a8c7: - F1 (major): fold-aware motion must be frontend-projection scoped. Shared EditorCore::move_up/down/page_* would make a simultaneous unfolded GPU session skip source lines it still displays (a grid + a semantic session can attach to one buffer, daemon.rs:876). Add a per-FrontendView `fold_projection_active` flag (editor_core.rs:240, set at attach / cleared at detach); gate ALL command-time visible-line reckoning (motion, paging, wheel, click, auto-scroll) on it. Render-time clamps are already grid-path-only. New Q#FD21 + simultaneous TUI+semantic acceptance. - F2 (major): render maps must be per WINDOW, not per frame — paint_frame and the presence pass iterate windows with distinct buffer_ids (editor.rs:2922, overlay_paint.rs:124). Specify one map per rendered nonterminal window (keyed on window buffer_id + TextView); peer presence uses the recipient window's map. New split-of-different-buffers acceptance. - F3 (moderate): hidden positions need COLUMN projection, not only row clamping. Add `visible_position_of(pos)` -> outermost fold's range.start (end of visible head line, Stage 1's point-move target) for local/peer carets and selection endpoints; hidden interiors still drop. New hidden-cursor-column-differs acceptance. - Nits: fix the Viewport<'a> typo; state build cost honestly as O(folds) with a byte->line lookup per fold (B4). PR #147 (the #142 housekeeping) confirmed clean by the reviewer, no findings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- docs/folding-stage2-framing.md | 292 +++++++++++++++++++++++---------- 1 file changed, 207 insertions(+), 85 deletions(-) diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md index 7b692f9..57974b6 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 2 — 2026-07-23. Status: DRAFT for review (rev 1's five findings +**Revision 3 — 2026-07-23. Status: DRAFT for review (rounds 1–2 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 @@ -48,7 +48,7 @@ parent's §8/§14. Numbering continues the parent's `Q#FD…` scheme from `Q#FD1 (`src/view.rs:130`). Rev 2 reframes the map as **one shared derivation/query primitive** with **separate short-lived instances** for rendering vs command/event handling (Q#FD12); the render instance is threaded as - `Option<&'a VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** (a shared + `Option<&'a VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>`** (a shared ref is `Copy`, so `Viewport` stays `Copy`); the primitive's home is usable from `EditorCore`, not render-only. - **F5 (moderate) — tighter unfold seam + settled undo decision.** Rev 2 keys the @@ -68,7 +68,45 @@ 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`. + §1) is a **separate docs PR**, kept out of `folding-tui` (landed as PR #147). + +### Round 2 (rev 2 → rev 3) + +- **F1 (major) — fold-aware motion must be frontend-projection scoped.** Q#FD17/18 + changed shared `EditorCore::move_up/down/page_*`, but Stage 2 leaves the GPU + visually unfolded (Stage 3). With a TUI and a semantic/GPU session on the shared + fold store simultaneously (`src/daemon.rs:876` — a frontend holds exactly one of + a grid `RenderState` or a semantic `SemanticRenderState`, both may attach to one + buffer), visible-line motion would make the **GPU cursor skip source lines it + still displays**. Rev 3 adds a **per-frontend `fold_projection_active`** decision + on `FrontendView` (`src/editor_core.rs:240`), set at attach + (`register_frontend_view`, `:540`) / cleared at detach (`:549`): grid/TUI ⇒ true + (Stage 2), semantic/GPU ⇒ false until Stage 3. **All command/event-time + visible-line reckoning** (motion, paging, wheel, click inverse, auto-scroll) is + gated on the acting frontend's flag; render-time clamps are already grid-path-only + (a semantic session never enters `paint_frame`). New simultaneous TUI+semantic + Down/Up/paging acceptance (Q#FD21, §7, §11). +- **F2 (major) — render maps must be per window, not per frame.** `paint_frame` + renders several windows that may show **different buffers** + (`src/editor.rs:2922`, `reg.get(window.buffer_id)`), and the presence pass + iterates recipient windows by `buffer_id` (`src/overlay_paint.rs:124`). Rev 2's + "builds one instance" was wrong. Rev 3 specifies **one map per nonterminal + 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). +- **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 + column on the head. Stage 1's precedent is exact: folding moves point to the + fold's `ByteRange.start` (the end of the visible head line). Rev 3 adds + **`visible_position_of(pos) -> Position`** mapping a hidden byte to the + **outermost** enclosing fold's `range.start`, used for local/peer carets and + selection **endpoints** (hidden selection interiors stay dropped). New acceptance + with a hidden cursor whose column differs materially from the head's end (§3.1, + §7, §11 acceptance 6). +- **Nits.** Fixed the `Viewport<'a>` typo; stated the map build cost honestly as + O(folds) with a byte→line lookup per fold (B4). ## 1. What Stage 2 ships @@ -92,9 +130,10 @@ Stage 2 makes the **daemon grid renderer fold-aware**: 5. **Fold-aware caret, local selection, and peer presence** — no caret, no selection cell, and no peer cursor renders on a hidden line; each clamps to the visible head or is projected/dropped. -6. **Fold-aware viewport/scroll/motion** — `view_top`, paging, wheel scroll, - clicks, auto-scroll, vertical line-motion, and the mode-line scroll indicator - all reckon in **visible** lines. +6. **Fold-aware viewport/scroll/motion** — on **fold-projecting (grid) frontends**, + `view_top`, paging, wheel scroll, clicks, auto-scroll, vertical line-motion, and + the mode-line scroll indicator all reckon in **visible** lines; a semantic/GPU + session on the same buffer keeps raw-line motion until Stage 3 (Q#FD21). 7. **Interactive-Lua-command unfold widening** — yank, query-replace, and comment-toggle unfold a fold at their edit point before the edit is visible. @@ -216,6 +255,27 @@ work in **raw source-line** space. There is no `beginning/end-of-buffer` command and **no recenter** (deferred, blocked on viewport facts; handoff §6). Stage 2 inherits the recenter deferral. +### 2.7 Windows and cursors are per-frontend; a frame renders several (F1, F2) + +Each attached frontend has its own `FrontendView` +(`EditorCore.views: HashMap`, `src/editor_core.rs:240`), +registered at attach (`register_frontend_view`, `:540`) and cleared at detach +(`unregister_frontend_view`, `:549`); windows/cursors are per-frontend instances a +`FrontendView` references. Shared `EditorCore` motion (`move_up/down/page_*`) acts +on `active_view()`/`active_window()` — **the acting frontend's own cursor**. The +daemon holds the projection kind per frontend: exactly one of a grid `RenderState` +or a semantic `SemanticRenderState` (`src/daemon.rs:875-881`), and **a grid and a +semantic frontend can attach to the same buffer at once** — the fact that makes +shared visible-line motion unsafe for a still-unfolded GPU session (F1). + +`paint_frame` (`src/editor.rs:2796`) renders **every** window in the acting +frontend's layout; distinct windows carry distinct `window.buffer_id` / +`view_top` / `text_view` (`:2922`, `reg.get(window.buffer_id)`), so a split can +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. + ## 3. The shared visible-line map primitive (Q#FD12) Stage 2's spine is **one derivation/query primitive** — a `VisibleLineMap` type @@ -236,42 +296,63 @@ non-overlapping hidden-line set `H` (a line is hidden regardless of which fold o it). The map answers, in **line space**: - `is_hidden(line) -> bool` — `line ∈ H`. -- **`visible_head_of(line) -> line`** — for a hidden `line`, the head 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. **Every clamp uses this** — caret, - diagnostics, peer cursor, relative-number cursor anchor, and the `view_top` - backward clamp. (Rev 1's innermost `head_of` is removed — F1.) +- **`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. - `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` (relative/hybrid distance; paging). - **`clamp_view_top(line) -> line`** — if `line ∈ H`, `visible_head_of(line)` - (**backward**, so a fold at the top shows its head — acceptance 6); else `line`. + (**backward**, so a fold at the top shows its head — acceptance 8); else `line`. This replaces rev 1's forward `first_visible_at_or_after`, which skipped past the fold and hid the head. -Build cost is O(folds) (folds are O(top-level blocks), parent B2), reusing the -render path's existing line-offset table — **Bet B4**. +Build cost is O(folds) with a byte→line lookup per fold — folds are O(top-level +blocks) (parent B2), each needs a binary search into the render path's existing +line-offset table, so O(folds · log lines); a linear merge with the table would be +O(folds + lines) but is unnecessary given how few folds there are (**Bet B4**). -### 3.2 Instances per phase (F4) +### 3.2 Instances per phase and per window (F2, F4) -- **Render frame.** `paint_frame` (`src/editor.rs:2796`) builds one instance and +The map is a **derivation/query primitive**, instantiated as short-lived instances +— **never one singleton per frame** (F2, F4). Each instance is keyed on a specific +window's `buffer_id`, its `TextView` line-offsets, and (for the render walk) its +`view_top`: + +- **Render frame — one instance per rendered nonterminal window (F2).** + `paint_frame` (`src/editor.rs:2796`) iterates the acting frontend's windows; for + each **document** window it builds that window's map from + `state.fold_registry.folds(window.buffer_id)` and the window's line-offsets, then (a) threads it to the overlay painters (1,3,4,5,6) as `Option<&'a - VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>>`** — a shared ref is - `Copy`, so `Viewport` stays `Copy` (F4); (b) passes the **same** instance - directly to the in-`paint_frame` sites (2,7,8,9). The `View::render` signature + VisibleLineMap>` on a **lifetime-bearing `Viewport<'a>`** — a shared ref is + `Copy`, so `Viewport` stays `Copy` (F4); (b) passes the **same** per-window + instance to the in-`paint_frame` sites (2,7,8,9). The `View::render` signature becomes `Viewport<'_>`; every construction site gains the field (non-fold callers - pass `None`). This is a settled compile-time change across the `View` impls and - `Viewport` constructions. -- **After the frame.** The peer-presence pass (`src/overlay_paint.rs`, site 10) - runs after `paint_frame` and takes its own arguments; it receives (or builds) a - fresh instance for the same buffer and clamps peer cursors / projects peer - selection through it. -- **Command / event.** Click inverse, auto-scroll, paging, wheel, and vertical - motion (sites 11–13, in `EditorCore` / `EditorState`) each build a short-lived - instance from `state.fold_registry` + the buffer at call time. "The map is - already built" (rev 1) was false for these — they run outside any frame. + pass `None`). Terminal windows are skipped (Q#FD9). A split of two different + buffers gets two independent maps — no leakage. This is a settled compile-time + change across the `View` impls and `Viewport` constructions. +- **After the frame — per recipient window (F2).** The peer-presence pass + (`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. `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 @@ -325,45 +406,58 @@ preserves the "there is a problem inside this collapsed region" signal. `DiagnosticView` reaches the map through `Viewport` (§3.2), needing no separate handle. -## 7. Caret, selection, peer presence, viewport, and motion (Q#FD16, Q#FD17, Q#FD18) +## 7. Caret, selection, peer presence, viewport, and motion (Q#FD16, Q#FD17, Q#FD18, Q#FD21) -**Caret clamp (Q#FD16).** Caret grid-row (`src/editor.rs:3033-3044`): if the -logical cursor's line `is_hidden`, render at `visible_head_of(line)`'s row — -satisfying the parent's per-cursor render-time invariant (Q#FD3), including the -shared-store case. +**Frontend scoping (Q#FD21, F1).** Render-time clamps (this section's caret, +selection, peer, gutter) live in the grid `paint_frame`/presence path, which a +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). +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. -**Local selection (Q#FD16, F2).** `paint_local_selection` (`src/editor.rs:3241`): -selection cells on hidden lines are dropped; the visible portion projects through -the map (a selection spanning a fold paints on the visible head row and the visible -tail rows, contiguous on screen). +**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. -**Peer presence (Q#FD16, F2).** `src/overlay_paint.rs:159`: a peer cursor on a -hidden line clamps to `visible_head_of`; peer selection cells on hidden lines are -dropped/projected exactly like local selection. Folds are per-buffer/shared, so the -peer's map is the same buffer's map. +**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`; +hidden interior cells are dropped; the visible portion paints on the visible head +row and the visible tail rows, contiguous on screen. -**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2233`): grid -row `k` maps to the `k`-th visible line, so a click never lands on a hidden line. +**Peer presence (Q#FD16, F2/F3).** `src/overlay_paint.rs:159`: a peer cursor on a +hidden line clamps via `visible_position_of`; peer selection endpoints project the +same way and hidden interiors drop. The map is the **recipient window's** map +(§3.2), built for that window's buffer. -**Viewport / scroll (Q#FD18).** `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 -(`next_visible`/`prev_visible`, `visible_between`). `move_to_line`/goto-line targets -a raw line; if hidden, `view_top` clamps so the target's **head** is visible (no -auto-unfold — that is the deferred search-reveal, §9). The **mode-line scroll -indicator** (`format_scroll_indicator`, `src/editor.rs:3803`) computes All/Top/Bot/% -in **visible-line** space (visible total, `view_top`'s visible ordinal, cursor's +**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. + +**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 +(`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** +(`format_scroll_indicator`, `src/editor.rs:3803`) computes All/Top/Bot/% in +**visible-line** space (visible total, `view_top`'s visible ordinal, cursor's visible ordinal). Recenter stays deferred (§2.6). -**Vertical line-motion (Q#FD17 — RULED: include).** `next-line`/`prev-line` (and -arrows) step to the adjacent **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** (after a shared fold or a goto-line into a -fold), it **first normalizes to `visible_head_of(cursor)`**, then steps to the -preceding/following visible line. The render-time caret clamp (Q#FD16) remains the -backstop for the pre-motion shared-fold frame. These reuse the command-time map -instance (§3.2). +**Vertical line-motion (Q#FD17 — RULED: include; Q#FD21-scoped).** On a +fold-projecting frontend, `next-line`/`prev-line` (and arrows) step to the adjacent +**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) +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). ## 8. Interactive-Lua-command unfold widening (Q#FD19) @@ -416,19 +510,25 @@ Each widened behavior is **bite-verified** (a test failing without the widening) ## 10. Bets -- **B4** The per-instance visible-line map (from `folds()` + line offsets) is cheap: - O(folds) build, reusing the render path's line table. Multiple short-lived - instances per interaction are still O(folds) each. FALSIFIABLE on a pathological - many-fold buffer (mitigation: derivation is O(folds), not O(lines)). +- **B4** The per-instance visible-line map is cheap: O(folds) build, one byte→line + binary search per fold into the render path's existing line-offset table + (O(folds · log lines)); folds are few, so per-window and command-time + re-derivation is negligible. FALSIFIABLE on a pathological many-fold buffer + (mitigation: cost scales with folds, not lines). - **B5** `view_top` stays a source-line index (clamped via `clamp_view_top`) — less churn than a visible-ordinal coordinate space, and preserves saveplace / `set_view_top`. - **B6** No wire/protocol change: `FoldState` (Stage 1) untouched; the TUI collapse is daemon-side; a semantic GPU session still gets `FoldState` and renders nothing new until Stage 3. -- **B7** Threading `Option<&'a VisibleLineMap>` on a lifetime-bearing `Viewport<'a>>` +- **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. ## 11. Acceptance — Stage 2 @@ -454,11 +554,13 @@ the precedents), sized for macOS startup + long temp-path width (handoff §5). second frontend), the caret renders on the **outermost** visible head; relative numbers anchor there; a `view_top` set into the nest clamps **backward** to that head. -6. **Caret, local selection, peer presence (F2).** Caret on a hidden line → head - row. A local selection spanning a fold paints on the visible head + visible tail - rows, nothing on hidden rows. A peer cursor on a hidden line clamps to the head; - peer selection cells on hidden lines drop/project. A click never selects a hidden - line. +6. **Caret/selection/peer column projection (F2/F3).** A caret whose hidden line's + column **differs materially from the head's end-of-content** renders at the head + row **and the head's end-of-content column** (`visible_position_of` → + `range.start`), never at the raw hidden column. A local selection spanning a fold + 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. 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. @@ -483,6 +585,16 @@ the precedents), sized for macOS startup + long temp-path width (handoff §5). 12. **Shared store, independent viewports.** Two TUI windows on one buffer both render the same fold collapsed, each with its own correct `view_top`, gutter, and caret. +13. **Frontend-scoped motion (F1, Q#FD21).** A **grid TUI** and a **semantic** + session attach to one buffer with a fold in the store. Down/Up and page-down on + the TUI move its cursor by **visible** lines (skipping the fold); the same + commands issued by the semantic session move its cursor by **raw** source lines + (it still displays them). Attach then detach the semantic session and re-check + the TUI is unaffected — the flag is per-`FrontendView`. +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. ## 12. Gates (Stage 2) @@ -499,11 +611,12 @@ failure as a regression (handoff §3). ## 13. Numbered decisions (continuing the parent's Q#FD scheme) - **Q#FD12** One shared visible-line map **primitive** (derivation + queries from - `folds()` + line offsets), instantiated as short-lived instances per phase - (render via `Option<&'a VisibleLineMap>` on `Viewport<'a>>` preserving `Copy`; - after-frame direct; command-time fresh), 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) + `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`. + `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 renders content + trailing ellipsis; folding is not an overlay. (§4) - **Q#FD14** Line numbers walk visible lines; Absolute uses raw `line+1`, @@ -511,14 +624,16 @@ 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 clamp to `visible_head_of` for caret, local selection, and - peer cursor on hidden lines; hidden selection cells drop/project; click inverse - maps grid rows to visible lines. (§7) +- **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; + 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 - from a hidden cursor first normalizes to the visible head. (§7) + from a hidden cursor first normalizes to the visible head. Scoped by Q#FD21. (§7) - **Q#FD18** Viewport/paging/auto-scroll/indicator reckon in visible lines; `view_top` set via `clamp_view_top` (backward to the head); goto-line leaves a - hidden target's head visible; recenter deferred. (§7) + hidden target's head visible; recenter deferred. Scoped by Q#FD21. (§7) - **Q#FD19** Interactive-unfold widening hooks **local** funnels only: `apply_active_edit` (top; subsumes the six; covers yank + query-replace) and the common `run_buffer_edit` gated on `InteractiveCommandOrigin.current()` **and** @@ -528,15 +643,22 @@ failure as a regression (handoff §3). - **Q#FD20** Fold gutter glyph is **conditional on a gutter existing**: off ⇒ 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 + 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) ## 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 2); Stage 2 implements on this same branch and opens as the second folding +1 → rev 3); 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) is a **separate docs PR**, kept out of `folding-tui` -(ruled). +`docs/agent-handoff.md` §1) **landed as the separate docs PR #147**, kept out of +`folding-tui` (ruled). From 4222ffae6edb9feb552d9ef0cd535c34c80beeeb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 17:25:55 -0400 Subject: [PATCH 04/12] =?UTF-8?q?docs(folding):=20Stage=202=20framing=20re?= =?UTF-8?q?v=204=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). From 313b1ff77a09a0a64f69baf6c315d7f9fff3b59b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:24:07 -0400 Subject: [PATCH 05/12] =?UTF-8?q?feat(fold):=20Stage=202=20=E2=80=94=20gri?= =?UTF-8?q?d=20(daemon-rendered)=20collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/folding-stage2-framing.md rev 4. The daemon grid renderer now consults the fold store: hidden lines are omitted, rows below shift up, and every consumer that assumed `display_row = source_line - view_top` routes through one shared projection. No wire schema change and no protocol bump (Bet B6) — the collapse is entirely daemon-side; the GPU path is Stage 3. The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived from `FoldRegistry::folds` plus a window's line offsets and never stored. Its unit is a merged **hidden component** — overlapping OR adjacent hidden intervals unioned, each keeping the one visible `head_line` and that line's exact `head_position`. Adjacent intervals merge because the later fold's head is itself hidden, which is what makes nesting, shared heads, and crossing overlap all resolve to a head that can actually render (round-3 F2). Instances are short-lived and built **per rendered window** and **per command/event operation**, never once per frame: `paint_frame` renders several windows that may show different buffers, so a singleton would leak one pane's folds into another (round-2 F2). The render instance rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy` (Bet B7). Rendering: - `TextView::render` walks visible lines; the head line gets a trailing content-area ellipsis (Q#FD13). - The gutter walks visible lines too: Absolute keeps the raw `line+1`, Relative/Hybrid measure VISIBLE distance anchored on the cursor's visible head (Q#FD14). The fold glyph takes the col-0 sign cell only when a gutter exists — line numbers default to Off, so with no gutter the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic clamped onto the head wins that cell by paint order. - A diagnostic on a hidden line clamps its SIGN to the outermost visible head (most-severe merge); the squiggle needs a real row, so only the sign clamps (Q#FD15). - Caret, local selection endpoints, and peer cursors project via `visible_position_of` — the head row AND the head's end-of-content column, never an arbitrary column (round-2 F3). Peer presence derives the RECIPIENT window's map. - Style/search/completion overlays route through `Viewport::row_offset_of`; the mode-line indicator reckons in visible-line space. Command/event time is scoped per frontend (Q#FD21): a `fold_projection` flag on `FrontendView`, set at attach from the negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet B8). Without it, shared `EditorCore` motion would make a simultaneous unfolded GPU session's cursor skip lines it still displays. The map's two axes stay separate (round-3 F1): the acting frontend supplies the policy, the operation's TARGET window supplies the buffer — a wheel event names a pane without activating it. Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and the auto-scroll clamp all step by visible lines under that gate; motion from a hidden logical cursor normalizes to the visible head first. `view_top` stays a source-line index (Bet B5), set only via `clamp_view_top` so it never rests hidden. Unfold widening (Q#FD19): the pre-edit unfold moves to the top of `apply_active_edit` — one funnel that subsumes the six primitives' calls and covers yank + query-replace, both of which place point at the edit site first. Interactive Lua mutators hook the common `run_buffer_edit`, above the managed/bypass split, gated on `InteractiveCommandOrigin` AND the edit targeting that frontend's active-window buffer. The remote/optimistic-CRDT path stays excluded (Stage 3); undo/redo unfold stays deferred. Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting on the real `paint_frame` cell grid, covering framing items 1–14 including crossing folds, a nested deeply-hidden cursor, a split of two different buffers with an inactive-pane wheel, and simultaneous grid+semantic motion. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- src/completion.rs | 14 +- src/daemon.rs | 21 +- src/desktop.rs | 2 + src/diag.rs | 38 +- src/editor.rs | 260 ++++- src/editor_core.rs | 186 +++- src/fold_view.rs | 466 +++++++++ src/highlight.rs | 20 +- src/hover.rs | 2 +- src/lib.rs | 1 + src/lua_bindings/mod.rs | 49 + src/menu.rs | 2 +- src/overlay.rs | 18 +- src/overlay_paint.rs | 76 +- src/search.rs | 20 +- src/signature.rs | 2 +- src/text_view.rs | 47 +- src/view.rs | 98 +- src/window.rs | 22 + tests/compile_mode_acceptance.rs | 1 + tests/folding_stage2_acceptance.rs | 1265 +++++++++++++++++++++++ tests/m4_acceptance.rs | 1 + tests/statusline_segments_acceptance.rs | 1 + tests/tab_width_acceptance.rs | 3 +- tests/vterm_stage3_acceptance.rs | 1 + 25 files changed, 2486 insertions(+), 130 deletions(-) create mode 100644 src/fold_view.rs create mode 100644 tests/folding_stage2_acceptance.rs diff --git a/src/completion.rs b/src/completion.rs index 70bc4ab..950b354 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -684,7 +684,7 @@ struct PopupRect { /// the viewport or nothing fits. fn resolve_popup_rect( buf: &Buffer, - viewport: Viewport, + viewport: Viewport<'_>, anchor: Position, rows: &[PopupCandidate], ) -> Option { @@ -700,10 +700,12 @@ fn resolve_popup_rect( let line_offsets = crate::diag::compute_line_offsets(&source); let start_line = crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32); let anchor_line = crate::diag::line_at_offset(&line_offsets, anchor); - if anchor_line < start_line { - return None; // anchor scrolled above the viewport - } - let anchor_row = anchor_line - start_line; + // Arc 6 Stage 2: the popup anchors on the anchor byte's VISIBLE row, + // so a completion below a collapsed region lands on the right row; + // an anchor inside a collapse has no row and paints nothing. + let Some(anchor_row) = viewport.row_offset_of(start_line as usize, anchor_line as usize) else { + return None; // anchor scrolled above the viewport, or collapsed + }; let max_rows = viewport.cell_size.rows; let max_cols = viewport.cell_size.cols; if anchor_row >= max_rows || max_cols == 0 { @@ -820,7 +822,7 @@ impl View for CompletionView { "completion-popup" } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { // Snapshot under the lock, then drop it before touching the rope. let (anchor, rows_data, selected_in_window): (Position, Vec, usize) = { let guard = self.popup.lock().expect("completion popup poisoned"); diff --git a/src/daemon.rs b/src/daemon.rs index fe28112..8a3c727 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1545,7 +1545,16 @@ fn handle_session_established( // Register the frontend's view (M10.8 Day 3: fresh scratch // buffer view; future milestones may clone LOCAL's view or // take an explicit initial-buffer argument). - let scratch_view = build_fresh_frontend_view(editor); + // + // Arc 6 Stage 2 (Q#FD21): the projection is decided in this same + // attach transaction and from the same bit that selects a grid + // `RenderState` vs a `SemanticRenderState` below — a grid session + // collapses folds, a semantic one keeps raw-line reckoning until + // Stage 3. + let scratch_view = build_fresh_frontend_view( + editor, + !session_state.negotiated_capabilities.semantic_render, + ); editor .core .borrow_mut() @@ -2622,7 +2631,13 @@ fn align_semantic_window_to_buffer( } } -fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView { +fn build_fresh_frontend_view( + editor: &mut EditorState, + // Arc 6 Stage 2 (Q#FD21, Bet B8): whether this session's display + // collapses folds. Passed explicitly from the negotiated + // selected-render bit at the call site — never inferred here. + fold_projection: bool, +) -> crate::window::FrontendView { use crate::text_view::TextView; use crate::window::{FrontendView, Layout, Window, WindowId}; let mut core = editor.core.borrow_mut(); @@ -2651,6 +2666,7 @@ fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::Fronten FrontendView { layout: Layout::single(id), active: id, + fold_projection, } } @@ -3442,6 +3458,7 @@ mod tests { FrontendView { layout: Layout::single(wid), active: wid, + fold_projection: true, }, ); } diff --git a/src/desktop.rs b/src/desktop.rs index 23ff5ac..7f44524 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -435,6 +435,8 @@ pub fn restore_into( FrontendView { layout: Layout { root }, active, + // Desktop restore rebuilds LOCAL's grid view (Q#FD21). + fold_projection: true, }, ); active diff --git a/src/diag.rs b/src/diag.rs index cd2909e..88aa6cc 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -493,7 +493,7 @@ impl View for DiagnosticView { "diagnostic" } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { // Snapshot the diagnostics under the lock and drop it // immediately so we don't hold the lock through rendering // (rendering touches the rope, which could in principle @@ -557,20 +557,23 @@ impl View for DiagnosticView { if line >= total_lines { break; } - if line < start_line_buf { + let row_offset = viewport.row_offset_of(start_line_buf as usize, line as usize); + let Some(sign_row) = sign_row_for(viewport, start_line_buf, line) else { continue; - } - let row_offset = line - start_line_buf; - if row_offset >= max_rows { + }; + if sign_row >= max_rows { break; } // Record the line marker before any byte-range work: // zero-width ranges (`byte_end <= byte_start` below) // skip the underline but still mark the line. line_markers - .entry(row_offset) + .entry(sign_row) .and_modify(|s| *s = (*s).min(diag.severity)) .or_insert(diag.severity); + let Some(row_offset) = row_offset else { + continue; + }; let line_start = line_offsets[line as usize]; let line_end = line_offsets .get(line as usize + 1) @@ -625,6 +628,25 @@ impl View for DiagnosticView { } } +/// The grid row a diagnostic on source `line` marks (Arc 6 Stage 2, +/// Q#FD15). +/// +/// Its own row normally; when the line is collapsed away, the fold's +/// **outermost visible head** row — a clamp, not a drop, so "there is a +/// problem inside this collapsed region" survives the collapse (the +/// most-severe-per-row merge then makes the head show the worst severity +/// among itself and every line its fold hides). `None` when neither has +/// a row in this viewport. Only the *sign* clamps: a squiggle needs a +/// real row, so a hidden line contributes no underline. +fn sign_row_for(viewport: Viewport<'_>, start_line: u32, line: u32) -> Option { + viewport + .row_offset_of(start_line as usize, line as usize) + .or_else(|| { + let map = viewport.folds?; + viewport.row_offset_of(start_line as usize, map.visible_head_of(line as usize)) + }) +} + /// Paint one severity marker per diagnostic line (UX gutter sub-arc 2). /// /// When the window reserves a gutter (`gutter_w > 0`), draw the severity @@ -1046,6 +1068,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 10), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -1110,6 +1133,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(3, 10), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -1185,6 +1209,7 @@ mod tests { cell_origin: CellCoord::new(0, 2), cell_size: CellSize::new(3, 8), gutter_w: 2, + folds: None, }, &mut grid, ); @@ -1253,6 +1278,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(2, 10), gutter_w: 0, + folds: None, }, &mut grid, ); diff --git a/src/editor.rs b/src/editor.rs index 00f0fd4..3cf59e6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -41,7 +41,7 @@ use crate::protocol::{ use crate::terminal::TerminalSnapshot; use crate::terminal::view::TerminalViewKey; use crate::view::{View, Viewport}; -use crate::window::{Rect, WindowId}; +use crate::window::{LineNumberMode, Rect, WindowId}; /// Ephemeral authenticated origin for one interactive command invocation. /// @@ -2230,8 +2230,20 @@ impl EditorState { core.set_active_window_id(win_id); let view_top = core.windows[&win_id].view_top; let buffer_id = core.windows[&win_id].buffer_id; - let display_row = view_top.saturating_add(local_row as usize); - let target = crate::view::DisplayCoord::new(display_row as u32, local_col); + // Arc 6 Stage 2 (Q#FD16/FD21): grid row `k` in this window shows + // its `k`-th VISIBLE line, so the inverse must walk the same way + // — a click can then never land on a collapsed line. The map is + // the CLICKED window's (round-3 F1), not the previously active + // one's. + let folds = core.fold_map_for_window(win_id); + let display_row = match folds.as_ref() { + Some(map) => map.nth_visible_from(view_top, local_row as usize), + None => view_top.saturating_add(local_row as usize), + }; + let Ok(display_row) = u32::try_from(display_row) else { + return; + }; + let target = crate::view::DisplayCoord::new(display_row, local_col); let pos = { let registry = core.registry.clone(); let reg = registry.borrow(); @@ -2263,23 +2275,33 @@ impl EditorState { /// `mouse-wheel-mode` and every modern editor's wheel behaviour. fn scroll_window(&mut self, win_id: WindowId, delta: i32) { let mut core = self.core.borrow_mut(); + // Arc 6 Stage 2 (Q#FD18/FD21, round-3 F1): a wheel event names + // the pane under the pointer and does NOT activate it, so the map + // must come from `win_id` — deriving the active window's would + // project a folded buffer onto an unfolded neighbour. The + // projection *policy* still comes from the acting frontend. + let folds = core.fold_map_for_window(win_id); let line_count = core.windows[&win_id].text_view.line_count(); let max_top = line_count.saturating_sub(1); let old_top = core.windows[&win_id].view_top; let scroll_up = delta < 0; let magnitude = delta.unsigned_abs() as usize; - let new_top = if scroll_up { - old_top.saturating_sub(magnitude) - } else { - old_top.saturating_add(magnitude).min(max_top) + let new_top = match folds.as_ref() { + Some(map) if scroll_up => map.nth_visible_back(old_top, magnitude), + Some(map) => map + .nth_visible_from(old_top, magnitude) + .min(map.visible_head_of(max_top)), + None if scroll_up => old_top.saturating_sub(magnitude), + None => old_top.saturating_add(magnitude).min(max_top), }; // Effective view delta — buffer-boundary clamping may shrink // the requested move, so the cursor only follows by however - // many lines the view actually shifted. - let view_shift = if scroll_up { - old_top.saturating_sub(new_top) - } else { - new_top.saturating_sub(old_top) + // many lines the view actually shifted (counted in VISIBLE + // lines once this window folds). + let view_shift = match folds.as_ref() { + Some(map) => map.visible_distance(old_top, new_top), + None if scroll_up => old_top.saturating_sub(new_top), + None => new_top.saturating_sub(old_top), }; let buffer_id = core.windows[&win_id].buffer_id; let new_cursor = { @@ -2289,10 +2311,13 @@ impl EditorState { let aw = &core.windows[&win_id]; let cur = aw.text_view.pos_to_display(buf, aw.cursor)?; let cur_row = cur.row as usize; - let target_row_usize = if scroll_up { - cur_row.saturating_sub(view_shift) - } else { - cur_row.saturating_add(view_shift).min(max_top) + let target_row_usize = match folds.as_ref() { + Some(map) if scroll_up => map.nth_visible_back(cur_row, view_shift), + Some(map) => map + .nth_visible_from(cur_row, view_shift) + .min(map.visible_head_of(max_top)), + None if scroll_up => cur_row.saturating_sub(view_shift), + None => cur_row.saturating_add(view_shift).min(max_top), }; let target_row = u32::try_from(target_row_usize).ok()?; aw.text_view @@ -2316,6 +2341,12 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Gutter marker drawn on a collapsed region's head row (Arc 6 Stage 2, +/// Q#FD20). Occupies the gutter's leading pad cell — the same cell the +/// diagnostic sign uses — so it adds no column and changes no width; it +/// therefore only appears when a line-number mode reserves a gutter. +const FOLD_GUTTER_GLYPH: char = '▸'; + /// Shared outer/content geometry consumed by terminal paint and PTY resize. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct WindowPlacement { @@ -2860,6 +2891,13 @@ pub fn paint_frame( && let Some(buf_id) = buf_id && let Ok(buf) = reg.get(buf_id) { + // Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in + // VISIBLE lines. Built from the active window itself, before + // the mutable borrow below. + let folds = core + .windows + .get(&active) + .and_then(|w| crate::fold_view::map_for_window(&state.fold_registry, w)); let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); @@ -2867,10 +2905,31 @@ pub fn paint_frame( .text_view .pos_to_display(buf, aw.cursor) .map_or(0, |d| d.row as usize); - if cursor_row < aw.view_top { - aw.view_top = cursor_row; - } else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize { - aw.view_top = cursor_row + 1 - inner_rows as usize; + match folds.as_ref() { + // The logical cursor may sit on a hidden line (a shared + // fold, or goto-line into one); the row that actually + // renders — and so the row to scroll to — is its visible + // head (Q#FD16/FD18, framing acceptance 8). + Some(map) => { + let anchor = map.visible_head_of(cursor_row); + let top = map.clamp_view_top(aw.view_top); + aw.view_top = if anchor < top { + anchor + } else if inner_rows > 0 + && map.visible_rows_between(top, anchor) >= inner_rows as usize + { + map.nth_visible_back(anchor, inner_rows as usize - 1) + } else { + top + }; + } + None => { + if cursor_row < aw.view_top { + aw.view_top = cursor_row; + } else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize { + aw.view_top = cursor_row + 1 - inner_rows as usize; + } + } } } } @@ -2923,6 +2982,19 @@ pub fn paint_frame( let Ok(buf) = reg.get(window.buffer_id) else { continue; }; + // Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per + // rendered document window, keyed on that window's own buffer and + // line offsets. A split may show different buffers with only one + // folded, so a per-frame singleton would leak one pane's folds + // into the other. `None` when this buffer has no folds — the + // unfolded path then paints exactly as before. + let folds = crate::fold_view::map_for_window(&state.fold_registry, window); + // `view_top` stays a source-line index (Bet B5) but must never + // rest on a hidden line: clamp BACKWARD so a fold at the top of + // the viewport shows its head (Q#FD18, acceptance 8). + if let Some(map) = folds.as_ref() { + window.view_top = map.clamp_view_top(window.view_top); + } let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); // UX gutter (Q#UX2): reserve a left strip for line numbers and // shrink+shift the text area into the remainder, so every @@ -2939,6 +3011,7 @@ pub fn paint_frame( cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), gutter_w, + folds: folds.as_ref(), }; // Composition (T M2.9): base text_view paints first, then the // gutter numbers — before the overlays, so a diagnostic overlay @@ -2947,24 +3020,52 @@ pub fn paint_frame( // overlay in attach order. See [`crate::view::View`]. window.text_view.render(buf, viewport, grid); if gutter_w > 0 { - paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, &theme); + paint_line_number_gutter( + grid, + window, + &rect, + inner_rows, + gutter_w, + folds.as_ref(), + &theme, + ); } for overlay in &mut window.overlays { overlay.render(buf, viewport, grid); } - paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, &theme); + paint_local_selection( + grid, + buf, + window, + &rect, + inner_rows, + gutter_w, + folds.as_ref(), + &theme, + ); // Mode line for this window. Painted last so the line // itself is always visible regardless of overlay activity. let coord = window .text_view .pos_to_display(buf, window.cursor) .unwrap_or_default(); - let scroll = format_scroll_indicator( - window.view_top, - inner_rows as usize, - window.text_view.line_count(), - coord.row as usize, - ); + // Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in + // VISIBLE-line space — a buffer whose remainder is collapsed + // reads "All", not "Top". The cursor's ordinal anchors on its + // visible head, since that is the row it renders on. + let (ind_top, ind_total, ind_cursor) = match folds.as_ref() { + Some(map) => ( + map.visible_rows_between(0, window.view_top), + map.visible_line_count(window.text_view.line_count()), + map.visible_rows_between(0, map.visible_head_of(coord.row as usize)), + ), + None => ( + window.view_top, + window.text_view.line_count(), + coord.row as usize, + ), + }; + let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); // Lock scoped to the summary computation only: the overlay // renders above include `DiagnosticView`, which takes this // same mutex — holding the guard across the loop deadlocked @@ -3030,9 +3131,31 @@ pub fn paint_frame( let aw = &core.windows[&active]; let inner_rows = inner_rows(&active_rect); let buf = reg.get(aw.buffer_id).ok()?; - let disp = aw.text_view.pos_to_display(buf, aw.cursor)?; - if (disp.row as usize) < aw.view_top || (disp.row as usize) >= aw.view_top + inner_rows as usize - { + // Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden + // line renders at its hidden component's head POSITION — the visible + // head row *and* that head's end-of-content column, i.e. exactly + // where Stage 1 moves point on a fold-at-cursor. Row-only clamping + // would leave the column unspecified; resolving through the merged + // component (rather than the innermost containing fold) also keeps a + // crossing overlap from landing on another hidden position. + let folds = crate::fold_view::map_for_window(&state.fold_registry, aw); + let cursor = match folds.as_ref() { + Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor), + None => aw.cursor, + }; + let disp = aw.text_view.pos_to_display(buf, cursor)?; + let row_offset = match folds.as_ref() { + Some(map) => { + let top = map.clamp_view_top(aw.view_top); + let row = disp.row as usize; + if row < top { + return None; + } + map.visible_rows_between(top, row) + } + None => (disp.row as usize).checked_sub(aw.view_top)?, + }; + if row_offset >= inner_rows as usize { return None; } // UX gutter: the terminal caret sits in the text area, past the @@ -3041,7 +3164,7 @@ pub fn paint_frame( let w = aw.gutter_width(); if w >= active_rect.size.cols { 0 } else { w } }; - let grid_row = active_rect.origin.row + (disp.row - aw.view_top as u32); + let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?; let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1); let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col); Some(CellCoord::new(grid_row, grid_col)) @@ -3180,13 +3303,23 @@ fn paint_line_number_gutter( rect: &crate::window::Rect, inner_rows: u32, gutter_w: u32, + // Arc 6 Stage 2: this window's collapsed regions, or `None` when it + // has no folds (then every line below is the pre-folding walk). + folds: Option<&crate::fold_view::VisibleLineMap>, theme: &crate::highlight::Theme, ) { let line_count = window.text_view.line_count(); // Relative/Hybrid measure distance from the cursor's buffer line; // Absolute ignores it. Computed once per frame (the gutter repaints on // cursor motion, so this stays current). + // + // Arc 6 Stage 2 (Q#FD14): the anchor is the cursor's **visible head** + // — a shared fold (or goto-line) can leave the logical cursor on a + // hidden line, and the distance must be measured from the row the + // caret actually renders on. With no folds this is `cursor_line` + // verbatim, so the unfolded gutter is unchanged. let cursor_line = window.text_view.line_at_offset(window.cursor); + let anchor = folds.map_or(cursor_line, |map| map.visible_head_of(cursor_line)); // Themes Q#TH5: a set `ui.gutter` face owns the strip within its // {fg} mask; unset keeps the dim Indexed(8). let style = theme.face("ui.gutter").map_or( @@ -3202,6 +3335,9 @@ fn paint_line_number_gutter( // The number's rightmost digit sits at `field - 1`; the last gutter // cell (`gutter_w - 1`) is a trailing pad separating it from the code. let field = gutter_w.saturating_sub(1); + // Row `r` shows the `r`-th VISIBLE line at or after `view_top`, the + // same walk `TextView::render` performs (Q#FD13/FD14). + let mut buffer_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top)); for r in 0..inner_rows { let grid_row = rect.origin.row + r; // Blank + style the whole strip first, so a number that shrank a @@ -3212,16 +3348,43 @@ fn paint_line_number_gutter( cell.style = style; cell.attachment = None; } - let buffer_line = window.view_top + r as usize; if buffer_line >= line_count { continue; // past end-of-buffer: blank gutter } + let this_line = buffer_line; + buffer_line = folds.map_or(this_line + 1, |map| map.next_visible(this_line)); + + // Fold marker (Q#FD20, round-1 F3): the col-0 sign cell only + // exists when a gutter does, so the glyph is conditional on it — + // with line numbers off the content-area ellipsis is the sole + // indicator. Painted here, before the overlays: `DiagnosticView` + // writes the same cell later in the frame, so a diagnostic + // clamped onto this head wins (an error inside the collapsed + // region is higher-signal than "this is collapsed"). + if folds.is_some_and(|map| map.is_head(this_line)) { + grid.at(CellCoord::new(grid_row, rect.origin.col)).glyph = + crate::cell::Glyph::Char(FOLD_GUTTER_GLYPH); + } + // The mode picks the number: absolute (`line+1`), relative // distance, or hybrid (absolute on the cursor line, else relative). // Written right-aligned, rightmost digit first, alloc-free. // `field >= digits(line_count)` by construction, so the leftmost // digit always leaves at least a leading pad cell. - let Some(mut val) = window.line_numbers.number_for(buffer_line, cursor_line) else { + // + // Arc 6 Stage 2 (Q#FD14): with folds present, Relative/Hybrid + // distance is counted in VISIBLE lines across the collapse; + // Absolute keeps the raw `line + 1` (hidden numbers simply do not + // appear, so the column jumps from the head's number to the first + // post-fold number). Without folds this is `number_for` verbatim. + let number = match (folds, window.line_numbers) { + (Some(map), LineNumberMode::Relative) => Some(map.visible_distance(anchor, this_line)), + (Some(map), LineNumberMode::Hybrid) if this_line != anchor => { + Some(map.visible_distance(anchor, this_line)) + } + _ => window.line_numbers.number_for(this_line, anchor), + }; + let Some(mut val) = number else { continue; }; let mut col = field; @@ -3238,6 +3401,10 @@ fn paint_line_number_gutter( } } +#[allow( + clippy::too_many_arguments, + reason = "one window's already-resolved paint geometry; mirrors paint_selection_in_window" +)] fn paint_local_selection( grid: &mut crate::cell::CellGrid<'_>, buf: &crate::buffer::Buffer, @@ -3248,11 +3415,24 @@ fn paint_local_selection( // text-relative display column shifted right by this (Q#UX2). 0 when // the gutter is off, so this is a no-op then. gutter_w: u32, + // Arc 6 Stage 2: this window's collapsed regions, or `None`. + folds: Option<&crate::fold_view::VisibleLineMap>, theme: &crate::highlight::Theme, ) { let Some((sel_start, sel_end)) = window.region() else { return; }; + // Arc 6 Stage 2 (Q#FD16): each ENDPOINT on a hidden line projects to + // its component's head position; hidden interior cells simply have no + // row and drop. The visible portion then paints on the visible head + // row and the visible tail rows, contiguous on screen. + let (sel_start, sel_end) = match folds { + Some(map) => ( + map.visible_position(window.text_view.line_at_offset(sel_start), sel_start), + map.visible_position(window.text_view.line_at_offset(sel_end), sel_end), + ), + None => (sel_start, sel_end), + }; // Themes Q#TH5: the selection is a wash — a set `ui.selection` // face replaces the default overlay wholesale within its {bg} // mask (an all-default face disables the wash; out-of-mask @@ -3272,9 +3452,11 @@ fn paint_local_selection( } let text_cols = rect.size.cols.saturating_sub(gutter_w); - let first_row = window.view_top; - let last_row = first_row.saturating_add(inner_rows as usize); - for display_row in first_row..last_row { + // Row `r` shows the `r`-th VISIBLE line at or after `view_top`. + let mut next_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top)); + for row_offset in 0..inner_rows { + let display_row = next_line; + next_line = folds.map_or(display_row + 1, |map| map.next_visible(display_row)); let Some(line_start) = window.text_view.line_offset(display_row) else { continue; }; @@ -3298,7 +3480,6 @@ fn paint_local_selection( continue; } - let row_offset = display_row.saturating_sub(first_row) as u32; let start_col = start_coord.col.min(text_cols); let end_col = end_coord.col.min(text_cols); if start_col >= end_col { @@ -4028,6 +4209,7 @@ mod tests { &rect, rows, 4, + None, &crate::highlight::Theme::empty(), ); @@ -6672,6 +6854,7 @@ mod tests { cell_origin: rect.origin, cell_size: CellSize::new(rect.size.rows, rect.size.cols), gutter_w: 0, + folds: None, }; let mut grid = CellGrid { cells: &mut backing, @@ -6806,6 +6989,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(24, 80), gutter_w: 0, + folds: None, }; // Two no-op overlays: probe the dispatch cost only. diff --git a/src/editor_core.rs b/src/editor_core.rs index 5afbe90..2a15e32 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -386,6 +386,8 @@ impl EditorCore { FrontendView { layout: Layout::single(id), active: id, + // LOCAL is the in-process grid editor (Q#FD21). + fold_projection: true, }, ); Self { @@ -534,6 +536,51 @@ impl EditorCore { self.windows.get_mut(&win_id) } + /// Whether the **acting** frontend's display collapses folds (Arc 6 + /// Stage 2, Q#FD21). + /// + /// The gate on every command/event-time visible-line reckoning — + /// motion, paging, wheel, the click inverse, the auto-scroll clamp. + /// A `semantic_render` (GPU) session still displays every source + /// line until Stage 3, so with this `false` those sites keep their + /// raw-line behavior and its cursor never skips a line it is showing + /// (even while a grid session folds the same shared buffer). + #[must_use] + pub fn fold_projection_active(&self) -> bool { + self.active_view().fold_projection + } + + /// The visible-line map for `win_id`'s buffer, or `None` when the + /// acting frontend does not project folds, `win_id` is unknown, or + /// that buffer has no folds (Q#FD12). + /// + /// The two axes are deliberately separate (round-3 F1): the **acting + /// frontend** supplies the projection policy, while the + /// **operation's target window** supplies the buffer and line + /// offsets. Motion, paging, and auto-scroll target the active + /// window; the click inverse and wheel scrolling name an explicit + /// `win_id` — a wheel event over an inactive pane does not activate + /// it, so deriving the active window's map there would project one + /// buffer's folds onto another. + #[must_use] + pub fn fold_map_for_window( + &self, + win_id: WindowId, + ) -> Option { + if !self.fold_projection_active() { + return None; + } + let window = self.windows.get(&win_id)?; + crate::fold_view::map_for_window(&self.fold_registry, window) + } + + /// [`Self::fold_map_for_window`] for the active window — the target + /// of motion, paging, and the auto-scroll clamp. + #[must_use] + pub fn fold_map_active(&self) -> Option { + self.fold_map_for_window(self.active_window_id()) + } + /// T M10.8 — register a `FrontendView` for `fid`. Called by the /// daemon on attach (Day 3 dispatcher work). Day 2's fallback /// path makes this optional; Day 3 makes it required. @@ -1264,6 +1311,16 @@ impl EditorCore { /// /// Returns a stringified error on buffer or view failure. pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { + // Arc 6 Stage 2 (Q#FD19): ONE pre-edit unfold funnel for every + // local point-anchored edit. This subsumes the six `dispatch_key` + // primitives' individual calls (retired) and widens the behavior + // to **yank** and **query-replace**, which reach the buffer + // through here rather than through those primitives — both place + // point at the edit site first, so keying on the active point + // covers them exactly. `apply_active_edit` is never the + // remote-apply path, so this funnel is inherently local: a remote + // peer's edit inside my fold must not unfold it (Stage 3). + self.unfold_before_point_edit(); let buffer_id = self.active_buffer_id(); // Scope the registry borrow: the origin translation below needs // `&mut self` after the views have been notified. @@ -1541,27 +1598,42 @@ impl EditorCore { } /// Move the cursor up one line, preserving display column. + /// + /// Arc 6 Stage 2 (Q#FD17, ruled: include): on a fold-projecting + /// frontend this steps to the previous **visible** line, so a + /// collapsed region is one motion step and the cursor never comes to + /// rest hidden. Motion that *begins* from a hidden logical cursor (a + /// shared fold, or goto-line into one) first normalizes to the + /// visible head. Scoped by Q#FD21 — a semantic frontend keeps + /// raw-line motion until Stage 3. pub fn move_up(&mut self) { + let folds = self.fold_map_active(); let id = self.active_buffer_id(); let cursor = self.active_window().cursor; let goal_col = self.active_window().goal_col; let result = { let reg = self.registry.borrow(); let Ok(buffer) = reg.get(id) else { return }; - let coord = self - .active_window() + let aw = self.active_window(); + let coord = aw .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); - if coord.row == 0 { + let from_row = folds.as_ref().map_or(coord.row as usize, |map| { + map.visible_head_of(coord.row as usize) + }); + if from_row == 0 { return; } + let target_row = folds + .as_ref() + .map_or(from_row - 1, |map| map.prev_visible(from_row)); let goal = goal_col.unwrap_or(coord.col); - let target = DisplayCoord::new(coord.row - 1, goal); - let new_pos = self - .active_window() - .text_view - .display_to_pos(buffer, target); + let Ok(target_row) = u32::try_from(target_row) else { + return; + }; + let target = DisplayCoord::new(target_row, goal); + let new_pos = aw.text_view.display_to_pos(buffer, target); (goal, new_pos) }; let (goal, new_pos) = result; @@ -1573,28 +1645,35 @@ impl EditorCore { } /// Move the cursor down one line, preserving display column. + /// Visible-line stepping mirrors [`Self::move_up`] (Q#FD17/FD21). pub fn move_down(&mut self) { + let folds = self.fold_map_active(); let id = self.active_buffer_id(); let cursor = self.active_window().cursor; let goal_col = self.active_window().goal_col; let result = { let reg = self.registry.borrow(); let Ok(buffer) = reg.get(id) else { return }; - let coord = self - .active_window() + let aw = self.active_window(); + let coord = aw .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); - let next_row = coord.row + 1; - if (next_row as usize) >= self.active_window().text_view.line_count() { + let from_row = folds.as_ref().map_or(coord.row as usize, |map| { + map.visible_head_of(coord.row as usize) + }); + let next_row = folds + .as_ref() + .map_or(from_row + 1, |map| map.next_visible(from_row)); + if next_row >= aw.text_view.line_count() { return; } let goal = goal_col.unwrap_or(coord.col); + let Ok(next_row) = u32::try_from(next_row) else { + return; + }; let target = DisplayCoord::new(next_row, goal); - let new_pos = self - .active_window() - .text_view - .display_to_pos(buffer, target); + let new_pos = aw.text_view.display_to_pos(buffer, target); (goal, new_pos) }; let (goal, new_pos) = result; @@ -1743,6 +1822,9 @@ impl EditorCore { /// of context); falls back to a sane default before the first /// frame has rendered. pub fn move_page_down(&mut self) { + // Arc 6 Stage 2 (Q#FD18/FD21): a screenful is a screenful of + // VISIBLE lines, and `view_top` never lands hidden. + let folds = self.fold_map_active(); let step = self.page_step(); let cursor = self.active_window().cursor; let view_top = self.active_window().view_top; @@ -1755,12 +1837,25 @@ impl EditorCore { .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); - let max_line = aw.text_view.line_count().saturating_sub(1) as u32; + let max_line = aw.text_view.line_count().saturating_sub(1); let goal_col = aw.goal_col.unwrap_or(coord.col); - let target_row = (coord.row + step).min(max_line); + let (target_row, new_top) = match folds.as_ref() { + Some(map) => ( + map.nth_visible_from(coord.row as usize, step as usize) + .min(map.visible_head_of(max_line)), + map.nth_visible_from(view_top, step as usize), + ), + None => ( + (coord.row as usize + step as usize).min(max_line), + view_top.saturating_add(step as usize), + ), + }; + let Ok(target_row) = u32::try_from(target_row) else { + return; + }; let target = DisplayCoord::new(target_row, goal_col); let new_pos = aw.text_view.display_to_pos(buffer, target); - (goal_col, new_pos, view_top.saturating_add(step as usize)) + (goal_col, new_pos, new_top) }; let (goal, new_pos, new_top) = result; let aw = self.active_window_mut(); @@ -1771,12 +1866,16 @@ impl EditorCore { // Also nudge view_top; render's scroll-into-view will clamp // and align further if needed. let max_top = aw.text_view.line_count().saturating_sub(1); - aw.view_top = new_top.min(max_top); + let clamped = new_top.min(max_top); + aw.view_top = folds + .as_ref() + .map_or(clamped, |map| map.clamp_view_top(clamped)); } /// Move the cursor up by approximately one screenful. Mirror of /// [`Self::move_page_down`]. pub fn move_page_up(&mut self) { + let folds = self.fold_map_active(); let step = self.page_step(); let cursor = self.active_window().cursor; let view_top = self.active_window().view_top; @@ -1790,10 +1889,22 @@ impl EditorCore { .pos_to_display(buffer, cursor) .unwrap_or_default(); let goal_col = aw.goal_col.unwrap_or(coord.col); - let target_row = coord.row.saturating_sub(step); + let (target_row, new_top) = match folds.as_ref() { + Some(map) => ( + map.nth_visible_back(coord.row as usize, step as usize), + map.nth_visible_back(view_top, step as usize), + ), + None => ( + (coord.row as usize).saturating_sub(step as usize), + view_top.saturating_sub(step as usize), + ), + }; + let Ok(target_row) = u32::try_from(target_row) else { + return; + }; let target = DisplayCoord::new(target_row, goal_col); let new_pos = aw.text_view.display_to_pos(buffer, target); - (goal_col, new_pos, view_top.saturating_sub(step as usize)) + (goal_col, new_pos, new_top) }; let (goal, new_pos, new_top) = result; let aw = self.active_window_mut(); @@ -1835,15 +1946,21 @@ impl EditorCore { aw.goal_col = None; } - /// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a - /// command-path point-anchored edit (the six primitives below), - /// unfold every fold containing the active point so a self-insert or - /// delete inside a collapsed region reveals it rather than landing - /// invisibly. Keyed on the authenticated source frontend's active - /// point (this is `active_window().cursor`), not the transport. A - /// no-op when the buffer has no folds. Interactive Lua-command edits - /// (yank/query-replace/comment) reach the buffer through a different - /// path and are a named Stage 2 widening; CRDT-origin is Stage 3. + /// Pre-edit unfold (Arc 6, Q#FD5 / Stage 2 Q#FD19). Before a local + /// point-anchored edit, unfold every fold containing the active point + /// so an edit inside a collapsed region reveals it rather than + /// landing invisibly. Keyed on the authenticated source frontend's + /// active point (`active_window().cursor`), not the transport. A + /// no-op when the buffer has no folds. + /// + /// **Stage 2 widening:** Stage 1 called this from each of the six + /// `dispatch_key` edit primitives. It now runs once at the top of + /// [`Self::apply_active_edit`] — the single funnel those primitives + /// (and yank, and query-replace) all pass through. Interactive + /// Lua-mutator edits (comment-toggle, yank-pop) take a *different* + /// path and are hooked at `run_buffer_edit` in the Lua bindings; the + /// remote/optimistic-CRDT apply path is deliberately excluded + /// (Stage 3), as is undo/redo (deferred). fn unfold_before_point_edit(&self) { let id = self.active_buffer_id(); let point = self.active_window().cursor; @@ -1855,7 +1972,6 @@ impl EditorCore { /// line and returns `false`, and callers must not mutate dependent /// state (e.g. selection anchors) on a failed insert (Q#AI9). pub fn insert_char(&mut self, ch: char) -> bool { - self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let s = ch.encode_utf8(&mut buf); @@ -1891,7 +2007,6 @@ impl EditorCore { /// delegates to [`Self::insert_char`] (a plain insert). The cursor /// lands just past the inserted bytes and any selection is cleared. pub fn insert_char_over_region(&mut self, ch: char) { - self.unfold_before_point_edit(); let Some((lo, hi)) = self.active_region() else { // Q#AI9: an empty selection (anchor == cursor) reports no // region yet stays armed — the insert moves the cursor off @@ -1933,7 +2048,6 @@ impl EditorCore { /// Delete the codepoint immediately before the cursor. pub fn backspace(&mut self) { - self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -1955,7 +2069,6 @@ impl EditorCore { /// Delete the codepoint at the cursor (forward delete). pub fn delete_forward(&mut self) { - self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); @@ -1979,7 +2092,6 @@ impl EditorCore { /// between the cursor and where [`Self::move_word_left`] would /// land. pub fn delete_word_backward(&mut self) { - self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; if cursor == 0 { @@ -2007,7 +2119,6 @@ impl EditorCore { /// [`Self::delete_forward`] over the gap from the cursor to where /// [`Self::move_word_right`] would land. pub fn delete_word_forward(&mut self) { - self.unfold_before_point_edit(); self.active_window_mut().goal_col = None; let cursor = self.active_window().cursor; let id = self.active_buffer_id(); @@ -3345,6 +3456,7 @@ mod tests { FrontendView { layout: Layout::single(win_id), active: win_id, + fold_projection: true, }, ); win_id diff --git a/src/fold_view.rs b/src/fold_view.rs new file mode 100644 index 0000000..bebaff6 --- /dev/null +++ b/src/fold_view.rs @@ -0,0 +1,466 @@ +// fold_view.rs --- The visible-line map (Arc 6, Stage 2). + +//! The source-line ↔ display-row projection that folding introduces. +//! +//! Before folding, every grid consumer assumed `display_row = +//! source_line − view_top` — an identity map baked into the text walk, +//! the gutter, every overlay, the caret, both selection painters, the +//! mode-line indicator, and the click/scroll/motion inverses. +//! [`DisplayCoord`](crate::view::DisplayCoord) anticipated a +//! non-identity map "once virtual lines, wrapping, and inline +//! expansions appear"; **folding is the first**. +//! +//! This module is that map — **one derivation/query primitive** +//! (`docs/folding-stage2-framing.md` Q#FD12), derived from +//! [`crate::fold::FoldRegistry::folds`] plus the buffer's line offsets +//! and **never stored**: the byte-range store in [`crate::fold`] stays +//! the single source of truth. Instances are short-lived and built +//! **per rendered window** and **per command/event operation**, never +//! once per frame — a frame paints several windows that may show +//! different buffers, so a per-frame singleton would leak one pane's +//! folds into another's (framing round-2 F2). +//! +//! # Hidden components, not folds +//! +//! The unit here is not a fold. [`crate::fold::FoldStore::insert`] +//! accepts any normalized range, so folds may nest, share a head line, +//! or **cross**: with fold `A` hiding lines 1–3 and fold `B` headed on +//! line 2 hiding lines 3–5, a point on line 5 is directly inside only +//! `B` — yet `B`'s own head is hidden by `A`, so projecting to `B`'s +//! `range.start` would land on another *hidden* position (round-3 F2). +//! +//! The derivation therefore unions overlapping **or adjacent** hidden +//! line intervals into sorted, non-overlapping **hidden components**. +//! Adjacent intervals merge because the later fold's head is hidden by +//! the earlier one, so it can never render. Each component keeps the one +//! visible line immediately before it (`head_line`) and that line's exact +//! end-of-content byte (`head_position` — the fold `range.start` Stage 1 +//! already moves point to). Resolving through the component is +//! equivalent to repeatedly projecting a hidden fold head until it is +//! visible, and so covers nesting, shared heads, and crossing overlap +//! alike. + +use pmacs_protocol::ByteRange; + +use crate::fold::FoldRegistry; +use crate::rope::Position; +use crate::window::Window; + +/// The visible-line map for one **window's** buffer, or `None` when that +/// buffer has no folds. +/// +/// The single construction rule shared by the render path and +/// `EditorCore` (Q#FD12): keyed on *this* window's `buffer_id` and its +/// own [`TextView`](crate::text_view::TextView) line offsets — never the +/// active buffer's — so a split showing two buffers gets two independent +/// maps and neither leaks into the other (round-2 F2). Returning `None` +/// rather than an empty map keeps the unfolded path byte-identical. +#[must_use] +pub fn map_for_window(registry: &FoldRegistry, window: &Window) -> Option { + let folds = registry.folds(window.buffer_id); + if folds.is_empty() { + return None; + } + Some(VisibleLineMap::build(&folds, |off| { + window.text_view.line_at_offset(off) + })) +} + +/// A maximal run of consecutive hidden source lines, plus the one +/// visible line that heads it. +/// +/// `first_hidden >= 1` always: a component's `head_line` is +/// `first_hidden - 1`, and a fold's head line is the line *above* its +/// first hidden line, so line 0 can never be hidden. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct HiddenComponent { + /// First hidden source line (inclusive). + first_hidden: usize, + /// Last hidden source line (inclusive). + last_hidden: usize, + /// End-of-content byte of `head_line` — the `ByteRange::start` of + /// the earliest fold participating in this component, which is + /// exactly where Stage 1 moves point on a fold-at-cursor. + head_position: Position, +} + +impl HiddenComponent { + /// The one visible line immediately above this component. + const fn head_line(&self) -> usize { + self.first_hidden - 1 + } +} + +/// A buffer's collapsed regions, projected into line space. +/// +/// Derived from a fold list and a byte→line lookup; cheap enough to +/// rebuild per window per frame (**Bet B4**: `O(folds)` with one binary +/// search into the caller's existing line-offset table per fold, and +/// folds are `O(top-level blocks)`). +/// +/// An empty map (`is_identity`) means "no folds" — callers pass `None` +/// rather than an empty map so the unfolded path stays byte-identical. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct VisibleLineMap { + /// Sorted by `first_hidden`, non-overlapping, and separated by at + /// least one visible line (adjacency is merged away at build time). + components: Vec, +} + +impl VisibleLineMap { + /// Derive the map from a buffer's folds. + /// + /// `line_at_offset` is the caller's own line-offset lookup (the + /// rendering window's [`TextView`](crate::text_view::TextView), the + /// only table guaranteed to agree with the rows being painted). A + /// fold's stored range is `[end of head line, end of last hidden + /// line]`, so `head_line = line_at_offset(start)` and `last_hidden = + /// line_at_offset(end)`; a fold that no longer spans a whole line + /// (mid-edit drift) contributes nothing. + #[must_use] + pub fn build(folds: &[ByteRange], line_at_offset: F) -> Self + where + F: Fn(Position) -> usize, + { + let mut raw: Vec = folds + .iter() + .filter_map(|f| { + let head_line = line_at_offset(f.start); + let last_hidden = line_at_offset(f.end); + (last_hidden > head_line).then_some(HiddenComponent { + first_hidden: head_line + 1, + last_hidden, + head_position: f.start, + }) + }) + .collect(); + raw.sort_by(|a, b| { + a.first_hidden + .cmp(&b.first_hidden) + .then(a.last_hidden.cmp(&b.last_hidden)) + }); + let mut components: Vec = Vec::with_capacity(raw.len()); + for c in raw { + match components.last_mut() { + // Overlapping OR adjacent: `c`'s head line is itself + // hidden by `prev`, so it can never render — the merged + // component keeps `prev`'s (visible) head. + Some(prev) if c.first_hidden <= prev.last_hidden + 1 => { + prev.last_hidden = prev.last_hidden.max(c.last_hidden); + } + _ => components.push(c), + } + } + Self { components } + } + + /// Whether this map hides nothing — the identity projection. + #[must_use] + pub fn is_identity(&self) -> bool { + self.components.is_empty() + } + + /// The component hiding `line`, if any. + fn component_of(&self, line: usize) -> Option<&HiddenComponent> { + let after = self.components.partition_point(|c| c.first_hidden <= line); + let c = self.components.get(after.checked_sub(1)?)?; + (line <= c.last_hidden).then_some(c) + } + + /// Whether `line` is collapsed away and renders no row. + #[must_use] + pub fn is_hidden(&self, line: usize) -> bool { + self.component_of(line).is_some() + } + + /// Whether `line` is the visible head of a collapsed region — the + /// row that carries the ellipsis and the gutter fold glyph. + #[must_use] + pub fn is_head(&self, line: usize) -> bool { + self.components + .binary_search_by(|c| c.first_hidden.cmp(&(line + 1))) + .is_ok() + } + + /// The **outermost visible head** of `line`: for a hidden line, its + /// component's head line; for a visible line, itself. + /// + /// The **row-only** clamp — diagnostic signs, the relative-number + /// cursor anchor, and the backward `view_top` clamp. Positions that + /// carry a column use [`Self::visible_position`] instead. + #[must_use] + pub fn visible_head_of(&self, line: usize) -> usize { + self.component_of(line) + .map_or(line, HiddenComponent::head_line) + } + + /// The **position** projection of a byte on `line`: for a hidden + /// line, its component's `head_position` (the head line's + /// end-of-content byte); for a visible line, `pos` unchanged. + /// + /// Used wherever a clamp carries a column — the local caret, peer + /// cursors, and selection endpoints — so a hidden point lands at the + /// head's end of content rather than at an arbitrary column on the + /// head (round-2 F3) or at a still-hidden crossing fold's start + /// (round-3 F2). + #[must_use] + pub fn visible_position(&self, line: usize, pos: Position) -> Position { + self.component_of(line).map_or(pos, |c| c.head_position) + } + + /// Clamp a candidate `view_top` **backward** to a visible line, so a + /// fold at the top of the viewport shows its head rather than being + /// skipped past (framing acceptance 8). + #[must_use] + pub fn clamp_view_top(&self, line: usize) -> usize { + self.visible_head_of(line) + } + + /// The next visible line strictly after `line`, skipping whole + /// collapsed regions. May exceed the buffer's line count; callers + /// bound it themselves. + #[must_use] + pub fn next_visible(&self, line: usize) -> usize { + let next = line + 1; + self.component_of(next) + .map_or(next, |c| c.last_hidden.saturating_add(1)) + } + + /// The previous visible line strictly before `line`, or `0` when + /// `line` is already the first line. + #[must_use] + pub fn prev_visible(&self, line: usize) -> usize { + match line.checked_sub(1) { + Some(prev) => self.visible_head_of(prev), + None => 0, + } + } + + /// Number of visible lines in the half-open range `[from, to)`; + /// `0` when `to <= from`. + /// + /// This is the framing's `visible_between` — exposed unsigned and + /// half-open (plus the symmetric [`Self::visible_distance`]) because + /// no consumer reads the sign: row offsets always measure forward + /// from `view_top`, and relative line numbers want a magnitude. + #[must_use] + pub fn visible_rows_between(&self, from: usize, to: usize) -> usize { + if to <= from { + return 0; + } + (to - from) - self.hidden_in(from, to) + } + + /// Visible-line distance between `a` and `b`, either order — the + /// relative/hybrid gutter number measured across collapses. + #[must_use] + pub fn visible_distance(&self, a: usize, b: usize) -> usize { + if a <= b { + self.visible_rows_between(a, b) + } else { + self.visible_rows_between(b, a) + } + } + + /// Hidden lines within the half-open range `[from, to)`. + fn hidden_in(&self, from: usize, to: usize) -> usize { + self.components + .iter() + .filter(|c| c.first_hidden < to && c.last_hidden >= from) + .map(|c| { + // `lo <= hi` holds under the filter, so this cannot + // underflow. + let lo = c.first_hidden.max(from); + let hi = c.last_hidden.min(to - 1); + hi + 1 - lo + }) + .sum() + } + + /// Total visible lines in a buffer of `total_lines` source lines — + /// the denominator the mode-line scroll indicator reckons in. + #[must_use] + pub fn visible_line_count(&self, total_lines: usize) -> usize { + total_lines - self.hidden_in(0, total_lines).min(total_lines) + } + + /// The line `n` visible steps forward from `from` (which is first + /// normalized to its visible head). `n == 0` yields that head. + #[must_use] + pub fn nth_visible_from(&self, from: usize, n: usize) -> usize { + let mut line = self.visible_head_of(from); + for _ in 0..n { + line = self.next_visible(line); + } + line + } + + /// The line `n` visible steps back from `from` (first normalized to + /// its visible head), saturating at line 0. + #[must_use] + pub fn nth_visible_back(&self, from: usize, n: usize) -> usize { + let mut line = self.visible_head_of(from); + for _ in 0..n { + if line == 0 { + break; + } + line = self.prev_visible(line); + } + line + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A 40-line buffer of `"L\n"`-ish rows, 8 bytes each, so line + /// `n` starts at `8n` and its content ends at `8n + 7`. + fn line_of(offset: Position) -> usize { + (offset / 8) as usize + } + + /// The fold that hides lines `first..=last` in that fixture. + fn fold(head: usize, last_hidden: usize) -> ByteRange { + ByteRange { + start: (head as u64) * 8 + 7, + end: (last_hidden as u64) * 8 + 7, + } + } + + fn map(folds: &[ByteRange]) -> VisibleLineMap { + VisibleLineMap::build(folds, line_of) + } + + #[test] + fn empty_map_is_identity() { + let m = map(&[]); + assert!(m.is_identity()); + assert!(!m.is_hidden(5)); + assert_eq!(m.visible_head_of(5), 5); + assert_eq!(m.next_visible(5), 6); + assert_eq!(m.visible_rows_between(0, 10), 10); + } + + #[test] + fn single_fold_hides_its_interior_only() { + // head 2, hidden 3..=6. + let m = map(&[fold(2, 6)]); + assert!(!m.is_hidden(2)); + assert!(m.is_head(2)); + for line in 3..=6 { + assert!(m.is_hidden(line), "line {line} should be hidden"); + assert_eq!(m.visible_head_of(line), 2); + } + assert!(!m.is_hidden(7)); + assert_eq!(m.next_visible(2), 7); + assert_eq!(m.prev_visible(7), 2); + // 0,1,2,7,8,9 visible in [0,10). + assert_eq!(m.visible_rows_between(0, 10), 6); + assert_eq!(m.visible_line_count(10), 6); + } + + #[test] + fn nested_folds_resolve_to_the_outermost_visible_head() { + // Outer: head 0, hidden 1..=9. Inner: head 3, hidden 4..=6. + let m = map(&[fold(0, 9), fold(3, 6)]); + assert_eq!(m.visible_head_of(5), 0, "inner head 3 is itself hidden"); + assert_eq!(m.visible_head_of(3), 0); + assert!(m.is_head(0)); + assert!(!m.is_head(3), "a hidden head renders no row"); + assert_eq!(m.next_visible(0), 10); + } + + #[test] + fn shared_head_folds_merge_to_the_longer_reach() { + // Two folds on head 4: one hides 5..=6, the other 5..=9. + let m = map(&[fold(4, 6), fold(4, 9)]); + assert_eq!(m.visible_head_of(9), 4); + assert_eq!(m.next_visible(4), 10); + assert_eq!(m.visible_position(9, 9 * 8 + 3), fold(4, 6).start); + } + + #[test] + fn crossing_folds_project_to_the_first_visible_head() { + // Round-3 F2: A hides 1..=3 (head 0); B is headed on line 2 and + // hides 3..=5. A point on line 5 is directly inside only B, but + // B's head is hidden by A — it must resolve to A's head. + let m = map(&[fold(0, 3), fold(2, 5)]); + assert!(m.is_hidden(5)); + assert_eq!(m.visible_head_of(5), 0); + assert_eq!( + m.visible_position(5, 5 * 8 + 4), + fold(0, 3).start, + "never B's still-hidden range.start" + ); + assert_eq!(m.next_visible(0), 6); + assert!(!m.is_head(2), "B's head is hidden, so it heads nothing"); + } + + #[test] + fn adjacent_folds_merge_because_the_later_head_is_hidden() { + // A hides 1..=3 (head 0); B is headed on line 3 (hidden by A) + // and hides 4..=5. Lines 1..=5 collapse under head 0. + let m = map(&[fold(0, 3), fold(3, 5)]); + for line in 1..=5 { + assert_eq!(m.visible_head_of(line), 0, "line {line}"); + } + assert_eq!(m.next_visible(0), 6); + } + + #[test] + fn a_visible_line_between_two_folds_keeps_them_separate() { + // A hides 1..=3 (head 0); B hides 5..=6 (head 4). Line 4 stays + // visible, so the components do not merge. + let m = map(&[fold(0, 3), fold(4, 6)]); + assert!(!m.is_hidden(4)); + assert_eq!(m.visible_head_of(3), 0); + assert_eq!(m.visible_head_of(6), 4); + assert_eq!(m.next_visible(0), 4); + assert_eq!(m.next_visible(4), 7); + } + + #[test] + fn visible_position_leaves_a_visible_byte_alone() { + let m = map(&[fold(2, 6)]); + assert_eq!(m.visible_position(7, 7 * 8 + 2), 7 * 8 + 2); + } + + #[test] + fn clamp_view_top_goes_backward_to_the_head() { + let m = map(&[fold(2, 6)]); + assert_eq!(m.clamp_view_top(5), 2); + assert_eq!(m.clamp_view_top(2), 2); + assert_eq!(m.clamp_view_top(7), 7); + } + + #[test] + fn visible_distance_is_symmetric_and_skips_folds() { + let m = map(&[fold(2, 6)]); + // Visible order: 0,1,2,7,8 — line 8 is 4 visible steps from 0. + assert_eq!(m.visible_distance(0, 8), 4); + assert_eq!(m.visible_distance(8, 0), 4); + assert_eq!(m.visible_distance(2, 7), 1); + } + + #[test] + fn nth_visible_walks_forward_and_back_over_folds() { + let m = map(&[fold(2, 6)]); + assert_eq!(m.nth_visible_from(0, 3), 7); + assert_eq!(m.nth_visible_back(8, 4), 0); + // A hidden origin normalizes to its head first. + assert_eq!(m.nth_visible_from(5, 1), 7); + assert_eq!(m.nth_visible_back(5, 1), 1); + } + + #[test] + fn build_drops_a_fold_that_no_longer_spans_a_line() { + // start and end inside one line: nothing to hide. + let degenerate = ByteRange { start: 10, end: 12 }; + assert!(map(&[degenerate]).is_identity()); + } +} diff --git a/src/highlight.rs b/src/highlight.rs index 8a8914d..e7e5a98 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -413,7 +413,7 @@ impl View for SyntaxHighlightView { "syntax-highlight" } - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { self.refresh_cache_if_stale(); let Some(bundle) = self.cache.bundle.clone() else { return; @@ -436,7 +436,11 @@ impl View for SyntaxHighlightView { // `compute_highlight_spans_for`) lets narrower captures override. for layer in &self.cache.layers { for row_offset in 0..max_rows { - let line_idx = start_line + row_offset; + // Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line, + // matching `TextView::render`'s walk (Q#FD13). + let line_idx = + u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset)) + .unwrap_or(u32::MAX); if line_idx >= total_lines { break; } @@ -593,7 +597,7 @@ impl View for LspStyleView { "lsp-style" } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let Some(path) = buf.file_path() else { return; // No path ⇒ no URI ⇒ nothing to look up. }; @@ -644,7 +648,10 @@ impl View for LspStyleView { let total_lines = line_offsets.len() as u32; for row_offset in 0..max_rows { - let line_idx = start_line + row_offset; + // Arc 6 Stage 2: row `r` shows the `r`-th VISIBLE line. + let line_idx = + u32::try_from(viewport.line_at_row_offset(start_line as usize, row_offset)) + .unwrap_or(u32::MAX); if line_idx >= total_lines { break; } @@ -1058,6 +1065,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 20), gutter_w: 0, + folds: None, }; let registry = state.core.borrow().registry.clone(); let reg = registry.borrow(); @@ -1155,6 +1163,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 20), gutter_w: 0, + folds: None, }; let registry = state.core.borrow().registry.clone(); let reg = registry.borrow(); @@ -1274,6 +1283,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 20), gutter_w: 0, + folds: None, }; let registry = state.core.borrow().registry.clone(); let reg = registry.borrow(); @@ -1333,6 +1343,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(rows as u32, cols as u32), gutter_w: 0, + folds: None, }; let registry = buf; // keep buf alive hv.render(®istry, viewport, &mut grid); @@ -1389,6 +1400,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(rows as u32, cols as u32), gutter_w: 0, + folds: None, }; let registry = buf; // keep buf alive hv.render(®istry, viewport, &mut grid); diff --git a/src/hover.rs b/src/hover.rs index 6de7660..d32a86b 100644 --- a/src/hover.rs +++ b/src/hover.rs @@ -208,7 +208,7 @@ impl HoverView { } impl View for HoverView { - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let lines: Vec = { let guard = self.store.lock().expect("hover store poisoned"); guard diff --git a/src/lib.rs b/src/lib.rs index 392f7b7..e3971a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,6 +76,7 @@ pub mod editor; pub mod editor_core; pub mod file_io; pub mod fold; +pub mod fold_view; pub mod font_pref; pub mod formatting; pub mod frontend; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7e84899..4747a7c 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1308,6 +1308,11 @@ fn run_buffer_edit( op: EditOp<'_>, bypass_intercept: bool, ) -> mlua::Result { + // Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam. + // Hooked HERE — above both `run_managed_edit` and `run_bypass_edit` — + // so an interactive command that passes `bypass_intercept` does not + // escape the widening. + unfold_before_interactive_lua_edit(lua, id); if bypass_intercept { run_bypass_edit(lua, id, op) } else { @@ -1315,6 +1320,50 @@ fn run_buffer_edit( } } +/// Unfold at the point before an **interactive** Lua-mutator edit +/// (comment-toggle, yank-pop, and any other `pmacs.buffer.X` mutation a +/// command body performs on the buffer the user is looking at). +/// +/// Unfolds only when **all** of: +/// +/// 1. [`InteractiveCommandOrigin::current`] is `Some(f)` — an +/// interactive command is running, and `f` is the authenticated +/// frontend that invoked it. This is the scoped authority that +/// distinguishes a user command's edit from a plugin's or the data +/// API's programmatic one; no command-history inference is involved, +/// and the guard clears even when the Lua command errors. +/// 2. The edited buffer **is `f`'s active-window buffer**. An explicit +/// mutation of some *other*, inactive buffer stays programmatic — it +/// is not an edit at the user's point. +/// 3. A fold actually contains that point (handled by +/// [`crate::fold::FoldRegistry::unfold_containing`], which is a no-op +/// otherwise). +/// +/// Matches Stage 1's data-API exemption: `pmacs.buffer.insert` called +/// from a plugin, a hook, or a bare Lua chunk unfolds nothing. +fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId) { + let Some(origin) = lua + .app_data_ref::() + .and_then(|origin| origin.current()) + else { + return; // programmatic: no interactive command in scope + }; + let Some(folds) = lua.app_data_ref::() else { + return; + }; + let Some(core) = lua.app_data_ref::() else { + return; + }; + let core = core.borrow(); + let Some(window) = core.active_window_for(origin) else { + return; // the invoking frontend has no view (nothing to anchor on) + }; + if window.buffer_id != id { + return; // an inactive-buffer mutation stays programmatic + } + folds.unfold_containing(id, window.cursor); +} + fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { with_registry_mut(lua, |r| { let buf = resolve_mut(r, id)?; diff --git a/src/menu.rs b/src/menu.rs index 5ce07a5..7de7bf7 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -378,7 +378,7 @@ impl View for MenuView { "context-menu" } - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let guard = self.menu.lock().expect("menu mutex poisoned"); let Some(menu) = guard.as_ref() else { return; diff --git a/src/overlay.rs b/src/overlay.rs index 5d41e28..cdbce35 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -101,7 +101,7 @@ impl StyleSpanOverlay { } impl View for StyleSpanOverlay { - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { for span in &self.spans { if span.row >= viewport.cell_size.rows { continue; @@ -312,7 +312,7 @@ impl View for BufferStyleOverlay { Some(Box::new(self.clone())) } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let spans = self .spans .lock() @@ -369,7 +369,7 @@ fn render_buffer_style_span( buf: &Buffer, line_offsets: &[u64], start_line: usize, - viewport: Viewport, + viewport: Viewport<'_>, cells: &mut CellGrid<'_>, span: BufferStyleSpan, ) { @@ -379,10 +379,11 @@ fn render_buffer_style_span( let first_line = line_at_offset(line_offsets, span.start); let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1)); for line in first_line..=last_line { - if line < start_line { + // Arc 6 Stage 2: a span on a collapsed line paints nothing (that + // line has no row); one below a fold lands on its shifted-up row. + let Some(row_offset) = viewport.row_offset_of(start_line, line) else { continue; - } - let row_offset = (line - start_line) as u32; + }; if row_offset >= viewport.cell_size.rows { break; } @@ -457,7 +458,7 @@ impl VirtualCellOverlay { } impl View for VirtualCellOverlay { - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { for vc in &self.cells { if vc.row >= viewport.cell_size.rows || vc.col >= viewport.cell_size.cols { continue; @@ -487,13 +488,14 @@ mod tests { vec![Cell::default(); (rows * cols) as usize] } - fn viewport(rows: u32, cols: u32) -> Viewport { + fn viewport(rows: u32, cols: u32) -> Viewport<'static> { Viewport { buffer_start: 0, buffer_end: u64::MAX, cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(rows, cols), gutter_w: 0, + folds: None, } } diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index af0ee7f..b16daae 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -144,21 +144,47 @@ pub fn paint_other_frontend_overlays( if g >= rect.size.cols { 0 } else { g } }; let text_cols = rect.size.cols.saturating_sub(gutter_w); + // Arc 6 Stage 2 (round-2 F2): the fold map belongs to THIS + // RECIPIENT WINDOW — built from its own buffer and line + // offsets, exactly like its `paint_frame` pass. A split + // showing two buffers derives two maps, and a peer in the + // unfolded pane is never projected through the folded one's. + let folds = crate::fold_view::map_for_window(&state.fold_registry, window); // Source's byte position → display coords via THIS // recipient window's text_view (the recipient's view - // of the buffer). - let Some(disp) = window - .text_view - .pos_to_display(buf, presence.snapshot.cursor) - else { + // of the buffer). Q#FD16: a peer cursor on a line this + // recipient has collapsed clamps to the head POSITION. + let peer_cursor = match folds.as_ref() { + Some(map) => map.visible_position( + window.text_view.line_at_offset(presence.snapshot.cursor), + presence.snapshot.cursor, + ), + None => presence.snapshot.cursor, + }; + let Some(disp) = window.text_view.pos_to_display(buf, peer_cursor) else { continue; }; // Filter to viewport visible range. `view_top` is the // top visible buffer-line; cells below it are in-frame - // until `view_top + inner_rows`. - let row_in_window = match (disp.row as usize).checked_sub(window.view_top) { - Some(r) if r < inner_rows as usize => r, - _ => continue, + // until `view_top + inner_rows` — counted in VISIBLE rows + // once this window has folds. + let row_in_window = match folds.as_ref() { + Some(map) => { + let top = map.clamp_view_top(window.view_top); + let row = disp.row as usize; + if row < top { + continue; + } + let r = map.visible_rows_between(top, row); + if r >= inner_rows as usize { + continue; + } + r + } + None => match (disp.row as usize).checked_sub(window.view_top) { + Some(r) if r < inner_rows as usize => r, + _ => continue, + }, }; // Column bounds: disp.col is the buffer column; window // doesn't horizontally scroll in v1.0, so cells past @@ -181,7 +207,16 @@ pub fn paint_other_frontend_overlays( (sel.active, sel.anchor) }; paint_selection_in_window( - grid, buf, window, rect, inner_rows, gutter_w, lo, hi, color, + grid, + buf, + window, + rect, + inner_rows, + gutter_w, + folds.as_ref(), + lo, + hi, + color, ); } } @@ -235,10 +270,21 @@ fn paint_selection_in_window( rect: Rect, inner_rows: u32, gutter_w: u32, + folds: Option<&crate::fold_view::VisibleLineMap>, lo: crate::rope::Position, hi: crate::rope::Position, color: Color, ) { + // Arc 6 Stage 2 (Q#FD16): endpoints on a collapsed line project to + // their component's head position; hidden interior bytes have no row + // and drop below. + let (lo, hi) = match folds { + Some(map) => ( + map.visible_position(window.text_view.line_at_offset(lo), lo), + map.visible_position(window.text_view.line_at_offset(hi), hi), + ), + None => (lo, hi), + }; if lo >= hi { return; } @@ -261,7 +307,15 @@ fn paint_selection_in_window( let Some(disp) = window.text_view.pos_to_display(buf, pos) else { break; }; - match (disp.row as usize).checked_sub(window.view_top) { + let row_in_window = match folds { + Some(map) if map.is_hidden(disp.row as usize) => None, + Some(map) => { + let top = map.clamp_view_top(window.view_top); + (disp.row as usize >= top).then(|| map.visible_rows_between(top, disp.row as usize)) + } + None => (disp.row as usize).checked_sub(window.view_top), + }; + match row_in_window { Some(r) if r < inner_rows as usize && disp.col < text_cols => { let grid_row = rect.origin.row + r as u32; let grid_col = rect.origin.col + gutter_w + disp.col; diff --git a/src/search.rs b/src/search.rs index 0ff1211..b77d231 100644 --- a/src/search.rs +++ b/src/search.rs @@ -411,7 +411,7 @@ impl View for SearchView { "search" } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let buffer_id = buf.id(); // Snapshot the matches under the lock, release immediately // (same discipline as DiagnosticView). @@ -439,6 +439,9 @@ impl View for SearchView { let line_offsets = crate::diag::compute_line_offsets(&source); let start_line_buf = crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32); + // Arc 6 Stage 2: source line → this viewport's row, `None` when + // the line is collapsed away (the wash then paints nothing). + let row_of = |line: u32| viewport.row_offset_of(start_line_buf as usize, line as usize); let max_rows = viewport.cell_size.rows; let max_cols = viewport.cell_size.cols; let cell_origin = viewport.cell_origin; @@ -479,17 +482,19 @@ impl View for SearchView { // Single-line matches (every literal match) touch one row. let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32); // Matches are sorted ascending, so once one starts below the - // viewport every later one does too — stop. - if first_line >= start_line_buf.saturating_add(max_rows) { + // viewport every later one does too — stop. Arc 6 Stage 2: + // measured in VISIBLE rows, since a collapse puts a far-away + // raw line back on screen; a hidden start yields no offset + // and falls through to the per-line skip below. + if row_of(first_line).is_some_and(|row| row >= max_rows) { break; } let last_byte = m.end.saturating_sub(1).max(m.start) as u32; let last_line = crate::diag::line_at_offset(&line_offsets, last_byte); for line in first_line..=last_line { - if line < start_line_buf { + let Some(row_offset) = row_of(line) else { continue; - } - let row_offset = line - start_line_buf; + }; if row_offset >= max_rows { break; } @@ -753,6 +758,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 10), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -781,6 +787,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 10), gutter_w: 0, + folds: None, }, &mut grid2, ); @@ -822,6 +829,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(rows, cols), gutter_w: 0, + folds: None, }, &mut grid, ); diff --git a/src/signature.rs b/src/signature.rs index 495e8bf..6121895 100644 --- a/src/signature.rs +++ b/src/signature.rs @@ -318,7 +318,7 @@ impl SignatureView { } impl View for SignatureView { - fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { let snap = self.snapshot(); let max_rows = viewport.cell_size.rows; let max_cols = viewport.cell_size.cols; diff --git a/src/text_view.rs b/src/text_view.rs index b94e03d..aae8c06 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -33,6 +33,10 @@ use crate::view::{DisplayCoord, View, Viewport}; /// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer. const STACK_CAP: usize = 256; +/// Trailing marker painted after a collapsed region's head line (Arc 6 +/// Stage 2, Q#FD13). One column wide, so it never disturbs layout. +pub const FOLD_ELLIPSIS: char = '…'; + // --------------------------------------------------------------------------- // TextView // --------------------------------------------------------------------------- @@ -204,14 +208,26 @@ impl View for TextView { Some(line_start + walked_bytes as u64) } - fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { - let start_line = self.line_at_offset(viewport.buffer_start); + fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { + // Arc 6 Stage 2 (Q#FD13): row `r` shows the `r`-th VISIBLE source + // line at or after `view_top`; a collapsed region's lines are + // skipped entirely and the rows below shift up. Without a fold + // map this walk is the pre-folding `start_line + row_offset` + // identity. Folding is deliberately not an overlay — overlays + // repaint cells, they cannot delete rows. + let folds = viewport.folds.filter(|m| !m.is_identity()); + // `view_top` is clamped backward before the frame, but a caller + // that hands us a hidden start still gets its head. + let start_line = { + let raw = self.line_at_offset(viewport.buffer_start); + folds.map_or(raw, |m| m.visible_head_of(raw)) + }; let max_rows = viewport.cell_size.rows; let max_cols = viewport.cell_size.cols; let origin = viewport.cell_origin; + let mut line = start_line; for row_offset in 0..max_rows { - let line = start_line + row_offset as usize; let cell_row = origin.row + row_offset; // Always clear the visible row first so previous content does @@ -222,8 +238,10 @@ impl View for TextView { if line >= self.line_count() { continue; } + let this_line = line; + line = folds.map_or(this_line + 1, |m| m.next_visible(this_line)); - let line_bytes = self.read_line_bytes(buf, line); + let line_bytes = self.read_line_bytes(buf, this_line); let Ok(s) = std::str::from_utf8(&line_bytes) else { continue; }; @@ -267,6 +285,23 @@ impl View for TextView { } col += width; } + + // The head of a collapsed region carries a trailing ellipsis + // in the CONTENT area (Q#FD13/FD20): the authoritative, + // layout-neutral fold indicator, present in every gutter + // state, clipped like any long line. + if folds.is_some_and(|m| m.is_head(this_line)) { + for marker in [' ', FOLD_ELLIPSIS] { + if col >= max_cols { + break; + } + let cell = cells.at(CellCoord::new(cell_row, origin.col + col)); + cell.glyph = Glyph::Char(marker); + cell.style = Style::default(); + cell.attachment = None; + col += 1; + } + } } } } @@ -534,6 +569,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 16), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -562,6 +598,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 16), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -593,6 +630,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(5, 5), gutter_w: 0, + folds: None, }, &mut grid, ); @@ -625,6 +663,7 @@ mod tests { cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 5), gutter_w: 0, + folds: None, }, &mut grid, ); diff --git a/src/view.rs b/src/view.rs index 313f411..424fd9e 100644 --- a/src/view.rs +++ b/src/view.rs @@ -40,6 +40,7 @@ use crate::buffer::{Buffer, BufferError, BufferId, EditOp}; use crate::cell::{CellCoord, CellGrid, CellSize}; +use crate::fold_view::VisibleLineMap; use crate::rope::{Edit, Position}; // --------------------------------------------------------------------------- @@ -127,7 +128,7 @@ impl DisplayCoord { /// cell origin) and hands it to the view. The view fills cells inside that /// origin/size window. #[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub struct Viewport { +pub struct Viewport<'a> { /// First byte in the buffer to consider rendering. pub buffer_start: Position, /// One past the last byte to consider. @@ -142,6 +143,58 @@ pub struct Viewport { /// `cell_origin.col - gutter_w`; overlays that only touch the text area /// ignore it (the origin is already shifted past the gutter). pub gutter_w: u32, + /// The rendering window's collapsed regions (Arc 6 Stage 2, Q#FD12), + /// or `None` when this window's buffer has no folds — the unfolded + /// path then stays byte-identical to the pre-folding renderer. + /// + /// Borrowed rather than owned so `Viewport` stays [`Copy`]: the frame + /// builds **one map per rendered window** (never one per frame — a + /// split may show different buffers) and hands the same shared + /// reference to every painter of that window. + pub folds: Option<&'a VisibleLineMap>, +} + +impl Viewport<'_> { + /// Row offset within this viewport for source `line`, given the + /// viewport's first (visible) source line. + /// + /// `None` when `line` is above `start_line` or collapsed away — a + /// hidden line simply has no row, so its painter skips it. Without a + /// map this is the identity `line - start_line` every consumer used + /// before folding. + /// + /// The result is monotonically non-decreasing in `line`, so a caller + /// that bounds its walk with `row_offset >= rows` may still `break`. + #[must_use] + pub fn row_offset_of(self, start_line: usize, line: usize) -> Option { + let raw = line.checked_sub(start_line)?; + let rows = match self.folds { + Some(map) if !map.is_identity() => { + if map.is_hidden(line) { + return None; + } + map.visible_rows_between(start_line, line) + } + _ => raw, + }; + u32::try_from(rows).ok() + } + + /// The inverse of [`Self::row_offset_of`]: the source line rendered + /// at `row_offset`, given the viewport's first (visible) source line. + /// + /// Painters that walk rows rather than spans (the syntax and LSP + /// style views) use this; the result may exceed the buffer's line + /// count, which those callers already bound. + #[must_use] + pub fn line_at_row_offset(self, start_line: usize, row_offset: u32) -> usize { + match self.folds { + Some(map) if !map.is_identity() => { + map.nth_visible_from(start_line, row_offset as usize) + } + _ => start_line + row_offset as usize, + } + } } // --------------------------------------------------------------------------- @@ -209,7 +262,7 @@ pub trait View { /// /// Default: no-op (used by views that participate in `on_edit` but not /// in rendering). - fn render(&mut self, _buf: &Buffer, _viewport: Viewport, _cells: &mut CellGrid<'_>) {} + fn render(&mut self, _buf: &Buffer, _viewport: Viewport<'_>, _cells: &mut CellGrid<'_>) {} /// Translate a buffer byte position to a display coordinate, if the /// view holds a meaningful mapping for that position. @@ -277,8 +330,45 @@ mod tests { #[test] fn viewport_is_copy() { // Compile-time assertion: Viewport must be Copy so the frontend can - // hand the same descriptor to multiple views without ceremony. + // hand the same descriptor to multiple views without ceremony. Arc 6 + // Stage 2 (Bet B7) keeps this true by borrowing the fold map — a + // shared reference is itself `Copy`. fn assert_copy() {} - assert_copy::(); + assert_copy::>(); + } + + #[test] + fn row_offset_without_folds_is_the_identity_map() { + let vp = Viewport { + buffer_start: 0, + buffer_end: 0, + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(10, 10), + gutter_w: 0, + folds: None, + }; + assert_eq!(vp.row_offset_of(4, 4), Some(0)); + assert_eq!(vp.row_offset_of(4, 9), Some(5)); + assert_eq!(vp.row_offset_of(4, 3), None, "above the viewport"); + } + + #[test] + fn row_offset_skips_hidden_lines_and_compacts_rows() { + // Fold heading line 1, hiding lines 2..=4 (8-byte lines). + let map = + VisibleLineMap::build(&[pmacs_protocol::ByteRange { start: 15, end: 39 }], |off| { + (off / 8) as usize + }); + let vp = Viewport { + buffer_start: 0, + buffer_end: 0, + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(10, 10), + gutter_w: 0, + folds: Some(&map), + }; + assert_eq!(vp.row_offset_of(0, 1), Some(1), "the head keeps its row"); + assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row"); + assert_eq!(vp.row_offset_of(0, 5), Some(2), "rows below shift up"); } } diff --git a/src/window.rs b/src/window.rs index 22add47..e1162c0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -324,6 +324,28 @@ pub struct FrontendView { /// `layout` references (invariant: `layout.iter_ids()` contains /// `active`). pub active: WindowId, + /// Whether this frontend's display *projects* folds — i.e. whether + /// it collapses hidden lines away (Arc 6 Stage 2, Q#FD21). + /// + /// Motion, paging, wheel scrolling, click inverses, and the + /// auto-scroll clamp all live in shared + /// [`EditorCore`](crate::editor_core::EditorCore) code, but Stage 2 + /// collapses only the **grid** renderer — a `semantic_render` (GPU) + /// session still displays every source line until Stage 3, and both + /// kinds may attach to one buffer at once over the same shared fold + /// store. Reckoning in visible lines unconditionally would make that + /// GPU session's cursor skip lines it is still showing, so every + /// command/event-time visible-line reckoning is gated on the + /// **acting** frontend's flag. Render-time clamps need no gate: a + /// semantic session never enters `paint_frame`. + /// + /// Set at attach from the negotiated selected-render bit (grid ⇒ + /// `true`, semantic ⇒ `false`), cleared with the view at detach, and + /// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId). + /// Deliberately has no `Default`: every construction site chooses + /// explicitly, so the projection is never inferred from a + /// `FrontendId` (**Bet B8**). + pub fold_projection: bool, } impl Layout { diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 56be46c..377b094 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -250,6 +250,7 @@ fn render_active_window_to_grid( cell_origin: rect.origin, cell_size: CellSize::new(rect.size.rows, rect.size.cols), gutter_w: 0, + folds: None, }; let mut grid = CellGrid { cells: &mut backing, diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs new file mode 100644 index 0000000..4f1a657 --- /dev/null +++ b/tests/folding_stage2_acceptance.rs @@ -0,0 +1,1265 @@ +// folding_stage2_acceptance.rs --- Arc 6 Stage 2 acceptance +// (docs/folding-stage2-framing.md, acceptance items 1–14). + +//! Grid (daemon-rendered) collapse. +//! +//! Every claim about what the user sees is asserted on the **rendered +//! cell grid** through the real `paint_frame` — the same pipeline the +//! daemon ships to a terminal client — not on the fold store or on a +//! painter in isolation. Folds are created through the real +//! `pmacs.fold` data API so the stored ranges are normalized exactly as +//! a user command would leave them. +//! +//! The fixture buffer is twelve four-byte lines (`L00\n` … `L11\n`), so +//! line `n` starts at `4n` and its content ends at `4n + 3` — which is +//! precisely a fold's `ByteRange::start` for a head line `n`. + +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseEvent, MouseEventKind, +}; +use pmacs::buffer::{BufferId, EditOp}; +use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Terminal geometry: 12 rows × 40 cols. `paint_frame` reserves the last +/// row for the status line and each window's last row for its mode line, +/// so a single window paints text into grid rows 0..=9. +const ROWS: u32 = 12; +const COLS: u32 = 40; +const TEXT_ROWS: u32 = 10; + +/// Bytes per fixture line (`"Lnn\n"`). +const LINE_BYTES: u64 = 4; + +fn fixture() -> String { + (0..12).fold(String::new(), |mut acc, n| { + use std::fmt::Write as _; + let _ = writeln!(acc, "L{n:02}"); + acc + }) +} + +/// A taller fixture for paging/scrolling: 80 five-byte lines +/// (`"Mnnn\n"`), so line `n` starts at `5n`. +fn long_fixture() -> String { + (0..80).fold(String::new(), |mut acc, n| { + use std::fmt::Write as _; + let _ = writeln!(acc, "M{n:03}"); + acc + }) +} + +/// Content-end byte of fixture line `n` — a fold's head `start`. +fn end_of(line: usize) -> u64 { + line as u64 * LINE_BYTES + 3 +} + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn active_id(s: &EditorState) -> BufferId { + s.core.borrow().active_buffer_id() +} + +/// Insert `text` at the start of `id` and notify every window, so the +/// per-window `TextView` line index matches what we are about to paint. +fn seed(s: &EditorState, id: BufferId, text: &str) { + let edit = { + let core = s.core.borrow(); + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + reg.get_mut(id) + .unwrap() + .apply_edit(EditOp::Insert { + pos: 0, + bytes: text.as_bytes(), + }) + .unwrap() + }; + s.core.borrow_mut().notify_buffer_edit(id, &edit); +} + +/// A buffer holding the fixture, seeded into the active window. +fn seeded() -> (EditorState, BufferId) { + let s = editor(); + let id = active_id(&s); + seed(&s, id, &fixture()); + (s, id) +} + +/// Collapse fixture lines `head + 1 ..= last_hidden` through the real +/// `pmacs.fold` data API. Panics if the range is rejected. +fn fold_lines(s: &EditorState, buffer: &str, head: usize, last_hidden: usize) { + let ok: bool = eval( + s, + &format!( + "return pmacs.fold.fold({buffer}, {{ start = {}, ['end'] = {} }})", + end_of(head), + end_of(last_hidden) + ), + ); + assert!(ok, "fold({head}..={last_hidden}) must be accepted"); +} + +/// Collapse in the *active* buffer. +fn fold_active(s: &EditorState, head: usize, last_hidden: usize) { + fold_lines(s, "pmacs.window.buffer()", head, last_hidden); +} + +/// Paint the full frame for `fid` and return the backing cells. +fn paint_for(state: &EditorState, fid: FrontendId) -> Vec { + let mut backing = vec![Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + let _cursor = pmacs::editor::paint_frame( + state, + fid, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(ROWS, COLS), + ); + backing +} + +fn paint(state: &EditorState) -> Vec { + paint_for(state, FrontendId::LOCAL) +} + +/// Paint the full frame and return the terminal caret cell. +fn paint_caret(state: &EditorState) -> Option { + let mut backing = vec![Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(ROWS, COLS), + ) +} + +fn at(cells: &[Cell], row: u32, col: u32) -> &Cell { + &cells[(row * COLS + col) as usize] +} + +fn row_text(cells: &[Cell], row: u32) -> String { + (0..COLS) + .map(|c| match at(cells, row, c).glyph { + Glyph::Char(ch) => ch, + _ => ' ', + }) + .collect::() + .trim_end() + .to_string() +} + +/// The text rows of a single-window frame, trimmed. +fn text_rows(cells: &[Cell]) -> Vec { + (0..TEXT_ROWS).map(|r| row_text(cells, r)).collect() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn alt(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn cursor_line(s: &EditorState) -> usize { + s.core.borrow().cursor_line() +} + +fn set_cursor(s: &EditorState, pos: u64) { + s.core.borrow_mut().set_cursor_byte(pos); +} + +fn view_top(s: &EditorState) -> usize { + s.core.borrow().active_window().view_top +} + +fn fold_count(s: &EditorState) -> usize { + let id = active_id(s); + s.fold_registry.folds(id).len() +} + +fn mouse(kind: MouseEventKind, row: u16, col: u16) -> MouseEvent { + MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::NONE, + } +} + +// --------------------------------------------------------------------------- +// 1. Collapse (framing acceptance 1) +// --------------------------------------------------------------------------- + +#[test] +fn collapse_omits_hidden_lines_and_shifts_rows_up() { + let (s, _id) = seeded(); + // Head = line 2, hidden = lines 3..=5. + fold_active(&s, 2, 5); + let cells = paint(&s); + let rows = text_rows(&cells); + + assert_eq!(rows[0], "L00"); + assert_eq!(rows[1], "L01"); + assert_eq!( + rows[2], "L02 …", + "the head keeps its text plus the ellipsis" + ); + // Lines 3..=5 are gone; the rows below shifted up. + assert_eq!(rows[3], "L06"); + assert_eq!(rows[4], "L07"); + assert_eq!(rows[5], "L08"); + assert!( + !rows + .iter() + .any(|r| r.starts_with("L03") || r.starts_with("L04") || r.starts_with("L05")), + "no hidden line renders any row: {rows:?}" + ); + // 12 source lines − 3 hidden = 9 content rows; row 9 is past the end. + assert_eq!(rows[8], "L11"); + assert_eq!( + rows[9], "", + "content row count equals the visible-line count" + ); +} + +#[test] +fn unfolded_frame_is_identical_to_the_pre_folding_baseline() { + // The `None` map path must be byte-identical, not merely equivalent. + let (s, _id) = seeded(); + let baseline = paint(&s); + fold_active(&s, 2, 5); + let folded = paint(&s); + assert_ne!(baseline, folded, "the fixture actually folds"); + + let (s2, _id2) = seeded(); + assert_eq!(baseline, paint(&s2), "no folds ⇒ unchanged rendering"); +} + +// --------------------------------------------------------------------------- +// 2. Head marker in both gutter states (framing acceptance 2, round-1 F3) +// --------------------------------------------------------------------------- + +#[test] +fn head_marker_gutter_off_is_ellipsis_only() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + // Line numbers default to Off ⇒ gutter_width() == 0 ⇒ no sign cell. + let cells = paint(&s); + assert_eq!(row_text(&cells, 2), "L02 …"); + assert_eq!( + at(&cells, 2, 0).glyph, + Glyph::Char('L'), + "with no gutter the text still starts at column 0 — no width change" + ); +} + +#[test] +fn head_marker_gutter_on_adds_the_fold_glyph() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + exec(&s, "pmacs.window.set_line_numbers('absolute')"); + let cells = paint(&s); + // 12 lines ⇒ 2 digits + 2 pad = 4-cell gutter; the fold glyph takes + // the leading pad cell, the number is right-aligned before the + // trailing pad, and the text begins at column 4. + assert_eq!( + at(&cells, 2, 0).glyph, + Glyph::Char('▸'), + "head row carries the gutter fold glyph" + ); + assert_eq!(row_text(&cells, 2), "▸ 3 L02 …"); + // A non-head row has no glyph. + assert_eq!(at(&cells, 1, 0).glyph, Glyph::Char(' ')); + assert_eq!(row_text(&cells, 1), " 2 L01"); +} + +#[test] +fn a_diagnostic_on_the_head_row_beats_the_fold_glyph() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + exec(&s, "pmacs.window.set_line_numbers('absolute')"); + attach_diags( + &s, + vec![diag_on(pmacs::diag::DiagnosticSeverity::Error, 2, 2)], + ); + let cells = paint(&s); + assert_eq!( + at(&cells, 2, 0).glyph, + Glyph::Char('E'), + "the diagnostic sign wins the shared cell (Q#FD20)" + ); +} + +// --------------------------------------------------------------------------- +// 3. Line numbers (framing acceptance 3, Q#FD14) +// --------------------------------------------------------------------------- + +#[test] +fn absolute_numbers_skip_hidden_lines() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + exec(&s, "pmacs.window.set_line_numbers('absolute')"); + let cells = paint(&s); + // Head shows 3, then the column jumps straight to 7 (line 6). + assert_eq!(row_text(&cells, 2), "▸ 3 L02 …"); + assert_eq!(row_text(&cells, 3), " 7 L06"); + assert_eq!(row_text(&cells, 4), " 8 L07"); +} + +#[test] +fn relative_numbers_measure_visible_distance_across_a_fold() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + set_cursor(&s, 0); // cursor on line 0 + exec(&s, "pmacs.window.set_line_numbers('relative')"); + let cells = paint(&s); + // Visible order: 0, 1, 2(head), 6, 7 … so line 6 is 3 visible steps + // from the cursor line, not 6 raw lines. + assert_eq!(row_text(&cells, 0), " 0 L00"); + assert_eq!(row_text(&cells, 1), " 1 L01"); + assert_eq!(row_text(&cells, 2), "▸ 2 L02 …"); + assert_eq!(row_text(&cells, 3), " 3 L06"); + assert_eq!(row_text(&cells, 4), " 4 L07"); +} + +#[test] +fn hybrid_shows_absolute_on_the_visible_cursor_row() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + // Cursor hidden on line 4 (a shared fold left it there). + set_cursor(&s, end_of(4)); + exec(&s, "pmacs.window.set_line_numbers('hybrid')"); + let cells = paint(&s); + // The anchor is the visible head (line 2), which shows absolute 3. + assert_eq!(row_text(&cells, 2), "▸ 3 L02 …"); + assert_eq!(row_text(&cells, 1), " 1 L01"); + assert_eq!(row_text(&cells, 3), " 1 L06", "one visible step below"); +} + +// --------------------------------------------------------------------------- +// 4. Diagnostic clamp (framing acceptance 4, Q#FD15) +// --------------------------------------------------------------------------- + +fn diag_on( + severity: pmacs::diag::DiagnosticSeverity, + line: u32, + end_col: u32, +) -> pmacs::diag::Diagnostic { + pmacs::diag::Diagnostic { + start_line: line, + start_col: 0, + end_line: line, + end_col, + severity, + message: "boom".into(), + source: None, + code: None, + } +} + +/// Attach the diagnostic overlay through the real Lua path and publish +/// `diags` for the active buffer. +fn attach_diags(state: &EditorState, diags: Vec) { + let uri: String = eval( + state, + r#" + local buf = pmacs.window.buffer() + local uri = "file:///tmp/folding_stage2_diag.rs" + assert(pmacs.diag._attach_view(buf, uri)) + return uri + "#, + ); + { + let core = state.core.borrow(); + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + let buf = reg.get_mut(core.active_buffer_id()).unwrap(); + buf.set_file_path(Some(std::path::PathBuf::from( + "/tmp/folding_stage2_diag.rs", + ))); + } + state + .lsp_manager + .borrow() + .diag_store() + .lock() + .expect("diag store lock") + .set(uri, diags); +} + +#[test] +fn a_hidden_diagnostic_clamps_to_the_outermost_visible_head() { + let (s, _id) = seeded(); + // Outer fold: head 0, hides 1..=9. Inner fold: head 3, hides 4..=6. + fold_active(&s, 0, 9); + fold_active(&s, 3, 6); + exec(&s, "pmacs.window.set_line_numbers('absolute')"); + // A warning on the outer body and an ERROR on a NESTED inner line. + attach_diags( + &s, + vec![ + diag_on(pmacs::diag::DiagnosticSeverity::Warning, 2, 3), + diag_on(pmacs::diag::DiagnosticSeverity::Error, 5, 3), + ], + ); + let cells = paint(&s); + assert_eq!( + at(&cells, 0, 0).glyph, + Glyph::Char('E'), + "most-severe of the head and every line its fold hides, including \ + a nested inner-fold line, surfaces on the OUTERMOST visible head" + ); + // Nothing leaks onto the rows below the collapse. + assert_eq!(row_text(&cells, 1), " 11 L10"); + assert_eq!(at(&cells, 1, 0).glyph, Glyph::Char(' ')); +} + +// --------------------------------------------------------------------------- +// 5. Nested fold / shared cursor (framing acceptance 5, round-1 F1) +// --------------------------------------------------------------------------- + +#[test] +fn nested_fold_with_a_deeply_hidden_cursor_resolves_outermost() { + let (s, _id) = seeded(); + fold_active(&s, 0, 9); // outer: hides 1..=9 + fold_active(&s, 3, 6); // inner: head 3 is itself hidden + // A second frontend folded through the shared store while this + // window's logical cursor sat deep inside the nest. + set_cursor(&s, end_of(5)); + exec(&s, "pmacs.window.set_line_numbers('relative')"); + + let caret = paint_caret(&s).expect("caret is on screen"); + assert_eq!(caret.row, 0, "caret renders on the OUTERMOST visible head"); + + let cells = paint(&s); + assert_eq!(row_text(&cells, 0), "▸ 0 L00 …", "relative anchors there"); + assert_eq!(row_text(&cells, 1), " 1 L10"); +} + +#[test] +fn a_view_top_inside_a_nest_clamps_backward_to_the_head() { + let (s, _id) = seeded(); + fold_active(&s, 0, 9); + fold_active(&s, 3, 6); + s.core.borrow_mut().active_window_mut().view_top = 5; + let cells = paint(&s); + assert_eq!( + view_top(&s), + 0, + "clamped BACKWARD to the head, not forward past the fold" + ); + assert_eq!(row_text(&cells, 0), "L00 …"); +} + +// --------------------------------------------------------------------------- +// 6. Caret / selection / peer column projection +// (framing acceptance 6, round-2 F3 + round-3 F2) +// --------------------------------------------------------------------------- + +#[test] +fn a_hidden_caret_lands_at_the_heads_end_of_content_column() { + let s = editor(); + let id = active_id(&s); + // Line 0 is deliberately SHORT and the hidden line is LONG, so a + // row-only clamp would leave the caret at a column the head does not + // even have. + seed(&s, id, "ab\nxxxxxxxxxxxxxxxxxxxx\ncd\nef\n"); + let ok: bool = eval( + &s, + "return pmacs.fold.fold(pmacs.window.buffer(), { start = 2, ['end'] = 23 })", + ); + assert!(ok); + // Cursor at column 15 of the hidden line 1. + set_cursor(&s, 3 + 15); + let caret = paint_caret(&s).expect("caret on screen"); + assert_eq!(caret.row, 0, "the head row"); + assert_eq!( + caret.col, 2, + "the head's end-of-content column, never the raw hidden column" + ); +} + +#[test] +fn crossing_folds_project_a_point_to_the_first_visible_head() { + // Round-3 F2. A hides lines 1..=3 (head 0); B is headed on line 2 — + // itself hidden by A — and hides 3..=5. A point on line 5 is directly + // inside only B, whose `range.start` is hidden. + let (s, _id) = seeded(); + fold_active(&s, 0, 3); + fold_active(&s, 2, 5); + assert_eq!(fold_count(&s), 2, "both crossing folds are stored"); + + set_cursor(&s, end_of(5)); + let caret = paint_caret(&s).expect("caret on screen"); + assert_eq!(caret.row, 0, "A's visible head, never B's hidden start"); + assert_eq!(caret.col, 3, "L00's end-of-content column"); + + let cells = paint(&s); + let rows = text_rows(&cells); + assert_eq!(rows[0], "L00 …"); + assert_eq!(rows[1], "L06", "lines 1..=5 all collapse under head 0"); +} + +#[test] +fn a_selection_spanning_a_fold_paints_only_visible_rows() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + // Select from the middle of line 1 through the middle of line 7. + { + let mut core = s.core.borrow_mut(); + let aw = core.active_window_mut(); + aw.selection = Some(pmacs::window::Selection { anchor: 5 }); + aw.cursor = end_of(7) - 1; + } + let cells = paint(&s); + let washed = |row: u32| { + (0..COLS) + .filter(|c| at(&cells, row, *c).style.reverse) + .count() + }; + assert!(washed(1) > 0, "row 1 (L01) is partly selected"); + assert!(washed(2) > 0, "row 2 (the visible head) is selected"); + assert!(washed(3) > 0, "row 3 (L06, shifted up) is selected"); + // Every painted row is a visible line; nothing renders for 3..=5 at + // all, so there is no hidden row to wash. + assert_eq!(row_text(&cells, 3), "L06"); + assert!(washed(5) == 0, "past the selection end"); +} + +#[test] +fn a_click_never_lands_on_a_hidden_line() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let mut s = s; + // Grid row 3 shows L06 (line 6) after the collapse. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + 3, + 0, + ), + CellSize::new(ROWS, COLS), + ); + assert_eq!(cursor_line(&s), 6, "the 3rd VISIBLE line, not line 3"); +} + +#[test] +fn a_peer_cursor_on_a_hidden_line_clamps_to_the_head_position() { + let (s, _id) = seeded(); + let id = active_id(&s); + fold_active(&s, 2, 5); + let presence = pmacs::overlay_paint::OtherPresence { + frontend_id: FrontendId(2), + snapshot: pmacs::presence::PresenceSnapshot { + buffer_id: id, + cursor: end_of(4), // hidden line 4 + selection: None, + }, + color_slot: 0, + }; + let mut backing = vec![Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + pmacs::overlay_paint::paint_other_frontend_overlays( + &s, + &mut grid, + CellSize::new(ROWS, COLS), + &[presence], + ); + // The peer paints on the head row at the head's end-of-content column. + assert!( + backing[(2 * COLS + 3) as usize].style.reverse, + "peer cursor clamped onto the visible head row/column" + ); + for row in 3..6u32 { + assert!( + (0..COLS).all(|c| !backing[(row * COLS + c) as usize].style.reverse), + "nothing painted on a row a hidden line would have owned ({row})" + ); + } +} + +// --------------------------------------------------------------------------- +// 7. Ordinary overlays across a fold (framing acceptance 7) +// --------------------------------------------------------------------------- + +#[test] +fn a_search_wash_across_a_fold_paints_only_visible_rows_correctly() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let mut s = s; + // Search for "L0" — matches on lines 0..=9, several of them hidden. + ctrl(&mut s, 's'); + type_str(&mut s, "L07"); + press(&mut s, KeyCode::Enter); + + let cells = paint(&s); + // L07 is on grid row 4 after the collapse (0,1,2,L06,L07). + assert_eq!(row_text(&cells, 4), "L07"); + let washed_row_4 = (0..COLS) + .filter(|c| at(&cells, 4, *c).style != pmacs::cell::Style::default()) + .count(); + assert!( + washed_row_4 > 0, + "the match washes the row the folded frame actually put it on" + ); + // Row 1 (L01) holds no match and stays unwashed. + assert!( + (0..COLS).all(|c| at(&cells, 1, c).style == pmacs::cell::Style::default()), + "no wash bleeds onto an unmatched visible row" + ); +} + +// --------------------------------------------------------------------------- +// 8. Viewport / paging / indicator (framing acceptance 8, Q#FD18) +// --------------------------------------------------------------------------- + +#[test] +fn page_down_advances_by_a_screenful_of_visible_lines() { + let s = editor(); + let id = active_id(&s); + let long = long_fixture(); + seed(&s, id, &long); + // Hide lines 1..=40 under head 0. + let ok: bool = eval( + &s, + "return pmacs.fold.fold(pmacs.window.buffer(), { start = 4, ['end'] = 204 })", + ); + assert!(ok); + let _ = paint(&s); // establish last_visible_rows + let before = cursor_line(&s); + assert_eq!(before, 0); + s.core.borrow_mut().move_page_down(); + let after = cursor_line(&s); + assert!( + after > 40, + "a screenful of VISIBLE lines steps past the whole collapse (landed on {after})" + ); + assert!( + !s.fold_registry + .folds(id) + .iter() + .any(|f| f.start < end_of_line_start(after) && end_of_line_start(after) <= f.end), + "the cursor never comes to rest on a hidden line" + ); +} + +/// Byte at the start of fixture-independent line `n` for a 5-byte line +/// fixture (`"Mnnn\n"`). +fn end_of_line_start(line: usize) -> u64 { + line as u64 * 5 +} + +#[test] +fn the_mode_line_indicator_reckons_in_visible_lines() { + let (s, _id) = seeded(); + // 12 lines in a 10-row window: not All. + let plain = paint(&s); + assert!( + row_text(&plain, 10).contains("Top"), + "unfolded 12 lines in 10 rows reads Top: {}", + row_text(&plain, 10) + ); + // Collapse 3 lines away → 9 visible lines fit in 10 rows → All. + fold_active(&s, 2, 5); + let folded = paint(&s); + assert!( + row_text(&folded, 10).contains("All"), + "9 visible lines fit the viewport: {}", + row_text(&folded, 10) + ); +} + +#[test] +fn goto_line_into_a_fold_leaves_its_head_visible() { + let s = editor(); + let id = active_id(&s); + let long = long_fixture(); + seed(&s, id, &long); + // Hide lines 51..=70 under head 50. + let ok: bool = eval( + &s, + "return pmacs.fold.fold(pmacs.window.buffer(), { start = 254, ['end'] = 354 })", + ); + assert!(ok); + s.core.borrow_mut().move_to_line(60); // a hidden line + let _ = paint(&s); + let top = view_top(&s); + assert!(top <= 50, "view_top is at or above the head (got {top})"); + assert!( + top + 10 > 50, + "and the head itself is inside the viewport (top {top})" + ); + assert_eq!(fold_count(&s), 1, "goto-line does NOT auto-unfold"); +} + +// --------------------------------------------------------------------------- +// 9. Vertical motion (framing acceptance 9, Q#FD17) +// --------------------------------------------------------------------------- + +#[test] +fn next_line_steps_across_a_collapsed_region_in_one_motion() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let mut s = s; + set_cursor(&s, 0); + press(&mut s, KeyCode::Down); + assert_eq!(cursor_line(&s), 1); + press(&mut s, KeyCode::Down); + assert_eq!(cursor_line(&s), 2, "the head"); + press(&mut s, KeyCode::Down); + assert_eq!(cursor_line(&s), 6, "one step clears the whole collapse"); + press(&mut s, KeyCode::Up); + assert_eq!(cursor_line(&s), 2, "and one step back returns to the head"); +} + +#[test] +fn motion_from_a_hidden_cursor_normalizes_to_the_visible_head_first() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let mut s = s; + // A shared fold left the logical cursor deep inside the collapse. + set_cursor(&s, end_of(4)); + press(&mut s, KeyCode::Down); + assert_eq!( + cursor_line(&s), + 6, + "normalize to head 2, then step to the next visible line" + ); + + set_cursor(&s, end_of(4)); + press(&mut s, KeyCode::Up); + assert_eq!( + cursor_line(&s), + 1, + "normalize to head 2, then step to the previous visible line" + ); +} + +// --------------------------------------------------------------------------- +// 10. Interactive unfold widening (framing acceptance 10, Q#FD19) +// --------------------------------------------------------------------------- + +#[test] +fn yank_at_a_point_inside_a_fold_unfolds_it() { + let (s, _id) = seeded(); + let mut s = s; + // Kill line 0's text so the kill ring has content. + set_cursor(&s, 0); + ctrl(&mut s, 'k'); + // Re-fold with the shortened buffer: head 2, hidden 3..=5 of the + // remaining text. Recompute from the live line index. + let (head_end, last_end) = line_ends(&s, 2, 5); + fold_range(&s, head_end, last_end); + assert_eq!(fold_count(&s), 1); + + // Put point INSIDE the fold and yank. + set_cursor(&s, last_end); + ctrl(&mut s, 'y'); + assert_eq!( + fold_count(&s), + 0, + "yank at a point inside a fold unfolds it" + ); +} + +#[test] +fn query_replace_inside_a_fold_unfolds_it() { + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + assert_eq!(fold_count(&s), 1); + set_cursor(&s, 0); + // M-% L04 RET Q04 RET, then `y` to replace the first match — which + // is on hidden line 4. + alt(&mut s, '%'); + type_str(&mut s, "L04"); + press(&mut s, KeyCode::Enter); + type_str(&mut s, "Q04"); + press(&mut s, KeyCode::Enter); + type_str(&mut s, "y"); + assert_eq!( + fold_count(&s), + 0, + "the replacement's edit point was inside the fold" + ); +} + +/// Define a command whose body is `body`, then run it the way a user +/// does — `M-x RET`. The **Rust** dispatch path is what installs +/// the `InteractiveCommandOrigin` scope (`pmacs.command.invoke_interactive` +/// alone does not), so the widening must be driven through it. +fn define(s: &EditorState, name: &str, body: &str) { + exec( + s, + &format!( + "pmacs.command.define {{ name = '{name}', \ + description = 'stage 2 test', fn = function() {body} end }}" + ), + ); +} + +fn m_x(s: &mut EditorState, name: &str) { + alt(s, 'x'); + type_str(s, name); + press(s, KeyCode::Enter); +} + +#[test] +fn an_interactive_lua_mutator_unfolds_but_a_programmatic_one_does_not() { + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + set_cursor(&s, end_of(4)); // point inside the fold + + // A PROGRAMMATIC data-API edit: no interactive command in scope. + // (Insert at 0 is before the fold, so it only translates it right.) + exec(&s, "pmacs.window.buffer():insert(0, 'x')"); + assert_eq!( + fold_count(&s), + 1, + "a bare data-API mutation stays programmatic — no unfold" + ); + + // The same call from INSIDE an interactive command unfolds. + define(&s, "test.poke", "pmacs.window.buffer():insert(0, 'y')"); + set_cursor(&s, end_of(4) + 1); // still inside the shifted fold + m_x(&mut s, "test.poke"); + assert_eq!( + fold_count(&s), + 0, + "an interactive command's edit at the point unfolds (Q#FD19)" + ); +} + +#[test] +fn a_bypass_intercept_interactive_edit_also_unfolds() { + // Pins the seam at `run_buffer_edit`, above the managed/bypass split: + // hooking only `run_managed_edit` would let this one escape. + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + set_cursor(&s, end_of(4)); + define( + &s, + "test.bypass", + "pmacs.window.buffer():insert(0, 'z', { bypass_intercept = true })", + ); + m_x(&mut s, "test.bypass"); + assert_eq!( + fold_count(&s), + 0, + "a bypass_intercept interactive edit must not escape the widening" + ); +} + +#[test] +fn an_interactive_edit_to_an_inactive_buffer_does_not_unfold() { + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + set_cursor(&s, end_of(4)); + // A second, NOT-displayed buffer; the command edits that one. + exec( + &s, + "other = pmacs.buffer.from_bytes('other.txt', 'aaa\\nbbb\\n')", + ); + define(&s, "test.other", "other:insert(0, 'q')"); + m_x(&mut s, "test.other"); + assert_eq!( + fold_count(&s), + 1, + "an explicit inactive-buffer mutation stays programmatic" + ); +} + +#[test] +fn undo_and_redo_do_not_unfold() { + // Explicitly DEFERRED in the framing (round-1 F5 ruling); pinned so + // the deferral is a decision, not an accident. + let (s, _id) = seeded(); + let mut s = s; + set_cursor(&s, end_of(7)); + type_str(&mut s, "Z"); // an edit to undo, outside any fold + fold_active(&s, 2, 5); + set_cursor(&s, end_of(4)); + ctrl(&mut s, '_'); // undo + assert_eq!(fold_count(&s), 1, "undo does not unfold (deferred)"); +} + +/// Content-end bytes of the current buffer's lines `a` and `b`. +fn line_ends(s: &EditorState, a: usize, b: usize) -> (u64, u64) { + let core = s.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(core.active_buffer_id()).unwrap(); + let tv = &core.active_window().text_view; + let end = |line: usize| tv.line_offset(line).unwrap() + tv.line_len(buf, line).unwrap(); + (end(a), end(b)) +} + +fn fold_range(s: &EditorState, start: u64, end: u64) { + let ok: bool = eval( + s, + &format!( + "return pmacs.fold.fold(pmacs.window.buffer(), {{ start = {start}, ['end'] = {end} }})" + ), + ); + assert!(ok, "fold({start}..{end}) must be accepted"); +} + +// --------------------------------------------------------------------------- +// 11. No wire / protocol change (framing acceptance 11, Bet B6) +// --------------------------------------------------------------------------- + +#[test] +fn stage_2_bumps_no_protocol_version() { + assert_eq!( + pmacs::protocol::PROTOCOL_VERSION, + 19, + "Stage 2 is entirely daemon-side: the TUI collapse ships no new wire data" + ); + assert_eq!( + *pmacs::protocol::SUPPORTED_PROTOCOL_VERSIONS.last().unwrap(), + 19 + ); +} + +// --------------------------------------------------------------------------- +// 12. Shared store, independent viewports (framing acceptance 12) +// --------------------------------------------------------------------------- + +#[test] +fn two_windows_on_one_buffer_both_collapse_with_their_own_view_tops() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + exec(&s, "pmacs.window.split_horizontal()"); + // Give the second window a different view_top (visible line 2 = head). + let ids: Vec = s.core.borrow().windows.keys().copied().collect(); + assert_eq!(ids.len(), 2, "the split produced two windows"); + s.core + .borrow_mut() + .windows + .get_mut(&ids[1]) + .unwrap() + .view_top = 1; + + let cells = paint(&s); + // 12 rows: status row 11; two stacked windows of 5 and 6 rows, each + // with a mode line. Window A text rows 0..=3, window B text rows + // 5..=9 (its own mode line last). + assert_eq!(row_text(&cells, 0), "L00"); + assert_eq!(row_text(&cells, 2), "L02 …", "window A collapses"); + let b_rows: Vec = (5..10).map(|r| row_text(&cells, r)).collect(); + assert!( + b_rows.iter().any(|r| r == "L02 …"), + "window B collapses too, from its own view_top: {b_rows:?}" + ); + assert!( + !b_rows.iter().any(|r| r.starts_with("L03")), + "no hidden line renders in the second window either: {b_rows:?}" + ); + // Both windows keep their own view_top. + assert_eq!(s.core.borrow().windows[&ids[1]].view_top, 1); +} + +// --------------------------------------------------------------------------- +// 13. Frontend-scoped motion (framing acceptance 13, round-2 F1 / Q#FD21) +// --------------------------------------------------------------------------- + +/// Register a second frontend on the SAME buffer, with an explicit +/// projection choice — the attach-time decision the daemon makes from the +/// negotiated selected-render bit. +fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let registry = core.registry.clone(); + let reg = registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).unwrap()) + }; + let win_id = WindowId::next(); + core.windows + .insert(win_id, Window::new(win_id, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win_id), + active: win_id, + fold_projection, + }, + ); + win_id +} + +/// Move down once as `fid` and report that frontend's resulting line. +fn move_down_as(s: &EditorState, fid: FrontendId, win_id: WindowId) -> usize { + let mut core = s.core.borrow_mut(); + core.active_frontend = fid; + core.move_down(); + let win = &core.windows[&win_id]; + win.text_view.line_at_offset(win.cursor) +} + +fn move_up_as(s: &EditorState, fid: FrontendId, win_id: WindowId) -> usize { + let mut core = s.core.borrow_mut(); + core.active_frontend = fid; + core.move_up(); + let win = &core.windows[&win_id]; + win.text_view.line_at_offset(win.cursor) +} + +#[test] +fn a_semantic_frontend_keeps_raw_line_motion_while_the_tui_folds() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + // A grid session and a semantic session on the same buffer, sharing + // the fold store (daemon.rs: a frontend holds exactly one of a + // RenderState or a SemanticRenderState; both may attach at once). + let grid_win = attach_frontend(&s, FrontendId(2), true); + let sem_win = attach_frontend(&s, FrontendId(3), false); + for win in [grid_win, sem_win] { + s.core.borrow_mut().windows.get_mut(&win).unwrap().cursor = end_of(2); + } + + assert_eq!( + move_down_as(&s, FrontendId(2), grid_win), + 6, + "the grid session steps by VISIBLE lines, skipping the collapse" + ); + assert_eq!( + move_down_as(&s, FrontendId(3), sem_win), + 3, + "the semantic session still DISPLAYS line 3, so it must land there" + ); + assert_eq!( + move_up_as(&s, FrontendId(3), sem_win), + 2, + "and steps back by raw lines too" + ); +} + +#[test] +fn semantic_paging_stays_raw_while_the_grid_pages_visibly() { + let s = editor(); + let id = active_id(&s); + let long = long_fixture(); + seed(&s, id, &long); + // Hide lines 1..=40 under head 0. + let ok: bool = eval( + &s, + "return pmacs.fold.fold(pmacs.window.buffer(), { start = 4, ['end'] = 204 })", + ); + assert!(ok); + let grid_win = attach_frontend(&s, FrontendId(2), true); + let sem_win = attach_frontend(&s, FrontendId(3), false); + + let page_as = |fid: FrontendId, win: WindowId| { + let mut core = s.core.borrow_mut(); + core.active_frontend = fid; + core.move_page_down(); + let w = &core.windows[&win]; + w.text_view.line_at_offset(w.cursor) + }; + let grid_line = page_as(FrontendId(2), grid_win); + let sem_line = page_as(FrontendId(3), sem_win); + assert!( + grid_line > 40, + "a grid page clears the whole collapse (landed on {grid_line})" + ); + assert!( + sem_line <= 40, + "a semantic page counts the lines it still shows (landed on {sem_line})" + ); +} + +#[test] +fn detaching_the_semantic_frontend_leaves_the_grid_unaffected() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let grid_win = attach_frontend(&s, FrontendId(2), true); + let sem_win = attach_frontend(&s, FrontendId(3), false); + s.core + .borrow_mut() + .windows + .get_mut(&sem_win) + .unwrap() + .cursor = end_of(2); + assert_eq!(move_down_as(&s, FrontendId(3), sem_win), 3); + + s.core.borrow_mut().unregister_frontend_view(FrontendId(3)); + s.core + .borrow_mut() + .windows + .get_mut(&grid_win) + .unwrap() + .cursor = end_of(2); + assert_eq!( + move_down_as(&s, FrontendId(2), grid_win), + 6, + "the flag is per-FrontendView — the grid never inherited it" + ); +} + +// --------------------------------------------------------------------------- +// 14. Split of different buffers, one folded (framing acceptance 14, +// round-2 F2 + round-3 F1) +// --------------------------------------------------------------------------- + +/// A vertical split showing buffer A (folded, active) beside buffer B +/// (unfolded). Returns `(a_window, b_window)`. +fn split_two_buffers(s: &EditorState) -> (WindowId, WindowId) { + let a_win = s.core.borrow().active_window_id(); + exec(s, "pmacs.window.split_vertical()"); + let ids: Vec = s.core.borrow().windows.keys().copied().collect(); + let b_win = *ids.iter().find(|w| **w != a_win).expect("second window"); + // Point B at a different buffer. + exec( + s, + "other = pmacs.buffer.from_bytes('other.txt', 'B0\\nB1\\nB2\\nB3\\nB4\\nB5\\nB6\\nB7\\n')", + ); + let other: BufferId = { + let core = s.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + reg.find_by_name("other.txt").expect("other.txt present") + }; + let text_view = { + let core = s.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + pmacs::text_view::TextView::new(reg.get(other).unwrap()) + }; + let mut core = s.core.borrow_mut(); + let w = core.windows.get_mut(&b_win).unwrap(); + w.buffer_id = other; + w.text_view = text_view; + w.cursor = 0; + drop(core); + s.core.borrow_mut().set_active_window_id(a_win); + (a_win, b_win) +} + +#[test] +fn an_unfolded_pane_beside_a_folded_one_is_byte_identical_to_its_baseline() { + let (s, _id) = seeded(); + let (_a_win, b_win) = split_two_buffers(&s); + let baseline = paint(&s); + + // Now fold buffer A only. + fold_active(&s, 2, 5); + let folded = paint(&s); + + // Window A collapsed … + let a_rows: Vec = (0..TEXT_ROWS).map(|r| row_text(&folded, r)).collect(); + assert!( + a_rows.iter().any(|r| r.contains("L02 …")), + "the folded pane collapsed: {a_rows:?}" + ); + // … while every cell of window B is unchanged. B occupies the right + // half of a vertical split. + let b_origin = COLS / 2; + for row in 0..TEXT_ROWS { + for col in b_origin..COLS { + assert_eq!( + at(&baseline, row, col), + at(&folded, row, col), + "buffer B's window leaked A's fold map at ({row},{col})" + ); + } + } + assert_eq!(s.core.borrow().windows[&b_win].view_top, 0); +} + +#[test] +fn wheel_over_an_inactive_unfolded_pane_uses_that_windows_map() { + let (s, _id) = seeded(); + let (a_win, b_win) = split_two_buffers(&s); + fold_active(&s, 2, 5); // buffer A (active) folds; B does not + let mut s = s; + let _ = paint(&s); + + // Wheel down over the RIGHT half — buffer B's pane, which the wheel + // does NOT activate. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollDown, 1, (COLS / 2 + 2) as u16), + CellSize::new(ROWS, COLS), + ); + + assert_eq!( + s.core.borrow().active_window_id(), + a_win, + "a wheel event does not move focus" + ); + let b_top = s.core.borrow().windows[&b_win].view_top; + assert_eq!( + b_top, 3, + "B scrolled by raw lines through ITS OWN map (SCROLL_LINES), \ + not through the folded active buffer's" + ); + assert_eq!( + s.core.borrow().windows[&a_win].view_top, + 0, + "the folded pane did not scroll" + ); +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 69c8500..ca44980 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -498,6 +498,7 @@ fn render_active_window_to_grid( cell_origin: rect.origin, cell_size: CellSize::new(rect.size.rows, rect.size.cols), gutter_w: 0, + folds: None, }; let mut grid = CellGrid { cells: &mut backing, diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 8d6f1d2..2963fea 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -464,6 +464,7 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() { pmacs::window::FrontendView { layout: pmacs::window::Layout::single(window_id), active: window_id, + fold_projection: true, }, ); } diff --git a/tests/tab_width_acceptance.rs b/tests/tab_width_acceptance.rs index ba15f52..e153983 100644 --- a/tests/tab_width_acceptance.rs +++ b/tests/tab_width_acceptance.rs @@ -8,13 +8,14 @@ use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans use pmacs::text_view::TextView; use pmacs::view::{DisplayCoord, View, Viewport}; -fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport { +fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> { Viewport { buffer_start: 0, buffer_end, cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(rows, cols), gutter_w: 0, + folds: None, } } diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 4a170af..8d7eb46 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -81,6 +81,7 @@ fn attach_view( FrontendView { layout: Layout::single(window_id), active: window_id, + fold_projection: true, }, ); window_id From b3e18150cca8d9137e93fffbe3f5a81b67924177 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:33:35 -0400 Subject: [PATCH 06/12] test(fold): make the inactive-buffer unfold guard actually bite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-verification found the Q#FD19 active-window-buffer requirement's test vacuous: `other` held no fold, so removing the guard changed nothing — `unfold_containing` on a store-less buffer is a no-op either way. The guard's real job is to stop the invoking frontend's POINT from naming a place in a buffer it is not looking at, so the fixture now gives `other` a fold whose range contains the active window's cursor byte. Reverting the guard now opens it (0 != 1). Verified: with `if window.buffer_id != id { return; }` replaced by a no-op, the test fails on a clean assertion. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- tests/folding_stage2_acceptance.rs | 32 ++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index 4f1a657..aaf785c 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -917,18 +917,42 @@ fn an_interactive_edit_to_an_inactive_buffer_does_not_unfold() { let (s, _id) = seeded(); let mut s = s; fold_active(&s, 2, 5); - set_cursor(&s, end_of(4)); - // A second, NOT-displayed buffer; the command edits that one. + set_cursor(&s, end_of(4)); // byte 19, inside the ACTIVE buffer's fold + + // A second, NOT-displayed buffer, deliberately shaped so the active + // window's cursor byte falls inside a fold of ITS OWN: without the + // active-window-buffer requirement the widening would anchor the + // wrong buffer's folds on this frontend's point and open it. exec( &s, - "other = pmacs.buffer.from_bytes('other.txt', 'aaa\\nbbb\\n')", + "other = pmacs.buffer.from_bytes('other.txt', 'aaa\\nbbb\\nccc\\nddd\\neee\\n')", ); + let other_folded: bool = eval( + &s, + "return pmacs.fold.fold(other, { start = 3, ['end'] = 19 })", + ); + assert!(other_folded); + let other_folds = |s: &EditorState| -> usize { + let core = s.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let id = reg.find_by_name("other.txt").expect("other.txt"); + s.fold_registry.folds(id).len() + }; + assert_eq!(other_folds(&s), 1); + define(&s, "test.other", "other:insert(0, 'q')"); m_x(&mut s, "test.other"); assert_eq!( fold_count(&s), 1, - "an explicit inactive-buffer mutation stays programmatic" + "the active buffer was not edited, so its fold stands" + ); + assert_eq!( + other_folds(&s), + 1, + "an explicit inactive-buffer mutation stays programmatic — the \ + invoking frontend's point does not name a place in THAT buffer" ); } From e6ace5c320067fb02f0a268b56570467f7aced25 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:37:18 -0400 Subject: [PATCH 07/12] fix(fold): choose the projection in the crdt-gated daemon fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo test --lib --features crdt` compiles two `#[cfg(feature = "crdt")]` daemon tests that call `build_fresh_frontend_view`. Both model a semantic session, so they pass `false` — the Q#FD21 value a `semantic_render` attach gets until Stage 3. Bet B8 in the framing is exactly this: the projection is chosen explicitly at every construction site, never inferred, so a new call site cannot silently default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- src/daemon.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 8a3c727..141623b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3589,7 +3589,9 @@ mod tests { let mut editor = EditorState::new(); let fid = FrontendId(99); - let view = build_fresh_frontend_view(&mut editor); + // Both these fixtures model a SEMANTIC session (Q#FD21: no fold + // projection until Stage 3). + let view = build_fresh_frontend_view(&mut editor, false); editor.core.borrow_mut().register_frontend_view(fid, view); let before = editor @@ -3650,7 +3652,9 @@ mod tests { // Attach: window shares LOCAL's active (scratch). let fid = FrontendId(99); - let view = build_fresh_frontend_view(&mut editor); + // Both these fixtures model a SEMANTIC session (Q#FD21: no fold + // projection until Stage 3). + let view = build_fresh_frontend_view(&mut editor, false); editor.core.borrow_mut().register_frontend_view(fid, view); assert_eq!( editor From 90bb86d355d431abadffaa487a3f53e98c2367af Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 20:46:47 -0400 Subject: [PATCH 08/12] fix(fold): address PR #149 review round 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings were correct. F1 (major) — the Lua widening keyed on the POINT, not the edit site. Q#FD19 condition (iii) is `edit.range.start`; `run_buffer_edit` discarded `op` and opened whatever fold contained `window.cursor`. Two wrong behaviors followed: a command inserting elsewhere opened an unrelated fold at the point, and — worse — a command editing INTO a fold from an outside point left its edit hidden. `run_buffer_edit` now reads `edit_start_of(&op)` before `op` is consumed and passes it down. The `apply_active_edit` funnel stays point-keyed, which is correct and deliberate: the six primitives, yank, and query-replace all place point at the edit site first, and only the Lua path can diverge — now said so in the doc comment. The acceptance test encoded the wrong behavior (inserted at byte 0, expected the cursor's fold to open). Replaced by two tests that pin both directions, plus the framing's named **comment-toggle** case driven through the real `M-;` on a file-backed Rust buffer rather than a synthetic mutator. F2 (major) — hidden-cursor motion normalized only the row. `move_up` / `move_down` clamped `coord.row` but derived `goal` from the hidden line's raw column, and returned early at a buffer boundary without normalizing at all, so `Up` inside a fold headed on line 0 left the logical cursor hidden. Added `normalize_cursor_to_visible`, which projects the whole POSITION through `visible_position` as a real mutation before any step is computed (and drops the sticky goal column, since the jump is discontinuous). Paging shares it — the same latent bug. The old test used equal-width lines and asserted only the line, so it could not tell the two apart; the new fixture is deliberately ragged and asserts the resulting BYTE in both directions, plus the boundary case. F3 (moderate) — `set_view_top` bypassed the fold clamp. Q#FD12/Q#FD18 say `view_top` is always set through `clamp_view_top`, naming the `set_view_top` contract specifically. Rendering repaired it at the next frame, but until then `view_top()` handed out a hidden line and command/event reckoning could start from a non-visible origin. The clamp now lives in the setter — the contract's home, what `saveplace` and `pmacs.editor.set_view_top` call. New test reads `view_top()` directly after the setter, before any paint. F4 (moderate) — the suite claimed items 1–14 but omitted approved clauses. Added: peer SELECTION endpoint projection with hidden interiors dropped; the crossing-fold repeat for the peer cursor AND a peer selection endpoint; a style/syntax span straddling a fold (alignment on the shifted row); the completion popup anchored below a fold; and peer presence in the split-buffer case using the RECIPIENT window's map. Suite: 35 -> 45 tests. Eight new bite cases, each falsifying exactly the line that implements its claim. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- src/editor_core.rs | 63 ++++- src/lua_bindings/mod.rs | 36 ++- tests/folding_stage2_acceptance.rs | 421 +++++++++++++++++++++++++++-- 3 files changed, 482 insertions(+), 38 deletions(-) diff --git a/src/editor_core.rs b/src/editor_core.rs index 2a15e32..28200cd 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -708,9 +708,21 @@ impl EditorCore { /// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop /// restore). A file that shrank since the desktop was saved can't /// scroll past its end. + /// Arc 6 Stage 2 (Q#FD12/Q#FD18, round-4 F3): `view_top` stays a + /// source-line index but must never *name* a hidden line, so a + /// fold-projecting frontend also clamps **backward** to the visible + /// head here. The render pass repairs a hidden `view_top` too, but + /// only at the next frame — until then [`Self::view_top`] would hand + /// out a collapsed line and command/event reckoning would start from + /// a non-visible origin. This setter is the contract's home (it is + /// what `saveplace` and `pmacs.editor.set_view_top` call), so the + /// invariant is established here rather than repaired downstream. pub fn set_view_top(&mut self, top: usize) { let lines = self.active_window().text_view.line_count(); let clamped = top.min(lines.saturating_sub(1)); + let clamped = self + .fold_map_active() + .map_or(clamped, |map| map.clamp_view_top(clamped)); self.active_window_mut().view_top = clamped; } @@ -1608,6 +1620,14 @@ impl EditorCore { /// raw-line motion until Stage 3. pub fn move_up(&mut self) { let folds = self.fold_map_active(); + // Normalize FIRST and as a real mutation (round-4 F2): a hidden + // logical cursor moves to its component's head POSITION — head + // row *and* head end-of-content column — before any step is + // computed. Deriving the goal column from the hidden line would + // carry a column the head may not even have, and returning early + // at a buffer boundary (a fold headed on line 0) would leave the + // cursor hidden entirely. + self.normalize_cursor_to_visible(folds.as_ref()); let id = self.active_buffer_id(); let cursor = self.active_window().cursor; let goal_col = self.active_window().goal_col; @@ -1619,9 +1639,7 @@ impl EditorCore { .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); - let from_row = folds.as_ref().map_or(coord.row as usize, |map| { - map.visible_head_of(coord.row as usize) - }); + let from_row = coord.row as usize; if from_row == 0 { return; } @@ -1648,6 +1666,7 @@ impl EditorCore { /// Visible-line stepping mirrors [`Self::move_up`] (Q#FD17/FD21). pub fn move_down(&mut self) { let folds = self.fold_map_active(); + self.normalize_cursor_to_visible(folds.as_ref()); let id = self.active_buffer_id(); let cursor = self.active_window().cursor; let goal_col = self.active_window().goal_col; @@ -1659,9 +1678,7 @@ impl EditorCore { .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); - let from_row = folds.as_ref().map_or(coord.row as usize, |map| { - map.visible_head_of(coord.row as usize) - }); + let from_row = coord.row as usize; let next_row = folds .as_ref() .map_or(from_row + 1, |map| map.next_visible(from_row)); @@ -1823,8 +1840,11 @@ impl EditorCore { /// frame has rendered. pub fn move_page_down(&mut self) { // Arc 6 Stage 2 (Q#FD18/FD21): a screenful is a screenful of - // VISIBLE lines, and `view_top` never lands hidden. + // VISIBLE lines, and `view_top` never lands hidden. Paging shares + // vertical motion's hidden-cursor normalization (round-4 F2) — + // the goal column must come from the head, not the hidden line. let folds = self.fold_map_active(); + self.normalize_cursor_to_visible(folds.as_ref()); let step = self.page_step(); let cursor = self.active_window().cursor; let view_top = self.active_window().view_top; @@ -1876,6 +1896,7 @@ impl EditorCore { /// [`Self::move_page_down`]. pub fn move_page_up(&mut self) { let folds = self.fold_map_active(); + self.normalize_cursor_to_visible(folds.as_ref()); let step = self.page_step(); let cursor = self.active_window().cursor; let view_top = self.active_window().view_top; @@ -1961,6 +1982,34 @@ impl EditorCore { /// path and are hooked at `run_buffer_edit` in the Lua bindings; the /// remote/optimistic-CRDT apply path is deliberately excluded /// (Stage 3), as is undo/redo (deferred). + /// Move a hidden logical cursor to its component's visible head + /// **position** — the head row *and* that head's end-of-content + /// column, i.e. exactly where Stage 1 moves point on a fold-at-cursor + /// (Q#FD16/Q#FD17, round-4 F2). + /// + /// A cursor can be hidden without this frontend ever having moved it + /// there: another frontend folds through the shared store, or + /// goto-line targets a line inside a collapse. Vertical motion and + /// paging normalize through here *before* computing their step, so + /// the goal column is the head's — never a column of a line that + /// renders no row — and so a step that turns out to be a no-op at a + /// buffer boundary still leaves the cursor visible. + /// + /// The jump is discontinuous, so the sticky goal column is dropped + /// (the click/goto convention), and only when the cursor actually + /// was hidden: ordinary motion keeps its sticky column untouched. + fn normalize_cursor_to_visible(&mut self, folds: Option<&crate::fold_view::VisibleLineMap>) { + let Some(map) = folds else { return }; + let aw = self.active_window(); + let line = aw.text_view.line_at_offset(aw.cursor); + let projected = map.visible_position(line, aw.cursor); + if projected != aw.cursor { + let aw = self.active_window_mut(); + aw.cursor = projected; + aw.goal_col = None; + } + } + fn unfold_before_point_edit(&self) { let id = self.active_buffer_id(); let point = self.active_window().cursor; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 4747a7c..290df7a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1311,8 +1311,9 @@ fn run_buffer_edit( // Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam. // Hooked HERE — above both `run_managed_edit` and `run_bypass_edit` — // so an interactive command that passes `bypass_intercept` does not - // escape the widening. - unfold_before_interactive_lua_edit(lua, id); + // escape the widening. Keyed on where the edit LANDS, read before + // `op` is consumed below. + unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op)); if bypass_intercept { run_bypass_edit(lua, id, op) } else { @@ -1320,7 +1321,16 @@ fn run_buffer_edit( } } -/// Unfold at the point before an **interactive** Lua-mutator edit +/// Where an [`EditOp`] lands — the `edit.range.start` Q#FD19 keys the +/// interactive unfold on. +fn edit_start_of(op: &EditOp<'_>) -> u64 { + match op { + EditOp::Insert { pos, .. } => *pos, + EditOp::Delete { range } | EditOp::Replace { range, .. } => range.start, + } +} + +/// Unfold at the **edit site** before an **interactive** Lua-mutator edit /// (comment-toggle, yank-pop, and any other `pmacs.buffer.X` mutation a /// command body performs on the buffer the user is looking at). /// @@ -1334,14 +1344,24 @@ fn run_buffer_edit( /// and the guard clears even when the Lua command errors. /// 2. The edited buffer **is `f`'s active-window buffer**. An explicit /// mutation of some *other*, inactive buffer stays programmatic — it -/// is not an edit at the user's point. -/// 3. A fold actually contains that point (handled by -/// [`crate::fold::FoldRegistry::unfold_containing`], which is a no-op +/// is not an edit the user can see land. +/// 3. A fold actually contains **`edit_start`** (handled by +/// [`crate::fold::FoldRegistry::unfold_containing`], a no-op /// otherwise). /// +/// Condition 3 is the edit site, **not the point** (Q#FD19). A Lua +/// command is free to edit somewhere other than where the cursor sits: +/// keying on the cursor would open an unrelated fold when a command +/// edits elsewhere, and — worse — leave the edit invisible when a +/// command edits *into* a fold from an outside point. The six +/// `EditorCore` primitives, yank, and query-replace all edit exactly at +/// the point, so +/// [`apply_active_edit`](crate::editor_core::EditorCore::apply_active_edit)'s +/// funnel keys on the point; only this Lua path can diverge. +/// /// Matches Stage 1's data-API exemption: `pmacs.buffer.insert` called /// from a plugin, a hook, or a bare Lua chunk unfolds nothing. -fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId) { +fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId, edit_start: u64) { let Some(origin) = lua .app_data_ref::() .and_then(|origin| origin.current()) @@ -1361,7 +1381,7 @@ fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId) { if window.buffer_id != id { return; // an inactive-buffer mutation stays programmatic } - folds.unfold_containing(id, window.cursor); + folds.unfold_containing(id, edit_start); } fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index aaf785c..23d297a 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -215,6 +215,10 @@ fn type_str(s: &mut EditorState, text: &str) { } } +fn cursor(s: &EditorState) -> u64 { + s.core.borrow().active_window().cursor +} + fn cursor_line(s: &EditorState) -> usize { s.core.borrow().cursor_line() } @@ -491,6 +495,29 @@ fn nested_fold_with_a_deeply_hidden_cursor_resolves_outermost() { assert_eq!(row_text(&cells, 1), " 1 L10"); } +#[test] +fn set_view_top_clamps_before_any_frame_is_painted() { + // Round-4 F3: the SETTER establishes the invariant, not the renderer. + // Between a `saveplace` restore (or `pmacs.editor.set_view_top`) and + // the next frame, `view_top()` must never name a collapsed line and + // command/event reckoning must never start from one. + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + exec(&s, "pmacs.editor.set_view_top(4)"); // a hidden line + assert_eq!( + view_top(&s), + 2, + "clamped backward to the visible head by the setter itself — \ + read here BEFORE any paint_frame call" + ); + // A visible target is untouched, and the raw line-count clamp still + // applies (Arc 3 Q#PS1). + exec(&s, "pmacs.editor.set_view_top(7)"); + assert_eq!(view_top(&s), 7); + exec(&s, "pmacs.editor.set_view_top(9999)"); + assert_eq!(view_top(&s), 12, "still clamped to the last line"); +} + #[test] fn a_view_top_inside_a_nest_clamps_backward_to_the_head() { let (s, _id) = seeded(); @@ -638,10 +665,176 @@ fn a_peer_cursor_on_a_hidden_line_clamps_to_the_head_position() { } } +/// Paint the presence pass for `presences` over a fresh grid. +fn paint_presence(s: &EditorState, presences: &[pmacs::overlay_paint::OtherPresence]) -> Vec { + let mut backing = vec![Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + pmacs::overlay_paint::paint_other_frontend_overlays( + s, + &mut grid, + CellSize::new(ROWS, COLS), + presences, + ); + backing +} + +fn peer( + buffer_id: BufferId, + cursor: u64, + selection: Option<(u64, u64)>, +) -> pmacs::overlay_paint::OtherPresence { + pmacs::overlay_paint::OtherPresence { + frontend_id: FrontendId(2), + snapshot: pmacs::presence::PresenceSnapshot { + buffer_id, + cursor, + selection: selection + .map(|(anchor, active)| pmacs::protocol::SelectionSnapshot { anchor, active }), + }, + color_slot: 0, + } +} + +#[test] +fn peer_selection_endpoints_project_and_hidden_interiors_drop() { + let (s, id) = seeded(); + fold_active(&s, 2, 5); + // A peer selects from the middle of line 1 through the middle of + // line 7 — straddling the whole collapse. + let cells = paint_presence(&s, &[peer(id, end_of(7) - 1, Some((5, end_of(7) - 1)))]); + let underlined = |row: u32| { + (0..COLS) + .filter(|c| at(&cells, row, *c).style.underline == pmacs::cell::UnderlineStyle::Single) + .count() + }; + assert!(underlined(1) > 0, "row 1 (L01) is partly selected"); + assert!(underlined(2) > 0, "row 2 (the visible head) is selected"); + assert!(underlined(3) > 0, "row 3 (L06, shifted up) is selected"); + assert!(underlined(4) > 0, "row 4 (L07) is selected"); + assert_eq!(underlined(5), 0, "past the selection end"); + // Rows 3 and 4 are L06/L07 — the hidden lines contributed no row at + // all, so nothing could have painted "through" them. + let frame = paint(&s); + assert_eq!(row_text(&frame, 3), "L06"); + assert_eq!(row_text(&frame, 4), "L07"); +} + +#[test] +fn a_peer_inside_crossing_folds_projects_to_the_first_visible_head() { + // Round-3 F2 repeated for the PEER path (framing acceptance 6): + // A hides 1..=3 (head 0); B is headed on hidden line 2 and hides + // 3..=5. A peer cursor and a peer selection endpoint on line 5 must + // both resolve to A's head position, never B's hidden `range.start`. + let (s, id) = seeded(); + fold_active(&s, 0, 3); + fold_active(&s, 2, 5); + + let cells = paint_presence(&s, &[peer(id, end_of(5), None)]); + assert!( + at(&cells, 0, 3).style.reverse, + "peer cursor on A's visible head row at its end-of-content column" + ); + for row in 1..4u32 { + assert!( + (0..COLS).all(|c| !cells[(row * COLS + c) as usize].style.reverse), + "nothing on a row a hidden line would have owned ({row})" + ); + } + + // A selection whose START endpoint is hidden inside the crossing + // region projects the same way. Visible order is 0(head), 6, 7, 8…, + // so the projected start puts the selection's first cell on ROW 0 — + // and that row is the discriminator: projecting to B's hidden + // `range.start` (end of line 2) instead would paint nothing there. + let sel = paint_presence( + &s, + &[peer(id, end_of(7) - 1, Some((end_of(5), end_of(7) - 1)))], + ); + let underlined = |row: u32| { + (0..COLS) + .filter(|c| at(&sel, row, *c).style.underline == pmacs::cell::UnderlineStyle::Single) + .count() + }; + assert!( + underlined(0) > 0, + "the projected start lands on A's visible head row, not B's hidden one" + ); + assert!(underlined(1) > 0, "L06, inside the projected span"); + assert!(underlined(2) > 0, "the visible tail (L07)"); + assert_eq!(underlined(3), 0, "L08 is past the selection end"); +} + // --------------------------------------------------------------------------- // 7. Ordinary overlays across a fold (framing acceptance 7) // --------------------------------------------------------------------------- +#[test] +fn a_style_span_straddling_a_fold_paints_only_visible_rows() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + // One buffer-byte span from the start of line 1 through the end of + // line 7 — it covers three hidden lines on the way. + exec( + &s, + &format!( + "ov = pmacs.buffer.add_style_overlay(pmacs.window.buffer()); \ + ov:add({}, {}, {{ bg = 4 }})", + LINE_BYTES, + end_of(7) + ), + ); + let cells = paint(&s); + let washed = |row: u32| { + (0..COLS) + .filter(|c| at(&cells, row, *c).style.bg == pmacs::cell::Color::Indexed(4)) + .count() + }; + assert!(washed(1) > 0, "L01 is inside the span"); + assert!(washed(2) > 0, "the visible head is inside the span"); + assert!(washed(3) > 0, "L06 — correctly aligned on its SHIFTED row"); + assert!(washed(4) > 0, "L07"); + assert_eq!(washed(5), 0, "L08 is past the span's end"); + assert_eq!(washed(0), 0, "L00 is before the span's start"); + // The alignment claim: row 3 really is L06, so the wash landed on + // the row the collapse put that line on, not on a raw offset. + assert_eq!(row_text(&cells, 3), "L06"); +} + +#[test] +fn a_completion_popup_below_a_fold_anchors_on_the_visible_row() { + let (s, _id) = seeded(); + fold_active(&s, 2, 5); + let mut s = s; + // Cursor at the end of line 7 — three collapsed lines above it, so + // its VISIBLE row is 4 (0, 1, 2·head, 6, 7) though its raw line is 7. + set_cursor(&s, end_of(7)); + // A separator first, so the word at point is the prefix itself. + type_str(&mut s, " L1"); // dabbrev prefix of L10 / L11 + let visible: bool = eval(&s, "return pmacs.completion.popup_visible()"); + assert!(visible, "the popup opened off dabbrev"); + assert_eq!(fold_count(&s), 1, "typing below the fold left it closed"); + + let cells = paint(&s); + // Row 5 is where the popup's first row belongs. Without a fold-aware + // anchor the popup would have gone to raw row 8 and row 5 would still + // show source line 8. + assert_ne!( + row_text(&cells, 5), + "L08", + "the popup covers the row directly below the anchor's VISIBLE row" + ); + assert!( + row_text(&cells, 5).contains("L1"), + "and that row holds a candidate: {:?}", + row_text(&cells, 5) + ); + assert_eq!(row_text(&cells, 4), "L07 L1", "the anchor row itself"); +} + #[test] fn a_search_wash_across_a_fold_paints_only_visible_rows_correctly() { let (s, _id) = seeded(); @@ -772,27 +965,98 @@ fn next_line_steps_across_a_collapsed_region_in_one_motion() { assert_eq!(cursor_line(&s), 2, "and one step back returns to the head"); } +/// Lines of deliberately UNEQUAL width, so a normalization that only +/// clamps the row (carrying the hidden line's raw column 12) lands on a +/// different byte than one that projects the whole position (head column +/// 2) — in BOTH directions. +/// +/// ```text +/// line 0: "ab" bytes 0..=1 end 2 +/// line 1: "PQRSTUVWXY" (10) bytes 3..=12 end 13 +/// line 2: "ef" bytes 14..=15 end 16 <- head +/// line 3: "0123456789ABCDEF" bytes 17..=32 end 33 <- hidden +/// line 4: "wxyz" bytes 34..=37 end 38 <- hidden +/// line 5: "ghijklmn" (8) bytes 39..=46 end 47 +/// ``` +const RAGGED: &str = "ab\nPQRSTUVWXY\nef\n0123456789ABCDEF\nwxyz\nghijklmn\n"; + +fn ragged() -> (EditorState, BufferId) { + let s = editor(); + let id = active_id(&s); + seed(&s, id, RAGGED); + (s, id) +} + +/// Column 12 of hidden line 3. +const DEEP: u64 = 17 + 12; + #[test] -fn motion_from_a_hidden_cursor_normalizes_to_the_visible_head_first() { - let (s, _id) = seeded(); - fold_active(&s, 2, 5); +fn motion_from_a_hidden_cursor_normalizes_the_whole_position() { + let (s, _id) = ragged(); + // Head = line 2 ("ef", ends at byte 16); hidden = lines 3..=4. + fold_range(&s, 16, 38); let mut s = s; - // A shared fold left the logical cursor deep inside the collapse. - set_cursor(&s, end_of(4)); + + // A shared fold left the logical cursor at column 12 of hidden line + // 3 — a column line 2 does not even have. + set_cursor(&s, DEEP); press(&mut s, KeyCode::Down); + assert_eq!(cursor_line(&s), 5, "normalize to head 2, then step down"); assert_eq!( - cursor_line(&s), - 6, - "normalize to head 2, then step to the next visible line" + cursor(&s), + 41, + "the goal column came from the HEAD's end of content (2), not the \ + hidden line's raw column (12): line 5 is 8 wide, so a raw column \ + would have clamped to its end (47)" ); - set_cursor(&s, end_of(4)); + set_cursor(&s, DEEP); + press(&mut s, KeyCode::Up); + assert_eq!(cursor_line(&s), 1, "normalize to head 2, then step up"); + assert_eq!( + cursor(&s), + 5, + "column 2 of line 1; a raw column 12 would have clamped to 13" + ); +} + +#[test] +fn a_boundary_step_still_leaves_a_hidden_cursor_visible() { + // Round-4 F2: with the fold headed on line 0 there is nowhere to step + // up TO, but the normalization must still have happened — returning + // early would leave the logical cursor hidden. + let (s, _id) = ragged(); + // Head = line 0 ("ab", ends at byte 2); hidden = lines 1..=4. + fold_range(&s, 2, 38); + let mut s = s; + set_cursor(&s, DEEP); // deep inside, on hidden line 3 + press(&mut s, KeyCode::Up); assert_eq!( cursor_line(&s), - 1, - "normalize to head 2, then step to the previous visible line" + 0, + "no step available, but still normalized" ); + assert_eq!(cursor(&s), 2, "at the head's end of content"); + let folds = s.fold_registry.folds(active_id(&s)); + assert!( + !folds + .iter() + .any(|f| f.start < cursor(&s) && cursor(&s) <= f.end), + "the cursor is no longer inside any fold" + ); +} + +#[test] +fn paging_from_a_hidden_cursor_normalizes_too() { + // Same class: paging shares the normalization, so a page from a + // hidden cursor also starts from the head's column. + let (s, _id) = ragged(); + fold_range(&s, 16, 38); + set_cursor(&s, DEEP); + s.core.borrow_mut().move_page_up(); + assert_eq!(cursor_line(&s), 0); + assert_eq!(cursor(&s), 2, "head column 2, not the hidden line's 12"); } // --------------------------------------------------------------------------- @@ -865,29 +1129,54 @@ fn m_x(s: &mut EditorState, name: &str) { } #[test] -fn an_interactive_lua_mutator_unfolds_but_a_programmatic_one_does_not() { +fn an_interactive_lua_mutator_unfolds_at_the_edit_site() { let (s, _id) = seeded(); let mut s = s; - fold_active(&s, 2, 5); - set_cursor(&s, end_of(4)); // point inside the fold + fold_active(&s, 2, 5); // head 2, hidden 3..=5; fold range (11, 23] - // A PROGRAMMATIC data-API edit: no interactive command in scope. - // (Insert at 0 is before the fold, so it only translates it right.) - exec(&s, "pmacs.window.buffer():insert(0, 'x')"); + // A PROGRAMMATIC data-API edit inside the fold: no interactive + // command in scope, so it stays hidden (Stage 1's exemption). + exec( + &s, + &format!("pmacs.window.buffer():insert({}, 'x')", end_of(4)), + ); assert_eq!( fold_count(&s), 1, "a bare data-API mutation stays programmatic — no unfold" ); - // The same call from INSIDE an interactive command unfolds. - define(&s, "test.poke", "pmacs.window.buffer():insert(0, 'y')"); - set_cursor(&s, end_of(4) + 1); // still inside the shifted fold + // The same edit from INSIDE an interactive command unfolds. + define( + &s, + "test.poke", + &format!("pmacs.window.buffer():insert({}, 'y')", end_of(4)), + ); + set_cursor(&s, 0); // point OUTSIDE the fold — the edit site is what counts m_x(&mut s, "test.poke"); assert_eq!( fold_count(&s), 0, - "an interactive command's edit at the point unfolds (Q#FD19)" + "Q#FD19 keys on edit.range.start: a command editing INTO a fold \ + from an outside point must reveal what it wrote" + ); +} + +#[test] +fn an_interactive_edit_outside_the_fold_leaves_it_closed() { + // The other half of round-4 F1: keying on the POINT would open an + // unrelated fold whenever a command edits somewhere else. + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + set_cursor(&s, end_of(4)); // point INSIDE the fold … + define(&s, "test.elsewhere", "pmacs.window.buffer():insert(0, 'x')"); + m_x(&mut s, "test.elsewhere"); // … but the edit lands at byte 0 + assert_eq!( + fold_count(&s), + 1, + "an edit outside the fold must not open it just because the \ + cursor happens to sit inside" ); } @@ -898,11 +1187,14 @@ fn a_bypass_intercept_interactive_edit_also_unfolds() { let (s, _id) = seeded(); let mut s = s; fold_active(&s, 2, 5); - set_cursor(&s, end_of(4)); + set_cursor(&s, 0); define( &s, "test.bypass", - "pmacs.window.buffer():insert(0, 'z', { bypass_intercept = true })", + &format!( + "pmacs.window.buffer():insert({}, 'z', {{ bypass_intercept = true }})", + end_of(4) + ), ); m_x(&mut s, "test.bypass"); assert_eq!( @@ -912,6 +1204,53 @@ fn a_bypass_intercept_interactive_edit_also_unfolds() { ); } +#[test] +fn comment_toggle_inside_a_fold_unfolds_it() { + // The framing's named Q#FD19 case, through the REAL `M-;` path + // (comment-toggle is a Lua mutator, so it exercises the + // `run_buffer_edit` seam end to end rather than a synthetic stand-in). + let dir = std::env::temp_dir().join(format!("pmacs-fold-s2-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("fold_comment.rs"); + let body = "fn a() {\n let x = 1;\n let y = 2;\n let z = 3;\n}\n"; + std::fs::write(&path, body).unwrap(); + + let s = editor(); + let mut s = s; + exec( + &s, + &format!( + "pmacs.buffer.find_or_open({:?})", + path.display().to_string() + ), + ); + exec(&s, "pmacs.editor.goto_byte(0)"); + // Hide the three `let` lines under the `fn a() {` head. + let head_end = body.find('\n').unwrap() as u64; + let last_hidden_end = body.rfind("let z = 3;").unwrap() as u64 + "let z = 3;".len() as u64; + fold_range(&s, head_end, last_hidden_end); + assert_eq!(fold_count(&s), 1); + + // Point on a hidden line, then M-; — the toggle rewrites that line. + let y_line = body.find(" let y").unwrap() as u64; + set_cursor(&s, y_line + 4); + alt(&mut s, ';'); + assert_eq!( + fold_count(&s), + 0, + "comment-toggle's edit landed inside the fold, so it must reveal it" + ); + let text: String = eval( + &s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + assert!( + text.contains("// "), + "the toggle actually commented a line: {text:?}" + ); + let _ = std::fs::remove_file(&path); +} + #[test] fn an_interactive_edit_to_an_inactive_buffer_does_not_unfold() { let (s, _id) = seeded(); @@ -1254,6 +1593,42 @@ fn an_unfolded_pane_beside_a_folded_one_is_byte_identical_to_its_baseline() { assert_eq!(s.core.borrow().windows[&b_win].view_top, 0); } +#[test] +fn peer_presence_in_the_unfolded_pane_uses_that_windows_map() { + // Framing acceptance 14's presence clause: the recipient window's + // map, not the active (folded) buffer's — a peer in B must land on + // B's RAW row even while A is collapsed. + let (s, _id) = seeded(); + let (_a_win, b_win) = split_two_buffers(&s); + fold_active(&s, 2, 5); // buffer A (active) folds; B does not + let b_buffer = s.core.borrow().windows[&b_win].buffer_id; + // B is "B0\nB1\n…B7\n" (3 bytes a line). Put the peer on B's line 6 — + // BELOW where A's collapse sits, so the two maps disagree: B's map + // (none) says row 6, while A's map — which hides lines 3..=5 — would + // shift it up to row 3. + let cells = paint_presence(&s, &[peer(b_buffer, 6 * 3, None)]); + + let b_origin = COLS / 2; + let peer_cell = + |row: u32| (b_origin..COLS).any(|c| cells[(row * COLS + c) as usize].style.reverse); + assert!( + peer_cell(6), + "B has no folds, so its peer stays on raw row 6" + ); + assert!( + !peer_cell(3), + "the recipient window's map is B's — projecting through the \ + active (folded) buffer's map would have put it on row 3" + ); + // And nothing painted into A's half for a peer in B's buffer. + for row in 0..TEXT_ROWS { + assert!( + (0..b_origin).all(|c| !cells[(row * COLS + c) as usize].style.reverse), + "a peer in buffer B must not paint into buffer A's pane (row {row})" + ); + } +} + #[test] fn wheel_over_an_inactive_unfolded_pane_uses_that_windows_map() { let (s, _id) = seeded(); From b750000d065ff34e46ea093aa56d3a7d0a7e4fe4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 21:45:33 -0400 Subject: [PATCH 09/12] fix(fold): key the managed Lua widening on the EFFECTIVE edit site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #149 review round 5, finding 1 — correct, and both round-4 bugs did survive through the intercept path. Round 4 moved the widening off the point and onto `edit_start_of(&op)`, but ran it in `run_buffer_edit` BEFORE `run_managed_edit`. A managed buffer intercept may legally rewrite `pos` / `start` / `end` (`LuaInterceptView::intercept_edit`), so the requested op is not where the edit lands: - requested outside -> intercept relocates inside: the edit stayed hidden; - requested inside -> intercept relocates outside: an unrelated fold opened. The seam still covers BOTH paths — hooking only `run_managed_edit` would let an interactive `bypass_intercept` edit escape, which is why the framing put it on the common entry — but each path now keys on its own effective site: - `run_bypass_edit` applies its op verbatim, so `run_buffer_edit` hooks it there; - `run_managed_edit` hooks after the intercept chain settles and before the apply, on the op the chain returned. A chain that raises applies nothing, so it unfolds nothing. The registry borrow is released at that point, and the helper reads only `SharedCore` + the fold registry, so no borrow conflicts with phase 3. Three new tests, all through real `M-x` with a real `pmacs.buffer.add_intercept`: relocate-into-a-fold must unfold, relocate-out-of-a-fold must not, and a rejected chain must not. Each asserts the buffer text first, so the test fails loudly if the intercept stops relocating rather than silently passing. Suite 45 -> 48. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV --- src/lua_bindings/mod.rs | 33 +++++++-- tests/folding_stage2_acceptance.rs | 112 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 290df7a..7cf0bc4 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1309,12 +1309,16 @@ fn run_buffer_edit( bypass_intercept: bool, ) -> mlua::Result { // Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam. - // Hooked HERE — above both `run_managed_edit` and `run_bypass_edit` — - // so an interactive command that passes `bypass_intercept` does not - // escape the widening. Keyed on where the edit LANDS, read before - // `op` is consumed below. - unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op)); + // BOTH paths are covered — hooking only `run_managed_edit` would let + // an interactive `bypass_intercept` edit escape — but each keys on + // *its own* effective edit site (round-5 F1): + // + // - bypass applies `op` verbatim, so the site is known right here; + // - managed runs the intercept chain first, and an intercept may + // legally relocate `pos` / `start` / `end`, so only the op the + // chain settles on names where the edit will land. if bypass_intercept { + unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op)); run_bypass_edit(lua, id, op) } else { run_managed_edit(lua, id, op) @@ -1359,6 +1363,12 @@ fn edit_start_of(op: &EditOp<'_>) -> u64 { /// [`apply_active_edit`](crate::editor_core::EditorCore::apply_active_edit)'s /// funnel keys on the point; only this Lua path can diverge. /// +/// "Edit site" means the **effective** one (round-5 F1). On the managed +/// path a buffer intercept may legally rewrite `pos` / `start` / `end` +/// before the op is applied, so [`run_managed_edit`] calls this *after* +/// the intercept chain settles; only [`run_bypass_edit`], which applies +/// its op verbatim, can name the site up front. +/// /// Matches Stage 1's data-API exemption: `pmacs.buffer.insert` called /// from a plugin, a hook, or a bare Lua chunk unfolds nothing. fn unfold_before_interactive_lua_edit(lua: &Lua, id: BufferId, edit_start: u64) { @@ -1435,6 +1445,19 @@ fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result