Merge githubsucks/main into gpu-initial-target
Integrate folding Stage 2 and its landed-state documentation with the protocol-v20 GPU initial-target branch. Preserve per-session fold projection selection in the target bootstrap transaction and retain v19 compatibility coverage after the later protocol bump.
This commit is contained in:
commit
6c06815ee4
|
|
@ -1,6 +1,6 @@
|
||||||
# Active work — cross-machine resume ledger
|
# Active work — cross-machine resume ledger
|
||||||
|
|
||||||
**Snapshot: 2026-07-23.** This file records volatile work that has not
|
**Snapshot: 2026-07-24.** This file records volatile work that has not
|
||||||
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
|
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
|
||||||
entries when their PR merges; do not let this become a second permanent
|
entries when their PR merges; do not let this become a second permanent
|
||||||
backlog.
|
backlog.
|
||||||
|
|
@ -14,9 +14,10 @@ backlog.
|
||||||
machine-local: `origin` may name this canonical URL, a release mirror,
|
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||||
or something else, and therefore has no authority by name alone.
|
or something else, and therefore has no authority by name alone.
|
||||||
- Canonical base at this snapshot:
|
- Canonical base at this snapshot:
|
||||||
`githubsucks/main` @ `47581f4` (web grammars #146 atop folding Stage 1
|
`githubsucks/main` @ `b168dca` (folding Stage 2 landed-doc refresh #150
|
||||||
#142, inline-math framing #145, and one-command GPU invocation #141;
|
atop folding Stage 2 #149, the ledger refresh #147, web grammars HTML+CSS
|
||||||
protocol v19).
|
#146, and the LaTeX Stage 1 #144 / inline-math framing #145 pair; protocol
|
||||||
|
v19).
|
||||||
- On the transfer source, `origin/main` named a release mirror at
|
- On the transfer source, `origin/main` named a release mirror at
|
||||||
`d3fa632` and lagged badly. On the current destination, `origin` names
|
`d3fa632` and lagged badly. On the current destination, `origin` names
|
||||||
the canonical URL. This difference is why all recovery begins by
|
the canonical URL. This difference is why all recovery begins by
|
||||||
|
|
@ -50,7 +51,7 @@ git worktree list
|
||||||
git status --short --branch
|
git status --short --branch
|
||||||
```
|
```
|
||||||
|
|
||||||
The `git log` command must expose `47581f4` or a newer intentional main.
|
The `git log` command must expose `b168dca` or a newer intentional main.
|
||||||
If it does not, stop and repair the remote/fetch configuration.
|
If it does not, stop and repair the remote/fetch configuration.
|
||||||
|
|
||||||
## Active lane: GPU initial target
|
## Active lane: GPU initial target
|
||||||
|
|
@ -61,21 +62,24 @@ If it does not, stop and repair the remote/fetch configuration.
|
||||||
- Approved framing branch: `githubsucks/gpu-initial-target-framing`;
|
- Approved framing branch: `githubsucks/gpu-initial-target-framing`;
|
||||||
Revision 2 checkpoint `71039d1`.
|
Revision 2 checkpoint `71039d1`.
|
||||||
- Original implementation base: canonical `githubsucks/main` @ `c49a8c7`
|
- Original implementation base: canonical `githubsucks/main` @ `c49a8c7`
|
||||||
(folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated
|
(folding Stage 1 #142); current canonical `main` @ `b168dca` is integrated
|
||||||
conflict-free by merge `d6d4be6`. Protocol was v19 before this work.
|
before publication of the second-review merge. Protocol was v19 before this
|
||||||
|
work.
|
||||||
- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint
|
- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint
|
||||||
`bef1c08`; second-review checkpoint `be8c67c`. Integrated, smoke-tested,
|
`bef1c08`; second-review checkpoint `be8c67c`. Current `main` is integrated,
|
||||||
fully gated, and published at protocol v20. PR #148 remains open for user
|
smoke-tested, and fully gated at protocol v20; merge publication is next.
|
||||||
review: `https://github.com/levineuwirth/pmacs/pull/148`.
|
PR #148 remains open for user review:
|
||||||
|
`https://github.com/levineuwirth/pmacs/pull/148`.
|
||||||
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
|
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
|
||||||
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
|
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
|
||||||
Unix path transport, pre-window target readiness, replica coherence, and the
|
Unix path transport, pre-window target readiness, replica coherence, and the
|
||||||
approved behavioral acceptance matrix.
|
approved behavioral acceptance matrix.
|
||||||
- Second-review verification: formatting and strict Clippy; 1,801 default +
|
- Post-integration verification: formatting and strict Clippy; 1,815 default +
|
||||||
1,978 CRDT library tests; target gate 1 default + 14 CRDT; M4 121; required
|
1,992 CRDT library tests; target and invocation gates 14 CRDT each; Folding
|
||||||
GPU 152; Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep
|
Stage 2 48 CRDT; M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT;
|
||||||
3,272 across 87 suites. The prior two-window Wayland/Vulkan isolation smoke
|
isolated-config workspace sweep 3,334 across 88 suites. The prior two-window
|
||||||
remains valid; this round changes only daemon failure/publication behavior.
|
Wayland/Vulkan isolation smoke remains valid; this round changes only daemon
|
||||||
|
failure/publication behavior.
|
||||||
- Deferred unchanged: automatic GUI selection, multiple files, general
|
- Deferred unchanged: automatic GUI selection, multiple files, general
|
||||||
live-open commands, packaging, and remote GPU paths.
|
live-open commands, packaging, and remote GPU paths.
|
||||||
|
|
||||||
|
|
@ -88,41 +92,25 @@ git worktree add --track \
|
||||||
githubsucks/gpu-initial-target
|
githubsucks/gpu-initial-target
|
||||||
```
|
```
|
||||||
|
|
||||||
## Folding framing lane (Arc 6)
|
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
|
||||||
|
|
||||||
- Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`.
|
Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3
|
||||||
- Base: **rebased onto canonical `main` @ `96d0bae`** at implementation
|
has **no branch and no framing yet**.
|
||||||
start (was `cac4961`; the earlier base fell behind the docs + tab-width
|
|
||||||
housekeeping).
|
|
||||||
- Framing head: revision 5 of `docs/folding-framing.md` (rev 1 → … → rev 4
|
|
||||||
absorbed three review rounds; rev 5 records approval + the Q#FD4 binding
|
|
||||||
decision).
|
|
||||||
- State: **Stage 1 (fold engine, headless) implemented; PR #142 OPEN**,
|
|
||||||
two review rounds landed on the branch. Bindings decided (Q#FD4 → Emacs
|
|
||||||
hideshow `C-c @` set); Bet B1 accepted as framed.
|
|
||||||
Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and
|
|
||||||
no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; v1
|
|
||||||
is structural node folding (block-like node ≥2 source lines, derived head
|
|
||||||
line, closer-aware tail), with indentation fallback and curated queries
|
|
||||||
deferred. `FoldState` already exists in the protocol; Stage 1 starts
|
|
||||||
*producing* it (authoritative-empty), no protocol bump; gutter markers are
|
|
||||||
frontend-derived like the diagnostic sign bars, so no new wire type. Staged
|
|
||||||
like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU.
|
|
||||||
- PR: **#142** (`Arc 6 folding — Stage 1: instance fold engine`), open
|
|
||||||
against `main`. Round 1 (tail-boundary delete bug + buffer-kill cleanup +
|
|
||||||
close-all point-move) and round 2 (pin the kill-path + close-all through
|
|
||||||
the real command surface) are landed as fix commits on the branch.
|
|
||||||
- Next: land Stage 1; Stages 2/3 are separate branches/PRs, each re-framed
|
|
||||||
in detail after the prior stage lands.
|
|
||||||
|
|
||||||
Recovery worktree:
|
- Stage 1 (headless fold engine) merged as **#142**, Stage 2 (grid/daemon
|
||||||
|
collapse) as **#149** — both under "Closed since the last snapshot".
|
||||||
```sh
|
- Retained, carrying nothing unmerged: branches `folding` / `folding-tui`
|
||||||
git worktree add --track \
|
and worktrees `../pmacs-folding` / `../pmacs-folding-tui`. The framings
|
||||||
-b folding \
|
`docs/folding-framing.md` (rev 5) and `docs/folding-stage2-framing.md`
|
||||||
../pmacs-folding \
|
(rev 4) are the approved artifacts Stage 3 re-scouts against.
|
||||||
githubsucks/folding
|
- **Stage 3 (GPU) obligations, already named by the framings** — the
|
||||||
```
|
starting point for its own framing doc: GPU collapse at TUI parity;
|
||||||
|
caret/hit-test fold-awareness; the `BufferSnapshot` **fold-mirror clear**
|
||||||
|
(parent R2-4 — without it, empty-after-revert diff suppression leaves
|
||||||
|
stale folds on the GPU, the same trap class as #120); CRDT-origin and
|
||||||
|
GPU-optimistic interactive unfold (parent R2-3); and flipping
|
||||||
|
`FrontendView.fold_projection` to `true` for semantic frontends, which
|
||||||
|
Stage 2 deliberately left `false` (Q#FD21).
|
||||||
|
|
||||||
## Parked lane: kill-ring browser + persistence
|
## Parked lane: kill-ring browser + persistence
|
||||||
|
|
||||||
|
|
@ -160,6 +148,72 @@ git worktree add --track \
|
||||||
|
|
||||||
## Closed since the last snapshot
|
## Closed since the last snapshot
|
||||||
|
|
||||||
|
- **Folding Stage 2 (grid/daemon collapse) — MERGED as #149** (`main` @
|
||||||
|
`6ed4fe9`, 2026-07-24, after **five** review rounds). The grid TUI now
|
||||||
|
renders collapses. Spine (Q#FD12): `src/fold_view.rs`'s `VisibleLineMap`,
|
||||||
|
derived from the fold store plus a window's line offsets and **never
|
||||||
|
stored**, threaded as `Option<&'a VisibleLineMap>` on a lifetime-bearing
|
||||||
|
`Viewport<'a>` that stays `Copy`. No wire schema or protocol change; the
|
||||||
|
GPU path is Stage 3. 48 acceptance tests on the real `paint_frame` grid,
|
||||||
|
every behavioral claim bite-verified. Durable design points, each a trap
|
||||||
|
Stage 3 inherits:
|
||||||
|
- the map's unit is a **merged hidden component** (overlapping *or
|
||||||
|
adjacent* intervals unioned, keeping the earliest visible head), not a
|
||||||
|
fold — folds may cross, and a later fold's own head can be hidden;
|
||||||
|
- instances are **per rendered window** and **per command/event
|
||||||
|
operation**, never per frame; a command's map follows the operation's
|
||||||
|
**target** window, since a wheel event names a pane without activating it;
|
||||||
|
- fold projection is **per-frontend** (`FrontendView.fold_projection`) —
|
||||||
|
shared `EditorCore` motion would otherwise make a simultaneous unfolded
|
||||||
|
GPU session's cursor skip lines it still displays;
|
||||||
|
- a hidden cursor normalizes by **position**, not row, and `set_view_top`
|
||||||
|
clamps in the setter rather than being repaired at render time;
|
||||||
|
- the interactive-Lua unfold keys on the **post-intercept** edit site — a
|
||||||
|
managed buffer intercept may legally relocate the op.
|
||||||
|
|
||||||
|
Process notes worth keeping: `main` moved under the arc, and the merge was
|
||||||
|
textually clean but **not semantically clean** (#146 added `Viewport`
|
||||||
|
literals the new `folds` field invalidated) — a clean `git merge-tree` does
|
||||||
|
not mean the merged tree compiles. CI was red at review on the macOS/luajit
|
||||||
|
`outline_5_level_100_entry_renders_within_100ms` budget flake and went
|
||||||
|
green on rerun.
|
||||||
|
|
||||||
|
- **Documentation ledger refresh — MERGED as #147** (`main` @ `0a479ae`,
|
||||||
|
2026-07-24). The #142 housekeeping, expanded after review found the ledger
|
||||||
|
stale through four merges rather than one. Its own macOS/luajit red was the
|
||||||
|
vterm `VTERM_ALT_READY` PTY timeout; green on rerun.
|
||||||
|
|
||||||
|
- **Web grammars HTML + CSS — MERGED as #146** (`main` @ `47581f4`,
|
||||||
|
2026-07-23). `.html/.htm/.xhtml` and `.css` highlight off the official
|
||||||
|
`tree-sitter-html` 0.23 / `tree-sitter-css` 0.25 crate query constants (no
|
||||||
|
in-repo overlay), and HTML's `INJECTIONS_QUERY` lights up `<script>` → js
|
||||||
|
and `<style>` → css. Durable lesson recorded in
|
||||||
|
`docs/web-grammars-html-css-framing.md`: the `highlight.rs` capture table
|
||||||
|
is **global**, so adding a capture name retro-paints every other language —
|
||||||
|
check the reverse direction and pin it.
|
||||||
|
|
||||||
|
- **LaTeX Stage 1 — MERGED as #144**, with its parent inline-math framing
|
||||||
|
committed as **#145** (`main` @ `f09b0a1`, 2026-07-23).
|
||||||
|
`.tex/.latex/.sty/.cls` highlight via `codebook-tree-sitter-latex` 0.6 plus
|
||||||
|
the first in-repo query overlay (`builtin/queries/latex/highlights.scm`,
|
||||||
|
`include_str!`) — the reusable pattern for grammars whose crate ships no
|
||||||
|
usable queries. The crates.io `tree-sitter-latex` is provably broken (no
|
||||||
|
`scanner.c`). The math parser and Tiers 3–4 are deferred to the inline-math
|
||||||
|
arc.
|
||||||
|
|
||||||
|
- **Folding Stage 1 (headless fold engine) — MERGED as #142** (`main` @
|
||||||
|
`c49a8c7`, 2026-07-23, after three review rounds; round 3 clean). The
|
||||||
|
instance-side fold store + translating/dropping `View`, the structural
|
||||||
|
source (derived head line, closer-aware tail), the Lua data API +
|
||||||
|
interactive `C-c @` commands, the command-path pre-edit unfold, and
|
||||||
|
authoritative-empty `FoldState` production landed with no protocol bump.
|
||||||
|
The `folding` branch and worktree (`../pmacs-folding`) are retained but
|
||||||
|
carry nothing unmerged; the `folding-framing.md` framing is preserved.
|
||||||
|
CI red at merge was an unrelated environmental perf flake
|
||||||
|
(`outline_5_level_100_entry_renders_within_100ms`, macOS/luajit only),
|
||||||
|
green on rerun. Stage 2 has since merged as **#149** (above); durable
|
||||||
|
substrate seams live in `docs/agent-handoff.md` §1.
|
||||||
|
|
||||||
- **Vterm Stage 3 (protocol v19 + GPU terminal) — MERGED as #135** (`main`
|
- **Vterm Stage 3 (protocol v19 + GPU terminal) — MERGED as #135** (`main`
|
||||||
@ `cac4961`, 2026-07-22, after two review rounds). Arc 5's terminal stage
|
@ `cac4961`, 2026-07-22, after two review rounds). Arc 5's terminal stage
|
||||||
is complete (compile mode #113, Stage 1 #126, Stage 2 #130, Stage 3 #135).
|
is complete (compile mode #113, Stage 1 #126, Stage 2 #130, Stage 3 #135).
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# Agent handoff — cross-machine continuity
|
# Agent handoff — cross-machine continuity
|
||||||
|
|
||||||
**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review fixes
|
**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review
|
||||||
and verification completed on branch `gpu-initial-target` (protocol v20),
|
fixes were integrated with folding Stage 2 (#149) and its landed-doc refresh
|
||||||
following one-command GPU invocation (#141), the documentation refresh (#140),
|
(#150), following web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math
|
||||||
Vterm Stage 3 (#135), tab-width rendering parity (#137), locals-query
|
framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation
|
||||||
processing (#134), modeline detection (#132), mode system wiring (#129),
|
(#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width
|
||||||
config registry (#127), Vterm Stages 1–2 (#126/#130), and completed Themes
|
rendering parity (#137), locals-query processing (#134), modeline detection
|
||||||
Arc 4 (#120/#124/#125).**
|
(#132), mode system wiring (#129), config registry (#127), Vterm Stages 1–2
|
||||||
|
(#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
|
||||||
This file is the
|
This file is the
|
||||||
bridge between development machines. If you are an agent reading
|
bridge between development machines. If you are an agent reading
|
||||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
this on a fresh clone: this document plus the `docs/*-framing.md`
|
||||||
|
|
@ -20,9 +21,11 @@ commands, read `docs/active-work.md` immediately after this file.
|
||||||
|
|
||||||
## 1. Where the project stands (2026-07-24)
|
## 1. Where the project stands (2026-07-24)
|
||||||
|
|
||||||
- `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142,
|
- `main` @ `b168dca` (folding Stage 2 landed-doc refresh #150 atop folding
|
||||||
inline-math framing #145, and one-command GPU invocation #141), protocol
|
Stage 2 #149, ledger refresh #147, web grammars #146, LaTeX Stage 1 #144 /
|
||||||
**v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events).
|
inline-math framing #145, and folding Stage 1 #142), protocol **v19**
|
||||||
|
(`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
|
||||||
|
`StatuslineSegments`, v19 = terminal frames/events).
|
||||||
- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review**
|
- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review**
|
||||||
(`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
|
(`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
|
||||||
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
|
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
|
||||||
|
|
@ -41,6 +44,71 @@ commands, read `docs/active-work.md` immediately after this file.
|
||||||
Existing no-target managed launch, direct attach, TUI, and legacy protocol
|
Existing no-target managed launch, direct attach, TUI, and legacy protocol
|
||||||
behavior remain intact. See `docs/active-work.md` for the portable checkpoint
|
behavior remain intact. See `docs/active-work.md` for the portable checkpoint
|
||||||
and verification.
|
and verification.
|
||||||
|
- **Folding Stage 1 (headless fold engine) LANDED — #142**
|
||||||
|
(`docs/folding-framing.md` rev 5; merge `c49a8c7`; three review rounds,
|
||||||
|
round 3 clean). Arc 6's engine — instance-side and headless; **no frontend
|
||||||
|
renders a collapse yet** (that is Stage 2). No protocol bump.
|
||||||
|
- `src/fold.rs`: a per-buffer `FoldStore` of byte ranges attached as a
|
||||||
|
translating/dropping `View` (the `BufferStyleSpanTranslator` pattern — it
|
||||||
|
translates strictly-inside edits and DROPS boundary-crossers,
|
||||||
|
provenance-blind); `FoldRegistry`/`SharedFoldRegistry` =
|
||||||
|
`Rc<RefCell<HashMap<BufferId, {Arc<Mutex<FoldStore>>, ViewId}>>>` (the
|
||||||
|
SyntaxRegistry per-buffer model), held on both `EditorCore`
|
||||||
|
(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`).
|
||||||
|
Containment is **start-exclusive, end-inclusive `(start, end]`**; the
|
||||||
|
stored range is `[end of head line, end of last hidden line]` (NOT the
|
||||||
|
`ByteRange` struct doc's `[start,end)`).
|
||||||
|
- Structural source: nearest enclosing block-like node ≥2 source lines →
|
||||||
|
resolve introducer↔body → **derived head line** (the line immediately
|
||||||
|
above the first hidden line, so wrapped signatures / `where` clauses stay
|
||||||
|
visible) → **closer-aware tail** (a closing-delimiter line stays visible,
|
||||||
|
e.g. `} else {`). Stale/absent parse tree refuses.
|
||||||
|
- The six `EditorCore` edit primitives call `unfold_before_point_edit`
|
||||||
|
first (command-path pre-edit unfold, keyed on the active frontend's
|
||||||
|
point). Interactive Lua-command unfold (yank/query-replace/comment) is a
|
||||||
|
Stage 2 obligation; CRDT-origin unfold is Stage 3.
|
||||||
|
- `src/lua_bindings/fold.rs` (`install_fold`): `pmacs.fold.*` data API
|
||||||
|
(explicit buffer, no ambient resolution, matching #127) + interactive
|
||||||
|
commands on the **Emacs hideshow `C-c @` prefix set**;
|
||||||
|
`builtin/runtime/fold.lua`.
|
||||||
|
- `src/semantic_render.rs`: `fold_state_msg` PRODUCES `FoldState`
|
||||||
|
(semantic/GPU sessions) — authoritative-empty, diff-suppressed, per-session
|
||||||
|
baseline reset on `BufferSnapshot` (the #120 stale-mirror trap class; the
|
||||||
|
GPU fold-mirror clear-on-snapshot is a named Stage 3 obligation).
|
||||||
|
- Durable lesson (round 2): after wiring a cleanup into a production hook,
|
||||||
|
PIN IT THROUGH THE REAL PATH — a direct-call unit test misses the wiring
|
||||||
|
(falsify by revert).
|
||||||
|
- **Stage 2 (grid/daemon collapse) LANDED — #149** (merge `6ed4fe9`; five
|
||||||
|
review rounds; `docs/folding-stage2-framing.md` rev 4). Its
|
||||||
|
load-bearing reframe: the TUI had **no non-identity
|
||||||
|
source-line↔display-row map** (`view_top + row` was baked into ~13 sites),
|
||||||
|
so Stage 2's spine is `src/fold_view.rs`'s `VisibleLineMap` — derived from
|
||||||
|
the fold store plus a window's line offsets, never stored — that the render
|
||||||
|
loop, gutter, diagnostics, caret, selection, peer presence,
|
||||||
|
viewport/scroll/motion, and the mode-line indicator all route through, plus
|
||||||
|
the interactive-Lua unfold widening. Threaded as `Option<&'a
|
||||||
|
VisibleLineMap>` on a lifetime-bearing `Viewport<'a>` that stays `Copy`.
|
||||||
|
Design points the review rounds forced, each a trap for Stage 3:
|
||||||
|
- the map's unit is a **merged hidden component** (overlapping *or
|
||||||
|
adjacent* intervals unioned, keeping the earliest visible head), not a
|
||||||
|
fold — folds may cross, and an inner/later fold's own head can be hidden;
|
||||||
|
- instances are **per rendered window** and **per command/event
|
||||||
|
operation**, never per frame; a command's map follows the operation's
|
||||||
|
TARGET window (a wheel event names a pane without activating it);
|
||||||
|
- fold projection is **per-frontend** (`FrontendView.fold_projection`, set
|
||||||
|
at attach from the negotiated `semantic_render` bit) — shared
|
||||||
|
`EditorCore` motion would otherwise make a simultaneous unfolded GPU
|
||||||
|
session's cursor skip lines it still displays;
|
||||||
|
- a hidden cursor normalizes by **position**, not row, and `set_view_top`
|
||||||
|
clamps in the setter rather than being repaired at render time;
|
||||||
|
- the interactive-Lua unfold keys on the **post-intercept** edit site — a
|
||||||
|
managed buffer intercept may legally relocate the op.
|
||||||
|
No protocol bump. **Stage 3 (GPU) is next and has no framing yet**; its
|
||||||
|
named obligations are GPU collapse at TUI parity, caret/hit-test
|
||||||
|
fold-awareness, the `BufferSnapshot` **fold-mirror clear** (parent R2-4 —
|
||||||
|
the #120 trap class), CRDT-origin / GPU-optimistic interactive unfold
|
||||||
|
(parent R2-3), and flipping `FrontendView.fold_projection` to `true` for
|
||||||
|
semantic frontends.
|
||||||
- **One-command GPU invocation LANDED — #141**
|
- **One-command GPU invocation LANDED — #141**
|
||||||
(`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation
|
(`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation
|
||||||
reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`;
|
reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`;
|
||||||
|
|
@ -440,8 +508,16 @@ commands, read `docs/active-work.md` immediately after this file.
|
||||||
lexical resolution, settled per-layer facts, shared TUI/GPU
|
lexical resolution, settled per-layer facts, shared TUI/GPU
|
||||||
local-predicate filtering, and a registry-wide locals-query invariant
|
local-predicate filtering, and a registry-wide locals-query invariant
|
||||||
shipped without a protocol change.
|
shipped without a protocol change.
|
||||||
- Remaining ranked arcs: 6 folding, 7 DAP, 8 GPU splits, plus the
|
- **Arc 6 (folding) Stages 1 and 2 LANDED — #142 and #149** — the headless
|
||||||
`.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
|
fold engine (store, structural source, Lua `C-c @` surface, command-path
|
||||||
|
unfold, `FoldState` production), then the grid/daemon collapse (the
|
||||||
|
`VisibleLineMap` spine, fold-aware gutter/diagnostics/caret/selection/
|
||||||
|
presence/viewport/motion, and the interactive unfold widening). **Stage 3
|
||||||
|
(GPU) is next**, unframed.
|
||||||
|
- **Web grammars HTML+CSS LANDED — #146**, and **LaTeX Stage 1 — #144**
|
||||||
|
with its inline-math parent framing **#145**.
|
||||||
|
- Remaining ranked arcs: 6 folding Stage 3, 7 DAP, 8 GPU splits, plus
|
||||||
|
the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
|
||||||
|
|
||||||
## 2. How we work (the part that must not drift)
|
## 2. How we work (the part that must not drift)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ByteRange>`
|
||||||
|
(`: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<FrontendId>` 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<FrontendId, FrontendView>`, `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).
|
||||||
|
|
@ -628,13 +628,14 @@ and the complete #141 lifecycle fixture coverage together.
|
||||||
Post-second-review verification on 2026-07-24:
|
Post-second-review verification on 2026-07-24:
|
||||||
|
|
||||||
- `cargo fmt --check` and strict workspace Clippy passed.
|
- `cargo fmt --check` and strict workspace Clippy passed.
|
||||||
- Library gates passed 1,801 default and 1,978 CRDT tests.
|
- Library gates passed 1,815 default and 1,992 CRDT tests.
|
||||||
- The named initial-target gate passed 1 default and 14 CRDT tests; the
|
- The named initial-target gate passed 1 default and 14 CRDT tests; the
|
||||||
underlying GPU invocation suite passed 14 CRDT tests.
|
underlying GPU invocation suite passed 14 CRDT tests.
|
||||||
|
- Folding Stage 2 passed 48 CRDT tests after integrating current `main`.
|
||||||
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
|
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
|
||||||
tests passed 152.
|
tests passed 152.
|
||||||
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
|
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
|
||||||
- The isolated-config workspace CRDT sweep passed 3,272 tests across 87 suites,
|
- The isolated-config workspace CRDT sweep passed 3,334 tests across 88 suites,
|
||||||
with 29 ignored and the documented basedpyright case filtered.
|
with 29 ignored and the documented basedpyright case filtered.
|
||||||
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
|
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
|
||||||
windows on one daemon, targeting distinct `alpha` and `beta` files. Both
|
windows on one daemon, targeting distinct `alpha` and `beta` files. Both
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,7 @@ struct PopupRect {
|
||||||
/// the viewport or nothing fits.
|
/// the viewport or nothing fits.
|
||||||
fn resolve_popup_rect(
|
fn resolve_popup_rect(
|
||||||
buf: &Buffer,
|
buf: &Buffer,
|
||||||
viewport: Viewport,
|
viewport: Viewport<'_>,
|
||||||
anchor: Position,
|
anchor: Position,
|
||||||
rows: &[PopupCandidate],
|
rows: &[PopupCandidate],
|
||||||
) -> Option<PopupRect> {
|
) -> Option<PopupRect> {
|
||||||
|
|
@ -700,10 +700,12 @@ fn resolve_popup_rect(
|
||||||
let line_offsets = crate::diag::compute_line_offsets(&source);
|
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 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);
|
let anchor_line = crate::diag::line_at_offset(&line_offsets, anchor);
|
||||||
if anchor_line < start_line {
|
// Arc 6 Stage 2: the popup anchors on the anchor byte's VISIBLE row,
|
||||||
return None; // anchor scrolled above the viewport
|
// so a completion below a collapsed region lands on the right row;
|
||||||
}
|
// an anchor inside a collapse has no row and paints nothing.
|
||||||
let anchor_row = anchor_line - start_line;
|
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_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
let max_cols = viewport.cell_size.cols;
|
||||||
if anchor_row >= max_rows || max_cols == 0 {
|
if anchor_row >= max_rows || max_cols == 0 {
|
||||||
|
|
@ -820,7 +822,7 @@ impl View for CompletionView {
|
||||||
"completion-popup"
|
"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.
|
// Snapshot under the lock, then drop it before touching the rope.
|
||||||
let (anchor, rows_data, selected_in_window): (Position, Vec<PopupCandidate>, usize) = {
|
let (anchor, rows_data, selected_in_window): (Position, Vec<PopupCandidate>, usize) = {
|
||||||
let guard = self.popup.lock().expect("completion popup poisoned");
|
let guard = self.popup.lock().expect("completion popup poisoned");
|
||||||
|
|
|
||||||
|
|
@ -1757,7 +1757,19 @@ fn handle_session_established(
|
||||||
initial_target: Option<InitialTarget>,
|
initial_target: Option<InitialTarget>,
|
||||||
mut write_stream: UnixStream,
|
mut write_stream: UnixStream,
|
||||||
) {
|
) {
|
||||||
let fresh_view = build_fresh_frontend_view(editor);
|
// 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).
|
||||||
|
//
|
||||||
|
// 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 fresh_view = build_fresh_frontend_view(
|
||||||
|
editor,
|
||||||
|
!session_state.negotiated_capabilities.semantic_render,
|
||||||
|
);
|
||||||
{
|
{
|
||||||
let mut core = editor.core.borrow_mut();
|
let mut core = editor.core.borrow_mut();
|
||||||
core.register_frontend_view(frontend_id, fresh_view);
|
core.register_frontend_view(frontend_id, fresh_view);
|
||||||
|
|
@ -2920,7 +2932,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::text_view::TextView;
|
||||||
use crate::window::{FrontendView, Layout, Window, WindowId};
|
use crate::window::{FrontendView, Layout, Window, WindowId};
|
||||||
let mut core = editor.core.borrow_mut();
|
let mut core = editor.core.borrow_mut();
|
||||||
|
|
@ -2949,6 +2967,7 @@ fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::Fronten
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout::single(id),
|
layout: Layout::single(id),
|
||||||
active: id,
|
active: id,
|
||||||
|
fold_projection,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3214,7 +3233,7 @@ mod tests {
|
||||||
let semantic_peer = FrontendId(20);
|
let semantic_peer = FrontendId(20);
|
||||||
let live_grid_peer = FrontendId(21);
|
let live_grid_peer = FrontendId(21);
|
||||||
let dead_grid_peer = FrontendId(22);
|
let dead_grid_peer = FrontendId(22);
|
||||||
let semantic_view = build_fresh_frontend_view(&mut editor);
|
let semantic_view = build_fresh_frontend_view(&mut editor, false);
|
||||||
editor
|
editor
|
||||||
.core
|
.core
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
|
|
@ -3865,6 +3884,7 @@ mod tests {
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout::single(wid),
|
layout: Layout::single(wid),
|
||||||
active: wid,
|
active: wid,
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3995,7 +4015,9 @@ mod tests {
|
||||||
|
|
||||||
let mut editor = EditorState::new();
|
let mut editor = EditorState::new();
|
||||||
let fid = FrontendId(99);
|
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);
|
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||||
|
|
||||||
let before = editor
|
let before = editor
|
||||||
|
|
@ -4056,7 +4078,9 @@ mod tests {
|
||||||
|
|
||||||
// Attach: window shares LOCAL's active (scratch).
|
// Attach: window shares LOCAL's active (scratch).
|
||||||
let fid = FrontendId(99);
|
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);
|
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
editor
|
editor
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,8 @@ pub fn restore_into(
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout { root },
|
layout: Layout { root },
|
||||||
active,
|
active,
|
||||||
|
// Desktop restore rebuilds LOCAL's grid view (Q#FD21).
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
active
|
active
|
||||||
|
|
|
||||||
38
src/diag.rs
38
src/diag.rs
|
|
@ -493,7 +493,7 @@ impl View for DiagnosticView {
|
||||||
"diagnostic"
|
"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
|
// Snapshot the diagnostics under the lock and drop it
|
||||||
// immediately so we don't hold the lock through rendering
|
// immediately so we don't hold the lock through rendering
|
||||||
// (rendering touches the rope, which could in principle
|
// (rendering touches the rope, which could in principle
|
||||||
|
|
@ -557,20 +557,23 @@ impl View for DiagnosticView {
|
||||||
if line >= total_lines {
|
if line >= total_lines {
|
||||||
break;
|
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;
|
continue;
|
||||||
}
|
};
|
||||||
let row_offset = line - start_line_buf;
|
if sign_row >= max_rows {
|
||||||
if row_offset >= max_rows {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Record the line marker before any byte-range work:
|
// Record the line marker before any byte-range work:
|
||||||
// zero-width ranges (`byte_end <= byte_start` below)
|
// zero-width ranges (`byte_end <= byte_start` below)
|
||||||
// skip the underline but still mark the line.
|
// skip the underline but still mark the line.
|
||||||
line_markers
|
line_markers
|
||||||
.entry(row_offset)
|
.entry(sign_row)
|
||||||
.and_modify(|s| *s = (*s).min(diag.severity))
|
.and_modify(|s| *s = (*s).min(diag.severity))
|
||||||
.or_insert(diag.severity);
|
.or_insert(diag.severity);
|
||||||
|
let Some(row_offset) = row_offset else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let line_start = line_offsets[line as usize];
|
let line_start = line_offsets[line as usize];
|
||||||
let line_end = line_offsets
|
let line_end = line_offsets
|
||||||
.get(line as usize + 1)
|
.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<u32> {
|
||||||
|
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).
|
/// Paint one severity marker per diagnostic line (UX gutter sub-arc 2).
|
||||||
///
|
///
|
||||||
/// When the window reserves a gutter (`gutter_w > 0`), draw the severity
|
/// 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_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 10),
|
cell_size: CellSize::new(1, 10),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1110,6 +1133,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(3, 10),
|
cell_size: CellSize::new(3, 10),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1185,6 +1209,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 2),
|
cell_origin: CellCoord::new(0, 2),
|
||||||
cell_size: CellSize::new(3, 8),
|
cell_size: CellSize::new(3, 8),
|
||||||
gutter_w: 2,
|
gutter_w: 2,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1253,6 +1278,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(2, 10),
|
cell_size: CellSize::new(2, 10),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
246
src/editor.rs
246
src/editor.rs
|
|
@ -41,7 +41,7 @@ use crate::protocol::{
|
||||||
use crate::terminal::TerminalSnapshot;
|
use crate::terminal::TerminalSnapshot;
|
||||||
use crate::terminal::view::TerminalViewKey;
|
use crate::terminal::view::TerminalViewKey;
|
||||||
use crate::view::{View, Viewport};
|
use crate::view::{View, Viewport};
|
||||||
use crate::window::{Rect, WindowId};
|
use crate::window::{LineNumberMode, Rect, WindowId};
|
||||||
|
|
||||||
/// Ephemeral authenticated origin for one interactive command invocation.
|
/// Ephemeral authenticated origin for one interactive command invocation.
|
||||||
///
|
///
|
||||||
|
|
@ -2230,8 +2230,20 @@ impl EditorState {
|
||||||
core.set_active_window_id(win_id);
|
core.set_active_window_id(win_id);
|
||||||
let view_top = core.windows[&win_id].view_top;
|
let view_top = core.windows[&win_id].view_top;
|
||||||
let buffer_id = core.windows[&win_id].buffer_id;
|
let buffer_id = core.windows[&win_id].buffer_id;
|
||||||
let display_row = view_top.saturating_add(local_row as usize);
|
// Arc 6 Stage 2 (Q#FD16/FD21): grid row `k` in this window shows
|
||||||
let target = crate::view::DisplayCoord::new(display_row as u32, local_col);
|
// 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 pos = {
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -2263,23 +2275,33 @@ impl EditorState {
|
||||||
/// `mouse-wheel-mode` and every modern editor's wheel behaviour.
|
/// `mouse-wheel-mode` and every modern editor's wheel behaviour.
|
||||||
fn scroll_window(&mut self, win_id: WindowId, delta: i32) {
|
fn scroll_window(&mut self, win_id: WindowId, delta: i32) {
|
||||||
let mut core = self.core.borrow_mut();
|
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 line_count = core.windows[&win_id].text_view.line_count();
|
||||||
let max_top = line_count.saturating_sub(1);
|
let max_top = line_count.saturating_sub(1);
|
||||||
let old_top = core.windows[&win_id].view_top;
|
let old_top = core.windows[&win_id].view_top;
|
||||||
let scroll_up = delta < 0;
|
let scroll_up = delta < 0;
|
||||||
let magnitude = delta.unsigned_abs() as usize;
|
let magnitude = delta.unsigned_abs() as usize;
|
||||||
let new_top = if scroll_up {
|
let new_top = match folds.as_ref() {
|
||||||
old_top.saturating_sub(magnitude)
|
Some(map) if scroll_up => map.nth_visible_back(old_top, magnitude),
|
||||||
} else {
|
Some(map) => map
|
||||||
old_top.saturating_add(magnitude).min(max_top)
|
.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
|
// Effective view delta — buffer-boundary clamping may shrink
|
||||||
// the requested move, so the cursor only follows by however
|
// the requested move, so the cursor only follows by however
|
||||||
// many lines the view actually shifted.
|
// many lines the view actually shifted (counted in VISIBLE
|
||||||
let view_shift = if scroll_up {
|
// lines once this window folds).
|
||||||
old_top.saturating_sub(new_top)
|
let view_shift = match folds.as_ref() {
|
||||||
} else {
|
Some(map) => map.visible_distance(old_top, new_top),
|
||||||
new_top.saturating_sub(old_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 buffer_id = core.windows[&win_id].buffer_id;
|
||||||
let new_cursor = {
|
let new_cursor = {
|
||||||
|
|
@ -2289,10 +2311,13 @@ impl EditorState {
|
||||||
let aw = &core.windows[&win_id];
|
let aw = &core.windows[&win_id];
|
||||||
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
||||||
let cur_row = cur.row as usize;
|
let cur_row = cur.row as usize;
|
||||||
let target_row_usize = if scroll_up {
|
let target_row_usize = match folds.as_ref() {
|
||||||
cur_row.saturating_sub(view_shift)
|
Some(map) if scroll_up => map.nth_visible_back(cur_row, view_shift),
|
||||||
} else {
|
Some(map) => map
|
||||||
cur_row.saturating_add(view_shift).min(max_top)
|
.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()?;
|
let target_row = u32::try_from(target_row_usize).ok()?;
|
||||||
aw.text_view
|
aw.text_view
|
||||||
|
|
@ -2316,6 +2341,12 @@ impl EditorState {
|
||||||
/// readline / Emacs default and is what most terminal users expect.
|
/// readline / Emacs default and is what most terminal users expect.
|
||||||
const SCROLL_LINES: i32 = 3;
|
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.
|
/// Shared outer/content geometry consumed by terminal paint and PTY resize.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct WindowPlacement {
|
pub(crate) struct WindowPlacement {
|
||||||
|
|
@ -2860,6 +2891,13 @@ pub fn paint_frame(
|
||||||
&& let Some(buf_id) = buf_id
|
&& let Some(buf_id) = buf_id
|
||||||
&& let Ok(buf) = reg.get(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(
|
let aw = core.windows.get_mut(&active).expect(
|
||||||
"invariant: active_window_id always references a live window in core.windows",
|
"invariant: active_window_id always references a live window in core.windows",
|
||||||
);
|
);
|
||||||
|
|
@ -2867,6 +2905,25 @@ pub fn paint_frame(
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buf, aw.cursor)
|
.pos_to_display(buf, aw.cursor)
|
||||||
.map_or(0, |d| d.row as usize);
|
.map_or(0, |d| d.row 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 {
|
if cursor_row < aw.view_top {
|
||||||
aw.view_top = cursor_row;
|
aw.view_top = cursor_row;
|
||||||
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
|
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
|
||||||
|
|
@ -2874,6 +2931,8 @@ pub fn paint_frame(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render every window.
|
// Render every window.
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
|
|
@ -2923,6 +2982,19 @@ pub fn paint_frame(
|
||||||
let Ok(buf) = reg.get(window.buffer_id) else {
|
let Ok(buf) = reg.get(window.buffer_id) else {
|
||||||
continue;
|
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);
|
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
|
// UX gutter (Q#UX2): reserve a left strip for line numbers and
|
||||||
// shrink+shift the text area into the remainder, so every
|
// 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_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
|
||||||
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
|
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
|
||||||
gutter_w,
|
gutter_w,
|
||||||
|
folds: folds.as_ref(),
|
||||||
};
|
};
|
||||||
// Composition (T M2.9): base text_view paints first, then the
|
// Composition (T M2.9): base text_view paints first, then the
|
||||||
// gutter numbers — before the overlays, so a diagnostic overlay
|
// 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`].
|
// overlay in attach order. See [`crate::view::View`].
|
||||||
window.text_view.render(buf, viewport, grid);
|
window.text_view.render(buf, viewport, grid);
|
||||||
if gutter_w > 0 {
|
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 {
|
for overlay in &mut window.overlays {
|
||||||
overlay.render(buf, viewport, grid);
|
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
|
// Mode line for this window. Painted last so the line
|
||||||
// itself is always visible regardless of overlay activity.
|
// itself is always visible regardless of overlay activity.
|
||||||
let coord = window
|
let coord = window
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buf, window.cursor)
|
.pos_to_display(buf, window.cursor)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let scroll = format_scroll_indicator(
|
// 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.view_top,
|
||||||
inner_rows as usize,
|
|
||||||
window.text_view.line_count(),
|
window.text_view.line_count(),
|
||||||
coord.row as usize,
|
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
|
// Lock scoped to the summary computation only: the overlay
|
||||||
// renders above include `DiagnosticView`, which takes this
|
// renders above include `DiagnosticView`, which takes this
|
||||||
// same mutex — holding the guard across the loop deadlocked
|
// same mutex — holding the guard across the loop deadlocked
|
||||||
|
|
@ -3030,9 +3131,31 @@ pub fn paint_frame(
|
||||||
let aw = &core.windows[&active];
|
let aw = &core.windows[&active];
|
||||||
let inner_rows = inner_rows(&active_rect);
|
let inner_rows = inner_rows(&active_rect);
|
||||||
let buf = reg.get(aw.buffer_id).ok()?;
|
let buf = reg.get(aw.buffer_id).ok()?;
|
||||||
let disp = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden
|
||||||
if (disp.row as usize) < aw.view_top || (disp.row as usize) >= aw.view_top + inner_rows as usize
|
// 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;
|
return None;
|
||||||
}
|
}
|
||||||
// UX gutter: the terminal caret sits in the text area, past the
|
// 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();
|
let w = aw.gutter_width();
|
||||||
if w >= active_rect.size.cols { 0 } else { w }
|
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 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);
|
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||||
Some(CellCoord::new(grid_row, grid_col))
|
Some(CellCoord::new(grid_row, grid_col))
|
||||||
|
|
@ -3180,13 +3303,23 @@ fn paint_line_number_gutter(
|
||||||
rect: &crate::window::Rect,
|
rect: &crate::window::Rect,
|
||||||
inner_rows: u32,
|
inner_rows: u32,
|
||||||
gutter_w: 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,
|
theme: &crate::highlight::Theme,
|
||||||
) {
|
) {
|
||||||
let line_count = window.text_view.line_count();
|
let line_count = window.text_view.line_count();
|
||||||
// Relative/Hybrid measure distance from the cursor's buffer line;
|
// Relative/Hybrid measure distance from the cursor's buffer line;
|
||||||
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
||||||
// cursor motion, so this stays current).
|
// 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 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
|
// Themes Q#TH5: a set `ui.gutter` face owns the strip within its
|
||||||
// {fg} mask; unset keeps the dim Indexed(8).
|
// {fg} mask; unset keeps the dim Indexed(8).
|
||||||
let style = theme.face("ui.gutter").map_or(
|
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
|
// 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.
|
// cell (`gutter_w - 1`) is a trailing pad separating it from the code.
|
||||||
let field = gutter_w.saturating_sub(1);
|
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 {
|
for r in 0..inner_rows {
|
||||||
let grid_row = rect.origin.row + r;
|
let grid_row = rect.origin.row + r;
|
||||||
// Blank + style the whole strip first, so a number that shrank a
|
// 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.style = style;
|
||||||
cell.attachment = None;
|
cell.attachment = None;
|
||||||
}
|
}
|
||||||
let buffer_line = window.view_top + r as usize;
|
|
||||||
if buffer_line >= line_count {
|
if buffer_line >= line_count {
|
||||||
continue; // past end-of-buffer: blank gutter
|
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
|
// The mode picks the number: absolute (`line+1`), relative
|
||||||
// distance, or hybrid (absolute on the cursor line, else relative).
|
// distance, or hybrid (absolute on the cursor line, else relative).
|
||||||
// Written right-aligned, rightmost digit first, alloc-free.
|
// Written right-aligned, rightmost digit first, alloc-free.
|
||||||
// `field >= digits(line_count)` by construction, so the leftmost
|
// `field >= digits(line_count)` by construction, so the leftmost
|
||||||
// digit always leaves at least a leading pad cell.
|
// 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;
|
continue;
|
||||||
};
|
};
|
||||||
let mut col = field;
|
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(
|
fn paint_local_selection(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
buf: &crate::buffer::Buffer,
|
buf: &crate::buffer::Buffer,
|
||||||
|
|
@ -3248,11 +3415,24 @@ fn paint_local_selection(
|
||||||
// text-relative display column shifted right by this (Q#UX2). 0 when
|
// text-relative display column shifted right by this (Q#UX2). 0 when
|
||||||
// the gutter is off, so this is a no-op then.
|
// the gutter is off, so this is a no-op then.
|
||||||
gutter_w: u32,
|
gutter_w: u32,
|
||||||
|
// Arc 6 Stage 2: this window's collapsed regions, or `None`.
|
||||||
|
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||||
theme: &crate::highlight::Theme,
|
theme: &crate::highlight::Theme,
|
||||||
) {
|
) {
|
||||||
let Some((sel_start, sel_end)) = window.region() else {
|
let Some((sel_start, sel_end)) = window.region() else {
|
||||||
return;
|
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`
|
// Themes Q#TH5: the selection is a wash — a set `ui.selection`
|
||||||
// face replaces the default overlay wholesale within its {bg}
|
// face replaces the default overlay wholesale within its {bg}
|
||||||
// mask (an all-default face disables the wash; out-of-mask
|
// 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 text_cols = rect.size.cols.saturating_sub(gutter_w);
|
||||||
|
|
||||||
let first_row = window.view_top;
|
// Row `r` shows the `r`-th VISIBLE line at or after `view_top`.
|
||||||
let last_row = first_row.saturating_add(inner_rows as usize);
|
let mut next_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top));
|
||||||
for display_row in first_row..last_row {
|
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 {
|
let Some(line_start) = window.text_view.line_offset(display_row) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -3298,7 +3480,6 @@ fn paint_local_selection(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let row_offset = display_row.saturating_sub(first_row) as u32;
|
|
||||||
let start_col = start_coord.col.min(text_cols);
|
let start_col = start_coord.col.min(text_cols);
|
||||||
let end_col = end_coord.col.min(text_cols);
|
let end_col = end_coord.col.min(text_cols);
|
||||||
if start_col >= end_col {
|
if start_col >= end_col {
|
||||||
|
|
@ -4028,6 +4209,7 @@ mod tests {
|
||||||
&rect,
|
&rect,
|
||||||
rows,
|
rows,
|
||||||
4,
|
4,
|
||||||
|
None,
|
||||||
&crate::highlight::Theme::empty(),
|
&crate::highlight::Theme::empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -6672,6 +6854,7 @@ mod tests {
|
||||||
cell_origin: rect.origin,
|
cell_origin: rect.origin,
|
||||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
@ -6806,6 +6989,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(24, 80),
|
cell_size: CellSize::new(24, 80),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two no-op overlays: probe the dispatch cost only.
|
// Two no-op overlays: probe the dispatch cost only.
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,8 @@ impl EditorCore {
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout::single(id),
|
layout: Layout::single(id),
|
||||||
active: id,
|
active: id,
|
||||||
|
// LOCAL is the in-process grid editor (Q#FD21).
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -534,6 +536,51 @@ impl EditorCore {
|
||||||
self.windows.get_mut(&win_id)
|
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<crate::fold_view::VisibleLineMap> {
|
||||||
|
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<crate::fold_view::VisibleLineMap> {
|
||||||
|
self.fold_map_for_window(self.active_window_id())
|
||||||
|
}
|
||||||
|
|
||||||
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
|
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
|
||||||
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
|
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
|
||||||
/// path makes this optional; Day 3 makes it required.
|
/// path makes this optional; Day 3 makes it required.
|
||||||
|
|
@ -664,9 +711,21 @@ impl EditorCore {
|
||||||
/// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop
|
/// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop
|
||||||
/// restore). A file that shrank since the desktop was saved can't
|
/// restore). A file that shrank since the desktop was saved can't
|
||||||
/// scroll past its end.
|
/// 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) {
|
pub fn set_view_top(&mut self, top: usize) {
|
||||||
let lines = self.active_window().text_view.line_count();
|
let lines = self.active_window().text_view.line_count();
|
||||||
let clamped = top.min(lines.saturating_sub(1));
|
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;
|
self.active_window_mut().view_top = clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1267,6 +1326,16 @@ impl EditorCore {
|
||||||
///
|
///
|
||||||
/// Returns a stringified error on buffer or view failure.
|
/// Returns a stringified error on buffer or view failure.
|
||||||
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<Edit, String> {
|
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<Edit, String> {
|
||||||
|
// 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();
|
let buffer_id = self.active_buffer_id();
|
||||||
// Scope the registry borrow: the origin translation below needs
|
// Scope the registry borrow: the origin translation below needs
|
||||||
// `&mut self` after the views have been notified.
|
// `&mut self` after the views have been notified.
|
||||||
|
|
@ -1544,27 +1613,48 @@ impl EditorCore {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the cursor up one line, preserving display column.
|
/// 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) {
|
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 id = self.active_buffer_id();
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let goal_col = self.active_window().goal_col;
|
let goal_col = self.active_window().goal_col;
|
||||||
let result = {
|
let result = {
|
||||||
let reg = self.registry.borrow();
|
let reg = self.registry.borrow();
|
||||||
let Ok(buffer) = reg.get(id) else { return };
|
let Ok(buffer) = reg.get(id) else { return };
|
||||||
let coord = self
|
let aw = self.active_window();
|
||||||
.active_window()
|
let coord = aw
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buffer, cursor)
|
.pos_to_display(buffer, cursor)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if coord.row == 0 {
|
let from_row = coord.row as usize;
|
||||||
|
if from_row == 0 {
|
||||||
return;
|
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 goal = goal_col.unwrap_or(coord.col);
|
||||||
let target = DisplayCoord::new(coord.row - 1, goal);
|
let Ok(target_row) = u32::try_from(target_row) else {
|
||||||
let new_pos = self
|
return;
|
||||||
.active_window()
|
};
|
||||||
.text_view
|
let target = DisplayCoord::new(target_row, goal);
|
||||||
.display_to_pos(buffer, target);
|
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||||
(goal, new_pos)
|
(goal, new_pos)
|
||||||
};
|
};
|
||||||
let (goal, new_pos) = result;
|
let (goal, new_pos) = result;
|
||||||
|
|
@ -1576,28 +1666,34 @@ impl EditorCore {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the cursor down one line, preserving display column.
|
/// 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) {
|
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 id = self.active_buffer_id();
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let goal_col = self.active_window().goal_col;
|
let goal_col = self.active_window().goal_col;
|
||||||
let result = {
|
let result = {
|
||||||
let reg = self.registry.borrow();
|
let reg = self.registry.borrow();
|
||||||
let Ok(buffer) = reg.get(id) else { return };
|
let Ok(buffer) = reg.get(id) else { return };
|
||||||
let coord = self
|
let aw = self.active_window();
|
||||||
.active_window()
|
let coord = aw
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buffer, cursor)
|
.pos_to_display(buffer, cursor)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let next_row = coord.row + 1;
|
let from_row = coord.row as usize;
|
||||||
if (next_row as usize) >= self.active_window().text_view.line_count() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let goal = goal_col.unwrap_or(coord.col);
|
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 target = DisplayCoord::new(next_row, goal);
|
||||||
let new_pos = self
|
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
||||||
.active_window()
|
|
||||||
.text_view
|
|
||||||
.display_to_pos(buffer, target);
|
|
||||||
(goal, new_pos)
|
(goal, new_pos)
|
||||||
};
|
};
|
||||||
let (goal, new_pos) = result;
|
let (goal, new_pos) = result;
|
||||||
|
|
@ -1746,6 +1842,12 @@ impl EditorCore {
|
||||||
/// of context); falls back to a sane default before the first
|
/// of context); falls back to a sane default before the first
|
||||||
/// frame has rendered.
|
/// frame has rendered.
|
||||||
pub fn move_page_down(&mut self) {
|
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 step = self.page_step();
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let view_top = self.active_window().view_top;
|
let view_top = self.active_window().view_top;
|
||||||
|
|
@ -1758,12 +1860,25 @@ impl EditorCore {
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buffer, cursor)
|
.pos_to_display(buffer, cursor)
|
||||||
.unwrap_or_default();
|
.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 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 target = DisplayCoord::new(target_row, goal_col);
|
||||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
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 (goal, new_pos, new_top) = result;
|
||||||
let aw = self.active_window_mut();
|
let aw = self.active_window_mut();
|
||||||
|
|
@ -1774,12 +1889,17 @@ impl EditorCore {
|
||||||
// Also nudge view_top; render's scroll-into-view will clamp
|
// Also nudge view_top; render's scroll-into-view will clamp
|
||||||
// and align further if needed.
|
// and align further if needed.
|
||||||
let max_top = aw.text_view.line_count().saturating_sub(1);
|
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
|
/// Move the cursor up by approximately one screenful. Mirror of
|
||||||
/// [`Self::move_page_down`].
|
/// [`Self::move_page_down`].
|
||||||
pub fn move_page_up(&mut self) {
|
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 step = self.page_step();
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let view_top = self.active_window().view_top;
|
let view_top = self.active_window().view_top;
|
||||||
|
|
@ -1793,10 +1913,22 @@ impl EditorCore {
|
||||||
.pos_to_display(buffer, cursor)
|
.pos_to_display(buffer, cursor)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let goal_col = aw.goal_col.unwrap_or(coord.col);
|
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 target = DisplayCoord::new(target_row, goal_col);
|
||||||
let new_pos = aw.text_view.display_to_pos(buffer, target);
|
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 (goal, new_pos, new_top) = result;
|
||||||
let aw = self.active_window_mut();
|
let aw = self.active_window_mut();
|
||||||
|
|
@ -1838,15 +1970,49 @@ impl EditorCore {
|
||||||
aw.goal_col = None;
|
aw.goal_col = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch-layer pre-edit unfold (Arc 6, Q#FD5). Before a
|
/// Pre-edit unfold (Arc 6, Q#FD5 / Stage 2 Q#FD19). Before a local
|
||||||
/// command-path point-anchored edit (the six primitives below),
|
/// point-anchored edit, unfold every fold containing the active point
|
||||||
/// unfold every fold containing the active point so a self-insert or
|
/// so an edit inside a collapsed region reveals it rather than
|
||||||
/// delete inside a collapsed region reveals it rather than landing
|
/// landing invisibly. Keyed on the authenticated source frontend's
|
||||||
/// invisibly. Keyed on the authenticated source frontend's active
|
/// active point (`active_window().cursor`), not the transport. A
|
||||||
/// point (this is `active_window().cursor`), not the transport. A
|
/// no-op when the buffer has no folds.
|
||||||
/// no-op when the buffer has no folds. Interactive Lua-command edits
|
///
|
||||||
/// (yank/query-replace/comment) reach the buffer through a different
|
/// **Stage 2 widening:** Stage 1 called this from each of the six
|
||||||
/// path and are a named Stage 2 widening; CRDT-origin is Stage 3.
|
/// `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) {
|
fn unfold_before_point_edit(&self) {
|
||||||
let id = self.active_buffer_id();
|
let id = self.active_buffer_id();
|
||||||
let point = self.active_window().cursor;
|
let point = self.active_window().cursor;
|
||||||
|
|
@ -1858,7 +2024,6 @@ impl EditorCore {
|
||||||
/// line and returns `false`, and callers must not mutate dependent
|
/// line and returns `false`, and callers must not mutate dependent
|
||||||
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
|
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
|
||||||
pub fn insert_char(&mut self, ch: char) -> bool {
|
pub fn insert_char(&mut self, ch: char) -> bool {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
self.active_window_mut().goal_col = None;
|
self.active_window_mut().goal_col = None;
|
||||||
let mut buf = [0u8; 4];
|
let mut buf = [0u8; 4];
|
||||||
let s = ch.encode_utf8(&mut buf);
|
let s = ch.encode_utf8(&mut buf);
|
||||||
|
|
@ -1894,7 +2059,6 @@ impl EditorCore {
|
||||||
/// delegates to [`Self::insert_char`] (a plain insert). The cursor
|
/// delegates to [`Self::insert_char`] (a plain insert). The cursor
|
||||||
/// lands just past the inserted bytes and any selection is cleared.
|
/// lands just past the inserted bytes and any selection is cleared.
|
||||||
pub fn insert_char_over_region(&mut self, ch: char) {
|
pub fn insert_char_over_region(&mut self, ch: char) {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
let Some((lo, hi)) = self.active_region() else {
|
let Some((lo, hi)) = self.active_region() else {
|
||||||
// Q#AI9: an empty selection (anchor == cursor) reports no
|
// Q#AI9: an empty selection (anchor == cursor) reports no
|
||||||
// region yet stays armed — the insert moves the cursor off
|
// region yet stays armed — the insert moves the cursor off
|
||||||
|
|
@ -1936,7 +2100,6 @@ impl EditorCore {
|
||||||
|
|
||||||
/// Delete the codepoint immediately before the cursor.
|
/// Delete the codepoint immediately before the cursor.
|
||||||
pub fn backspace(&mut self) {
|
pub fn backspace(&mut self) {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
self.active_window_mut().goal_col = None;
|
self.active_window_mut().goal_col = None;
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
if cursor == 0 {
|
if cursor == 0 {
|
||||||
|
|
@ -1958,7 +2121,6 @@ impl EditorCore {
|
||||||
|
|
||||||
/// Delete the codepoint at the cursor (forward delete).
|
/// Delete the codepoint at the cursor (forward delete).
|
||||||
pub fn delete_forward(&mut self) {
|
pub fn delete_forward(&mut self) {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
self.active_window_mut().goal_col = None;
|
self.active_window_mut().goal_col = None;
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let id = self.active_buffer_id();
|
let id = self.active_buffer_id();
|
||||||
|
|
@ -1982,7 +2144,6 @@ impl EditorCore {
|
||||||
/// between the cursor and where [`Self::move_word_left`] would
|
/// between the cursor and where [`Self::move_word_left`] would
|
||||||
/// land.
|
/// land.
|
||||||
pub fn delete_word_backward(&mut self) {
|
pub fn delete_word_backward(&mut self) {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
self.active_window_mut().goal_col = None;
|
self.active_window_mut().goal_col = None;
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
if cursor == 0 {
|
if cursor == 0 {
|
||||||
|
|
@ -2010,7 +2171,6 @@ impl EditorCore {
|
||||||
/// [`Self::delete_forward`] over the gap from the cursor to where
|
/// [`Self::delete_forward`] over the gap from the cursor to where
|
||||||
/// [`Self::move_word_right`] would land.
|
/// [`Self::move_word_right`] would land.
|
||||||
pub fn delete_word_forward(&mut self) {
|
pub fn delete_word_forward(&mut self) {
|
||||||
self.unfold_before_point_edit();
|
|
||||||
self.active_window_mut().goal_col = None;
|
self.active_window_mut().goal_col = None;
|
||||||
let cursor = self.active_window().cursor;
|
let cursor = self.active_window().cursor;
|
||||||
let id = self.active_buffer_id();
|
let id = self.active_buffer_id();
|
||||||
|
|
@ -3348,6 +3508,7 @@ mod tests {
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout::single(win_id),
|
layout: Layout::single(win_id),
|
||||||
active: win_id,
|
active: win_id,
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
win_id
|
win_id
|
||||||
|
|
|
||||||
|
|
@ -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<VisibleLineMap> {
|
||||||
|
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<HiddenComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<F>(folds: &[ByteRange], line_at_offset: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(Position) -> usize,
|
||||||
|
{
|
||||||
|
let mut raw: Vec<HiddenComponent> = 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<HiddenComponent> = 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>\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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -418,7 +418,7 @@ impl View for SyntaxHighlightView {
|
||||||
"syntax-highlight"
|
"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();
|
self.refresh_cache_if_stale();
|
||||||
let Some(bundle) = self.cache.bundle.clone() else {
|
let Some(bundle) = self.cache.bundle.clone() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -441,7 +441,11 @@ impl View for SyntaxHighlightView {
|
||||||
// `compute_highlight_spans_for`) lets narrower captures override.
|
// `compute_highlight_spans_for`) lets narrower captures override.
|
||||||
for layer in &self.cache.layers {
|
for layer in &self.cache.layers {
|
||||||
for row_offset in 0..max_rows {
|
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 {
|
if line_idx >= total_lines {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -598,7 +602,7 @@ impl View for LspStyleView {
|
||||||
"lsp-style"
|
"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 {
|
let Some(path) = buf.file_path() else {
|
||||||
return; // No path ⇒ no URI ⇒ nothing to look up.
|
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;
|
let total_lines = line_offsets.len() as u32;
|
||||||
|
|
||||||
for row_offset in 0..max_rows {
|
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 {
|
if line_idx >= total_lines {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1063,6 +1070,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 20),
|
cell_size: CellSize::new(1, 20),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1160,6 +1168,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 20),
|
cell_size: CellSize::new(1, 20),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1279,6 +1288,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 20),
|
cell_size: CellSize::new(1, 20),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1338,6 +1348,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = buf; // keep buf alive
|
let registry = buf; // keep buf alive
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1394,6 +1405,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = buf; // keep buf alive
|
let registry = buf; // keep buf alive
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1458,6 +1470,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1510,6 +1523,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1572,6 +1586,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ impl HoverView {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for 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<String> = {
|
let lines: Vec<String> = {
|
||||||
let guard = self.store.lock().expect("hover store poisoned");
|
let guard = self.store.lock().expect("hover store poisoned");
|
||||||
guard
|
guard
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ pub mod editor;
|
||||||
pub mod editor_core;
|
pub mod editor_core;
|
||||||
pub mod file_io;
|
pub mod file_io;
|
||||||
pub mod fold;
|
pub mod fold;
|
||||||
|
pub mod fold_view;
|
||||||
pub mod font_pref;
|
pub mod font_pref;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod frontend;
|
pub mod frontend;
|
||||||
|
|
|
||||||
|
|
@ -1308,13 +1308,92 @@ fn run_buffer_edit(
|
||||||
op: EditOp<'_>,
|
op: EditOp<'_>,
|
||||||
bypass_intercept: bool,
|
bypass_intercept: bool,
|
||||||
) -> mlua::Result<crate::rope::Edit> {
|
) -> mlua::Result<crate::rope::Edit> {
|
||||||
|
// 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 {
|
if bypass_intercept {
|
||||||
|
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
|
||||||
run_bypass_edit(lua, id, op)
|
run_bypass_edit(lua, id, op)
|
||||||
} else {
|
} else {
|
||||||
run_managed_edit(lua, id, op)
|
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::<InteractiveCommandOrigin>()
|
||||||
|
.and_then(|origin| origin.current())
|
||||||
|
else {
|
||||||
|
return; // programmatic: no interactive command in scope
|
||||||
|
};
|
||||||
|
let Some(folds) = lua.app_data_ref::<crate::fold::SharedFoldRegistry>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(core) = lua.app_data_ref::<SharedCore>() 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<crate::rope::Edit> {
|
fn run_bypass_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<crate::rope::Edit> {
|
||||||
with_registry_mut(lua, |r| {
|
with_registry_mut(lua, |r| {
|
||||||
let buf = resolve_mut(r, id)?;
|
let buf = resolve_mut(r, id)?;
|
||||||
|
|
@ -1366,6 +1445,19 @@ fn run_managed_edit(lua: &Lua, id: BufferId, op: EditOp<'_>) -> mlua::Result<cra
|
||||||
Ok(current)
|
Ok(current)
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Arc 6 Stage 2 (Q#FD19, round-5 F1): the interactive unfold keys on
|
||||||
|
// the EFFECTIVE edit site, so it must run here — after the chain has
|
||||||
|
// settled, before the apply. An intercept may legally relocate `pos`
|
||||||
|
// / `start` / `end`, so widening on the *requested* op would leave a
|
||||||
|
// relocated-into-a-fold edit invisible, and would open an unrelated
|
||||||
|
// fold when the intercept moved the edit out of one. A rejected
|
||||||
|
// chain (`Err`) applies nothing, so it unfolds nothing. The registry
|
||||||
|
// borrow is released at this point; this reads `SharedCore` and the
|
||||||
|
// fold registry only.
|
||||||
|
if let Ok(final_op) = intercept_result.as_ref() {
|
||||||
|
unfold_before_interactive_lua_edit(lua, id, edit_start_of(final_op));
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: re-borrow, restore views, clear mid-edit flag, apply.
|
// Phase 3: re-borrow, restore views, clear mid-edit flag, apply.
|
||||||
// We restore views and clear the flag even on intercept error,
|
// We restore views and clear the flag even on intercept error,
|
||||||
// so the buffer is left in a usable state.
|
// so the buffer is left in a usable state.
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,7 @@ impl View for MenuView {
|
||||||
"context-menu"
|
"context-menu"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
fn render(&mut self, _buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||||
let guard = self.menu.lock().expect("menu mutex poisoned");
|
let guard = self.menu.lock().expect("menu mutex poisoned");
|
||||||
let Some(menu) = guard.as_ref() else {
|
let Some(menu) = guard.as_ref() else {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ impl StyleSpanOverlay {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for 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 {
|
for span in &self.spans {
|
||||||
if span.row >= viewport.cell_size.rows {
|
if span.row >= viewport.cell_size.rows {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -312,7 +312,7 @@ impl View for BufferStyleOverlay {
|
||||||
Some(Box::new(self.clone()))
|
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
|
let spans = self
|
||||||
.spans
|
.spans
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -369,7 +369,7 @@ fn render_buffer_style_span(
|
||||||
buf: &Buffer,
|
buf: &Buffer,
|
||||||
line_offsets: &[u64],
|
line_offsets: &[u64],
|
||||||
start_line: usize,
|
start_line: usize,
|
||||||
viewport: Viewport,
|
viewport: Viewport<'_>,
|
||||||
cells: &mut CellGrid<'_>,
|
cells: &mut CellGrid<'_>,
|
||||||
span: BufferStyleSpan,
|
span: BufferStyleSpan,
|
||||||
) {
|
) {
|
||||||
|
|
@ -379,10 +379,11 @@ fn render_buffer_style_span(
|
||||||
let first_line = line_at_offset(line_offsets, span.start);
|
let first_line = line_at_offset(line_offsets, span.start);
|
||||||
let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1));
|
let last_line = line_at_offset(line_offsets, span.end.saturating_sub(1));
|
||||||
for line in first_line..=last_line {
|
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;
|
continue;
|
||||||
}
|
};
|
||||||
let row_offset = (line - start_line) as u32;
|
|
||||||
if row_offset >= viewport.cell_size.rows {
|
if row_offset >= viewport.cell_size.rows {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -457,7 +458,7 @@ impl VirtualCellOverlay {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for 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 {
|
for vc in &self.cells {
|
||||||
if vc.row >= viewport.cell_size.rows || vc.col >= viewport.cell_size.cols {
|
if vc.row >= viewport.cell_size.rows || vc.col >= viewport.cell_size.cols {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -487,13 +488,14 @@ mod tests {
|
||||||
vec![Cell::default(); (rows * cols) as usize]
|
vec![Cell::default(); (rows * cols) as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn viewport(rows: u32, cols: u32) -> Viewport {
|
fn viewport(rows: u32, cols: u32) -> Viewport<'static> {
|
||||||
Viewport {
|
Viewport {
|
||||||
buffer_start: 0,
|
buffer_start: 0,
|
||||||
buffer_end: u64::MAX,
|
buffer_end: u64::MAX,
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows, cols),
|
cell_size: CellSize::new(rows, cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,21 +144,47 @@ pub fn paint_other_frontend_overlays(
|
||||||
if g >= rect.size.cols { 0 } else { g }
|
if g >= rect.size.cols { 0 } else { g }
|
||||||
};
|
};
|
||||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
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
|
// Source's byte position → display coords via THIS
|
||||||
// recipient window's text_view (the recipient's view
|
// recipient window's text_view (the recipient's view
|
||||||
// of the buffer).
|
// of the buffer). Q#FD16: a peer cursor on a line this
|
||||||
let Some(disp) = window
|
// recipient has collapsed clamps to the head POSITION.
|
||||||
.text_view
|
let peer_cursor = match folds.as_ref() {
|
||||||
.pos_to_display(buf, presence.snapshot.cursor)
|
Some(map) => map.visible_position(
|
||||||
else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
// Filter to viewport visible range. `view_top` is the
|
// Filter to viewport visible range. `view_top` is the
|
||||||
// top visible buffer-line; cells below it are in-frame
|
// top visible buffer-line; cells below it are in-frame
|
||||||
// until `view_top + inner_rows`.
|
// until `view_top + inner_rows` — counted in VISIBLE rows
|
||||||
let row_in_window = match (disp.row as usize).checked_sub(window.view_top) {
|
// 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,
|
Some(r) if r < inner_rows as usize => r,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
// Column bounds: disp.col is the buffer column; window
|
// Column bounds: disp.col is the buffer column; window
|
||||||
// doesn't horizontally scroll in v1.0, so cells past
|
// doesn't horizontally scroll in v1.0, so cells past
|
||||||
|
|
@ -181,7 +207,16 @@ pub fn paint_other_frontend_overlays(
|
||||||
(sel.active, sel.anchor)
|
(sel.active, sel.anchor)
|
||||||
};
|
};
|
||||||
paint_selection_in_window(
|
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,
|
rect: Rect,
|
||||||
inner_rows: u32,
|
inner_rows: u32,
|
||||||
gutter_w: u32,
|
gutter_w: u32,
|
||||||
|
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||||
lo: crate::rope::Position,
|
lo: crate::rope::Position,
|
||||||
hi: crate::rope::Position,
|
hi: crate::rope::Position,
|
||||||
color: Color,
|
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 {
|
if lo >= hi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +307,15 @@ fn paint_selection_in_window(
|
||||||
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
|
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
|
||||||
break;
|
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 => {
|
Some(r) if r < inner_rows as usize && disp.col < text_cols => {
|
||||||
let grid_row = rect.origin.row + r as u32;
|
let grid_row = rect.origin.row + r as u32;
|
||||||
let grid_col = rect.origin.col + gutter_w + disp.col;
|
let grid_col = rect.origin.col + gutter_w + disp.col;
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ impl View for SearchView {
|
||||||
"search"
|
"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();
|
let buffer_id = buf.id();
|
||||||
// Snapshot the matches under the lock, release immediately
|
// Snapshot the matches under the lock, release immediately
|
||||||
// (same discipline as DiagnosticView).
|
// (same discipline as DiagnosticView).
|
||||||
|
|
@ -439,6 +439,9 @@ impl View for SearchView {
|
||||||
let line_offsets = crate::diag::compute_line_offsets(&source);
|
let line_offsets = crate::diag::compute_line_offsets(&source);
|
||||||
let start_line_buf =
|
let start_line_buf =
|
||||||
crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
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_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
let max_cols = viewport.cell_size.cols;
|
||||||
let cell_origin = viewport.cell_origin;
|
let cell_origin = viewport.cell_origin;
|
||||||
|
|
@ -479,17 +482,19 @@ impl View for SearchView {
|
||||||
// Single-line matches (every literal match) touch one row.
|
// Single-line matches (every literal match) touch one row.
|
||||||
let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||||
// Matches are sorted ascending, so once one starts below the
|
// Matches are sorted ascending, so once one starts below the
|
||||||
// viewport every later one does too — stop.
|
// viewport every later one does too — stop. Arc 6 Stage 2:
|
||||||
if first_line >= start_line_buf.saturating_add(max_rows) {
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
let last_byte = m.end.saturating_sub(1).max(m.start) as u32;
|
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);
|
let last_line = crate::diag::line_at_offset(&line_offsets, last_byte);
|
||||||
for line in first_line..=last_line {
|
for line in first_line..=last_line {
|
||||||
if line < start_line_buf {
|
let Some(row_offset) = row_of(line) else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let row_offset = line - start_line_buf;
|
|
||||||
if row_offset >= max_rows {
|
if row_offset >= max_rows {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -753,6 +758,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 10),
|
cell_size: CellSize::new(1, 10),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -781,6 +787,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 10),
|
cell_size: CellSize::new(1, 10),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid2,
|
&mut grid2,
|
||||||
);
|
);
|
||||||
|
|
@ -822,6 +829,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows, cols),
|
cell_size: CellSize::new(rows, cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ impl SignatureView {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for 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 snap = self.snapshot();
|
||||||
let max_rows = viewport.cell_size.rows;
|
let max_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
let max_cols = viewport.cell_size.cols;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ use crate::view::{DisplayCoord, View, Viewport};
|
||||||
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
|
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
|
||||||
const STACK_CAP: usize = 256;
|
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
|
// TextView
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -204,14 +208,26 @@ impl View for TextView {
|
||||||
Some(line_start + walked_bytes as u64)
|
Some(line_start + walked_bytes as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||||||
let start_line = self.line_at_offset(viewport.buffer_start);
|
// 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_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
let max_cols = viewport.cell_size.cols;
|
||||||
let origin = viewport.cell_origin;
|
let origin = viewport.cell_origin;
|
||||||
|
|
||||||
|
let mut line = start_line;
|
||||||
for row_offset in 0..max_rows {
|
for row_offset in 0..max_rows {
|
||||||
let line = start_line + row_offset as usize;
|
|
||||||
let cell_row = origin.row + row_offset;
|
let cell_row = origin.row + row_offset;
|
||||||
|
|
||||||
// Always clear the visible row first so previous content does
|
// Always clear the visible row first so previous content does
|
||||||
|
|
@ -222,8 +238,10 @@ impl View for TextView {
|
||||||
if line >= self.line_count() {
|
if line >= self.line_count() {
|
||||||
continue;
|
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 {
|
let Ok(s) = std::str::from_utf8(&line_bytes) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -267,6 +285,23 @@ impl View for TextView {
|
||||||
}
|
}
|
||||||
col += width;
|
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_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 16),
|
cell_size: CellSize::new(1, 16),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -562,6 +598,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 16),
|
cell_size: CellSize::new(1, 16),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -593,6 +630,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(5, 5),
|
cell_size: CellSize::new(5, 5),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -625,6 +663,7 @@ mod tests {
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(1, 5),
|
cell_size: CellSize::new(1, 5),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
98
src/view.rs
98
src/view.rs
|
|
@ -40,6 +40,7 @@
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferError, BufferId, EditOp};
|
use crate::buffer::{Buffer, BufferError, BufferId, EditOp};
|
||||||
use crate::cell::{CellCoord, CellGrid, CellSize};
|
use crate::cell::{CellCoord, CellGrid, CellSize};
|
||||||
|
use crate::fold_view::VisibleLineMap;
|
||||||
use crate::rope::{Edit, Position};
|
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
|
/// cell origin) and hands it to the view. The view fills cells inside that
|
||||||
/// origin/size window.
|
/// origin/size window.
|
||||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||||
pub struct Viewport {
|
pub struct Viewport<'a> {
|
||||||
/// First byte in the buffer to consider rendering.
|
/// First byte in the buffer to consider rendering.
|
||||||
pub buffer_start: Position,
|
pub buffer_start: Position,
|
||||||
/// One past the last byte to consider.
|
/// 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
|
/// `cell_origin.col - gutter_w`; overlays that only touch the text area
|
||||||
/// ignore it (the origin is already shifted past the gutter).
|
/// ignore it (the origin is already shifted past the gutter).
|
||||||
pub gutter_w: u32,
|
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<u32> {
|
||||||
|
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
|
/// Default: no-op (used by views that participate in `on_edit` but not
|
||||||
/// in rendering).
|
/// 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
|
/// Translate a buffer byte position to a display coordinate, if the
|
||||||
/// view holds a meaningful mapping for that position.
|
/// view holds a meaningful mapping for that position.
|
||||||
|
|
@ -277,8 +330,45 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn viewport_is_copy() {
|
fn viewport_is_copy() {
|
||||||
// Compile-time assertion: Viewport must be Copy so the frontend can
|
// 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<T: Copy>() {}
|
fn assert_copy<T: Copy>() {}
|
||||||
assert_copy::<Viewport>();
|
assert_copy::<Viewport<'_>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,28 @@ pub struct FrontendView {
|
||||||
/// `layout` references (invariant: `layout.iter_ids()` contains
|
/// `layout` references (invariant: `layout.iter_ids()` contains
|
||||||
/// `active`).
|
/// `active`).
|
||||||
pub active: WindowId,
|
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 {
|
impl Layout {
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ fn render_active_window_to_grid(
|
||||||
cell_origin: rect.origin,
|
cell_origin: rect.origin,
|
||||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -498,6 +498,7 @@ fn render_active_window_to_grid(
|
||||||
cell_origin: rect.origin,
|
cell_origin: rect.origin,
|
||||||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,7 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() {
|
||||||
pmacs::window::FrontendView {
|
pmacs::window::FrontendView {
|
||||||
layout: pmacs::window::Layout::single(window_id),
|
layout: pmacs::window::Layout::single(window_id),
|
||||||
active: window_id,
|
active: window_id,
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,14 @@ use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans
|
||||||
use pmacs::text_view::TextView;
|
use pmacs::text_view::TextView;
|
||||||
use pmacs::view::{DisplayCoord, View, Viewport};
|
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 {
|
Viewport {
|
||||||
buffer_start: 0,
|
buffer_start: 0,
|
||||||
buffer_end,
|
buffer_end,
|
||||||
cell_origin: CellCoord::new(0, 0),
|
cell_origin: CellCoord::new(0, 0),
|
||||||
cell_size: CellSize::new(rows, cols),
|
cell_size: CellSize::new(rows, cols),
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ fn attach_view(
|
||||||
FrontendView {
|
FrontendView {
|
||||||
layout: Layout::single(window_id),
|
layout: Layout::single(window_id),
|
||||||
active: window_id,
|
active: window_id,
|
||||||
|
fold_projection: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
window_id
|
window_id
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue