diff --git a/docs/folding-stage2-framing.md b/docs/folding-stage2-framing.md new file mode 100644 index 0000000..8403b3d --- /dev/null +++ b/docs/folding-stage2-framing.md @@ -0,0 +1,728 @@ +# Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6) + +**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 +parent's §8/§14. 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` (PR #147, ready for + merge but not yet landed). + +### 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 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 + 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). + +### 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 +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; 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** 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** — 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. + +**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 + review-verified 2026-07-23, `main` @ `c49a8c7`) + +### 2.1 The grid render path is layered — collapse belongs deep in it + +`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 + → 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: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**. + +### 2.2 The complete set of identity-assuming consumers (F2) + +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 | 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 | + +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. + +### 2.3 The renderer can 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)`. + +### 2.4 The gutter + +- `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). + +### 2.5 The interactive-edit unfold seam (F5, correcting rev 1) + +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. + +- **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.6 Scroll/viewport commands count raw lines; no recenter + +`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. + +### 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). 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) + +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. + +### 3.1 Queries + +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`, 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` + (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 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) 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 and per window (F2, F4) + +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** 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`). 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 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 +(`saveplace.lua:60`) and `_view_top`/`set_view_top` contracts. Visible-line +*ordinals* are derived where needed, never stored. + +## 4. Collapse rendering (Q#FD13) + +`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). **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). + +**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). + +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 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 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. + +## 7. Caret, selection, peer presence, viewport, and motion (Q#FD16, Q#FD17, Q#FD18, Q#FD21) + +**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 +`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 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`; +hidden interior cells are dropped; the visible portion paints on the visible head +row and the visible tail rows, contiguous on screen. + +**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. + +**Click inverse (Q#FD16, Q#FD21).** `activate_and_position` (`src/editor.rs:2233`): +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); +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** +(`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; 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_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). + +## 8. Interactive-Lua-command unfold widening (Q#FD19) + +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. 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. + +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). +- **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-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>` + 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 + `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 + +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 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/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. + 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. +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. +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. 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) + +`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, 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` +(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** 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 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 + 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 **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 + 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. 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** + 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) +- **Q#FD21** Fold projection is **per-frontend**: a `fold_projection_active` flag on + `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, 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 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) is **ready as the separate, unmerged docs PR #147**, +kept out of `folding-tui` (ruled). 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..141623b 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, }, ); } @@ -3572,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 @@ -3633,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 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..28200cd 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. @@ -661,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; } @@ -1264,6 +1323,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 +1610,48 @@ 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(); + // 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; 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 = 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 +1663,34 @@ 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(); + 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; 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 = 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 +1839,12 @@ 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. 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; @@ -1755,12 +1857,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 +1886,17 @@ 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(); + 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; @@ -1790,10 +1910,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 +1967,49 @@ 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). + /// 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; @@ -1855,7 +2021,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 +2056,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 +2097,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 +2118,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 +2141,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 +2168,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 +3505,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 45ff93f..2c7369f 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -418,7 +418,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; @@ -441,7 +441,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; } @@ -598,7 +602,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. }; @@ -649,7 +653,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; } @@ -1063,6 +1070,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(); @@ -1160,6 +1168,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(); @@ -1279,6 +1288,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(); @@ -1338,6 +1348,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); @@ -1394,6 +1405,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); @@ -1458,6 +1470,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; hv.render(®istry, viewport, &mut grid); @@ -1510,6 +1523,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; hv.render(®istry, viewport, &mut grid); @@ -1572,6 +1586,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; 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..7cf0bc4 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -1308,13 +1308,92 @@ fn run_buffer_edit( op: EditOp<'_>, bypass_intercept: bool, ) -> mlua::Result { + // Arc 6 Stage 2 (Q#FD19): the interactive-Lua-command unfold seam. + // 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) } } +/// 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). +/// +/// 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 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. +/// +/// "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) { + 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, edit_start); +} + fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result { with_registry_mut(lua, |r| { let buf = resolve_mut(r, id)?; @@ -1366,6 +1445,19 @@ fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result) { + 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..de03a74 --- /dev/null +++ b/tests/folding_stage2_acceptance.rs @@ -0,0 +1,1776 @@ +// 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(s: &EditorState) -> u64 { + s.core.borrow().active_window().cursor +} + +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 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(); + 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})" + ); + } +} + +/// 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(); + 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"); +} + +/// 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_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 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(&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, 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), + 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"); +} + +// --------------------------------------------------------------------------- +// 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_at_the_edit_site() { + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); // head 2, hidden 3..=5; fold range (11, 23] + + // 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 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, + "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" + ); +} + +/// Attach a buffer intercept that relocates every `insert` to `to`. +/// This is a supported, documented rewrite (`pos` / `start` / `end` may +/// be overridden), and it is what makes the *requested* op's position an +/// unreliable answer to "where will this edit land?". +fn relocate_inserts_to(s: &EditorState, to: u64) { + exec( + s, + &format!( + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" then + return {{ kind = "insert", pos = {to} }} + end + return nil + end) + "# + ), + ); +} + +#[test] +fn an_intercept_relocating_the_edit_into_a_fold_unfolds_it() { + // Round-5 F1. The command requests an edit OUTSIDE the fold; a + // managed intercept moves it INSIDE. Keying on the requested op + // would leave what the user just wrote invisible. + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); // head 2, hidden 3..=5 — fold range (11, 23] + relocate_inserts_to(&s, end_of(4)); // 19, inside the fold + set_cursor(&s, 0); + define( + &s, + "test.relocate_in", + "pmacs.window.buffer():insert(0, 'y')", + ); + m_x(&mut s, "test.relocate_in"); + + let text: String = eval( + &s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + assert!( + text.starts_with("L00\nL01\nL02\nL03\nL04y"), + "the intercept really relocated the insert: {text:?}" + ); + assert_eq!( + fold_count(&s), + 0, + "the EFFECTIVE edit site was inside the fold, so it must be revealed" + ); +} + +#[test] +fn an_intercept_relocating_the_edit_out_of_a_fold_leaves_it_closed() { + // The other direction: requested INSIDE, intercept moves it OUTSIDE. + // Keying on the requested op would open a fold nothing was written to. + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + relocate_inserts_to(&s, 0); // outside the fold + set_cursor(&s, 0); + define( + &s, + "test.relocate_out", + &format!("pmacs.window.buffer():insert({}, 'y')", end_of(4)), + ); + m_x(&mut s, "test.relocate_out"); + + let text: String = eval( + &s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + assert!( + text.starts_with("yL00"), + "the intercept really relocated the insert: {text:?}" + ); + assert_eq!( + fold_count(&s), + 1, + "nothing landed inside the fold, so it must stay closed" + ); +} + +#[test] +fn a_rejected_intercept_chain_unfolds_nothing() { + // A chain that raises applies no edit at all, so there is nothing to + // reveal — the widening must not fire on a request that never lands. + let (s, _id) = seeded(); + let mut s = s; + fold_active(&s, 2, 5); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" then error("nope") end + return nil + end) + "#, + ); + set_cursor(&s, 0); + define( + &s, + "test.rejected", + &format!( + "pcall(function() pmacs.window.buffer():insert({}, 'y') end)", + end_of(4) + ), + ); + m_x(&mut s, "test.rejected"); + assert_eq!(fold_count(&s), 1, "a rejected edit reveals nothing"); +} + +#[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, 0); + define( + &s, + "test.bypass", + &format!( + "pmacs.window.buffer():insert({}, 'z', {{ bypass_intercept = true }})", + end_of(4) + ), + ); + m_x(&mut s, "test.bypass"); + assert_eq!( + fold_count(&s), + 0, + "a bypass_intercept interactive edit must not escape the widening" + ); +} + +#[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(); + let mut s = s; + fold_active(&s, 2, 5); + 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\\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, + "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" + ); +} + +#[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 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(); + 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