docs(folding): frame Stage 2 — grid (daemon) collapse (rev 1)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
parent
c49a8c71be
commit
59410c004d
|
|
@ -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<ByteRange>` (`:327`) — whole-buffer, sorted,
|
||||||
|
empty when no store. The one-call read a renderer wants.
|
||||||
|
- `FoldStore::containing(p) -> Vec<ByteRange>` (`: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<line>` — 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).
|
||||||
Loading…
Reference in New Issue