From c27f75a1b24433200d57a5aa2f93bdf649cfed41 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 13:00:46 -0400 Subject: [PATCH 01/10] docs: bottom panel framing (revision 4, amended) Adds the approved framing for the bottom-panel arc: a buffer displayed in a fixed-height window pinned to the bottom of the frame, targeted by policy rather than by stealing the selected window, plus the missing display-buffer/window-parameter concept underneath it. Revision 4 follows three review rounds, an integration review, and a landed-state audit against GPU initial target (#148, protocol v20) and folding Stage 2 (#149). Amended before branching by the pre-implementation dependency verification recorded in section 0.6: - the folding dependency is cleared and re-verified against canonical main at ddaa80d (nothing in flight, folding Stage 2 acceptance 48/48 green, every borrowed anchor reproducing, and folding's only window.rs edit confined to one 22-line hunk that leaves the layout functions pre-folding code); - R5-B1: Layout::compute has TWO production callers, not one. The second, the peer-presence overlay pass in src/overlay_paint.rs, builds its own text-area rect from active_layout() and never routes through window_placements, so the planned compute(area, fixed) signature change would otherwise leave every peer cursor painted at its no-panel row. Corrected in section 1.1 and Q#BP2, pinned by acceptance 1, and the fixed map is now specified to come from one shared helper rather than being assembled per call site. Stage 1 (window placement + TUI side windows) changes no wire shape. --- docs/bottom-panel-framing.md | 1818 ++++++++++++++++++++++++++++++++++ 1 file changed, 1818 insertions(+) create mode 100644 docs/bottom-panel-framing.md diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md new file mode 100644 index 0000000..35abd22 --- /dev/null +++ b/docs/bottom-panel-framing.md @@ -0,0 +1,1818 @@ +# Bottom panel — framing (window placement + side windows) + +**Revision 4 — pre-implementation, DRAFT after review round 3 plus landed-state +audit. Ground truth: canonical `main` @ `ddaa80d` (documentation landing #152; +runtime @ `0dd16a5`, GPU initial target / #148 after folding Stage 2 / #149), +protocol v20, 2026-07-24. Amended by the pre-implementation dependency +verification in §0.6: the folding dependency is cleared, and one geometry +caller-census error is corrected.** + +Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window +pinned to the bottom of the frame, resizable by dragging its divider, which +feature code targets **by policy** instead of by stealing the selected window. +This is what makes vterm feel like Emacs's vterm rather than `term` in a stolen +buffer, and it is the presentation substrate flycheck, compile, LSP panels, DAP +(`docs/dap-debugging-framing.md`, **parked awaiting this arc**), remotes, and +MCP surfaces all want. + +The feature is a bottom panel. The **missing concept** underneath it is Emacs's +`display-buffer` + window parameters: pmacs has a real window tree but no way to +say *where* a buffer should appear, and no window that is anything other than +proportionally sized. + +## 0. Revision history + +### 0.1 Round 1 (rev 1 → rev 2) — 8 blocking, 6 revision points, all closed + +Verdict: **panel-as-window (Q#BP1) confirmed; everything downstream of it in the +GPU and display-policy contracts rejected.** R1-1 → Q#BP14; R1-2 → Q#BP15; +R1-3 → Q#BP16; R1-4 → Q#BP2a; R1-5 → Q#BP11a; R1-6 → Q#BP13; R1-7 → Q#BP5a; +R1-8 → Q#BP7; rp-1 → Q#BP6; rp-2 → Q#BP10a; rp-3 → Q#BP4; rp-4 → Q#BP5b; +rp-5 → Q#BP2; rp-6 → acceptance. + +### 0.2 Round 2 (rev 2 → rev 3) — 7 blocking, 5 revision points + +Verdict: substantially stronger, still not approvable. **Bets B1, B6, and B7 +falsified as written.** Every anchor below was re-verified against `6ed4fe9` +before this revision; all seven findings reproduce in the code. + +| # | Finding | Closed in | +| --- | --- | --- | +| R2-1 | Q#BP14 doesn't fully separate projection from focus; census incomplete | Q#BP14 (rewritten) | +| R2-2 | Auto round-trip marking is buffer-global, not panel-local | Q#BP14a (new) | +| R2-3 | `PanelResize { size }` conflates three geometries; first-open cycle | Q#BP15a (new) | +| R2-4 | Q#BP4 erases `select = true` | Q#BP4 (rewritten) | +| R2-5 | Real visit paths lack a target-aware load; no compile/terminal entry points | Q#BP11b (new) | +| R2-6 | The jump ring becomes wrong once a panel is a separate window | Q#BP11c (new) | +| R2-7 | `PanelPointer` has no stale-frame identity | Q#BP16 | +| rp-1 | Minimum-height must be recursive; "never violate" too strong | Q#BP2 | +| rp-2 | Hidden-panel focus must be a durable transition | Q#BP2b (new) | +| rp-3 | B6 / acceptance 2 mathematically impossible as written | Bet B6 | +| rp-4 | Q#BP5b ancestor rule and `resize(win, …)` resolution | Q#BP5b | +| rp-5 | `WindowParams` gaps: remembered id, `no_other_window`, `dedicated` vs raw switch, fallback hygiene | Q#BP2c (new) | + +**Bet corrections:** + +- **B1 falsified as written** — Q#BP14a needs one panel-aware condition in + `dispatch_idle_for`. Narrowed to terminal controller / escape routing only. +- **B6 falsified as written** — opening an N-row panel *necessarily* changes + document rectangles. Restated over the document subtree's **structure**. +- **B7 falsified** — the active-window census is larger than + `primary_document_window`; four more producers (R2-1) plus the input-side + validator. Replaced by B7' over an explicit classified census. + +### 0.3 Round 3 + integration review (rev 3 → rev 4) — 22 blocking, +7 revision points, all closed + +Verdict: the three corrected bets were honestly restated, but **B7' was +falsified immediately** by four indirect active-buffer consumers. The remaining +findings were contract holes in geometry ownership, presentation identity, +placement precedence, real adopter lifecycle, jump-history ownership, and +interactive minima. The integration review extended that audit through +terminal/statusline helpers, render preparation, wire bounds, and the Stage 3 +default lifecycle rather than stopping at the originally reported eight. + +| # | Finding | Closed in | +| --- | --- | --- | +| R3-B1 | §1.3 missed four `active_buffer_id()` semantic producers; focus chrome could disappear or remain stale | §1.3, Q#BP14, Q#BP14b | +| R3-B2 | Hidden-panel reconciliation had no authoritative geometry or mutation seam | Q#BP2b, Q#BP4, Q#BP13 | +| R3-B3 | `buffer_id` cannot identify a close/hide/reopen presentation of the same buffer | Q#BP15, Q#BP16 | +| R3-B4 | Global reuse-first defeated requested side/exact-window placement; dedication was not universal | Q#BP3, Q#BP11b | +| R3-B5 | `listview` had no Stage 1 opt-in or panel-aware quit path; compile lifecycle was incomplete | Q#BP11b, Q#BP12 | +| R3-B6 | A global jump ring plus frontend-tagged entries lets one frontend consume another's history; origin buffer was not revalidated | Q#BP11c | +| R3-B7 | `FrontendCellGeometry` lacked an exact pixel→cell contract, an unknown initial state, and a session-kind gate | Q#BP15a, Q#BP9 | +| R3-B8 | `window.min-height` had no interactive recursion and sub-floor requested heights hid a satisfiable panel | Q#BP2, Q#BP5, Q#BP5b | +| R3-B9 | The viewport terminal-context guard and semantic terminal helpers still resolved the focused panel instead of the full-window document surface | §1.3, Q#BP14 | +| R3-B10 | `StatuslineEvaluationTarget::Semantic` captures `view.active` transitively; focusing a panel could clear the document statusline and the panel painter had no callback result | §1.3, Q#BP8, Q#BP14 | +| R3-B11 | `QuitAction` had no neutral non-side state and restored only a buffer/action, leaking replacement height or dedication into the prior presentation | Q#BP2, Q#BP2c, Q#BP11b | +| R3-B12 | Ordinary reuse admitted a side window, and side→ordinary fallback could carry panel-only parameters into a document window | Q#BP3 | +| R3-B13 | The non-side invariant fallback attempted to fabricate a document leaf instead of failing closed | Q#BP11a | +| R3-B14 | A horizontal boundary may sit below a subtree, so one divider can span several exposed leaf mode-line segments | Q#BP5, Q#BP5a, Q#BP5b | +| R3-B15 | Falling back from an invalid side-origin jump could duplicate a hidden panel buffer into the document window | Q#BP11c | +| R3-B16 | A frame for old font/scale geometry could arrive after a new declaration; presentation epoch alone cannot detect that race | Q#BP15, Q#BP15a, Q#BP16 | +| R3-B17 | `display_file` resolved a default target before dedup/eligibility, so a dedicated origin could force load-before-failure | Q#BP11b | +| R3-B18 | A creation-only `origin_document` becomes stale after the user enters the panel from another document split | Q#BP2c, Q#BP11a | +| R3-B19 | A semantic panel terminal cannot use full-window `TerminalResize`, and attach `term_sizes` is the wrong 24×80 source | Q#BP7, Q#BP15a | +| R3-B20 | Active-window cursor auto-scroll lives before the per-window paint loop; extracting only the loop leaves a focused panel caret off-screen | Q#BP8, Bet B2' | +| R3-B21 | Terminal's 512-column PTY bound is not a generic panel-grid bound; wide GPU frames can exceed it | Q#BP15, Q#BP15a, Bet B5' | +| R3-B22 | Stage 3 flipped defaults without an explicit current-window opt-out or its own acceptance contract | Q#BP11b, Q#BP12, acceptance | +| R3-rp1 | Do not publish an intentionally inert `no_other_window` API | Q#BP2c, §6 | +| R3-rp2 | Keep raw `switch_buffer` as the dedication escape hatch, but make display policy honor dedication everywhere | Q#BP2c, Q#BP3 | +| R3-rp3 | `origin_document` is implementation-owned, not caller-settable | Q#BP2c, Q#BP11 | +| R3-rp4 | Terminal bell state is per session, but choosing which session to drain is focus-facing | §1.3, Q#BP14b | +| R3-rp5 | The exact base advanced to `b168dcad`; only handoff/ledger documentation changed after `6ed4fe9` | title, §1, §7 | +| R3-rp6 | An unspecified `window.toggle-panel` and focus traversal into a hidden panel are not shippable contracts | Q#BP6, Q#BP11, §6 | +| R3-rp7 | Open PR #148 overlaps the attach/protocol seams this arc must edit | §7 | + +**Bet corrections:** B7' was too syntactic. Searching only literal +`active_window_for` / `active_window` calls missed helpers such as +`active_buffer_id()` that resolve through the same focused window. It is +replaced by a transitive contract. The integration pass then falsified B7'' as +well: `semantic_terminal_key` reaches `view.active` from `src/editor.rs`, and +`StatuslineEvaluationTarget::Semantic` captures it directly in +`src/statusline.rs`. B7''' is now over **all transitive active-context reads +reached by the daemon/semantic projection**, not merely reads spelled in those +two files, plus the explicit surface-routing matrix in Q#BP14b. B2 is narrowed +to B2': the concrete painters are origin-agnostic, but the per-window active +auto-scroll preparation at `src/editor.rs:2883-2935` must be extracted with +them. B5 is narrowed to B5': cell/topology/aggregate wire validation is shared, +but terminal-specific per-axis PTY limits do not apply to a generic panel grid. + +### 0.4 What the post-folding re-scout established (2026-07-24) + +Folding Stage 2 merged as **#149**. Carried forward from rev 2, still true: + +1. **The capability seam exists.** `FrontendView.fold_projection` + (`src/window.rs:348`) is a non-`Default` bool passed explicitly into + `build_fresh_frontend_view` (`src/daemon.rs:2935`) from the attach + transaction (`src/daemon.rs:1769`), where both + `negotiated_capabilities.semantic_render` and `negotiated_protocol_version` + are in hand (`src/presence.rs:74-84`). Q#BP13 copies it. +2. **`DispatchIdle` is the existing optimistic-apply gate** — per frontend, + keyed on `active_window_for(fid)` (`src/editor.rs:753-769` → + `src/daemon.rs:1223-1248` → `pmacs-gpu/src/main.rs:4135`). Rev 2 concluded this + needed no code; round 2 corrected that (Q#BP14a). +3. **The panel band breaks a folding invariant** — `paint_frame`'s per-window + map is built ungated (`src/editor.rs:2991`) on the premise that a semantic + session never enters it. Q#BP17. +4. **No protocol version is reserved.** #148 has now landed as protocol v20; + its final `InstanceMessage` variant is `InitialTargetResult`, while + `TerminalPointer` remains the final `FrontendEvent` variant. Q#BP9. + +`Layout::compute` / `split_node` / `remove_leaf` / `collapse_single_child_splits` +took no folding edits, so Q#BP2/Q#BP2a stand. `Viewport<'a>` carries +`folds: Option<&'a VisibleLineMap>` (`src/view.rs:131-155`) and stays `Copy`. + +### 0.5 Landed #148 + final integration audit — 9 blocking findings, +1 revision point, all closed + +The runtime moved to `0dd16a5` during this review, then canonical `main` +advanced to `ddaa80d` through #152's handoff/ledger documentation only. The new +initial-target transaction directly overlaps attach, target loading, semantic +snapshot publication, and the protocol append point, so the document was +re-scouted against the landed runtime rather than retaining an open-PR +sequencing note. +**B7''' is falsified**: #148 added #21, while the final transitive scan found +the older attach inheritance (#22) and input-side source-window use (#23). +B7'''' is the corrected census bet after integrating them. + +| # | Finding | Closed in | +| --- | --- | --- | +| R4-B1 | A hidden side leaf had durable focus state but no defined effective placement; the requested fixed extent could still steal rows or become flexible | Q#BP2, Q#BP2b, acceptance 7 | +| R4-B2 | #148's semantic replica-publication filter asks whether a peer displays a buffer through its focused window; panel focus can cause both a missed document snapshot and a panel-driven mirror swap | §1.3, Q#BP14, acceptance 43 | +| R4-B3 | Fresh no-target attaches inherit `LOCAL`'s focused buffer; attaching while the TUI focuses a panel would make that panel the new frontend's document | §1.3, Q#BP13, Q#BP14, acceptance 51 | +| R4-B4 | #148's private target loader reselects `view.active` after hooks; once hooks can create/select a panel, bootstrap could overwrite it instead of reasserting the requested document | Q#BP11b, acceptance 55 | +| R4-B5 | Omitted `height`/`dedicated` semantics were undefined across same-presentation redisplay, replacement, and creation | Q#BP3, acceptance 13 | +| R4-B6 | Recursive `QuitAction::Restore` history grew without bound under repeated panel replacement | Q#BP2c, acceptance 20 | +| R4-B7 | Wire/client documentation still defines semantic `CursorByte`/mirror state as the focused “active buffer”; after panel focus that name means the primary document surface, not input focus | Q#BP14, acceptance 42 | +| R4-B8 | A panel-owned `SearchPrompt` names the panel buffer, but the GPU currently displays prompts only when their buffer matches the document mirror; frame/chrome ordering and validation were undefined | Q#BP14b, acceptance 45 | +| R4-B9 | New geometry/drag/move events omitted the GPU outbox's bounded tail-coalescing contract, so a stalled daemon could turn normal resize/pointer traffic into lossless-queue overflow | Q#BP15a, Q#BP16, acceptance 47–48 | +| R4-rp1 | The focus census also omitted remote-CRDT source-window cursor/provenance application even though its classification remains Focus | §1.3, Q#BP14a | + +### 0.6 Pre-implementation dependency verification (2026-07-24) — folding +cleared, 1 correction + +Run against canonical `main` @ `ddaa80d` before branching Stage 1. + +**The folding dependency is cleared.** #149 (`6ed4fe9`) and its landed-doc +refresh #150 (`b168dca`) are both ancestors of `ddaa80d`; no PR is open; the +retained `folding` and `folding-tui` branches carry zero commits beyond +`githubsucks/main`; and Stage 3 has neither a branch nor a framing +(`docs/active-work.md`). `cargo test --test folding_stage2_acceptance` is +48/48 green on this base. Every anchor this document borrows from the arc +reproduces: `fold_projection` (`src/window.rs:348`, non-`Default`), its attach +install (`src/daemon.rs:1769`), `build_fresh_frontend_view` +(`src/daemon.rs:2935`) and its `LOCAL`-active inheritance (`:2949-2958`), the +ungated per-window map in `paint_frame` (`src/editor.rs:2991`), the +active-frontend gate behind `fold_map_for_window` +(`src/editor_core.rs:566` → `fold_projection_active` `:549-551`), and the +`Copy` `Viewport<'a>` with `folds` (`src/view.rs:130`, `:155`). Q#BP17's stale +comment is at `src/window.rs:339-340`. Folding's entire `src/window.rs` diff was +one 22-line hunk at `:324`, and nothing has touched that file since `6ed4fe9`, +so `compute` / `compute_node` / `split_node` / `remove_leaf` / +`collapse_single_child_splits` remain pre-folding code and Q#BP2/Q#BP2a stand +unchanged. The only surviving coupling is forward and non-blocking: Stage 1 has +no folding surface at all, and if folding Stage 3 flips `fold_projection` true +for semantic sessions before this arc's Stage 2 lands, Q#BP17's "pass `None`" +becomes "pass that window's map". + +| # | Finding | Closed in | +| --- | --- | --- | +| R5-B1 | `Layout::compute` has **two** production callers, not one; the second (`src/overlay_paint.rs:112`) derives its own text-area `Rect` and never consults `window_placements`, so the Q#BP2 signature change would leave peer-cursor overlays on unfixed geometry | §1.1, Q#BP2, acceptance 1 | + +## 1. Ground truth (re-scouted 2026-07-24 against canonical `main` @ +`ddaa80d`; runtime @ `0dd16a5`) + +### 1.1 What already exists + +- **A real window tree, per frontend.** `LayoutNode::{Leaf, Split{orientation, + weights, children}}` (`src/window.rs:283`); `FrontendView { layout, active, + fold_projection }` (`:320`); all windows in one flat `core.windows`. +- **Geometry is purely proportional.** `compute_node` (`src/window.rs:435`) + divides by weight, last child takes the remainder; **zero extents are an + explicitly permitted outcome on a tiny frame** (`src/window.rs:362-365`). + `compute` (`src/window.rs:367`) has **two** production callers (R5-B1): + `window_placements` (`src/editor.rs:2359`, calling at `:2372`), and the + peer-presence overlay pass (`src/overlay_paint.rs:112`), which builds its own + text-area `Rect` from `core.active_layout()` and never routes through + `window_placements`. The remaining `compute` calls (`src/editor.rs:6363`, + `:6783`, `:6788`) are inside the `cfg(test)` module opening at `:4098`. + `compute` is the only producer of window rectangles; every other layout + consumer (`statusline.rs`, `desktop.rs`, `editor_core.rs`, `lua_bindings`) + reaches the tree through `iter_ids` / focus traversal and needs no fixed-extent + argument. +- **`WindowPlacement { outer, content }`**, `content = outer` minus the mode + line (`src/editor.rs:2352`); frame area is `rows - 1`. +- **The mode-line row is reserved in the mouse path** — `window_at_cell` + (`src/editor.rs:2391`), early return at `local_row >= inner_rows` + ("Mode-line click: reserved.", `:1865`); `MouseClickState` (`:204`). +- **`paint_frame` is a per-window loop** (`src/editor.rs:2937`) through an + origin-agnostic `Viewport<'a>` (`:3008`); one fold map per rendered window + (`:2991`). +- **The terminal controller is keyed on the window** (`src/terminal/view.rs:19`, + `:86`, `:105`); `active_terminal_key` reads `view.active` + (`src/editor.rs:989`). +- **Terminal scroll is anchor-based**, `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`), `view_geometry` (`:625`), + `record_view_size` (`:273`), controller-only PTY resize + (`src/editor.rs:1234-1249`). +- **Attach-time capability plumbing is proven** (`src/presence.rs:74-84`, + `peer_declared_terminal_support` `src/daemon.rs:888`, folding's install at + `src/daemon.rs:1769`). +- **The GPU has a bottom band and renders a foreign cell grid** + (`pmacs-gpu/src/main.rs:395-402`, `:1574-1609`, `:6670-6683`; + `TerminalFrame` + `pmacs-protocol/src/terminal.rs:103`, planner `pmacs-gpu/src/terminal.rs`). + +### 1.2 What does not exist + +- **No placement policy** — `pmacs.window` (`src/lua_bindings/mod.rs:12167`) + acts only on the active window; no `display`, `pop_to_buffer`, `quit_window`. +- **No window parameters** on `Window` (`src/window.rs:158`). +- **No divider drag, no keyboard resize**, in either frontend. +- **No `CursorIcon` / `set_cursor` in `pmacs-gpu/`.** +- **No `WindowId` in `pmacs-protocol/`.** +- **No `MIN_WINDOW_*` constant, no `window.min-height`.** +- **No general target-aware load.** #148 added the private attach-only + `open_initial_target` transaction (`src/daemon.rs:1625-1677`) and the + side-effect-free `EditorCore::get_or_load_buffer` seam + (`src/editor_core.rs:660-684`), but the former still switches + `view.active` before and after hooks. The public + `pmacs.buffer.find_or_open` likewise switches the **active** window in both + branches before firing `buffer.after-switch` / `after-load` + (`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`). Neither accepts an exact + destination window. +- **No way to open a terminal off-active.** `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into that frontend's active window + (`src/lua_bindings/mod.rs:8500`), and rolls the session back if it fails. + Compile creates its buffer then `switch_buffer`s (`compile.lua:263`, `:808`). + +### 1.3 The active-context census (R2-1, R2-2, R3-B1) — every consumer, +transitively classified + +Round 2 found the literal active-window consumers. Round 3 found the remaining +trap: `active_buffer_id()` is itself an active-window read, so a census of only +the spelling `active_window*` is not exhaustive. The contract is therefore over +**every transitive read of the focused window/buffer** in the daemon and +semantic producer, including helper calls. + +The bounded production scope is: reads that choose an attached frontend's +semantic document messages/alignment, focus chrome, snapshot routing, attach +inheritance, presence/bell surface, or optimistic-input acceptance/application +in `src/daemon.rs`, `src/semantic_render.rs`, and `src/statusline.rs`, plus +their named `src/editor.rs` helpers. Ordinary grid per-window painting and +normal key/mouse command semantics are excluded—they already operate on the +real window that owns them—as are test-only reads. + +| # | Consumer | Site | Class | +| --- | --- | --- | --- | +| 1 | Semantic buffer-follow + `BufferSnapshot` re-send | `src/daemon.rs:1133-1149` | **Projection** | +| 2 | Lazy CRDT upgrade + replica broadcast | `src/daemon.rs:1096`, `:2319-2343` | **Projection** | +| 3 | `CursorByte` | `src/daemon.rs:1418-1428` | **Projection** | +| 4 | `LineNumbers` mode | `src/semantic_render.rs:1350` | **Projection** | +| 5 | Selection decorations | `src/semantic_render.rs:1706` | **Projection** | +| 6 | Terminal-frame suppression of the document pass | `src/semantic_render.rs:610` | **Projection** | +| 7 | `Viewport` alignment | `src/daemon.rs:2020` → `align_semantic_window_to_buffer` `:2900` | **Projection** (must not move focus) | +| 8 | Document `Pointer` | `src/daemon.rs:2085` → same helper | **Projection + focus** | +| 9 | `Viewport` terminal-context gate | `src/daemon.rs:2002-2009` | **Projection** — a terminal panel must not suppress a document viewport | +| 10 | Full-window semantic terminal declaration/snapshot/sync | `src/daemon.rs:2026-2046` → `semantic_terminal_key`, `src/editor.rs:1157` | **Projection** — these describe the primary document surface, never the panel band | +| 11 | Full-window `TerminalPointer` | `src/daemon.rs:2048-2067` → `dispatch_semantic_terminal_pointer`, `src/editor.rs:1276` | **Projection + focus** — a non-hover gesture on the document terminal takes document focus | +| 12 | Semantic statusline target capture | `src/semantic_render.rs:628` → `capture_target_contexts`, `src/statusline.rs:634` | **Projection + panel projection** — document segments use the primary document; panel segments paint in its mode line | +| 13 | Remote CRDT-op validation | `src/daemon.rs:2531-2537` | **Focus** | +| 14 | `dispatch_idle_for` | `src/editor.rs:753-769` | **Focus** | +| 15 | Presence snapshot (peer cursor broadcast) | `build_presence_snapshot`, `src/daemon.rs:2988-3000` | **Focus** — it answers "where is this user working", which *is* the panel when the panel is focused | +| 16 | `SearchPrompt` active-buffer gate | `src/semantic_render.rs:1106` | **Focus chrome** — the prompt follows the modal session; match washes paint on its owning window | +| 17 | `MenuPrompt` active-buffer gate | `src/semantic_render.rs:1164` | **Surface-routed** — a document menu is semantic chrome; a panel menu is painted in `PanelFrame`; the other surface receives a clear | +| 18 | `MinibufferPrompt` active-buffer gate | `src/semantic_render.rs:1220` | **Focus chrome, global** — bufferless and emitted independently of the document viewport | +| 19 | `CompletionPopup` active-buffer/window gate | `src/semantic_render.rs:1027`, `:1030` | **Surface-routed** — a document popup is semantic chrome; a panel popup is a window overlay in `PanelFrame`; the other surface receives a clear | +| 20 | Terminal bell drain | `take_pending_terminal_bell`, `src/daemon.rs:1565-1600` | **Focus/session** — the counter is per session, but the active window chooses which session may drain | +| 21 | Semantic recipient filter for lazy/initial-target `BufferSnapshot` publication | `publish_buffer_snapshot_to_replicas`, `src/daemon.rs:2441-2475` | **Projection** — “displays this buffer” means the peer's primary document surface | +| 22 | Buffer inherited by a fresh no-target frontend view | `build_fresh_frontend_view`, `src/daemon.rs:2949-2958` | **Projection** — inherit `LOCAL`'s primary document, never its focused panel | +| 23 | Remote CRDT-op source-window cursor/provenance application | `handle_remote_crdt_op`, `src/daemon.rs:2735-2797` | **Focus/input** — the validated op applies to the source's actually focused window | + +**Why #2 is the sharpest.** Focusing a *fresh generated* panel buffer triggers +the lazy CRDT upgrade, which **broadcasts a `BufferSnapshot` to every replica** +(`src/daemon.rs:1096-1107`) and records it in `last_active_buffer_sent`. That +swaps the GPU's mirror to the panel buffer — directly contradicting rev 2's +acceptance 34. It is not reachable from rev 2's three-coupling model at all. + +**Why #9–#12 are separate from ordinary document projection.** The viewport +gate, terminal declaration/key, terminal pointer, and statusline target all +reach focused-window state outside the main `render_frame` buffer producers. +If left unchanged, a focused terminal panel rejects the still-visible document +viewport, a full-window document terminal cannot repaint or receive a click, +and `DeclaredBufferMismatch` clears the document's statusline. The document +terminal events and document statusline must resolve the primary document +window, while the panel gets its own `PanelPointer` and painted mode line. + +**Why #13 and #23 forbid an opt-out.** Remote-op validation requires the op's +`buffer_id` to equal the **source's active window buffer** +(`src/daemon.rs:2531-2537`), and the accepted-op path applies cursor/provenance +to that same source window (`:2735-2797`). If a panel is focused while the GPU +still optimistically edits its document mirror, every resulting op is rejected +— silently diverging the mirror. Optimistic apply and daemon input must agree +on *one* window. + +**Why #16–#19 cannot inherit the document viewport.** `render_frame` invokes +all four producers with `vp.buffer_id` (`src/semantic_render.rs:800-807`), but +their current guards compare it with `core.active_buffer_id()`. With a panel +focused, a panel-opened `M-x` emits no `MinibufferPrompt`; search/menu chrome +can disappear; and a document completion popup can remain stuck because the +producer returns before emitting its authoritative close. Q#BP14b splits +per-window overlays from global/native semantic chrome and makes both open and +clear paths explicit. + +**Why #21 and #22 are projection even though they sit outside +`render_frame`.** #148 publishes a target/upgraded snapshot to an existing +semantic peer only when that peer “displays” the buffer. Testing the focused +panel would miss a buffer visible in the document or replace the GPU mirror +because only the panel showed it. A fresh no-target attach has the same +surface question when it clones `LOCAL`: panel focus must not turn panel +content into the new frontend's full document. Both therefore use +`primary_document_window`, not focus. + +## 2. What ships (staged) + +- **Stage 1 — window placement + TUI side windows. No wire change** (inherits + the protocol version on its eventual base). +- **Stage 2 — the GPU panel band. Next available protocol version.** Own + re-framing before implementation. +- **Stage 3 — default placement flip**, after Stage 2 (Q#BP12). + +## 3. Decisions + +### Q#BP1 — A panel is a WINDOW, not a new kind of slot *(confirmed round 1)* + +Side windows are ordinary leaves in `Layout`, carrying parameters. +`TerminalController` is keyed `(frontend_id, window_id)` and +`active_terminal_key` reads `view.active` (`src/editor.rs:989`), so child-input +routing, the fixed `C-c` escape, atomic controller replacement, and +release-on-blur need no new machinery — **this is the whole of B1 now** (round 2 +correctly removed the input-gating half; see Q#BP14a). A non-window slot would +need a second copy of the controller model plus per-window overlays, gutter, +statusline, mouse routing, selection, and desktop handling. + +### Q#BP2 — Window parameters; fixed extents; the recursive minimum (rp-1, rp-5) + +```rust +pub struct WindowParams { + pub side: Option, // immutable after placement (Q#BP2a) + pub fixed_rows: Option, // outer rows, incl. the mode line + pub dedicated: bool, + quit_action: Option, // implementation-owned; None off-side + origin_document: Option, // implementation-owned; read-only to Lua +} + +pub enum QuitAction { + Delete, + Restore { + buffer_id: BufferId, + fixed_rows: u32, + dedicated: bool, + cursor: Position, + view_top: usize, + goal_col: Option, + selection: Option, + then: Box, + }, +} +``` + +`Layout::compute(area)` → `Layout::compute(area, fixed: &HashMap)`. **Both** production callers supply the map (R5-B1): `window_placements` +(`src/editor.rs:2372`) and the peer-presence overlay pass +(`src/overlay_paint.rs:112`). The second is easy to miss because it derives its +own text-area `Rect` from `core.active_layout()` instead of reusing +`window_placements`; leaving it on unfixed geometry would paint every peer +cursor at the row it would occupy with no panel open. Since both callers need +the same `HashMap`, the fixed map is derived by one shared +helper over the frontend's side windows rather than assembled at each call +site. Two-pass inside a split: subtract fixed children, then divide the +remainder by weight among flexible children (preserving +last-flexible-takes-the-remainder). + +**The minimum is recursive (rp-1).** Rev 2's "leave the document subtree two +rows" is wrong: two rows at the root does not give each nested leaf two rows. +Define, over row extent: + +``` +subtree_min_rows(Leaf) = MIN_WINDOW_OUTER_ROWS // 2 +subtree_min_rows(Split{Horizontal, kids}) = Σ subtree_min_rows(kid) +subtree_min_rows(Split{Vertical, kids}) = max subtree_min_rows(kid) +``` + +(Horizontal splits stack rows, so minima add; vertical splits share rows, so the +tallest child governs.) + +**And the promise is bounded (rp-1).** The layout **already permits zero +extents** on an intrinsically tiny frame (`src/window.rs:360`) and renderers +already skip empty rects — rev 2's "the layout can never violate the floor" was +too strong. The honest contract: **the panel allocator never makes an otherwise +satisfiable document tree unsatisfiable.** Formally, the panel takes +`min(fixed_rows, area.rows.saturating_sub(subtree_min_rows(document_root)))`, +and if that is below `MIN_WINDOW_OUTER_ROWS` the panel is **hidden** (Q#BP2b). +What the frame does to a document tree that could not fit anyway is unchanged +behavior. + +`MIN_WINDOW_OUTER_ROWS = 2` (one text row + one mode line, since `content = +outer - 1`) is a structural floor. Every programmatic source of `fixed_rows` +(`height`, `window.panel-height`, and `set_params`) clamps a nonzero request to +that floor; a request of `0` is rejected rather than being an invisible +"open". A frame shrinking under the floor hides the panel, but a caller asking +for one row on a large frame gets a two-row panel. Side creation resolves an +omitted height through `window.panel-height`, so a live side leaf always has +`fixed_rows = Some(requested_rows)`; `None` remains the ordinary-window value. + +**Hidden has an exact effective geometry.** The requested `fixed_rows` remains +stored, but `window_placements`/`Layout::compute` receives the reconciled +effective state: while `panel_hidden`, the side leaf receives an empty rect and +the prior document root receives the full frame area (minus the one global +status row), as if the wrapper's side child consumed zero rows. The side leaf, +wrapper, weights, `WindowId`, and requested extent remain intact. It must not +fall through as a flexible child, and the requested fixed extent must not +continue stealing rows while hidden. + +`window.min-height` is a **user preference clamped into +`[MIN_WINDOW_OUTER_ROWS, …]`** that applies only to *interactive* resize (drag, +keyboard, and GPU `PanelResizeRows`). Define a second recursion with the same +sum/max shape: + +``` +interactive_min_rows(Leaf) = window.min-height +interactive_min_rows(Split{Horizontal, kids}) = Σ interactive_min_rows(kid) +interactive_min_rows(Split{Vertical, kids}) = max interactive_min_rows(kid) +``` + +Each leaf resolves the setting against that window's current `buffer_id` +(buffer-local override → global → default), and one gesture snapshots the +result before changing geometry. Side creation similarly resolves +`window.panel-height` against the buffer being displayed. + +Interactive boundary motion preserves the preferred minimum on **both** sides +when the current frame can satisfy it; if the frame is already smaller, the +motion may not make either side worse. The ordinary layout pass and +frame-resize reconciliation consult only `subtree_min_rows`, so changing a +preference never invalidates an existing layout. + +### Q#BP2a — Side-window topology (R1-4) + +- **At most one bottom-side leaf per `FrontendView`.** +- Installed as the **final child of a root-level horizontal split wrapping the + entire prior root**: `root := Split { Horizontal, weights: [1, 1], + children: [, Leaf(panel)] }`. `fixed_rows` makes the panel's + weight inert; the prior root takes the flexible remainder. +- **Closing collapses the wrapper** via `collapse_single_child_splits` + (`src/window.rs:537`) once `remove_leaf` drops the panel — no new tree code. +- **`fixed_rows` is interpreted only on that root-level side child**; elsewhere + it is inert (Q#BP2's `fixed` map is built from side windows only). +- **`side` is immutable after placement.** `set_params` rejects adding, + changing, or clearing it. Transactional rehoming is deferred by name. + +### Q#BP2b — Hiding is a durable state transition, not a per-frame effect (rp-2) + +Rev 2 said a hidden panel "hands focus to a document window for that frame". +That is a render-time dodge: keys would still route to an invisible window, and +the terminal resize path merely returns on zero content **without releasing the +controller** (`src/editor.rs:1118`). Geometry currently lives outside +`EditorCore` — the local loop reads `frontend.size()`, while the daemon owns +`term_sizes` — so "wherever layout is recomputed" is not a mutation seam. + +Each `FrontendView` therefore gains +`frame_geometry: Option` and `panel_hidden: bool`, where +`DeclaredFrameGeometry = { geometry_epoch: u64, total: CellSize }`. `None` +means **unknown**, not 24×80. Grid/LOCAL views cache their real attach/resize +size with an internal epoch; a semantic view stays `None` until its first +authenticated `FrontendCellGeometry` in Stage 2 (Q#BP15a). + +`EditorState::reconcile_panel_layout(frontend_id)` is the single idempotent +mutable transaction. It runs after attach/resize, display/split/close, +`fixed_rows`/setting changes, and any Lua hook or callback transaction that can +mutate the layout (including statusline evaluation); it also runs defensively +before final-focus resolution, input dispatch, terminal sync, and paint. Thus +two events drained in one burst cannot route the second event to a panel the +first event made invisible, and a render callback cannot leave stale panel +geometry for the painter. The transaction: + +1. If there is no live side window, set `panel_hidden = false`; Stage 2 records + and emits `Absent` as needed, clears presentation input authority, and + returns. `panel_hidden` never describes a panel that no longer exists. +2. Compute the panel's allocation per Q#BP2. Unknown geometry or + `frame_geometry.total.cols == 0` is not presentable and follows the hidden + arm; no zero-width `Present` frame is legal. Install Q#BP2's effective + empty-side/full-document placement for the hidden state rather than feeding + the stored request to ordinary fixed/flexible allocation. +3. If it is below the floor and the panel is currently visible → mark + `panel_hidden = true`; if `view.active` is the panel, **set `view.active` to + the non-side target** (Q#BP11a); **release the terminal controller** for + that view key (`release_controller`, the existing call at + `src/editor.rs:1113`). +4. If it becomes satisfiable again → `panel_hidden = false`, restore its + allocation from the still-stored requested `fixed_rows`. **Focus is not + restored** — the user moved on; `C-x o` returns. +5. Stage 2: a hide or unhide emits `PanelFrame::Absent` / a fresh `Present` + authoritatively (Q#BP15). + +`panel_hidden` is cached derived layout state on the `FrontendView`, not a +`WindowParams` field. It is recomputed from authoritative geometry and must +never be persisted or set by Lua. + +### Q#BP2c — Parameter semantics the API must pin (rp-5) + +- **`origin_document: Option`** is the remembered document window + Q#BP11a needs; rev 2 described it but omitted it from the struct. Recorded at + panel creation, then refreshed on every focus transition from a non-side + window into the panel (keyboard, pointer, or selecting display). + Panel→panel redisplay and passive display do not overwrite it. + It is **revalidated on every use** (live, in this frontend's layout, + non-side) and cleared when it fails. It is implementation-owned: + `params(win)` may report it for diagnostics, but `set_params` rejects it. +- **`no_other_window` does not ship in v1.** A public parameter that is stored + and deliberately ignored is a false contract. The whole parameter and its + traversal semantics are deferred. If added later, traversal filters it only + as a **destination**; a currently focused no-other window can always leave, + so the caller cannot strand focus. +- **`dedicated` binds `display_buffer` only.** Raw `pmacs.window.switch_buffer` + and `switch_active_buffer_for` **ignore it**: they are the deliberate + low-level escape hatch, and every existing caller predates this arc. + `display_buffer` is the policy layer and checks dedication on **every** + candidate, not only the side slot (Q#BP3); making the primitive enforce + policy would change existing behavior silently. +- **Quit restoration preserves replacement history.** `QuitAction` is + present only on a side window. Creating the side installs `Some(Delete)`; + ordinary windows and every fallback carry `None`. Replacing a side + presentation captures its buffer, requested height, dedication, cursor, + viewport/goal/selection state, and prior action in `Restore`. Restoring + rebuilds `TextView`, clamps the saved positions against the buffer's current + contents, reinstalls `then`, and fires the normal switch hook so overlays + reattach. Derived `last_visible_rows` and trait-object overlays are never + snapshotted. Thus C→B→A→delete restores the actual presentation rather than + forgetting A or leaking C's height/dedication into it. A killed restore + buffer still fails closed to `Delete` (Q#BP10a), dropping the unusable chain. + `MAX_PANEL_QUIT_DEPTH = 64` bounds the recursive history: before wrapping an + existing action, count iteratively; if the new depth would exceed the cap, + truncate the oldest retained `Restore` by replacing its `then` with + `Delete`. The newest 64 presentations therefore remain LIFO-restorable and + the following quit closes the slot. Construction, traversal, and truncation + never recurse past the same bound. +- **The two bookkeeping fields are read-only.** `params(win)` may expose + `origin_document` and a diagnostic description of `quit_action`; + `set_params` rejects both. Lua cannot forge a window id, buffer restore + chain, or stale cursor state. `window.quit` on a window with + `quit_action = None` returns a pointed error without closing or switching + anything; non-side adopter fallbacks call their existing restore path + instead. +- **Capability fallback discards every side-specific parameter.** When + `!panel_capable` (Q#BP13), `display_buffer` drops `side`, `fixed_rows`, + `dedicated`, `quit_action`, and `origin_document`, and displays into the + non-side target as an ordinary buffer switch. A fallback must never dedicate, + pin, or otherwise poison the primary document window. + +### Q#BP3 — `display_buffer`: the placement policy + +Placement affinity precedes generic reuse; otherwise a persistent compilation +buffer already visible in a document window makes `{side = "bottom"}` silently +ignore its requested placement. `action.window` and `action.side` are mutually +exclusive; supplying both is an error. `height` requires a side request or an +exact target that is already the side window. Stage 1 accepts only +`Side::Bottom`; every other side value is a pointed unsupported error, not an +ordinary fallback. + +1. **Exact target (`action.window`).** Validate that it is live and belongs to + this frontend. Use that exact window or error; generic reuse may not + substitute another. A target dedicated to a different buffer errors. +2. **Side target (`action.side`).** + 1. Reuse a window on the requested side already showing `buffer_id`. + 2. Otherwise use that side slot if absent or not dedicated to another + buffer, creating it per Q#BP2a when absent. + 3. If the one side slot is dedicated to another buffer, never create a + second one: fall back to the ordinary non-side policy below **after + discarding `side`, `height`, `dedicated`, and quit bookkeeping**. Only an + explicitly supplied `select` survives. A failed placement request may + not pin or dedicate a document window. + A non-side window already showing the buffer **does not preempt** a usable + requested side slot; displaying the same buffer in two windows is legal and + avoids the deferred rehoming problem. +3. **Ordinary target (no usable exact/side target).** + 1. Reuse a visible **non-side** window on this frontend already showing + `buffer_id`. An ordinary display never selects the panel by coincidence. + 2. Otherwise use the first candidate from Q#BP11a that is not dedicated to a + different buffer. Continue in `iter_ids()` order when the preferred + document target is dedicated. + 3. If no eligible non-side window exists, return a pointed error; do not + overwrite a dedicated window or create an unrequested split. + +After target resolution, an omitted `action.select` defaults to **false** for +an actual side target and **true** for an ordinary target. An explicit +`select` survives fallback unchanged. Placement and reuse are strictly per +frontend. + +`height` and `dedicated` are option-valued at the policy boundary; omission is +not silently equivalent to an explicit zero/false: + +- creating the side slot uses `window.panel-height` when `height` is omitted + and `dedicated = false` when dedication is omitted; +- redisplaying the **same continuously presented buffer** preserves its + current requested height, dedication, and quit action unless an explicit + value changes the first two; +- replacing the buffer in an existing usable side slot preserves its current + requested height when `height` is omitted, but the new presentation defaults + to `dedicated = false`; an explicit dedication applies only after the old + presentation passed eligibility and cannot be used to clear-and-bypass an + existing dedication in the same call; +- an ordinary/exact non-side replacement defaults to undedicated, while a + same-buffer redisplay preserves existing dedication unless explicitly + changed. + +These rules let a user-resized panel keep its height as compile/listview +replace one another, prevent a harmless same-buffer redisplay from unpinning a +window, and still make every adopter's newly installed presentation +undedicated by default. + +### Q#BP4 — The display transaction and the final-focus matrix (R2-4, rp-3) + +Rev 2's Phase 2 *always* restored `saved_active`, which erases `select = true` +outright, and restored only when the saved window was non-side, so +`select = false` from a live focused panel blurred it. Both are wrong. + +**Phase 1 (core, no Lua).** `EditorCore::display_buffer` chooses the target +(Q#BP3/Q#BP11a), installs the buffer, records `saved_active: WindowId`, returns +`DisplayOutcome { target, saved_active, select, fire: HookKind }` where +`HookKind ∈ { AfterSwitch, AfterLoad, None }`. + +**Phase 2 (the Lua-owning layer).** Activate `target`, fire the hook, run +`reconcile_panel_layout(frontend_id)` (hooks may resize, close, or replace the +target), then **revalidate both ids** against `core.windows`, the frontend's +`layout.iter_ids()`, and `panel_hidden`, and apply: + +| `select` | `target` after hooks | `saved_active` after hooks | Final focus | +| --- | --- | --- | --- | +| `true` | live + visible | — | **`target`** | +| `true` | dead or `panel_hidden` | live + visible | `saved_active` | +| `true` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | +| `false` | — | live + visible (**side or not**) | **`saved_active`** | +| `false` | live + visible | dead or `panel_hidden` | `target` | +| `false` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | + +Two corrections encoded here: `select = true` **keeps the target selected**, and +`select = false` restores a saved window **even when it is the panel** — a +passive display invoked from a focused panel must not blur it. "Visible" means +not `panel_hidden` per Q#BP2b. + +The hook-failure arms are tested in **both** `select` modes (acceptance). + +### Q#BP5 — The divider is a general split-boundary drag (sub-problem 1) + +`window_at_cell` maps a mode-line row to the window above it and `dispatch_mouse` +already reserves that row (`src/editor.rs:1865`). We spend that reservation. + +- A leaf's outer bottom row is a **drag handle** when it is an exposed segment + of a horizontal ancestor boundary. If that ancestor's upper child is a + vertical/nested subtree, every leaf segment touching the same bottom edge + paints and resolves to the **same boundary**; dragging any segment has the + same result. +- `Down` arms `WindowDragState { frontend_id, boundary, start_row, + start_extents }` beside `MouseClickState`; `Drag` recomputes — `fixed_rows` + when one side is fixed, weights when both are flexible — clamped by Q#BP2's + **interactive** recursive minimum when satisfiable, and never worsening an + already-unsatisfied side; `Up` disarms. Selection is untouched. +- A flexible pair writes weights (ratio survives a terminal resize); a side + window writes `fixed_rows` (absolute height survives). That difference is the + point. +- **No pointer-shape change in the TUI** (`OSC 22` is xterm-only) — deferred by + name. The affordance is `ui.divider` (Q#BP5a) plus keyboard parity (Q#BP5b). + +### Q#BP5a — Where the divider actually is (R1-7) + +- **TUI.** The divider is the upper subtree's exposed existing mode-line + segment(s). No row is added or consumed; every adjacent leaf segment along + that boundary renders with the reserved theme face **`ui.divider`** + (`src/highlight.rs:234-248`) plus a handle glyph. The root panel divider is + therefore full width even when the document subtree ends in several columns. + **`fixed_rows` excludes it.** +- **GPU.** The projected panel grid holds the panel window's rows and **its own + bottom mode line** — not the document's mode line, which is not part of the + panel window. So the GPU **paints its own divider chrome**: a `ui.divider` + rule of `BASE_DIVIDER_HEIGHT`, **frontend-local, outside the projected grid + and outside `fixed_rows`**, exactly as the status band is chrome outside the + document. The drag hit strip is that rule. + +**The daemon is authoritative for rows**: the GPU converts pixels to rows and +sends rows (Q#BP15a), never the reverse. + +### Q#BP5b — Keyboard resize, boundary resolution, and `resize(win, …)` (rp-4) + +`window.enlarge` (`C-x ^`) / `window.shrink` (`C-x C-^`) act on the **active** +window. `pmacs.window.resize(win, delta_rows)` resolves from the **supplied +`win`** — the Lua entry point is explicit, the commands are implicitly active. +Both resolve the boundary identically: + +1. Active/supplied window is a **side window** → its own fixed boundary. +2. Otherwise → walk up from the leaf to the **nearest horizontal-split ancestor + at which the path child has a following sibling**, and move that boundary. + (Rev 2 said "nearest horizontal ancestor", which is wrong when the subtree is + that ancestor's *final* child — there is no boundary below it there.) +3. No such ancestor → report "no adjustable horizontal boundary", no-op. + +Rule 2 moves the same boundary a drag on that window's bottom mode line moves — +that identity is an acceptance case, tested in a nested layout where the naïve +"nearest horizontal ancestor" reading picks the wrong one. All three +interactive entry points share the Q#BP2 preference clamp; programmatic +`display(..., {height = ...})` uses only the structural floor. + +### Q#BP6 — Focus, child input, and the window guards + +Input needs no new code (Q#BP1). The **guards** do: + +- `close_active` (`src/editor_core.rs:2349`) must refuse **only when the target + is the last non-side window**. Closing the side window itself is always legal, + including as the only other window. +- `close_others` from a document window also deletes the panel; from a **side + window it errors**. `split_active` from a side window errors. +- `focus_next/prev` include a side window only while it is visible. A hidden + panel is never a focus destination; after it reappears, traversal reaches it + normally (Q#BP2b/Q#BP2c). + +### Q#BP7 — Panel height vs scrollback (sub-problem 3) + +**Invariant: a height change is a viewport change, never a scroll change.** +`top` is preserved verbatim. Not "preserve the bottom row" — that fights +tail-follow. + +1. **Growth reaching the live tail re-arms follow** (`top` → `None`) — **only + when no selection is active** (R1-8). `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`) and `view_geometry` (`:625`) already + encode the freeze; the re-arm goes in the **shared viewport-size path** + (`record_view_size` / `snapshot_for_view`) so grid and semantic declarations + agree. +2. **Shrink to zero never happens**: Q#BP2's clamp plus Q#BP2b's hide, and + `record_view_size` already fails closed (`src/terminal/view.rs:273-296`). +3. **Only the controller resizes the PTY** (`src/editor.rs:1234-1249`); two + frontends may hold different panel heights over one child. Pinned, not + "fixed". +4. **A semantic panel terminal sizes from the panel content rect.** At the + existing pre-child-drain terminal-sync point, the daemon resolves the + visible side window against `frame_geometry`, derives + `(fixed_rows - mode_line) × total.cols`, records that exact + `TerminalViewKey` size, and resizes the PTY only if it is the controller. + It never consumes the GPU attach `term_sizes` placeholder or the + full-document `TerminalResize` declaration. + +### Q#BP8 — The GPU panel band (Stage 2) + +The daemon projects the frontend's bottom side window into a standalone cell +grid; the GPU paints it as a band above the status band and shrinks its text +area by band + divider height. The panel projection is a sibling pass: it runs +independently of whether the primary document has declared a byte viewport or +is in full-window terminal mode, so neither existing early return can suppress +the band. + +- **Extraction, not new rendering**: `paint_frame`'s per-window body already + paints one window into a `CellGrid` through an origin-agnostic `Viewport<'a>` + (`src/editor.rs:2937-3040`); painting into a panel-sized grid at `(0,0)` is + that body lifted out. The extraction also takes the active-window + cursor-visible preparation currently just before the loop + (`src/editor.rs:2883-2935`): it runs for the panel only when that window owns + focus, uses the same supplied fold map, and leaves passive `view_top` + untouched. No concrete text/gutter/overlay/modeline painter forks (Bet B2'). + `pmacs-gpu/src/terminal.rs` is already a pure cell-space planner for this + payload shape. +- **The extraction boundary is per-window, not per-frame.** Text, gutter, + selection, mode line, and window-attached overlays (including a panel's + `SearchView` / `MenuView` / `CompletionView`) enter `PanelFrame`. The + frame-global status row, search prompt, and minibuffer do not; semantic focus + chrome carries those surfaces per Q#BP14b. +- **Statusline callbacks still run once.** Generalize the existing + `StatuslineEvaluationTarget::Grid` fan-out into a frontend-layout target: + the grid target keeps today's layout-leaf fan-out but omits a derived-hidden + side, while a semantic panel target captures exactly the primary document + window plus its visible side window (unprojected document splits do not run + callbacks). Evaluate before paint, then transactionally revalidate as today. + Route the primary document result to semantic `StatuslineSegments` and the + side result to the panel mode line. A callback mutation runs Q#BP2b + reconciliation before either result is consumed, and an invalidated + evaluation paints no stale text. This closes the indirect `view.active` read + at `src/statusline.rs:634` without evaluating a provider twice. +- **One transport for every panel kind** — a terminal panel is painted + daemon-side by the same `paint_terminal_snapshot` the TUI uses. +- **Accepted consequence**: panels are **monospace cell grids** in the GPU. + Documents keep the rich renderer. +- **Document declarations follow the installed band.** Applying + `Present`/`Absent` recomputes the GPU document clip and emits the ordinary + document `Viewport` or full-window `TerminalResize` if its effective size + changed. `FrontendCellGeometry` does not change in response—the whole-frame + declaration deliberately excludes panel presence—so this cannot feed back + into panel sizing. +- **Discipline inherited from `TerminalFrame`**: whole-grid replacement, + `validate` both sides, atomic rejection retaining the previous valid frame, + duplicate suppression on the complete ordered payload, byte-bounded payload. + +### Q#BP14 — One authoritative primary-document context (R2-1, R3-B1) + +Rev 2 proposed `primary_document_window(fid)` and named three couplings. The +transitive §1.3 census now finds twenty-three. The projection contract: + +**Definition.** `EditorCore::primary_document_window(fid) -> Option` — +the frontend's active window when it is non-side, else its non-side target +(Q#BP11a). **Every consumer classified Projection in §1.3 (#1–#12 and +#21–#22) routes through it.** Focus and surface-routed consumers follow +Q#BP14b. + +The census rule is transitive: a new call to `active_window_for`, +`active_window`, `active_buffer_id`, or any helper that reaches one of them in +the daemon/semantic projection — including helpers implemented in +`src/editor.rs` or `src/statusline.rs` — must add or reaffirm its +classification. This is a review checklist item, not a lint; acceptance pins +each class. + +**The alignment helper splits in two.** `align_semantic_window_to_buffer` +(`src/daemon.rs:2900`) unconditionally rewrites `view.active`'s buffer, which is +exactly why rejecting panel-named events does not fix the *document* event — +with the panel focused, an ordinary document `Viewport` overwrites the panel's +buffer with the document buffer. + +- **`align_primary_document_window(fid, buffer_id)`** — rewrites the **primary + document window's** buffer/`TextView`/cursor. **Never touches `view.active`.** + Used by `Viewport` (#7). +- **Document `Pointer` (#8)** calls the same aligner **and then activates the + primary document window** before dispatching the gesture — a click in the + document area means "work here", so it moves focus out of the panel. This is + the one place projection and focus legitimately move together. +- **The `Viewport` terminal-context guard (#9)** tests the primary document + window plus the declared buffer, never the focused panel. A terminal panel + therefore cannot reject the still-visible document's viewport. +- **Existing full-window terminal transport (#10–#11)** remains the document + surface. `TerminalResize`, terminal snapshot/sync, and terminal-frame + suppression resolve a terminal key from the primary document window. + `TerminalPointer` validates against that declaration; any accepted + non-`Move` gesture activates the primary document window before replaying the + existing terminal gesture, while hover neither focuses nor claims control. + Panel terminals use `PanelFrame`/`PanelPointer`, never these declarations. +- **Statusline evaluation (#12)** uses Q#BP8's one frontend-layout fan-out: + primary-document segments remain on the semantic document status band while + the side context paints only in the panel mode line. +- **Semantic snapshot publication (#21)** tests whether a recipient displays + the published buffer through that recipient's primary document window. This + predicate is shared by lazy-upgrade and #148 initial-target publication: + panel-only visibility never swaps the document mirror, while panel focus + never hides a matching document surface from the publication. +- **Fresh no-target view construction (#22)** clones the buffer in + `primary_document_window(FrontendId::LOCAL)`, not `local_view.active`. A TUI + panel may own focus at attach without becoming the new frontend's + full-window document. The new view still starts as one ordinary leaf focused + on that inherited document buffer. +- **`PanelPointer` (Q#BP16)** activates the **panel**. + +So: `Viewport` never steals focus; document clicks take it; panel clicks give +it back. And because #1–#12 plus #21–#22 use the +primary-document/surface split, focusing the panel re-sends no snapshot, +suppresses no document, swaps no mirror, clears no document terminal or +statusline declaration, and cannot leak into a newly attached document view. + +**“Active buffer” in the semantic replica is now a document-surface term, not +an input-focus term.** Stage 2 audits and updates the contracts/comments/tests +for `InstanceMessage::CursorByte`, `BufferMirror::active_buffer`, +`SemanticRenderState`, `StatusFacts`, `LineNumbers`, `StatuslineSegments`, and +`TerminalFrame`: for a panel-capable semantic session these identify the +primary document declaration/mirror while a panel may separately own focus. +No wire field is renamed and legacy/grid behavior is unchanged; grid clients +already discard the semantic families. `DispatchIdle`, authenticated input, +presence, and Q#BP14b remain the authorities for actual focus. This vocabulary +split is load-bearing—leaving “active means focused” in the replica contract +invites a later producer to reintroduce the mirror swap. + +**The lazy CRDT upgrade (#2) is the sharpest case** and gets its own rule: the +upgrade + broadcast (`src/daemon.rs:1096`) keys on the **primary document +window**, so focusing a fresh generated panel buffer never broadcasts a +`BufferSnapshot` for it. A panel buffer that genuinely needs CRDT backing gets +it when it is displayed as a document, not as a side effect of focus. + +### Q#BP14a — Panel input gating is per-window, not per-buffer (R2-2) + +Rev 2 proposed auto-marking every side window's buffer round-trip, with an +opt-out. Both are wrong. `round_trip_buffers` is a **global set keyed by +`BufferId`** across every frontend and window (`src/editor_core.rs:349`), so +marking buffer A because *one* frontend panels it disables optimistic input for +another frontend editing A as its document; replacement and close would need +reference counting plus preservation of any pre-existing mark. And an opt-out is +unsafe: with the panel focused, the GPU would optimistically edit its document +mirror while daemon input targets the panel, and every resulting op fails +remote-op validation (#13, `src/daemon.rs:2531-2537`) — a silent mirror +divergence. The accepted-op cursor/provenance path (#23) intentionally retains +the focused source window; it is not redirected to the primary document. + +**The rule: `dispatch_idle_for` returns `false` whenever the acting frontend's +active window is a side window**, independently of the buffer-global set +(`src/editor.rs:753-769`). No auto-marking, no reference counting, no opt-out. +Existing `listview` / compile / terminal marks stay exactly as they are and keep +governing their full-window behavior. + +This is **one panel-aware producer condition** — which is why B1 is narrowed to +terminal controller/escape routing rather than claiming all input gating is free. + +### Q#BP14b — Focus chrome and per-window overlay routing (R3-B1, R3-rp4) + +The semantic producer gains a **focus-chrome pass** that runs once per semantic +frontend independently of whether a document viewport exists and independently +of the document/terminal projection pass. It reads modal state through the +acting frontend's focused context, never through `vp.buffer_id`. + +| Surface | Document focused | Panel focused | +| --- | --- | --- | +| Search | `SearchPrompt` on the semantic status band; document `SearchView` supplies washes | `SearchPrompt` still uses the semantic status band; panel `SearchView` washes are in `PanelFrame` | +| Minibuffer | `MinibufferPrompt` | `MinibufferPrompt` — it is global and bufferless | +| Menu | `MenuPrompt` native popup; no document cell-grid menu | `MenuView` is painted in `PanelFrame`; semantic `MenuPrompt` emits/retains authoritative empty | +| Completion | `CompletionPopup` native popup | `CompletionView` is painted in `PanelFrame`; semantic `CompletionPopup` emits/retains authoritative close | + +The menu/completion baselines track the **currently owned surface**, not merely +a per-buffer payload. A document→panel focus change therefore emits the clear +for a formerly open native popup even if the focused panel carries a different +buffer; a panel→document change cannot leave a pre-painted panel popup in +native GPU state. `BufferSnapshot` baseline resets audit both the open and clear +mirrors, following the #120 rule. + +No new focus-owner wire field is needed: a current +`PanelFrame::Present { buffer_id, focused: true, ... }` is the authenticated +panel-surface declaration. A surface transition is ordered: +**authoritative closes for the old owner → new `PanelFrame` +focus/presence → opens/updates for the new owner**. Thus a panel-owned search +clear is accepted while the old focused declaration still exists; only then +may `Absent` or `focused = false` remove that authority. Conversely, a newly +panel-owned prompt follows the `focused = true` frame it relies on. The GPU +accepts `SearchPrompt { buffer_id, ... }` when `buffer_id` matches either its +primary document mirror or its current focused `Present` panel; the latter +still renders prompt text in the semantic status band while match washes come +only from the panel grid. A prompt naming neither surface is stale and is +dropped without changing the current prompt. +Document-native completion validates against the document as today; panel +completion/menu opens only inside `PanelFrame`, while the semantic native +variants carry authoritative close. `MinibufferPrompt` remains bufferless. + +Focus consumers #13–#15 and #23 keep the focused window. #16–#19 use the +routing table above. Bell drain #20 keeps its per-session counter but uses the +focused window to choose the eligible terminal; passive/historical bells +remain baseline-suppressed exactly as today. + +### Q#BP15 — `PanelFrame` lifecycle (R1-2) + +- **Explicit presence.** `InstanceMessage::PanelFrame(PanelFramePayload)` where + the payload is `Present(PanelFrame)` | `Absent`. **`Absent` is authoritative + and must be sent** on close *and* on hide (Q#BP2b) — silence would leave the + last valid frame on screen forever under the retain-on-invalid rule. `Absent` + is duplicate-suppressed like any payload. +- **Cursor and focus travel with the frame.** `paint_frame` returns the cursor + separately (`src/editor.rs:2833`), so cells alone lose the caret. + `PanelFrame` carries `cursor: Option` and `focused: bool` — the GPU + paints the band caret only when the panel owns focus. `focused` is + presentation/focus-chrome routing only (Q#BP14b); the *keys* decision is + `DispatchIdle` (Q#BP14a). +- **Presentation identity.** `PanelFrame` carries `buffer_id` **and** + `panel_epoch: u64`, plus the frontend-owned `geometry_epoch` it is answering. + The panel epoch is opaque and monotonic per frontend. It stays stable across + ordinary frames of one continuously present window/buffer, and changes on + buffer replacement, new side-window creation, and every + `Absent`→`Present` transition. Thus closing/hiding and reopening the same + persistent buffer cannot reuse the identity of an old frame (Q#BP16). + Allocation is checked; exhaustion fails closed to `Absent` rather than + wrapping into a stale identity. + `geometry_epoch` is different: it changes whenever the frontend declares new + effective cell geometry, even if the panel presentation is otherwise the + same (Q#BP15a). +- **Absent clears input authority.** Emitting or applying `Absent` clears the + last declared panel size and **panel** epoch on both sides before any later + event can validate. Whole-frame geometry remains valid until superseded by a + newer authenticated declaration. +- **Cell-grid validation is shared, terminal dimensions are not.** Factor the + cell count, cursor, glyph width/continuation topology, aggregate glyph-byte, + visible-cell, and transport-safety checks out of + `pmacs-protocol/src/terminal.rs` into one parameterized wire-cell-grid + validator. `TerminalFrame` still adds its PTY-specific + `MAX_TERMINAL_ROWS/COLS = 512`; `PanelFrame` does **not** inherit that + per-axis cap. A common 4K/small-font panel wider than 512 columns remains + legal as long as its checked area and aggregate glyph bytes fit the shared + wire budget (Bet B5'). + +### Q#BP15a — Three geometries, two messages, one exact conversion +(R2-3, R3-B2, R3-B7) + +Rev 2's `PanelResize { size: CellSize }` conflated the frontend's total frame, +the requested panel rows, and the resulting grid — and created a **first-open +cycle**: the declaration was gated on a side window existing, but the daemon +needs columns before it can paint the first frame. The GPU's attach `CellSize` +cannot fill the gap: it is permanently the placeholder `24×80` +(`pmacs-gpu/src/attach.rs:420-429`, `:573-577`) and no resize updates it. + +Two messages with different lifetimes: + +- **`FrontendEvent::FrontendCellGeometry { frontend_id, geometry_epoch, + total: CellSize }`** — the frontend's authoritative cell-equivalent layout + capacity. It is valid **without a side window**, sent immediately after + attach acceptance and refreshed on **window resize, font change, and scale + change**. `geometry_epoch` is a checked, monotonically increasing + frontend-owned declaration id; exhaustion fails closed rather than wrapping, + and a lower/repeated epoch with different data is stale/invalid. The event is + accepted only from the authenticated, negotiated + panel-capable semantic session; the word "without" refers to side-window + presence, not protocol/session gates. +- **`FrontendEvent::PanelResizeRows { frontend_id, geometry_epoch, + panel_epoch, rows }`** — the requested fixed panel rows from a divider drag. + Its only size component is rows; the epochs are identities, not geometry. It + is accepted only for the currently visible `Present` panel matching both the + latest geometry declaration and presentation epoch, then clamped by Q#BP2's + interactive preference. + +Both events join `pmacs-gpu/src/attach.rs`'s bounded outbox policy as distinct +same-kind **tail-coalescible** classes. Geometry is latest-wins (epochs need +only increase, not be consecutive); resize drag is latest-wins over the +complete event, including its epochs, so a new presentation may supersede a +queued stale drag. Tail-only replacement preserves ordering across a click, +key, `PanelPointer`, or geometry transition, and daemon-side epoch validation +still rejects anything stale. Neither human-rate stream consumes the 8192 +lossless-event budget while the writer is stalled. + +The GPU declares **whole-cell capacity**, not pixels or a guessed grid. For +current GPU geometry: + +``` +available_height_px = + max(0, surface_height_px + - status_band_height_px + - TEXT_TOP_px + - divider_height_px) + +layout_rows = floor(available_height_px / code_line_height_px) +total.rows = layout_rows + 1 // virtual daemon status row +total.cols = floor(surface_width_px / resolved_monospace_advance_px) +``` + +All quantities use the frontend's current scale. `divider_height_px` is the +scaled frontend-local divider reserved **for sizing purposes even while the +panel is absent**; this keeps the declaration independent of panel presence +and breaks the first-open cycle. The document renderer does not actually lose +those divider pixels until a `Present` panel is painted. `total.cols` describes +the full-width panel grid beginning at x=0; document `TEXT_LEFT`/gutter padding +is unrelated. Only full cells count. While the band is present, any fractional +right-edge remainder is painted as panel background but maps to no cell and +emits no `PanelPointer`; above the band, the document keeps its normal full +pixel width. + +The conversion accepts only finite, positive line-height/advance metrics and +uses checked/saturating conversion to `u32`. A zero surface, non-finite metric, +or non-positive advance/line height declares zero usable geometry under a new +epoch and therefore hides the panel; it never divides, wraps, or emits a giant +grid. Aggregate area validation still applies after conversion. + +The added row is virtual because the shared grid placement helper subtracts one +global status row before laying out windows. The GPU's real status band remains +pixel chrome; it is not painted into `PanelFrame`. + +**The daemon derives the third geometry.** Panel grid cols = `total.cols`. +Rows are `fixed_rows` clamped per Q#BP2 against `total` and, for a semantic +panel, by `shared_visible_cell_budget / total.cols`; if that wire-area cap is +below the structural two-row floor, the panel follows Q#BP2b's hidden arm. +The requested `fixed_rows` remains stored, so a later narrower geometry can +restore it. The daemon paints and ships the resulting grid in `PanelFrame`; the +GPU never asserts its size. The rendered band is exactly +`grid.rows * code_line_height_px`; divider and status-band pixels remain +frontend chrome, so document shrink is exact and contains no row-rounding +feedback loop. For a terminal panel, the grid's content rows exclude its one +mode line and feed Q#BP7's pre-drain terminal view/controller sync before the +snapshot is painted. + +**Unknown is first-class.** A semantic `FrontendView` starts with +`frame_geometry = None`; the daemon must not consult the attach request's 24×80 +placeholder for panel layout. A panel requested before the first real +declaration remains non-presentable under Q#BP2b. The GPU sends geometry before +enabling user input; receipt stores it, reconciles visibility, and permits the +first `Present`. Grid/LOCAL frontends continue to populate the same cached field +from their existing real attach/resize sizes and never send this new event. + +**Geometry changes fail closed.** As soon as the GPU sends a new +`geometry_epoch`, it retains but does not paint or hit-test an older +`PanelFrame`; only a matching `Present` can make the band visible and +interactive again. An `Absent` is always safe to apply because it only removes +paint/input authority. Every `Present` echoes the daemon's latest accepted +geometry epoch. This is the font/scale/resize analogue of terminal-frame size +validation and prevents an old grid from being interpreted under new metrics. + +### Q#BP16 — GPU panel pointer transport and presentation identity +(R1-3, R2-7, R3-B3) + +Existing events cannot carry panel gestures: semantic `Pointer` carries a +**document byte**, `TerminalPointer` is keyed to a terminal buffer, and `Mouse` +is contractually the **grid** path (`src/daemon.rs:3122-3130` drops terminal +declarations from grid sessions for exactly this reason). + +`FrontendEvent::PanelPointer { frontend_id, geometry_epoch, panel_epoch, +buffer_id, coord: CellCoord, kind: MouseKind, mods: Modifiers }`. + +`buffer_id` catches A→B replacement, but it cannot catch close/hide/reopen of +the **same** persistent buffer. `panel_epoch` closes that hole without putting +`WindowId` on the wire. `PanelPointer` is validated in this order: + +1. The authenticated source negotiated the panel event and matches/owns the + claimed `frontend_id`. +2. Its `FrontendView` has a live side window that is **not + `panel_hidden`**, and its latest daemon→frontend declaration is `Present`. +3. The payload's `geometry_epoch` equals both the latest accepted frontend + geometry and the echoed epoch in that `Present`. +4. The payload's `panel_epoch` equals that declaration's presentation epoch. +5. The side window's current `buffer_id` equals the payload's. +6. `coord` is inside that declaration's panel size. + +`Absent` clears steps 4–6's presentation state. Any failure drops the event +before any view, controller, selection, menu, or PTY mutation. A +`PanelResizeRows` follows the same +source/visible/Present/geometry-epoch/panel-epoch validation before changing +`fixed_rows`. + +`PanelPointer` events whose `kind` is `Move` or `Drag` receive their own +same-kind tail-coalescing tags beside document/terminal motion and drag. +Every `Down`/`Up` and wheel step remains lossless and ordered: repeated left +`Down`s are what the existing daemon click state interprets as a multi-click, +and `Down(Right)` is the context-menu gesture, so neither may collapse. The +event's geometry/presentation identities remain part of daemon validation; +coalescing never crosses an intervening event or combines different kinds. + +Once accepted, the daemon re-derives the panel window and replays existing +semantics: a terminal panel takes the Stage 2 vterm pointer path (child SGR +reporting when eligible, else per-view scroll/selection/menu); otherwise the +ordinary document gesture path in cell space. Click-to-focus is a `Down` on the +band; it activates the panel and, per Q#BP14, does **not** disturb the document +mirror. One terminal-specific consequence is explicit: every accepted +non-`Move` terminal gesture activates the panel before the shared terminal +adapter runs, because that adapter deliberately claims the controller for +wheel/press/drag/release as well as clicks. Bare hover neither focuses nor +claims. Non-terminal wheel motion keeps today's scroll-without-focus behavior. + +### Q#BP17 — Fold projection for the panel grid + +Folding asserts *"a semantic session never enters `paint_frame`"* and builds the +per-window map **ungated** on that basis (`src/editor.rs:2991`). The panel band +breaks the premise. + +**Rule: the panel projection honors the owning frontend's `fold_projection`.** +The extracted per-window painter takes the map as a **parameter** rather than +building it; the panel path passes `None` when the owning frontend's +`fold_projection` is false. The panel path must **not** call +`EditorCore::fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:566`) — right for command-time reckoning, wrong for +painting another frontend's panel. **Updating the now-stale invariant comment at +`src/window.rs:339` is part of Stage 2.** + +### Q#BP9 — Protocol: Stage 1 none; Stage 2 takes the next available version + +- Stage 1 changes no wire shape. The reviewed base is v20 after #148 and Stage + 1 inherits it without adding or reserving another version. +- Stage 2 appends `InstanceMessage::PanelFrame` after whatever that enum's final + variant is at the time, and appends + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` after + that enum's final variant. **Each extended enum gets a byte pin on its own + previous final variant's discriminant.** On `0dd16a5`, those pins are + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer`. Gated in both directions. +- **No future version is reserved.** Stage 2 takes the next available version + at implementation time—v21 if no intervening protocol PR lands—per + `docs/dap-debugging-framing.md` Q#DAP8. +- **Every gate keys on the daemon's own state.** All three events require an + authenticated semantic session whose claimed `frontend_id` equals the + transport source and that negotiated the panel version/capability. + `PanelResizeRows` / `PanelPointer` additionally require the current visible + `Present` declaration and matching geometry/presentation epochs; + `FrontendCellGeometry` deliberately does **not** require a side window + (Q#BP15a). A grid session or pre-panel semantic peer sending any new event is + rejected before payload state is trusted. + +### Q#BP10 — Persistence: side windows are not saved + +`src/desktop.rs`'s save walk skips side leaves; restore never creates one. +The v1 `SavedLeaf` shape remains unchanged: every restored ordinary window gets +default `WindowParams` (`side/fixed_rows/quit_action/origin_document` empty, +`dedicated = false`). Thus this arc does not bump `DESKTOP_VERSION` merely to +persist transient display policy. +Deferred: persisting panel geometry as a setting (blocked on settings +persistence). + +### Q#BP10a — Killing a panel buffer (rp-2 of round 1) + +`kill_buffer` redirects **every** window showing the victim to `*scratch*` +(`src/editor_core.rs:3046`). For a side window that is wrong twice. + +- Killing the buffer in a **side window closes the side window** (Q#BP2a + collapse) rather than redirecting it. +- If that would leave no non-side window — impossible under Q#BP6, asserted + anyway — the wrapper collapse restores the prior root, which by construction + holds a leaf. +- `QuitAction::Restore { buffer_id, .. }` **revalidates** at quit time; a + killed target degrades to `Delete`. This lifts `listview`'s existing fallback + (`builtin/runtime/listview.lua:164-166`) into the core. + +### Q#BP11 — Lua surface + +```lua +pmacs.window.display(buf, { side = "bottom", height = 12, + dedicated = true, select = false }) +pmacs.window.display_file(path, { window = win, select = true }) -- Q#BP11b +pmacs.window.quit() +pmacs.window.panel() +pmacs.window.params(win) / set_params(win, {...}) -- side/origin/quit action are read-only +pmacs.window.resize(win, delta_rows) -- boundary per Q#BP5b +pmacs.window.display_target() -- the non-side target +``` + +Commands: `window.quit`, `window.enlarge`, `window.shrink`. Settings: +`window.panel-height` (default 12 outer rows), `window.min-height` (Q#BP2). +Every Lua operation taking a `WindowId` validates that it is live and belongs +to the acting frontend's layout; a cross-frontend id is a pointed error before +read or mutation. + +### Q#BP11a — The non-side target rule (R1-5) + +1. Selected window is **not** side → it is the target (byte-identical to today). +2. Else the **remembered document window** (`origin_document`, Q#BP2c) if it + revalidates. +3. Else the **first non-side window in `iter_ids()` order**. +4. Else (no non-side window — forbidden as a resting state by Q#BP6) → + `debug_assert!` the broken invariant and return a pointed error without + mutation. There is no document leaf from which a valid fallback can be + fabricated. + +### Q#BP11b — A target-aware load transaction (R2-5) + +`display_target()` returns a *window*, but Lua has **no operation that loads or +switches into an arbitrary window**. `pmacs.buffer.find_or_open` switches the +**active** window in both branches before firing hooks +(`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`), and LSP +(`builtin/runtime/lsp.lua:1597`) and compile (`builtin/runtime/compile.lua:869`) +call it directly. #148's private `open_initial_target` +(`src/daemon.rs:1625-1677`) proves the useful off-ambient load seam +(`EditorCore::get_or_load_buffer`), but it too installs and reasserts through +`switch_active_buffer_for`, so it is not an arbitrary-window API. A visit to a +**previously unopened file** would still replace a focused panel before +`display_buffer` could help. Rev 2's Q#BP4 also covered only `after-switch`, +while a fresh load must fire `after-load` with the **document target** active. + +**`pmacs.window.display_file(path, { window, select })`** — one transaction: + +1. Construct the same path key `find_or_open` uses and perform its + side-effect-free registry dedup; do **not** read the file yet. +2. Resolve the destination before I/O. An explicit `window` is an **exact + target** under Q#BP3, not a hint; it must be live, owned by this frontend, + and not dedicated to a different already-open buffer (or, on a miss, to any + buffer). With no explicit window, an existing buffer uses Q#BP3's ordinary + non-side reuse/candidate policy; a miss chooses the first non-dedicated + Q#BP11a candidate. No eligible target is an error **before loading**. +3. On a registry miss, load/create the buffer; on a hit, preserve its unsaved + contents exactly as `find_or_open` does. +4. Enter Q#BP4's transaction with `fire = AfterLoad` on a fresh load, + `AfterSwitch` on a reuse (including a same-buffer no-op), and `None` for a + newly created `NotFound` path, matching #148/local-startup behavior — so any + hook observes the **document target** as active, which saveplace / recentf / + syntax / LSP all require. +5. Apply Q#BP4's final-focus matrix. + +The implementation factors one Rust/editor-core **resolve/load-without-switch** +primitive and one exact-window install primitive for both `display_file` and +#148's `open_initial_target`; the daemon bootstrap does not call back through +the public Lua binding. This prevents two path-normalization, dedup, and hook +transactions from drifting. + +Initial-target bootstrap retains its stronger Q#GT5/Q#GT8 postcondition. It +captures the fresh view's original document window before I/O and runs the +shared exact-window transaction with `select = true`. After its one hook, it +revalidates the target `BufferId`: removal is still bootstrap failure. If the +original document window remains live, reassert the target there and activate +it; if a hook closed that window, resolve an eligible non-side window in the +same new frontend, install the target there without firing a second hook, and +activate it. A hook-created/selected side window is never overwritten merely +because it became `view.active`. Snapshot publication and +`InitialTargetResult::Opened` retain #148's existing order and name the +reasserted document buffer. + +Adopters route through this: `listview` visit +(`builtin/runtime/listview.lua:126`), LSP `visit_location`, compile +`visit_error`. Raw `find_or_open` stays for programmatic use. + +**Stage 1 also needs real opt-in entry points**, because compile, terminal, and +listview currently create/switch their buffers through active-window-only +paths. Calling a generic display afterward was the rev-3 vacuous path; Q#BP3's +placement-aware rule and these entry points make the requested side placement +the first real display: + +All three parse the same strict placement value: +`display = "current" | "panel"`. Unknown values error before creating a buffer, +session, process, or wrapper. In Stages 1–2, omission means `"current"`; in +Stage 3, omission means `"panel"`. Explicit `"current"` always preserves the +adopter's pre-arc selected-window behavior and is the user-facing opt-out from +the default flip. + +- `pmacs.terminal.open{ display = "panel" }` — `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into the active window + (`src/lua_bindings/mod.rs:8500`) and rolls the session back on failure. The + binding takes an optional exact target window (mutually exclusive with + `display = "panel"`), defaulting to today's behavior; the panel opt-in uses + `select = true`. Placement failure removes any side wrapper created by the + transaction before the existing session/buffer rollback completes. +- `compile.run{ display = "panel" }` — compile creates its buffer + (`compile.lua:263`) then `switch_buffer`s (`:808`); the first display becomes + a side-affine `display` call even when an older document window already shows + `*compilation*`, explicitly with `select = false`. Recompile reuses the + current panel. `compile.quit` routes through `pmacs.window.quit` when the + compilation buffer is in a side window, so it deletes/restores the + presentation instead of leaving a source buffer stranded in the side slot. + In capability fallback it keeps today's previous-buffer restore in the + selected document window. +- `pmacs.listview.open{ ..., display = "panel" }` — `listview.open` currently + hardwires `switch_buffer` (`listview.lua:126`). The opt-in calls + `display(..., {side = "bottom", select = true})`, because `seat_cursor` and + refresh are active-window-only. `listview.quit` keeps the same `q` command and + user-visible behavior, delegating to `pmacs.window.quit` only when the + listview is in a side window; capability fallback retains the current + previous-buffer switch. + +For all three adopters the default panel is **undedicated**, so the one side +slot can be replaced. Creating a new side slot records +`Some(QuitAction::Delete)`. Replacing it snapshots the prior buffer, height, +dedication, cursor/view/selection state, and quit action into +`Some(QuitAction::Restore { … })`; merely redisplaying the same buffer +preserves its action. `origin_document` belongs to the slot lifetime: a +replacement retains the existing valid origin rather than remembering the +currently focused panel. + +`window.quit` executes through Q#BP4's activate–switch-hook–reconcile +transaction. Restoring C→B→A reinstalls each saved presentation and its +`then`; executing `Delete` collapses the wrapper and focuses the revalidated +origin/non-side target. Capability fallback creates no window-level quit +action and leaves no side parameters behind; each adopter uses its existing +ordinary document-window restore path. + +Acceptance pre-seeds the persistent listview/compilation buffer in a document +window before asking for panel placement. That is the bite against accidentally +restoring global reuse-first. + +### Q#BP11c — Jump-ring origins (R2-6) + +The jump ring stores only `(BufferId, Position)` (`src/editor_core.rs:279`), and +`jump_back` switches the **currently active** window to that buffer +(`src/editor_core.rs:811`). After `RET` from an outline or compilation panel, +`M-,` would put the **panel buffer into the document window** while the panel +stays open — a duplicate-buffer/window corruption, and a regression of today's +"M-, returns to the panel row" behavior. + +**History becomes per frontend**, matching `command_history`: +`HashMap>`, where `JumpEntry` is +`{ window_id, buffer_id, position, side_origin }`. `push_jump` and `jump_back` +address only the acting frontend's vector; detach purges it. One frontend can +therefore neither pop nor destroy another frontend's navigation trail. +`JUMP_RING_CAP` applies independently to each vector with today's oldest-entry +eviction. + +`jump_back` restores into the **origin window** only when all of these +revalidate: the window is live, belongs to the acting frontend's layout, is not +hidden when side, **and still shows the recorded `BufferId`**. A live panel +that has since been replaced does not resurrect its old buffer. When validation +fails for a **non-side** origin, the entry degrades to today's active-window +switch behavior within the same acting frontend. When it fails for a recorded +**side** origin (closed, hidden, replaced, or moved out of the layout), the +entry is skipped: switching its buffer into the document window would recreate +the duplicate-panel corruption this design is meant to remove. Entries whose +buffer is gone are likewise skipped. + +Acceptance runs the real paths: **panel → `RET` source → `M-,`** for both +outline and compilation, asserting focus returns to the **existing** panel with +its row restored and the document window unchanged. A second acceptance +interleaves two frontends' jump histories and replaces one origin window's +buffer before `M-,`. + +### Q#BP12 — Default placement flips in Stage 3 + +Stage 1 ships the mechanism **opt-in**; existing acceptance suites keep their +meaning. Between Stage 1 and Stage 2 a semantic frontend could hold a side +window it cannot render, so the flip waits. + +**Stage 3 is not "one line per consumer"**: each adopter also moves its visit +path onto `display_file`/`display_target` and takes its own `select` decision: + +| Adopter | Panel placement | Dedicated | Quit action | Visit | `select` on visit | +| --- | --- | --- | --- | --- | --- | +| `listview` (references/outline) | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| compile output | panel, `select = false` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| terminal | panel, `select = true` | `false` | delete if created; restore replaced panel | n/a | n/a | +| DAP stack/variables | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | + +An interactive `listview` **must** take `select = true`: `seat_cursor` +(`builtin/runtime/listview.lua:64`) and `listview.refresh` are active-window-only +and would silently seat the wrong window otherwise. + +The Stage 3 default is resolved as a panel request and therefore still passes +through Q#BP13 capability fallback. It is not a hidden global setting. +Explicit `display = "current"` bypasses side placement deliberately and keeps +the old adopter-specific quit/previous-buffer path; like today's entry points, +it uses the raw switch escape and does not consult display-policy dedication. + +### Q#BP13 — Panel capability: a per-`FrontendView` bit set at attach (R1-6) + +```rust +pub struct FrontendView { + pub layout: Layout, + pub active: WindowId, + pub fold_projection: bool, // Arc 6 Stage 2 + pub panel_capable: bool, // this arc; no Default + pub frame_geometry: Option, // epoch + total; None != 24x80 + pub panel_hidden: bool, // cached derived state, never persisted +} +``` + +Set in the attach transaction that already computes `fold_projection` +(`src/daemon.rs:1769`) from `SessionState` (`src/presence.rs:74-84`): + +| Session | `panel_capable` | +| --- | --- | +| `FrontendId::LOCAL` / grid | `true` | +| semantic, `negotiated_protocol_version < PANEL_MIN_VERSION` | `false` | +| semantic, `>= PANEL_MIN_VERSION` | `true` | + +`peer_declared_terminal_support` (`src/daemon.rs:888`) is the helper shape. +`peer_declared_panel_support` is explicitly +`semantic_render && negotiated_protocol_version >= PANEL_MIN_VERSION`; no +client-asserted standalone boolean is trusted. Stage 1 sets `true` for +grid/LOCAL, `false` for every semantic session; Stage 2 flips the version arm +on. `display_buffer` with a `side` falls back to the non-side target **and +discards every side-specific parameter** (Q#BP2c). + +Grid/LOCAL construction supplies real geometry before first input/render. +Semantic construction supplies `None`; Stage 2's authenticated declaration +fills it. Desktop restore spells all fields explicitly, preserving folding's +non-`Default` discipline. Stage 2 additionally holds the current presentation +epoch/declaration beside the semantic render baseline; it is runtime-only and +never desktop state. The same constructor inherits its initial buffer through +Q#BP14's `primary_document_window(LOCAL)`, so adding the capability fields +cannot preserve the old panel-focused attach leak. + +## 4. Bets (explicit, falsifiable) + +- **B1 (narrowed after R2-2) — panel-as-window means the terminal controller, + the `C-c` escape, and release-on-blur need zero new code.** Falsified if any + `TerminalViewKey` / `TerminalController` / escape-dispatch code needs a panel + case. *Input gating is explicitly excluded: Q#BP14a is one new condition.* +- **B2' (narrowed after R3-B20) — the active-window preparation plus + `paint_frame`'s per-window body extract to a standalone panel grid without + modifying a concrete painter.** Falsified if a text/gutter/overlay/modeline + painter reads absolute frame coordinates or `term_size` rather than its + `Viewport<'a>`/placement, or if the shared preparation cannot keep a focused + panel cursor visible. +- **B3 — the terminal's anchor model absorbs height changes with no new state.** + Falsified if Q#BP7 needs a new `TerminalViewState` field. +- **B4 — no document painter breaks when a window's rect becomes fixed rather + than proportional.** Falsified if any painter assumes the flexible-remainder + rule. +- **B5' (narrowed after R3-B21) — `PanelFrame` reuses one factored wire-cell + validator and aggregate area/glyph/transport budgets, but not terminal PTY + per-axis limits.** Falsified if panel cells need a second glyph/topology + implementation or if a legal >512-column, area-bounded panel cannot + round-trip. +- **B6 (restated after rp-3) — opening a panel leaves the prior document + subtree's STRUCTURE byte-identical**: same nodes, same weights, same order, + same `WindowId`s. Its **rectangles necessarily change**, being recomputed + inside the smaller flexible remainder. Falsified if opening a panel reorders, + reweights, or re-ids any document node. +- **B7'''' (replacing the falsified B7'/B7''/B7''') — the transitive §1.3 + census and Q#BP14b surface matrix are complete.** Falsified if any direct or helper- + mediated read of active window/buffer state reached by the daemon/semantic + projection is missing, if a focus surface inherits the document viewport + again, or if an open/clear baseline survives on the wrong surface under + acceptance. + +## 5. Acceptance + +**Stage 1 — core + TUI (no wire change from its eventual base).** + +1. `Layout::compute` honors a fixed extent: a bottom child of N rows gets + exactly N; siblings divide the remainder by weight. **Both production + callers are pinned through their real paths** (R5-B1): a document window's + rows come from `window_placements`, and a peer cursor in that same window is + painted by the overlay pass (`src/overlay_paint.rs:112`) at an identical row + whether or not a panel is open — the assertion that fails if the second + caller keeps computing unfixed geometry. +2. Opening a panel leaves the prior document subtree's **structure** identical + (nodes, weights, order, ids); its rects are recomputed (B6). +3. `subtree_min_rows` is recursive: a **nested** document tree (horizontal + inside vertical inside horizontal) keeps every leaf at the floor, and the + panel is clamped — not the document — when they compete. +4. Programmatic `height`, `window.panel-height`, and side `fixed_rows` requests + of one row clamp to `MIN_WINDOW_OUTER_ROWS`; zero rejects. An intrinsically + too-small or zero-column frame uses saturating arithmetic and hides rather + than underflows or emitting a zero-width panel. +5. A terminal resize preserves a side window's **absolute** height and a + flexible pair's **ratio**, in one layout. +6. Geometry is cached before first input. A command/hook opens and selects a + panel in a too-small frame, then a second key in the **same drained burst**: + reconciliation marks the panel hidden, moves focus to a document, and + releases the observed terminal controller before that key dispatches. +7. Growing the frame enough to make the request satisfiable restores the panel + at its exact requested `fixed_rows`; focus is **not** auto-restored, and + `focus_next/prev` skip it while hidden but reach it after reappearance. + While hidden, its rect is empty and the unchanged document subtree receives + every reclaimed row; the stored request, wrapper, ids, weights, and order + remain intact. +8. Keys typed while the panel is hidden reach the document window, never the + invisible panel. +9. `window.min-height` below the structural floor clamps; a value materially + above it constrains drag/keyboard resize recursively across a nested tree, + while frame-resize layout ignores the preference. +10. Closing the panel collapses the wrapper and restores the prior root exactly. +11. `set_params` rejects adding/changing/clearing `side` and rejects + `origin_document`; `params` may report the origin; a stray `fixed_rows` on + a non-side window is inert. Every `WindowId`-taking Lua operation rejects a + live id owned by another frontend. +12. Raw `switch_buffer` **ignores** `dedicated`; `display_buffer` honors it on + side, reused, exact, and non-side candidates, falling through or erroring + without overwriting one. An ordinary display never reuses a matching side + window. +13. Side placement is affinity-aware: a buffer already visible in a document + window does not preempt a requested usable side slot. An explicit + `window` is exact. A dedicated side fallback never creates a second side + window and discards height/dedication/quit state before touching the + document target; `window` + `side` and a freestanding `height` reject. + Same-buffer redisplay preserves omitted height/dedication/action; + replacement preserves an omitted user-resized height but defaults the new + presentation undedicated; creation uses the setting/default. Explicit + `dedicated = false` cannot bypass an existing dedication in the same call. +14. Capability fallback discards all side-only parameters and leaves the + document target undedicated/unpinned. +15. **Final-focus matrix (Q#BP4), all six rows**, including `select = true` + leaving the target selected and `select = false` restoring a **side** + `saved_active`. +16. The three hook-failure arms (hook closes target / closes saved / switches + buffers) are covered in **both** `select` modes, with reconciliation between + the hook and final-focus decision. +17. A panel displayed into a passive window has its overlays re-attached. +18. **`display_file` to a previously unopened file from a focused panel** opens + it in the exact document target, leaves the panel intact, and fires + `buffer.after-load` with the **document target** active — asserted through + the real LSP and compile visit paths. A dedicated exact target fails + without loading/switching it; an omitted target skips a dedicated + remembered origin and chooses the next eligible non-side window before I/O. + A `NotFound` path creates a path-backed buffer and fires no load/switch + hook, matching initial-target/local-startup behavior. +19. `pmacs.terminal.open{display="panel"}`, + `compile.run{display="panel"}`, and + `pmacs.listview.open{display="panel"}` place through their real entry + points. The fixture first shows persistent `*compilation*` / `*outline*` in + a document window, proving side-affine placement is not vacuous. Unknown + `display` values fail before buffer/process/session/wrapper creation. +20. `listview`/compile `q` route through `window.quit`: the first panel deletes + its wrapper; C→B→A restores each saved height, dedication, + cursor/view/goal/selection, hook-attached overlays, and prior quit action; + a killed restore target collapses safely. Capability fallback restores the + prior document through the adopter's old path and leaves no quit action. + Terminal placement failure removes a newly created wrapper before its + existing session/buffer rollback completes. Replacing more than + `MAX_PANEL_QUIT_DEPTH` times retains exactly the newest 64 presentations, + then terminates in `Delete`; depth never grows beyond the cap. +21. **`panel → RET source → M-,`** for outline and compilation returns focus to + the same still-showing-origin panel row; the document window remains + unchanged and no duplicate presentation is created. +22. Jump histories are per frontend: interleaved pushes/pops cannot consume a + peer's entries. A live origin window now showing a different buffer + is skipped when it was a side origin rather than resurrecting or + duplicating the old panel; an invalid non-side origin retains today's + acting-frontend fallback. +23. `window.quit` revalidates `QuitAction::Restore`; a killed restore target + degrades to delete. +24. Killing a panel buffer **closes the side window** rather than redirecting to + `*scratch*`. +25. `close_active` refuses only when the target is the last **non-side** window; + closing the side window itself is legal even as the only other window. +26. `close_others` from a document window deletes the panel; from a side window + it errors. `split_active` from a side window errors. +27. `C-x o` reaches the panel and returns; the terminal controller is claimed on + entry and released on exit. With two document windows, entering the panel + from B refreshes `origin_document`, so `display_target`, a panel visit, and + a Delete-form `window.quit` target B rather than the window from panel + creation. +28. With the panel focused, unescaped bound keys reach the child; `C-c` escapes + for exactly one key; `C-c C-c` sends one literal interrupt. **B1 pin.** +29. A focused side window makes `dispatch_idle_for` return `false` **without** + marking its buffer round-trip, and another frontend editing that same + buffer as a document keeps optimistic apply. A forged/stale optimistic op + for the document is rejected before source-window cursor/provenance + mutation; a valid round-trip edit still updates the focused panel window. +30. Divider drag changes side `fixed_rows` and document-pair weights under the + interactive recursive preference; a click on the reserved row creates no + selection, and `ui.divider` resolves through the `ui.*` face walk. A + boundary whose upper child is a vertical split paints all adjacent exposed + mode-line segments, and dragging either segment resolves the same boundary. +31. `window.enlarge`/`shrink` equal the equivalent drag in a **nested** layout + where the active subtree is its nearest horizontal ancestor's final child; + `resize(win, …)` resolves from `win`; no horizontal ancestor reports/no-ops. +32. A terminal panel scrolled back keeps its `top` across a height change; + growth reaching the tail re-arms follow; later output scrolls in. +33. Growth reaching the tail with a historical selection leaves the selection + and anchor frozen, via the shared viewport-size path. +34. Only the controller's height change resizes the PTY. A semantic panel + terminal uses the daemon-derived panel content rect at the pre-drain sync + point, never the 24×80 attach placeholder or full-window terminal + declaration. +35. The desktop round-trips a layout containing a file-backed side window + **without** the side leaf or its root wrapper; restored document leaves + have default parameters and the desktop format version does not change. +36. Full gate suite per `AGENTS.md`; because Stage 1 factors #148's target-load + seam, this includes `gpu_initial_target_acceptance` in default and CRDT + configurations in addition to the new/touched panel suites. + +**Stage 2 — GPU band (own re-framing; next available protocol version).** + +37. `PanelFrame` round-trips, including `panel_epoch` and `geometry_epoch`; + independent **byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants** catch a shift in either + extended enum. +38. Full lifecycle: **open → replace buffer → hidden by a tiny frame → + reappear → close**, with authoritative `Absent` at hide/close and a new + epoch on replacement/reappearance. +39. An invalid `PanelFrame` is rejected atomically; the previous valid frame is + retained. A duplicate valid frame (including duplicate `Absent`) does no + work. Shared cell/topology/glyph/area validation accepts an area-bounded + panel wider than 512 columns, while terminal frames retain their 512-column + PTY cap; maximum legal panel encoding stays below the transport limit. +40. **First open at a non-80×24 frame before any valid panel baseline** remains + absent until real `FrontendCellGeometry` arrives, then produces the correct + grid without consulting the 24×80 attach placeholder. +41. Pixel→cell conversion is pinned at fractional widths/heights: status band, + `TEXT_TOP`, potential divider, virtual status row, full-width monospace + columns, and floor rounding agree. Geometry refreshes on window resize, + font change, and scale change; the daemon alone derives the grid. After a + new `geometry_epoch` is sent, an older retained frame neither paints nor + accepts input until a matching `Present` arrives; `Absent` remains an + always-safe removal, and stale/conflicting epochs reject. A requested panel + whose rows×cols would exceed the shared wire-area budget is row-clamped + without losing its stored request, or hidden when even two rows cannot fit. + Zero/non-finite/non-positive metric inputs fail closed to zero usable + geometry without overflow or an oversized allocation. +42. **Focus into and out of a terminal panel while the document stays visible + and unchanged**: no `BufferSnapshot` re-send, no document suppression, no + mirror swap, no `CursorByte` for the panel buffer, no line-number, + selection-decoration, document-terminal declaration, or document + statusline replacement/clear with the panel buffer. Document statusline + callbacks may truthfully observe `active = false`; presence reports the + focused panel context. The GPU replica's `active_buffer` and authoritative + cursor remain the primary document buffer/cursor while `DispatchIdle` is + false, and the revised protocol/client contract tests name that distinction. +43. **Focusing a fresh generated panel buffer triggers no lazy-CRDT-upgrade + broadcast** (§1.3 #2 — the case rev 2 could not see). With semantic peer A + focused in panel P over document D, a target launch/upgraded-buffer + publication for D still reaches A, while one visible only as P does not + replace A's document mirror (§1.3 #21). +44. A document `Viewport` naming the document buffer while the panel is focused + aligns the **primary document window** and **does not move focus**; a + document `Pointer` aligns **and** activates the document window. With a + full-window document terminal under a focused panel, its viewport and + `TerminalResize` remain accepted, bare `TerminalPointer::Move` does not + focus or claim, and every accepted non-hover terminal gesture activates + the document before replay. +45. From a focused panel, `M-x` opens/types/closes a visible + `MinibufferPrompt`; isearch keeps its semantic prompt while panel washes + paint in the grid. A new focused `PanelFrame` arrives before the + panel-buffer `SearchPrompt`, which the GPU accepts without changing its + document mirror; on hide/close/focus-out, the old panel prompt clears + before its focused declaration is removed. A prompt naming neither current + surface is ignored. + Document→panel focus authoritatively clears a native document + menu/completion popup, while panel menu/completion overlays paint only in + `PanelFrame`; returning to the document reverses ownership cleanly. + The global prompt/clear pass also works before a document viewport exists + and while the primary document is a full-window terminal. + One statusline provider invocation supplies the primary-document wire + segments and panel mode line; a provider that mutates the layout + invalidates stale results and reconciliation runs before paint. +46. The band + divider shrink the document text area by exactly their pixel + height; document carets, hits, and scroll geometry respect the reduced + area. `Present`/`Absent` refresh the ordinary document `Viewport` or + full-window `TerminalResize` without sending a new whole-frame geometry + declaration. +47. Dragging the divider sends + `PanelResizeRows {geometry_epoch, panel_epoch, rows}` and honors + `window.min-height`; hover shows `CursorIcon::RowResize`. A stalled-writer + outbox tail-coalesces repeated resize rows and whole-frame geometry + declarations without crossing an intervening event or exhausting the + lossless queue. +48. `PanelPointer` drives listview row selection, panel selection, terminal + mouse reporting, and click-to-focus without disturbing the document mirror. + A terminal panel's non-`Move` wheel/press/drag/release first activates it + so controller ownership remains consistent; hover does neither. Keyboard + motion beyond a focused panel's viewport runs the extracted active-window + auto-scroll clamp, while a passive panel preserves `view_top`. Panel + move/drag tails coalesce; press/release/context/wheel remain lossless and + ordered. +49. Stale panel events are dropped before mutation for all four cases: + A→B replacement (`buffer_id`), close/reopen of the same A, and + hide/reappear of A (`panel_epoch` / latest-`Present` check), plus a + font/scale/resize declaration race (`geometry_epoch`). `Absent` clears + declared panel size/presentation epoch on both sides without discarding the + whole-frame geometry declaration. +50. `PanelResizeRows` / `PanelPointer` from a source with no visible current + `Present` panel are dropped. `FrontendCellGeometry` from the correctly + negotiated semantic source is accepted without a side window; grid, + pre-panel, forged-source, and wrong-version variants are rejected. +51. **Mixed session**: a pre-panel semantic frontend falls back to a document + window — with every side-specific parameter discarded, leaving the document + window undedicated (Q#BP2c) — while a grid frontend on the same daemon gets + its side window. With `LOCAL` focused in that panel, a fresh no-target + semantic attach inherits `LOCAL`'s primary document buffer, never the + panel buffer (§1.3 #22). +52. A panel projected for a `fold_projection = false` frontend does **not** + collapse folds; the stale comment at `src/window.rs:339` is updated in the + same PR. +53. Bell drain remains focus/session-scoped: a focused panel terminal rings + once per frontend, while passive and historical bells remain suppressed. +54. A `--headless-probe` run drives one real daemon + real PTY + real wgpu + through a panel-hosted terminal, followed by the full gate suite for the + Stage 2 PR. +55. A v20 initial-target attach whose `after-load`/`after-switch` hook + creates and selects a side window still reasserts the requested buffer in + and activates a non-side document window without overwriting the panel. + Closing the original document window in the hook rehomes the target to a + remaining eligible non-side window without a second hook; killing the + target buffer still fails bootstrap. The target snapshot precedes matching + `InitialTargetResult::Opened` exactly as in #148. + +**Stage 3 — adopter default flip.** + +56. Omitting `display` from real listview, compile, and terminal entry points + resolves to the Q#BP12 panel/select policy on a panel-capable grid and + semantic frontend; explicit `display = "current"` preserves each + adopter's pre-arc selected-window behavior. +57. On a pre-panel semantic frontend, the omitted Stage 3 default takes + capability fallback with no side parameters or quit action left on the + document window; its visit and `q` paths remain the existing non-side ones. +58. Updated default-placement suites exercise + open→visit→return→quit through each adopter rather than a generic helper, + preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR + then runs the full gate suite. + +## 6. Deferred (named) + +Left / right / top side windows; multiple slots per side; **rehoming a leaf +across the tree**; the entire **`no_other_window` parameter and destination-only +traversal semantics**; manual panel hide/show and a future +`window.toggle-panel`; user-facing `display-buffer-alist`-style rules; **GPU +document splits (Arc 8)**; panel +persistence (blocked on settings persistence); `OSC 22` pointer shape in the +TUI; per-panel statusline segments on the wire; proportional-font panels in the +GPU; `window-configuration` registers; atomic windows; panel-local keymaps +beyond buffer and mode scopes; horizontal (`C-x {`/`}`) resize. + +## 7. Interaction with other work + +- **Folding Stage 2 has landed** (#149, runtime base `6ed4fe9`) — the blocking + dependency, now cleared and re-verified against `ddaa80d` in §0.6 (nothing in + flight, suite green, every borrowed anchor reproducing). Canonical `main` is + now `ddaa80d`; any eventual branch starts from current canonical main. + Folding's + `FrontendView` policy-bit pattern is Q#BP13's model, its `Viewport<'a>` is what + Q#BP8 inherits, and Q#BP17 owns the one invariant this arc invalidates. + **Folding Stage 3 (GPU)** and this arc's Stage 2 both touch the semantic + projection; whichever is framed second re-scouts the other's landed state. +- **GPU initial target #148 has landed** at runtime commit `0dd16a5` and owns + protocol v20; #152 then refreshed only the durable handoff/active-work + documentation at canonical `main` `ddaa80d`. + Its attach transaction, `build_fresh_frontend_view`, private target loader, + semantic snapshot publication filter, and previous-final wire variant were + all re-scouted in §0.5. Q#BP9 now starts from v20; Q#BP11b shares the landed + load seam without routing bootstrap through Lua; Q#BP14 covers both the + publication predicate and no-target buffer inheritance. There is no + remaining branch-order dependency on #148. +- **DAP** stays parked until this arc's **Stage 1** lands, then re-baselines its + §0 touch census. Its Stage 2 panels become `display` + `display_file` calls. + +## 8. Prior art in pmacs + +Folding Stage 2 (`docs/folding-stage2-framing.md`, `src/fold_view.rs`) for the +per-`FrontendView` policy bit, the non-`Default` discipline, and per-window map +derivation; Vterm Stage 2 for the controller model, the `C-c` escape, and +per-view projection; Vterm Stage 3 for the whole-grid frame message, `validate`, +payload-complete suppression, stale-declaration rejection (extended here from +terminal-unique `buffer_id` to a panel presentation epoch), and the +`--headless-probe` seam; `listview.lua` for what a panel needs and currently +fakes; `src/desktop.rs:444-452` for activate-then-fire-per-leaf; M11.6's +`DispatchIdle` for the input gate. From 6c8a76e235e004f57b057de41f4520331979a56f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 13:53:14 -0400 Subject: [PATCH 02/10] feat(window): window parameters, fixed extents, and the display policy Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md). - `WindowParams` (side / fixed_rows / dedicated + implementation-owned quit action and remembered document origin), `Side`, `QuitAction` with a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor. - `Layout::compute(area, fixed)` allocates fixed rows before dividing the remainder by weight; both production callers feed the same shared map, including the peer-presence overlay pass that derives its own rect. - `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and `boundary_below` for the shared drag / keyboard resize boundary rule. - `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived `panel_hidden`, each spelled explicitly at every construction site. - `EditorCore`: `primary_document_window`, the non-side target rule, `display_buffer` + placement policy, `quit_window`, side-window removal on `kill_buffer`, per-frontend jump entries with origin windows, and the shared resolve/load-without-switch seam the initial-target bootstrap now uses too. - `EditorState`: the panel reconciliation transaction, geometry declaration, the side-window `dispatch_idle_for` gate, divider paint, and divider drag. - `pmacs.window.display / display_file / quit / panel / params / set_params / resize / display_target`, plus `builtin/runtime/window.lua` with `window.panel-height`, `window.min-height`, and the resize commands. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/window.lua | 58 + src/daemon.rs | 143 ++- src/desktop.rs | 14 + src/editor.rs | 317 ++++- src/editor_core.rs | 1459 ++++++++++++++++++++++- src/lua_bindings/mod.rs | 36 +- src/lua_bindings/window_panel.rs | 499 ++++++++ src/overlay_paint.rs | 7 +- src/window.rs | 597 +++++++++- tests/folding_stage2_acceptance.rs | 3 + tests/statusline_segments_acceptance.rs | 3 + tests/vterm_stage3_acceptance.rs | 3 + 12 files changed, 3027 insertions(+), 112 deletions(-) create mode 100644 builtin/runtime/window.lua create mode 100644 src/lua_bindings/window_panel.rs diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua new file mode 100644 index 0000000..cf63443 --- /dev/null +++ b/builtin/runtime/window.lua @@ -0,0 +1,58 @@ +-- window.lua --- side-window settings, quit, and keyboard resize. +-- +-- The Lua half of the bottom-panel arc's window surface. The placement +-- policy itself is Rust (`pmacs.window.display` / `display_file` / +-- `quit` / `resize`); this module owns the two settings those paths +-- resolve, plus the interactive commands and their Emacs bindings. +-- +-- Both settings are read against the window's OWN buffer (buffer-local +-- override -> global -> default), so a project or a mode hook can pin a +-- taller panel for one buffer with `pmacs.config.set_local`. +-- +-- Framing: docs/bottom-panel-framing.md (Q#BP2, Q#BP5b, Q#BP11). + +-- Outer rows (text + mode line) a freshly created panel takes when the +-- caller supplies no explicit `height`. Only consulted at CREATION: a +-- replacement preserves whatever height the user dragged the slot to. +pmacs.config.define { + name = "window.panel-height", + description = "Outer rows a newly created bottom panel occupies.", + type = "integer", + default = 12, + min = 2, + mutability = "live", +} + +-- A preference, not a structural rule: it constrains INTERACTIVE resize +-- (drag and the commands below) and is deliberately ignored by the +-- ordinary layout pass and by frame-resize reconciliation, so raising it +-- can never invalidate a layout that already exists. +pmacs.config.define { + name = "window.min-height", + description = "Smallest outer rows interactive resize will leave a window.", + type = "integer", + default = 2, + min = 2, + mutability = "live", +} + +pmacs.command.define { + name = "window.quit", + description = "Quit the selected side window: restore or delete it", + fn = function() pmacs.window.quit() end, +} + +pmacs.command.define { + name = "window.enlarge", + description = "Make the selected window one row taller", + fn = function() pmacs.window.resize(nil, 1) end, +} + +pmacs.command.define { + name = "window.shrink", + description = "Make the selected window one row shorter", + fn = function() pmacs.window.resize(nil, -1) end, +} + +pmacs.keymap.bind { scope = "global", sequence = "C-x ^", command = "window.enlarge" } +pmacs.keymap.bind { scope = "global", sequence = "C-x C-^", command = "window.shrink" } diff --git a/src/daemon.rs b/src/daemon.rs index 98a688a..cb82c3b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -894,6 +894,21 @@ fn peer_declared_terminal_support( .is_some_and(|state| state.negotiated_protocol_version >= 19) } +/// Whether a session can **render** a side window (bottom-panel arc, +/// Q#BP13). +/// +/// Grid sessions paint the whole cell grid the daemon composes, so a side +/// window is just another leaf for them. A semantic session needs the +/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1 +/// answers `false` for every semantic peer, whatever it declares. No +/// client-asserted standalone boolean is trusted: the answer is derived +/// from the daemon's own negotiated state, and Stage 2 turns the version +/// arm on (`semantic_render && negotiated_protocol_version >= +/// PANEL_MIN_VERSION`). +fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { + !session_state.negotiated_capabilities.semantic_render +} + /// The same belt-and-braces write-loop gate for the additive /// protocol-v19 terminal frame. The semantic producer skips construction /// for an older peer; this filter independently prevents an unknown @@ -1628,38 +1643,40 @@ fn open_initial_target( target: InitialTarget, ) -> Result { let path = resolve_initial_target(target); - let display_path = path.display().to_string(); - let (buffer_id, newly_loaded, newly_created) = { + // Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's + // ORIGINAL document window before any I/O. A startup hook may now + // create and select a side window, and bootstrap must reassert the + // requested buffer in a document window rather than overwriting a + // panel merely because it became `view.active`. + let (origin_window, buffer_id, fire) = { let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; - let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) { - Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false), - Err(error) if error.kind() == ErrorKind::NotFound => { - let buffer_id = core.registry.borrow_mut().create(display_path.clone()); - core.set_buffer_path(buffer_id, Some(path.clone())); - "[new file]".clone_into(&mut core.status); - (buffer_id, false, true) - } - Err(error) => { - return Err(format!("cannot open {}: {error}", path.display())); - } - }; - core.switch_active_buffer_for(frontend_id, buffer_id) + let origin_window = core + .primary_document_window(frontend_id) + .ok_or_else(|| "attaching frontend has no document window".to_string())?; + let (buffer_id, fire) = core.resolve_target_buffer(&path)?; + core.install_buffer_in_window(origin_window, buffer_id) .map_err(|error| format!("cannot select {}: {error}", path.display()))?; - (buffer_id, newly_loaded, newly_created) + core.focus_window(frontend_id, origin_window); + (origin_window, buffer_id, fire) }; - if newly_loaded { - editor - .lua_host - .run_hook("buffer.after-load", mlua::MultiValue::new()); - } else if !newly_created { + match fire { + crate::editor_core::HookKind::AfterLoad => { + editor + .lua_host + .run_hook("buffer.after-load", mlua::MultiValue::new()); + } // Dedup is a logical switch even when the fresh view already shares // this BufferId; configuration must observe it exactly once. - editor - .lua_host - .run_hook("buffer.after-switch", mlua::MultiValue::new()); + crate::editor_core::HookKind::AfterSwitch => { + editor + .lua_host + .run_hook("buffer.after-switch", mlua::MultiValue::new()); + } + crate::editor_core::HookKind::None => {} } + editor.reconcile_panel_layout(frontend_id); let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; @@ -1669,11 +1686,28 @@ fn open_initial_target( path.display() )); } - core.switch_active_buffer_for(frontend_id, buffer_id) + // Reassert into the original document window when it is still live; + // if a hook closed it, rehome to an eligible non-side window in the + // same frontend WITHOUT firing a second hook. + let destination = if core + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.iter_ids().contains(&origin_window)) + { + origin_window + } else { + core.non_side_target(frontend_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))? + }; + core.install_buffer_in_window(destination, buffer_id) .map_err(|error| format!("cannot reselect {}: {error}", path.display()))?; + core.focus_window(frontend_id, destination); Ok(OpenedInitialTarget { buffer_id, - publish_to_replicas: newly_loaded || newly_created, + publish_to_replicas: matches!( + fire, + crate::editor_core::HookKind::AfterLoad | crate::editor_core::HookKind::None + ), }) } @@ -1766,9 +1800,15 @@ fn handle_session_established( // `RenderState` vs a `SemanticRenderState` below — a grid session // collapses folds, a semantic one keeps raw-line reckoning until // Stage 3. + // Bottom-panel arc (Q#BP13): panel capability comes from the SAME + // negotiated bit in this same transaction. Stage 1 ships the TUI + // side windows only, so a semantic session is not panel-capable and + // a `side` request falls back to its document target with every + // side-specific parameter discarded. let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, + peer_declared_panel_support(&session_state), ); { let mut core = editor.core.borrow_mut(); @@ -1850,6 +1890,14 @@ fn handle_session_established( } streams.insert(frontend_id, write_stream); term_sizes.insert(frontend_id, initial_size); + // Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its + // authoritative geometry declaration, cached BEFORE any input can + // reach it. A semantic session deliberately stays UNKNOWN — Stage 2's + // authenticated `FrontendCellGeometry` fills it, and the permanent + // 24x80 attach placeholder is never consulted for panel layout. + if editor.core.borrow().panel_capable_for(frontend_id) { + editor.sync_frame_geometry(frontend_id, initial_size); + } if let Some(opened) = opened_target { last_active_buffer_sent.insert(frontend_id, opened.buffer_id); @@ -1933,6 +1981,13 @@ fn handle_dispatcher_event( if let Some(ts) = term_sizes.get_mut(&source) { *ts = size; } + // Bottom-panel arc (Q#BP2b): a frame that can no + // longer satisfy the panel hides it, moves focus out, + // and releases its terminal controller here — before + // the next drained event dispatches. + if editor.core.borrow().panel_capable_for(source) { + editor.sync_frame_geometry(source, size); + } } #[cfg(feature = "crdt")] FrontendEvent::CrdtOp { @@ -2938,6 +2993,10 @@ fn build_fresh_frontend_view( // collapses folds. Passed explicitly from the negotiated // selected-render bit at the call site — never inferred here. fold_projection: bool, + // Bottom-panel arc (Q#BP13): whether this session can RENDER a side + // window. Same explicit-at-the-call-site discipline as + // `fold_projection`; never inferred from a `FrontendId` here. + panel_capable: bool, ) -> crate::window::FrontendView { use crate::text_view::TextView; use crate::window::{FrontendView, Layout, Window, WindowId}; @@ -2946,16 +3005,14 @@ fn build_fresh_frontend_view( // scratch). M10.8's fresh-scratch behavior made overlays // never fire because attaching frontends were in distinct // buffers. - let local_view = core - .views - .get(&FrontendId::LOCAL) - .expect("LOCAL view present"); - let local_active_win_id = local_view.active; + // + // Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT + // buffer, not `local_view.active`. A TUI panel may own focus at + // attach time, and panel content must never become a newly attached + // frontend's full-window document. let buffer_id = core - .windows - .get(&local_active_win_id) - .expect("LOCAL's active window present in core.windows") - .buffer_id; + .primary_document_buffer(FrontendId::LOCAL) + .expect("LOCAL always retains a document window"); let text_view = { let reg = core.registry.borrow(); let buf = reg.get(buffer_id).expect("shared buffer present"); @@ -2968,6 +3025,13 @@ fn build_fresh_frontend_view( layout: Layout::single(id), active: id, fold_projection, + panel_capable, + // Grid sessions cache their real attach/resize size; a semantic + // session stays UNKNOWN until Stage 2's authenticated + // declaration, and must never be sized against the attach + // request's permanent 24×80 placeholder (Q#BP15a). + frame_geometry: None, + panel_hidden: false, } } @@ -3233,7 +3297,7 @@ mod tests { let semantic_peer = FrontendId(20); let live_grid_peer = FrontendId(21); let dead_grid_peer = FrontendId(22); - let semantic_view = build_fresh_frontend_view(&mut editor, false); + let semantic_view = build_fresh_frontend_view(&mut editor, false, false); editor .core .borrow_mut() @@ -3885,6 +3949,9 @@ mod tests { layout: Layout::single(wid), active: wid, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); } @@ -4017,7 +4084,7 @@ mod tests { let fid = FrontendId(99); // Both these fixtures model a SEMANTIC session (Q#FD21: no fold // projection until Stage 3). - let view = build_fresh_frontend_view(&mut editor, false); + let view = build_fresh_frontend_view(&mut editor, false, false); editor.core.borrow_mut().register_frontend_view(fid, view); let before = editor @@ -4080,7 +4147,7 @@ mod tests { let fid = FrontendId(99); // Both these fixtures model a SEMANTIC session (Q#FD21: no fold // projection until Stage 3). - let view = build_fresh_frontend_view(&mut editor, false); + let view = build_fresh_frontend_view(&mut editor, false, false); editor.core.borrow_mut().register_frontend_view(fid, view); assert_eq!( editor diff --git a/src/desktop.rs b/src/desktop.rs index 7f44524..16c15cc 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option let resolve = |wid: WindowId| -> Option { let win = core.windows.get(&wid)?; + // Bottom-panel arc (Q#BP10): side windows are transient display + // policy, never desktop state. Dropping the leaf here makes the + // existing single-surviving-child collapse remove the root + // wrapper too, so the saved tree is the document tree exactly — + // no `SavedLeaf` shape change and no `DESKTOP_VERSION` bump. + if win.is_side() { + return None; + } let path = reg.get(win.buffer_id).ok()?.file_path()?; Some(SavedLeaf { path: path.display().to_string(), @@ -437,6 +445,12 @@ pub fn restore_into( active, // Desktop restore rebuilds LOCAL's grid view (Q#FD21). fold_projection: true, + // …which renders side windows natively (Q#BP13). Every + // field is spelled explicitly, preserving folding's + // non-`Default` discipline. + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); active diff --git a/src/editor.rs b/src/editor.rs index 3cf59e6..d6c6a51 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,6 +161,11 @@ pub struct EditorState { /// Last left-button down event, used to synthesize terminal double /// clicks from crossterm's plain Down/Up mouse event stream. mouse_click: Option, + /// In-progress split-boundary drag (bottom-panel arc, Q#BP5), armed + /// by a left press on a mode-line row that is an exposed segment of a + /// horizontal boundary. Lives beside `mouse_click`; selection is + /// untouched for the whole gesture. + window_drag: Option, } #[derive(Default)] @@ -208,8 +213,26 @@ struct MouseClickState { at: Instant, } +/// An armed split-boundary drag (Q#BP5). +/// +/// `owner` is the window whose bottom mode-line row was pressed; the +/// boundary it resolves to is recomputed on every motion, so a layout +/// mutation mid-drag cannot move a boundary that no longer exists. +#[derive(Copy, Clone)] +struct WindowDragState { + frontend_id: FrontendId, + owner: WindowId, + last_row: u32, +} + const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500); +/// Grip glyph stamped at the right end of a divider segment (Q#BP5a). +/// +/// It lands on the mode line's protected trailing blank, so it adds no +/// column and clobbers no information. +const DIVIDER_HANDLE_GLYPH: char = '⇕'; + impl EditorState { /// Construct a fresh editor for an unnamed scratch buffer. /// @@ -488,6 +511,16 @@ impl EditorState { include_str!("../builtin/runtime/indent.lua"), ) .expect("load indent builtin chunk"); + // Bottom-panel arc: `window.panel-height` / `window.min-height` + // plus the quit and keyboard-resize commands. Must load BEFORE + // listview/compile/terminal, which resolve `window.panel-height` + // when they open a panel. + lua_host + .eval( + Some("@pmacs/builtin/runtime/window.lua"), + include_str!("../builtin/runtime/window.lua"), + ) + .expect("load window builtin chunk"); // Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT: // compile.lua must load AFTER lsp.lua. It takes over // `M-g n` / `M-g p` for the unified error dispatchers, and @@ -586,6 +619,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, + window_drag: None, } } @@ -763,9 +797,74 @@ impl EditorState { && !core.search_active() && !core.query_replace_active() && !core.menu_is_open() - && core - .active_window_for(frontend_id) - .is_some_and(|window| !core.buffer_round_trips(window.buffer_id)) + && core.active_window_for(frontend_id).is_some_and(|window| { + // Bottom-panel arc (Q#BP14a): a focused SIDE window turns + // optimistic apply off for this frontend, independently + // of the buffer-global round-trip set. + // + // Marking the panel's BUFFER round-trip instead would be + // wrong twice: `round_trip_buffers` is keyed by + // `BufferId` across every frontend and window, so it + // would disable optimistic input for another frontend + // editing the same buffer as its document; and an opt-out + // would be unsafe, because the GPU would optimistically + // edit its document mirror while daemon input targets the + // panel — every resulting op then fails remote-op + // validation and the mirror silently diverges. + !window.is_side() && !core.buffer_round_trips(window.buffer_id) + }) + } + + /// The idempotent panel-reconciliation transaction (Q#BP2b). + /// + /// Runs after attach / resize / display / split / close, after any + /// `fixed_rows` or setting change, after any Lua hook or callback + /// transaction that can mutate the layout, and **defensively** before + /// final-focus resolution, input dispatch, terminal sync, and paint. + /// Two events drained in one burst therefore cannot route the second + /// to a panel the first made invisible, and a render callback cannot + /// leave stale panel geometry for the painter. + pub fn reconcile_panel_layout(&self, frontend_id: FrontendId) -> bool { + let outcome = self + .core + .borrow_mut() + .reconcile_panel_layout_core(frontend_id); + if let Some(window_id) = outcome.released_terminal { + // Hiding is a DURABLE transition: the terminal resize path + // merely returns on zero content without releasing the + // controller, so an invisible panel would otherwise keep + // owning its child. + let buffer_id = self + .core + .borrow() + .windows + .get(&window_id) + .map(|window| window.buffer_id); + if let Some(buffer_id) = buffer_id { + let _ = self + .terminal_manager + .borrow_mut() + .release_controller(crate::terminal::TerminalViewKey { + frontend_id, + window_id, + buffer_id, + }); + } + } + outcome.changed + } + + /// Cache one frontend's authoritative frame capacity and reconcile + /// (Q#BP2b / Q#BP15a). + /// + /// The single seam for grid and `LOCAL` views, whose real attach and + /// resize sizes ARE the declaration. A semantic view never calls this + /// in Stage 1; its geometry stays **unknown**. + pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) { + self.core + .borrow_mut() + .declare_frame_geometry(frontend_id, total); + self.reconcile_panel_layout(frontend_id); } /// Local-frontend compatibility wrapper. @@ -800,6 +899,10 @@ impl EditorState { // Authenticate every path through this input event, including modal // callbacks such as M-x minibuffer acceptance. let _origin = self.interactive_origin.enter(frontend_id); + // Bottom-panel arc (Q#BP2b): reconcile defensively before input + // dispatch, so two events drained in one burst cannot route the + // second to a panel the first made invisible. + self.reconcile_panel_layout(frontend_id); let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); @@ -1084,6 +1187,10 @@ impl EditorState { /// /// This is called before process drain and paint, never from rendering. pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool { + // Bottom-panel arc (Q#BP2b): a panel that just became + // unsatisfiable must have released its controller before this + // runs, or the child would be resized against a dead rect. + self.reconcile_panel_layout(frontend_id); let Some(key) = self .terminal_manager .borrow() @@ -1815,6 +1922,21 @@ impl EditorState { return; } + // Bottom-panel arc (Q#BP5): an armed divider drag owns the + // pointer for the whole gesture, INCLUDING rows outside any + // window — otherwise tracking would stop the moment the pointer + // crossed the frame's status row. + if self.window_drag.is_some() { + match ev.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.drag_window_boundary(frontend_id, cell_row, term_size); + } + MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, + _ => self.window_drag = None, + } + return; + } + let Some((win_id, rect)) = window_at_cell( &self.core.borrow(), frontend_id, @@ -1826,6 +1948,15 @@ impl EditorState { }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); + // A press on a mode-line row that is an exposed segment of a + // horizontal boundary arms a divider drag, ahead of the terminal + // router: a document terminal above the panel owns a boundary + // too. Selection is untouched, so this click still creates none. + if matches!(ev.kind, MouseEventKind::Down(MouseButton::Left)) && local_row >= inner_rows { + self.mouse_click = None; + self.arm_window_drag(frontend_id, win_id, cell_row); + return; + } let buffer_id = self.core.borrow().windows[&win_id].buffer_id; if self.terminal_manager.borrow().is_terminal(buffer_id) { let content_size = CellSize::new(inner_rows, rect.size.cols); @@ -1928,6 +2059,112 @@ impl EditorState { } } + /// Arm a divider drag if `owner`'s bottom row really is an exposed + /// segment of a horizontal boundary (Q#BP5). + fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { + let is_divider = self.core.borrow().views.get(&frontend_id).is_some_and(|view| { + view.layout.boundary_below(owner).is_some() + }); + self.window_drag = is_divider.then_some(WindowDragState { + frontend_id, + owner, + last_row: cell_row, + }); + } + + /// Continue an armed divider drag (Q#BP5). + /// + /// The boundary is re-resolved from `owner` on every motion, so a + /// layout mutation mid-drag cannot move a boundary that no longer + /// exists. Motion is applied incrementally and re-anchored each + /// event, so the clamp absorbs over-travel instead of accumulating it. + fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { + let Some(drag) = self.window_drag else { + return; + }; + if drag.frontend_id != frontend_id { + return; + } + self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); + let delta = i64::from(cell_row) - i64::from(drag.last_row); + let Ok(delta) = i32::try_from(delta) else { + return; + }; + if delta == 0 || term_size.rows < 2 { + return; + } + // A drag that runs into the clamp is a no-op, not an error to + // surface: the pointer simply cannot move the boundary further. + let _ = self.resize_window_boundary(frontend_id, drag.owner, delta, term_size.rows - 1); + } + + /// Move the boundary `win` owns by `delta_rows`, growing `win` + /// (Q#BP5 / Q#BP5b), under the interactive `window.min-height` + /// preference snapshotted before any geometry changes. + /// + /// Returns the core's pointed error, if any; a `no adjustable + /// horizontal boundary` result is a no-op by construction. + pub fn resize_window_boundary( + &self, + frontend_id: FrontendId, + win: WindowId, + delta_rows: i32, + area_rows: u32, + ) -> Result<(), String> { + // One gesture, one set of minima: resolved against each leaf's + // CURRENT buffer (buffer-local override → global → default) + // before the geometry moves. + let minima: HashMap = { + let core = self.core.borrow(); + core.views + .get(&frontend_id) + .map(|view| { + view.layout + .iter_ids() + .into_iter() + .map(|id| { + let buffer_id = core.windows.get(&id).map(|w| w.buffer_id); + (id, self.window_min_height(buffer_id)) + }) + .collect() + }) + .unwrap_or_default() + }; + let result = self.core.borrow_mut().resize_boundary( + frontend_id, + win, + delta_rows, + area_rows, + &|id| { + minima + .get(&id) + .copied() + .unwrap_or(crate::window::MIN_WINDOW_OUTER_ROWS) + }, + ); + if result.is_ok() { + self.reconcile_panel_layout(frontend_id); + } + result + } + + /// Resolve the `window.min-height` preference for a buffer, clamped + /// into `[MIN_WINDOW_OUTER_ROWS, …]` (Q#BP2). + /// + /// A core with no Lua host — or one whose runtime has not defined the + /// setting — falls back to the structural floor, so the preference + /// can never make an existing layout invalid. + #[must_use] + pub fn window_min_height(&self, buffer_id: Option) -> u32 { + crate::lua_bindings::config_u32( + self.lua_host.lua(), + "window.min-height", + buffer_id, + crate::window::MIN_WINDOW_OUTER_ROWS, + ) + .max(crate::window::MIN_WINDOW_OUTER_ROWS) + } + fn dispatch_terminal_mouse( &mut self, key: TerminalViewKey, @@ -2368,8 +2605,12 @@ pub(crate) fn window_placements( return HashMap::new(); }; let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); + // Bottom-panel arc (Q#BP2, R5-B1): both production `Layout::compute` + // callers feed in the SAME shared fixed map, so a side window's rows + // are identical in the placement pass and the peer-overlay pass. + let fixed = core.panel_fixed_rows(frontend_id, area.size.rows); view.layout - .compute(area) + .compute(area, &fixed) .into_iter() .map(|(window_id, outer)| { let content = Rect::new( @@ -2834,6 +3075,13 @@ pub fn paint_frame( if term_size.rows < 2 || term_size.cols == 0 { return None; } + // Bottom-panel arc (Q#BP2b/Q#BP15a): a grid frontend's real frame + // size IS its authoritative geometry declaration. Declaring and + // reconciling here — before the statusline fan-out and before the + // long mutable borrow — means the painter never sees stale panel + // geometry, and a panel the frame can no longer satisfy has already + // surrendered focus and its terminal controller. + state.sync_frame_geometry(frontend_id, term_size); // Statusline callbacks may call arbitrary editor APIs. Evaluate the // complete visible-window fan-out before the long mutable core borrow // below, then paint only the transactionally validated owned results. @@ -2871,6 +3119,21 @@ pub fn paint_frame( let placements = window_placements(core, frontend_id, term_size); let active = core.views.get(&frontend_id)?.active; + // Bottom-panel arc (Q#BP5a): the divider IS the upper subtree's + // existing mode-line row — no row is added or consumed, and + // `fixed_rows` excludes it. Resolved once per frame, before the + // mutable per-window loop borrows `core.windows`. A boundary whose + // upper child is a nested subtree exposes SEVERAL leaf segments along + // the same edge, so the root panel divider is full width even when + // the document subtree ends in several columns. + let divider_windows: Vec = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { + view.layout + .iter_ids() + .into_iter() + .filter(|id| view.layout.boundary_below(*id).is_some()) + .collect() + }); + let divider_style = theme.face("ui.divider"); // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -3094,6 +3357,12 @@ pub fn paint_frame( } drop(reg); + for id in ÷r_windows { + if let Some(placement) = placements.get(id) { + paint_divider_segment(grid, &placement.outer, divider_style); + } + } + paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme); // An active isearch owns the bottom row (its prompt + match @@ -3582,6 +3851,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { /// Paint complete graphemes at a logical signed origin. A grapheme that /// straddles either clip edge is omitted wholesale, so a wide glyph can never /// leave a dangling half-cell at a window or left/right collision boundary. +/// Restyle one exposed segment of a horizontal split boundary and stamp +/// its grip (Q#BP5a). +/// +/// The segment is the window's own mode-line row: the glyphs the mode +/// line already painted are preserved, only the *surface* changes, and +/// the grip lands on the protected suffix's trailing blank. `ui.divider` +/// resolves through the ordinary `ui.*` face walk, so an unset face +/// leaves today's mode-line surface untouched and the affordance is the +/// grip alone. +fn paint_divider_segment( + grid: &mut crate::cell::CellGrid<'_>, + rect: &crate::window::Rect, + style: Option, +) { + if rect.size.rows == 0 || rect.size.cols == 0 { + return; + } + let row = rect.origin.row + rect.size.rows - 1; + if let Some(style) = style { + for col in 0..rect.size.cols { + grid.at(CellCoord::new(row, rect.origin.col + col)).style = style; + } + } + let cell = grid.at(CellCoord::new(row, rect.origin.col + rect.size.cols - 1)); + cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH); +} + fn paint_mode_line_graphemes( grid: &mut crate::cell::CellGrid<'_>, rect: &crate::window::Rect, @@ -6360,7 +6656,8 @@ mod tests { let core = s.core.borrow(); assert_eq!(core.windows.len(), 8); let area = crate::window::Rect::new(0, 0, 40, 120); - let placements = core.active_layout().compute(area); + let fixed = core.panel_fixed_rows(core.active_frontend_key(), area.size.rows); + let placements = core.active_layout().compute(area, &fixed); assert_eq!(placements.len(), 8); for r in placements.values() { assert!(!r.is_empty(), "rect was empty: {r:?}"); @@ -6780,12 +7077,18 @@ mod tests { .core .borrow() .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 90)); + .compute( + crate::window::Rect::new(0, 0, 24, 90), + &std::collections::HashMap::new(), + ); let p2 = s .core .borrow() .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 60)); + .compute( + crate::window::Rect::new(0, 0, 24, 60), + &std::collections::HashMap::new(), + ); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 37cdabf..9ab4bca 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -33,7 +33,10 @@ use crate::rope::Edit; use crate::rope::{Position, Range}; use crate::text_view::TextView; use crate::view::{DisplayCoord, View}; -use crate::window::{FrontendView, Layout, Orientation, Window, WindowId}; +use crate::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Side, Window, WindowId, subtree_min_rows, +}; /// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op. /// @@ -57,6 +60,157 @@ pub enum CrdtOpOrigin { DaemonKey, } +/// One recorded jump origin (bottom-panel arc, Q#BP11c). +/// +/// `window_id` and `side_origin` are what make `M-,` correct once a panel +/// can be a separate window: restoring into the recorded window keeps the +/// document window untouched, and a *side* origin that no longer +/// revalidates is **skipped** rather than degrading to an active-window +/// switch — that degradation is exactly the duplicate-panel corruption +/// this design removes. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct JumpEntry { + /// Window the origin was recorded in. + pub window_id: WindowId, + /// Buffer displayed there at the time. + pub buffer_id: BufferId, + /// Cursor position to restore. + pub position: Position, + /// Whether `window_id` was a side window when recorded. + pub side_origin: bool, +} + +/// Which lifecycle hook Phase 2 of the display transaction must fire +/// **with the target window active** (Q#BP4 / Q#BP11b). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum HookKind { + /// `buffer.after-switch` — a reuse, including a same-buffer no-op. + AfterSwitch, + /// `buffer.after-load` — a fresh load. saveplace, recentf, syntax + /// and LSP all require the document target to be active for this. + AfterLoad, + /// Nothing to fire (a newly created path-backed buffer for a + /// `NotFound` path, matching initial-target / local-startup). + None, +} + +/// A `display_buffer` request (Q#BP3). +/// +/// `height` and `dedicated` are deliberately option-valued at the policy +/// boundary: omission is **not** silently equivalent to an explicit +/// zero/false, which is what lets a user-resized panel keep its height as +/// compile and listview replace one another. +#[derive(Clone, Debug)] +pub struct DisplayRequest { + /// Buffer to display. + pub buffer_id: BufferId, + /// Exact target window. Mutually exclusive with `side`. + pub window: Option, + /// Requested side. Mutually exclusive with `window`. + pub side: Option, + /// Explicit requested outer rows for a side placement. + pub height: Option, + /// Explicit dedication for the installed presentation. + pub dedicated: Option, + /// Explicit final-focus request. Omission defaults to `false` for an + /// actual side target and `true` for an ordinary one; an explicit + /// value survives fallback unchanged. + pub select: Option, + /// The caller's resolved `window.panel-height`, used only when a side + /// slot is **created** with no explicit `height`. + pub default_panel_rows: u32, +} + +impl DisplayRequest { + /// A bare ordinary-placement request for `buffer_id`. + #[must_use] + pub fn new(buffer_id: BufferId) -> Self { + Self { + buffer_id, + window: None, + side: None, + height: None, + dedicated: None, + select: None, + default_panel_rows: crate::window::DEFAULT_PANEL_ROWS, + } + } +} + +/// What Phase 1 of the display transaction decided (Q#BP4). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DisplayOutcome { + /// Window the buffer was installed in. + pub target: WindowId, + /// The frontend's focused window before Phase 1 ran. + pub saved_active: WindowId, + /// Resolved final-focus request. + pub select: bool, + /// Whether this call created the side window — the adopter rollback + /// hook (a terminal whose session fails to start must remove the + /// wrapper it just created). + pub created_side: bool, +} + +/// What [`EditorCore::reconcile_panel_layout_core`] resolved (Q#BP2b). +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct PanelReconciliation { + /// The panel's effective visibility after the transaction. + pub hidden: bool, + /// Whether `hidden` changed in this transaction — Stage 2 keys its + /// authoritative `PanelFrame::Absent` / fresh `Present` on this. + pub changed: bool, + /// A side window whose terminal controller the caller must release, + /// because focus just left an invisible panel. + pub released_terminal: Option, +} + +/// Row extent of an arbitrary subtree, derived from its leaves' computed +/// rects: leaves tile their parent, so the union's height is the node's. +fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { + let ids = crate::window::node_ids(node); + let mut lo = u32::MAX; + let mut hi = 0u32; + for id in ids { + let Some(rect) = placements.get(&id) else { + continue; + }; + lo = lo.min(rect.origin.row); + hi = hi.max(rect.origin.row + rect.size.rows); + } + if lo == u32::MAX { 0 } else { hi - lo } +} + +/// What Phase 1 of `window.quit` did (Q#BP2c). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum QuitOutcome { + /// The side window was closed and its wrapper collapsed. + Deleted { + /// Where focus landed, when the frontend still has a view. + focus: Option, + }, + /// A saved presentation was reinstalled; Phase 2 must fire the + /// ordinary switch hook so overlays reattach. + Restored { + /// The window that was restored. + target: WindowId, + /// The buffer now displayed there. + buffer_id: BufferId, + }, +} + +#[derive(Copy, Clone, Debug)] +struct Placement { + target: WindowId, + kind: PlacementKind, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PlacementKind { + Ordinary, + Side { created: bool, replacing: bool }, +} + /// Live state of an in-progress incremental search (Q#SR5). /// /// Present only while an isearch is running (`EditorCore::search`); @@ -285,7 +439,16 @@ pub struct EditorCore { /// this without limit. Entries naming a now-removed buffer are /// skipped on pop (stale-handle safe, mirrors the registry's /// `Missing` contract). - pub jump_ring: Vec<(BufferId, Position)>, + /// + /// **Per frontend** (bottom-panel arc, Q#BP11c), matching + /// `command_history`. Once a panel is a separate window, an entry + /// must remember *which window* it was recorded in — otherwise `M-,` + /// from a source file would switch the **document** window to the + /// panel's buffer while the panel stays open, duplicating the + /// presentation. Keying the whole ring by frontend additionally + /// stops one frontend consuming or destroying another's navigation + /// trail; detach purges the vector. + pub jump_ring: HashMap>, /// In-buffer incremental search store (Q#SR1). Per-buffer query + /// matches + active index, written by the search session / /// `search.*` commands and read by the decorations producer @@ -388,6 +551,11 @@ impl EditorCore { active: id, // LOCAL is the in-process grid editor (Q#FD21). fold_projection: true, + // …and it renders side windows natively (Q#BP13). + panel_capable: true, + // Real geometry arrives with the first render/resize. + frame_geometry: None, + panel_hidden: false, }, ); Self { @@ -400,7 +568,7 @@ impl EditorCore { minibuffer: Minibuffer::new(), active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), - jump_ring: Vec::new(), + jump_ring: HashMap::new(), search_store: crate::search::make_shared_store(), theme: None, search: None, @@ -595,6 +763,10 @@ impl EditorCore { /// closing a window left others intact). pub fn unregister_frontend_view(&mut self, fid: FrontendId) { self.views.remove(&fid); + // Bottom-panel arc (Q#BP11c): a detached frontend's navigation + // trail dies with its view — its `WindowId`s are gone, and no + // other frontend may pop or destroy those entries. + self.jump_ring.remove(&fid); if self.active_frontend == fid { self.active_frontend = FrontendId::LOCAL; } @@ -668,10 +840,10 @@ impl EditorCore { /// Propagates a load failure (e.g. a since-deleted file) so restore /// can skip that leaf rather than abort. pub fn get_or_load_buffer(&mut self, path: &Path) -> std::io::Result<(BufferId, bool)> { - let normalized = normalize_buffer_path(path.to_path_buf()); - if let Some(id) = self.registry.borrow().find_by_path(&normalized) { + if let Some(id) = self.find_buffer_for_path(path) { return Ok((id, false)); } + let normalized = normalize_buffer_path(path.to_path_buf()); let (bytes, meta) = crate::file_io::load_file(path)?; let display_name = path.display().to_string(); let id = self @@ -683,6 +855,51 @@ impl EditorCore { Ok((id, true)) } + /// The buffer already bound to `path`, under the same normalization + /// [`Self::get_or_load_buffer`] uses — **side-effect free**, so a + /// target-aware display can resolve its destination *before* any I/O + /// (Q#BP11b step 1: an ineligible destination must fail without + /// loading the file). + #[must_use] + pub fn find_buffer_for_path(&self, path: &Path) -> Option { + let normalized = normalize_buffer_path(path.to_path_buf()); + self.registry.borrow().find_by_path(&normalized) + } + + /// The shared resolve/load-without-switch primitive behind both + /// `pmacs.window.display_file` and the daemon's initial-target + /// bootstrap (Q#BP11b). + /// + /// Returns the buffer plus the hook Phase 2 must fire **with the + /// destination window active**: `AfterSwitch` for a dedup hit + /// (including a same-buffer no-op), `AfterLoad` for a fresh load, and + /// `None` for a path that does not exist yet — a `NotFound` path + /// becomes an empty path-backed buffer and fires nothing, matching + /// the initial-target and local-startup contract. + /// + /// One primitive, so two path-normalization, dedup, and hook + /// transactions cannot drift apart. + /// + /// # Errors + /// Any load failure other than `NotFound`. + pub fn resolve_target_buffer( + &mut self, + path: &Path, + ) -> Result<(BufferId, HookKind), String> { + match self.get_or_load_buffer(path) { + Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), + Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let display_path = path.display().to_string(); + let buffer_id = self.registry.borrow_mut().create(display_path); + self.set_buffer_path(buffer_id, Some(path.to_path_buf())); + "[new file]".clone_into(&mut self.status); + Ok((buffer_id, HookKind::None)) + } + Err(error) => Err(format!("cannot open {}: {error}", path.display())), + } + } + /// Cursor of the active window (compatibility shim for callers /// migrated from pre-M2.8 code). #[must_use] @@ -796,11 +1013,27 @@ impl EditorCore { /// origin is evicted (front drop) — the user keeps the most /// recent trail, which is the one they're likely to unwind. pub fn push_jump(&mut self) { - let entry = (self.active_buffer_id(), self.cursor()); - if self.jump_ring.len() >= Self::JUMP_RING_CAP { - self.jump_ring.remove(0); + let fid = self.active_frontend; + let window_id = self.active_window_id(); + let entry = JumpEntry { + window_id, + buffer_id: self.active_buffer_id(), + position: self.cursor(), + side_origin: self + .windows + .get(&window_id) + .is_some_and(crate::window::Window::is_side), + }; + let ring = self.jump_ring.entry(fid).or_default(); + if ring.len() >= Self::JUMP_RING_CAP { + ring.remove(0); } - self.jump_ring.push(entry); + ring.push(entry); + } + + /// Drop one detached frontend's navigation trail (Q#BP11c). + pub fn purge_jump_ring(&mut self, fid: FrontendId) { + self.jump_ring.remove(&fid); } /// Pop the most recent jump origin and move there. Returns @@ -811,21 +1044,66 @@ impl EditorCore { /// it finds a live target or the ring empties), so a jump-back /// never lands on a missing buffer. The restored cursor is /// clamped to the (possibly now shorter) buffer length. + /// + /// # Origin windows (Q#BP11c) + /// + /// The entry restores into its **origin window** when that window is + /// live, belongs to the acting frontend's layout, is not a hidden + /// side window, and **still shows the recorded buffer**. A live panel + /// that has since been replaced does not resurrect its old buffer. + /// + /// When revalidation fails the entry degrades differently by origin + /// kind. A **non-side** origin falls back to today's active-window + /// switch. A **side** origin is *skipped*: switching a panel's buffer + /// into the document window is precisely the duplicate-presentation + /// corruption this design removes. pub fn jump_back(&mut self) -> bool { - while let Some((bid, pos)) = self.jump_ring.pop() { - if !self.registry.borrow().contains(bid) { + let fid = self.active_frontend; + loop { + let Some(entry) = self + .jump_ring + .get_mut(&fid) + .and_then(std::vec::Vec::pop) + else { + return false; + }; + if !self.registry.borrow().contains(entry.buffer_id) { continue; } - if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() { + let origin_valid = self + .views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&entry.window_id)) + && self + .windows + .get(&entry.window_id) + .is_some_and(|window| window.buffer_id == entry.buffer_id) + && !self.side_window_is_hidden(fid, entry.window_id); + if origin_valid { + self.set_active_window_id(entry.window_id); + } else if entry.side_origin { + continue; + } else if self.active_buffer_id() != entry.buffer_id + && self.switch_active_buffer(entry.buffer_id).is_err() + { continue; } - let clamped = pos.min(self.active_buffer_len()); + let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); aw.cursor = clamped; aw.goal_col = None; return true; } - false + } + + /// True when `win` is a side window on `fid` and that frontend's + /// panel is currently derived-hidden (Q#BP2b). + #[must_use] + fn side_window_is_hidden(&self, fid: FrontendId, win: WindowId) -> bool { + self.windows + .get(&win) + .is_some_and(crate::window::Window::is_side) + && self.views.get(&fid).is_some_and(|view| view.panel_hidden) } // ---- incremental search (Q#SR5) ---------------------------------------- @@ -2296,6 +2574,27 @@ impl EditorCore { // ---- window operations ------------------------------------------------- + /// [`Self::split_active`], refusing a side window (Q#BP6): the panel + /// is a leaf of the root-level wrapper, so splitting it would produce + /// a second, unallocatable side slot. + /// + /// # Errors + /// When the active window is a side window. + pub fn try_split_active( + &mut self, + orientation: Orientation, + same_buffer: bool, + ) -> Result { + if self + .windows + .get(&self.active_window_id()) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.split: not available in a side window".into()); + } + Ok(self.split_active(orientation, same_buffer)) + } + /// Split the active window. Returns the new window's id. /// `same_buffer` controls whether the new window opens on the /// active buffer (Emacs default) or a fresh `*scratch*` buffer. @@ -2334,52 +2633,129 @@ impl EditorCore { /// Move focus to the next window in iteration order. pub fn focus_next(&mut self) { - let active = self.active_window_id(); - let next = self.active_layout().focus_next(active); - self.set_active_window_id(next); + self.focus_step(true); } /// Move focus to the previous window in iteration order. pub fn focus_prev(&mut self) { - let active = self.active_window_id(); - let prev = self.active_layout().focus_prev(active); - self.set_active_window_id(prev); + self.focus_step(false); } - /// Close the active window (unless it's the only one in this - /// frontend). Returns false if the active frontend's layout has a - /// single window. + /// Shared `C-x o` traversal, skipping a **hidden** side window + /// (Q#BP6): keys must never route to an invisible panel, and once it + /// reappears traversal reaches it normally again. + /// + /// Also the seam that refreshes `origin_document` (Q#BP2c): entering + /// the panel from document window B must retarget `display_target`, + /// panel visits, and a `Delete`-form `window.quit` at B rather than + /// at whichever window happened to create the panel. + fn focus_step(&mut self, forward: bool) { + let fid = self.active_frontend_key(); + let active = self.active_window_id(); + let hidden_panel = if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + self.side_window_for(fid) + } else { + None + }; + let next = self + .active_layout() + .focus_step(active, forward, &|id| Some(id) != hidden_panel); + self.set_active_window_id(next); + self.note_focus_transition(fid, active, next); + } + + /// Focus an explicit window in the acting frontend, refreshing the + /// panel's remembered document origin on the way (Q#BP2c). + pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + let previous = view.active; + view.active = target; + self.note_focus_transition(fid, previous, target); + } + + /// Close the active window. Returns false when the layout would be + /// left with no **document** window. + /// + /// Q#BP6 narrows the pre-arc "unless it's the only one" rule: a side + /// window is never load-bearing, so closing the panel itself is + /// always legal — including when it is the only other window — while + /// closing the last *non-side* window is always refused. pub fn close_active(&mut self) -> bool { // Per-frontend: gate on the *active frontend's* window count, not // the global `self.windows` set. Every attached frontend keeps its // own windows in `self.windows`, so a global `<= 1` check let a // multi-frontend session close a frontend's last window and then // panic picking a successor from the now-empty layout. - if self.active_layout().iter_ids().len() <= 1 { - return false; - } + let fid = self.active_frontend_key(); let target = self.active_window_id(); + let target_is_side = self + .windows + .get(&target) + .is_some_and(crate::window::Window::is_side); + if !target_is_side { + let remaining_documents = self + .active_layout() + .iter_ids() + .into_iter() + .filter(|id| { + *id != target + && !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .count(); + if remaining_documents == 0 { + return false; + } + } self.active_layout_mut().close_window(target); self.windows.remove(&target); - // Pick an adjacent window as the new focus. - let next = *self - .active_layout() - .iter_ids() - .first() - .expect("at least one window remains"); + if target_is_side { + if let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; + } + } + // Pick an adjacent window as the new focus, preferring a document. + let ids = self.active_layout().iter_ids(); + let next = ids + .iter() + .copied() + .find(|id| { + !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .unwrap_or_else(|| *ids.first().expect("at least one window remains")); + let previous = self.active_window_id(); self.set_active_window_id(next); + self.note_focus_transition(fid, previous, next); true } /// Close every window except the active one, *within the active - /// frontend*. - pub fn close_others(&mut self) { + /// frontend* — including the panel (Q#BP6). + /// + /// # Errors + /// From a side window: a panel cannot swallow the document tree. + pub fn close_others(&mut self) -> Result<(), String> { // Per-frontend: only prune the active frontend's own layout. The // global `self.windows` set holds every frontend's windows, so a // global `retain(|id| id == keep)` deleted OTHER frontends' // windows — leaving their `view.active` dangling and panicking the // next `active_window()` (the multi-frontend close-others crash). let keep = self.active_window_id(); + if self + .windows + .get(&keep) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.close-others: not available in a side window".into()); + } + let fid = self.active_frontend_key(); let doomed: Vec = self .active_layout() .iter_ids() @@ -2390,6 +2766,993 @@ impl EditorCore { for id in doomed { self.windows.remove(&id); } + if let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; + } + Ok(()) + } + + /// The `views` key the active-frontend accessors resolve to. + #[must_use] + pub fn active_frontend_key(&self) -> FrontendId { + if self.views.contains_key(&self.active_frontend) { + self.active_frontend + } else { + FrontendId::LOCAL + } + } + + // ---- side windows + display policy (bottom-panel arc) ------------------ + + /// The one side leaf in `fid`'s layout, if it has one (Q#BP2a). + #[must_use] + pub fn side_window_for(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }) + } + + /// Whether `fid`'s side window exists but is currently hidden. + #[must_use] + pub fn panel_hidden_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_hidden) + && self.side_window_for(fid).is_some() + } + + /// Whether `fid` can render a side window at all (Q#BP13). + #[must_use] + pub fn panel_capable_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_capable) + } + + /// **The** primary document window for `fid` (Q#BP14). + /// + /// The frontend's active window when it is non-side, else its + /// non-side target. Every consumer classified *Projection* in the + /// framing's §1.3 census routes through this rather than through + /// `active_window_for` / `active_buffer_id`, so focusing a panel + /// re-sends no snapshot, suppresses no document, swaps no mirror, + /// and cannot leak into a newly attached frontend's document view. + #[must_use] + pub fn primary_document_window(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + if !self + .windows + .get(&view.active) + .is_some_and(crate::window::Window::is_side) + { + return Some(view.active); + } + self.non_side_target(fid).ok() + } + + /// [`Self::primary_document_window`]'s buffer, falling back to the + /// focused window's when the layout is degenerate. + #[must_use] + pub fn primary_document_buffer(&self, fid: FrontendId) -> Option { + let win = self.primary_document_window(fid)?; + self.windows.get(&win).map(|window| window.buffer_id) + } + + /// The non-side target rule (Q#BP11a). + /// + /// 1. the selected window when it is **not** a side window + /// (byte-identical to pre-arc behavior), + /// 2. else the remembered `origin_document`, when it revalidates, + /// 3. else the first non-side window in `iter_ids()` order, + /// 4. else a pointed error. There is no document leaf from which a + /// valid fallback could be fabricated, and Q#BP6 forbids this as + /// a resting state, so the broken invariant is asserted rather + /// than papered over. + /// + /// # Errors + /// When `fid` has no view, or its layout holds no non-side window. + pub fn non_side_target(&self, fid: FrontendId) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if !is_side(view.active) { + return Ok(view.active); + } + if let Some(origin) = self + .windows + .get(&view.active) + .and_then(|w| w.params.origin_document()) + && view.layout.iter_ids().contains(&origin) + && !is_side(origin) + { + return Ok(origin); + } + if let Some(first) = view.layout.iter_ids().into_iter().find(|id| !is_side(*id)) { + return Ok(first); + } + debug_assert!( + false, + "invariant (Q#BP6): a frontend layout always retains at least one non-side window" + ); + Err("no document window is available".into()) + } + + /// Record the document window a focus transition into the panel came + /// from (Q#BP2c). + /// + /// Called on every focus change. Only a **non-side → side** + /// transition refreshes the memory: panel→panel redisplay and + /// passive display must not overwrite it, and a creation-only + /// origin would go stale the moment the user entered the panel from + /// a different document split. + pub fn note_focus_transition(&mut self, fid: FrontendId, from: WindowId, to: WindowId) { + if from == to { + return; + } + debug_assert!( + self.views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&to)), + "focus transition target must belong to the acting frontend's layout" + ); + let from_side = self + .windows + .get(&from) + .is_some_and(crate::window::Window::is_side); + let to_side = self + .windows + .get(&to) + .is_some_and(crate::window::Window::is_side); + if from_side || !to_side { + return; + } + if let Some(window) = self.windows.get_mut(&to) { + window.params.set_origin_document(Some(from)); + } + } + + /// Minimum outer rows the document subtree beneath `fid`'s panel + /// wrapper needs (Q#BP2). Falls back to the whole root when the tree + /// does not have the wrapper shape. + #[must_use] + fn document_min_rows(&self, fid: FrontendId) -> u32 { + let Some(view) = self.views.get(&fid) else { + return MIN_WINDOW_OUTER_ROWS; + }; + let node = self + .side_window_for(fid) + .and_then(|side| view.layout.document_subtree(side)) + .unwrap_or(&view.layout.root); + subtree_min_rows(node) + } + + /// The panel's **effective** row allocation on a frame whose window + /// area is `area_rows` (Q#BP2), or `None` when it cannot be + /// satisfied and must be hidden. + /// + /// `min(requested, area_rows - subtree_min_rows(document_root))`, then + /// the structural floor. This is the whole bounded promise: the panel + /// allocator never makes an otherwise satisfiable document tree + /// unsatisfiable, and what the frame does to a document tree that + /// could not fit anyway is unchanged behavior. + #[must_use] + pub fn panel_allocation(&self, fid: FrontendId, area_rows: u32) -> Option { + let side = self.side_window_for(fid)?; + let requested = self.windows.get(&side)?.params.fixed_rows?; + let allowed = area_rows.saturating_sub(self.document_min_rows(fid)); + let alloc = requested.min(allowed); + (alloc >= MIN_WINDOW_OUTER_ROWS).then_some(alloc) + } + + /// The fixed-extent map both [`crate::window::Layout::compute`] + /// production callers feed in (Q#BP2, R5-B1). + /// + /// Derived by this one shared helper rather than assembled at each + /// call site: `window_placements` and the peer-presence overlay pass + /// build different areas, and leaving the second on unfixed geometry + /// would paint every peer cursor at the row it would occupy with no + /// panel open. + /// + /// A hidden panel maps to `0`, which is Q#BP2's exact effective + /// geometry for that state: the side leaf gets an empty rect, the + /// document subtree receives every reclaimed row, and the stored + /// request, wrapper, ids, weights, and order all stay intact. + #[must_use] + pub fn panel_fixed_rows(&self, fid: FrontendId, area_rows: u32) -> HashMap { + let mut fixed = HashMap::new(); + let Some(side) = self.side_window_for(fid) else { + return fixed; + }; + if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + fixed.insert(side, 0); + return fixed; + } + fixed.insert(side, self.panel_allocation(fid, area_rows).unwrap_or(0)); + fixed + } + + /// Delete `side` from `fid`'s layout, collapsing the root-level + /// wrapper and rehoming focus (Q#BP2a). + /// + /// Idempotent and safe to call from `kill_buffer`: the wrapper + /// collapse is `Layout::close_window`'s existing + /// `collapse_single_child_splits` pass, so no new tree code runs. + pub fn remove_side_window(&mut self, fid: FrontendId, side: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if !view.layout.close_window(side) { + return; + } + view.panel_hidden = false; + let was_active = view.active == side; + if was_active { + let fallback = *view + .layout + .iter_ids() + .first() + .expect("Q#BP6: a document leaf always survives the wrapper collapse"); + view.active = fallback; + } + self.windows.remove(&side); + if was_active + && let Ok(target) = self.non_side_target(fid) + { + if let Some(view) = self.views.get_mut(&fid) { + view.active = target; + } + } + // A remembered origin pointing at a now-dead window is cleared by + // `non_side_target`'s revalidation on next use; nothing else here + // may reference the removed id. + for window in self.windows.values_mut() { + if window.params.origin_document() == Some(side) { + window.params.set_origin_document(None); + } + } + } + + /// Phase 1 of `window.quit` (Q#BP2c / Q#BP11b). + /// + /// Executes the window's recorded [`QuitAction`], returning the + /// Phase-2 transaction Q#BP4 owns. A `Restore` whose buffer has been + /// killed fails closed to `Delete`, dropping the unusable chain. + /// + /// # Errors + /// A window with no recorded action returns a pointed error **without + /// closing or switching anything** — non-side adopter fallbacks call + /// their own existing restore path instead. + pub fn quit_window( + &mut self, + fid: FrontendId, + target: WindowId, + ) -> Result { + let action = self + .windows + .get(&target) + .ok_or_else(|| format!("window {} is not live", target.raw()))? + .params + .quit_action() + .cloned() + .ok_or_else(|| "window.quit: this window has no quit action".to_string())?; + let action = match action { + QuitAction::Restore { buffer_id, .. } + if !self.registry.borrow().contains(buffer_id) => + { + QuitAction::Delete + } + other => other, + }; + match action { + QuitAction::Delete => { + let saved_active = self.views.get(&fid).map(|view| view.active); + self.remove_side_window(fid, target); + Ok(QuitOutcome::Deleted { + focus: self + .views + .get(&fid) + .map(|view| view.active) + .or(saved_active), + }) + } + QuitAction::Restore { + buffer_id, + fixed_rows, + dedicated, + cursor, + view_top, + goal_col, + selection, + then, + } => { + self.install_buffer_in_window(target, buffer_id)?; + let len = { + let reg = self.registry.borrow(); + reg.get(buffer_id).map(Buffer::len).unwrap_or(0) + }; + let window = self + .windows + .get_mut(&target) + .ok_or_else(|| "window.quit: target vanished".to_string())?; + window.params.fixed_rows = Some(fixed_rows.max(MIN_WINDOW_OUTER_ROWS)); + window.params.dedicated = dedicated; + window.params.set_quit_action(Some(*then)); + // Clamp saved positions against the buffer's CURRENT + // contents: it may have shrunk while the panel showed + // something else. Derived `last_visible_rows` and + // trait-object overlays are deliberately not snapshotted — + // the switch hook reattaches overlays. + window.cursor = cursor.min(len); + window.view_top = view_top; + window.goal_col = goal_col; + window.selection = selection.filter(|sel| sel.anchor <= len); + Ok(QuitOutcome::Restored { + target, + buffer_id, + }) + } + } + } + + /// Clamp a programmatic `fixed_rows` request (Q#BP2). + /// + /// # Errors + /// A request of `0` is rejected rather than being an invisible + /// "open". + pub fn clamp_panel_rows(rows: u32) -> Result { + if rows == 0 { + return Err("panel height must be at least 1 row".into()); + } + Ok(rows.max(MIN_WINDOW_OUTER_ROWS)) + } + + /// The window area a frontend's layout is computed into: the whole + /// declared frame minus the one global status row, matching + /// `window_placements`. `None` while geometry is **unknown**. + #[must_use] + pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { + let geometry = self.views.get(&fid)?.frame_geometry?; + (geometry.total.rows >= 2 && geometry.total.cols > 0) + .then(|| geometry.total.rows - 1) + } + + /// Cache a frontend's authoritative frame capacity (Q#BP2b). + /// + /// Grid / `LOCAL` views call this from their real attach and resize + /// sizes with an internally minted epoch; a semantic view stays + /// `None` until Stage 2's authenticated declaration. A repeated + /// identical size is not a new declaration. + pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if view + .frame_geometry + .is_some_and(|geometry| geometry.total == total) + { + return; + } + let next = view + .frame_geometry + .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); + view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { + geometry_epoch: next, + total, + }); + } + + /// Core half of the idempotent panel-reconciliation transaction + /// (Q#BP2b). The caller owns the terminal manager, so releasing a + /// controller is reported rather than performed. + /// + /// Hiding is a **durable state transition**, not a per-frame effect: + /// a render-time dodge would still route keys to an invisible window + /// and would leave the terminal controller claimed, because the + /// resize path merely returns on zero content without releasing it. + pub fn reconcile_panel_layout_core(&mut self, fid: FrontendId) -> PanelReconciliation { + let mut result = PanelReconciliation::default(); + let Some(side) = self.side_window_for(fid) else { + // `panel_hidden` never describes a panel that no longer + // exists. + if let Some(view) = self.views.get_mut(&fid) { + result.changed = view.panel_hidden; + view.panel_hidden = false; + } + return result; + }; + let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); + // Unknown geometry (a semantic view before Stage 2's declaration) + // and a zero-column frame are both non-presentable, and follow the + // hidden arm rather than being sized against a placeholder. + let satisfiable = self + .frontend_area_rows(fid) + .and_then(|rows| self.panel_allocation(fid, rows)) + .is_some(); + let Some(view) = self.views.get_mut(&fid) else { + return result; + }; + view.panel_hidden = !satisfiable; + result.hidden = !satisfiable; + result.changed = was_hidden != result.hidden; + if satisfiable { + // Focus is deliberately NOT restored when the panel + // reappears — the user moved on; `C-x o` returns. + return result; + } + if view.active == side { + // Durable transition: move focus out and tell the caller to + // release the terminal controller for this view key. + result.released_terminal = Some(side); + if let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) + { + view.active = target; + } + } + result + } + + /// Move the horizontal boundary that `win` owns by `delta_rows`, + /// growing `win` (Q#BP5 / Q#BP5b). + /// + /// `min_for` resolves each leaf's `window.min-height` preference; it + /// is snapshotted by the caller **before** any geometry changes, so + /// one gesture uses one set of minima. + /// + /// # Errors + /// When `win` is not live in `fid`'s layout, when the panel is + /// hidden, or when no adjustable horizontal boundary exists. + pub fn resize_boundary( + &mut self, + fid: FrontendId, + win: WindowId, + delta_rows: i32, + area_rows: u32, + min_for: &impl Fn(WindowId) -> u32, + ) -> Result<(), String> { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + if !view.layout.iter_ids().contains(&win) { + return Err(format!( + "window {} does not belong to this frontend", + win.raw() + )); + } + let win_is_side = self + .windows + .get(&win) + .is_some_and(crate::window::Window::is_side); + if win_is_side && view.panel_hidden { + return Err("window.resize: the panel is not currently visible".into()); + } + // Q#BP5b rule 1: a side window resolves to its OWN fixed + // boundary; rule 2: any other window resolves to the nearest + // horizontal ancestor at which its path child has a following + // sibling — the same boundary a drag on its bottom mode-line row + // moves. + let (boundary, lower_grows) = if win_is_side { + let side = self + .side_window_for(fid) + .ok_or_else(|| "window.resize: no side window".to_string())?; + let path = view + .layout + .path_to(side) + .ok_or_else(|| "window.resize: side window is not in the layout".to_string())?; + let (&last, parent) = path + .split_last() + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?; + if last == 0 { + return Err("window.resize: no adjustable horizontal boundary".into()); + } + ( + crate::window::SplitBoundary { + path: parent.to_vec(), + upper: last - 1, + }, + true, + ) + } else { + ( + view.layout + .boundary_below(win) + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + false, + ) + }; + + let placements = view.layout.compute( + crate::window::Rect::new(0, 0, area_rows, 1), + &self.panel_fixed_rows(fid, area_rows), + ); + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let LayoutNode::Split { children, .. } = view + .layout + .node_at(&boundary.path) + .ok_or_else(|| "window.resize: boundary vanished".to_string())? + else { + return Err("window.resize: boundary is not a split".into()); + }; + let upper_node = &children[boundary.upper]; + let lower_node = &children[boundary.upper + 1]; + let upper_rows = node_row_extent(upper_node, &placements); + let lower_rows = node_row_extent(lower_node, &placements); + let total = upper_rows + lower_rows; + let min_upper = crate::window::interactive_min_rows(upper_node, min_for); + let min_lower = crate::window::interactive_min_rows(lower_node, min_for); + // Preserve the preferred minimum on BOTH sides when the frame can + // satisfy it; when it is already smaller, the motion may not make + // either side worse than it already is. + let floor_upper = min_upper.min(upper_rows); + let floor_lower = min_lower.min(lower_rows); + let boundary_delta = if lower_grows { -delta_rows } else { delta_rows }; + let proposed = i64::from(upper_rows) + i64::from(boundary_delta); + let lo = i64::from(floor_upper); + let hi = i64::from(total.saturating_sub(floor_lower)); + if hi < lo { + return Err("window.resize: no room to move this boundary".into()); + } + let new_upper = u32::try_from(proposed.clamp(lo, hi)) + .map_err(|_| "window.resize: boundary out of range".to_string())?; + let new_lower = total - new_upper; + + // A side window writes `fixed_rows` (its ABSOLUTE height survives + // a terminal resize); a flexible pair writes weights (its RATIO + // survives). That difference is the point. + let lower_id = match lower_node { + LayoutNode::Leaf(id) => Some(*id), + LayoutNode::Split { .. } => None, + }; + let lower_is_side = lower_id.is_some_and(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }); + if lower_is_side { + let id = lower_id.expect("checked above"); + if let Some(window) = self.windows.get_mut(&id) { + window.params.fixed_rows = Some(new_lower.max(MIN_WINDOW_OUTER_ROWS)); + } + return Ok(()); + } + // Rewrite every flexible child's weight as its current row + // extent, with the two adjacent children replaced. Untouched + // siblings therefore keep the extents they already had. + let extents: Vec = children + .iter() + .enumerate() + .map(|(i, child)| { + if i == boundary.upper { + new_upper + } else if i == boundary.upper + 1 { + new_lower + } else { + node_row_extent(child, &placements) + } + }) + .collect(); + let fixed = self.panel_fixed_rows(fid, area_rows); + let view = self + .views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let Some(LayoutNode::Split { + weights, children, .. + }) = view.layout.node_at_mut(&boundary.path) + else { + return Err("window.resize: boundary vanished".into()); + }; + weights.resize(children.len(), 1); + for (i, child) in children.iter().enumerate() { + let pinned = matches!(child, LayoutNode::Leaf(id) if fixed.contains_key(id)); + if !pinned { + weights[i] = extents[i].max(1); + } + } + Ok(()) + } + + /// Install `buffer_id` in an explicit window, resetting its view + /// state exactly as [`Self::switch_active_buffer_for`] does — except + /// that redisplaying the buffer a window **already shows** is a no-op + /// on cursor, viewport, selection, and overlays. + /// + /// # Errors + /// Unknown window or buffer. + pub fn install_buffer_in_window( + &mut self, + window_id: WindowId, + buffer_id: BufferId, + ) -> Result<(), String> { + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let window = self + .windows + .get_mut(&window_id) + .ok_or_else(|| format!("window {window_id:?} is not live"))?; + if window.buffer_id == buffer_id { + return Ok(()); + } + window.buffer_id = buffer_id; + window.text_view = text_view; + window.overlays.clear(); + window.cursor = 0; + window.selection = None; + window.view_top = 0; + window.goal_col = None; + Ok(()) + } + + /// Phase 1 of the display transaction (Q#BP4): choose a target, + /// install the buffer, and report what Phase 2 must do. + /// + /// Contains **no** Lua: the hook fan-out, the reconciliation, and the + /// final-focus matrix all belong to the layer that owns the Lua host. + /// + /// # Errors + /// An unusable exact target, an unsatisfiable placement request, or a + /// layout with no eligible document window. + pub fn display_buffer( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + let saved_active = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .active; + let placement = self.resolve_placement(fid, request)?; + self.apply_placement(fid, request, &placement)?; + let select = request + .select + .unwrap_or(!matches!(placement.kind, PlacementKind::Side { .. })); + Ok(DisplayOutcome { + target: placement.target, + saved_active, + select, + created_side: matches!( + placement.kind, + PlacementKind::Side { + created: true, + .. + } + ), + }) + } + + /// Answer "is there a usable destination for this visit?" **without + /// loading anything** (Q#BP11b step 2, R3-B17). + /// + /// `existing` is the side-effect-free dedup result: `None` means the + /// file is not open yet, in which case an eligible destination must + /// not be dedicated to *any* buffer — otherwise a dedicated origin + /// could force a load that then has nowhere to go. + /// + /// # Errors + /// An exact target that is dead, foreign, or dedicated; or a layout + /// with no eligible document window. + pub fn probe_display_target( + &self, + fid: FrontendId, + existing: Option, + window: Option, + ) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let eligible = |id: WindowId| { + self.windows.get(&id).is_some_and(|w| { + !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) + }) + }; + if let Some(target) = window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display_file: window {} does not belong to this frontend", + target.raw() + )); + } + if !eligible(target) { + return Err(format!( + "display_file: window {} is dedicated to another buffer", + target.raw() + )); + } + return Ok(target); + } + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if let Some(buffer_id) = existing + && let Some(showing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == buffer_id) + }) + { + return Ok(showing); + } + let mut candidates: Vec = Vec::new(); + if let Ok(preferred) = self.non_side_target(fid) { + candidates.push(preferred); + } + candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates + .into_iter() + .find(|id| eligible(*id)) + .ok_or_else(|| "display_file: no eligible document window is available".into()) + } + + /// Q#BP3's precedence: exact target, then side affinity, then + /// ordinary reuse. Placement affinity precedes generic reuse — + /// otherwise a persistent `*compilation*` buffer already visible in a + /// document window makes `{side = "bottom"}` silently ignore its + /// requested placement. + fn resolve_placement( + &self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + if request.window.is_some() && request.side.is_some() { + return Err("display: `window` and `side` are mutually exclusive".into()); + } + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + + // 1. Exact target. + if let Some(target) = request.window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display: window {} does not belong to this frontend", + target.raw() + )); + } + let window = self + .windows + .get(&target) + .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; + if window.params.dedicated && window.buffer_id != request.buffer_id { + return Err(format!( + "display: window {} is dedicated to another buffer", + target.raw() + )); + } + if request.height.is_some() && !window.is_side() { + return Err("display: `height` requires a side window".into()); + } + return Ok(Placement { + target, + kind: if window.is_side() { + PlacementKind::Side { + created: false, + replacing: window.buffer_id != request.buffer_id, + } + } else { + PlacementKind::Ordinary + }, + }); + } + + // 2. Side target — only on a panel-capable frontend. + if request.side.is_some() && view.panel_capable { + match self.side_window_for(fid) { + Some(side) => { + let window = self + .windows + .get(&side) + .ok_or_else(|| "display: side window is not live".to_string())?; + if window.buffer_id == request.buffer_id { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: false, + }, + }); + } + if !window.params.dedicated { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: true, + }, + }); + } + // The one side slot is dedicated to another buffer. + // Never create a second one: fall through to the + // ordinary policy, discarding every side-specific + // parameter (Q#BP3 2.iii). + } + None => { + return Ok(Placement { + target: WindowId::next(), + kind: PlacementKind::Side { + created: true, + replacing: false, + }, + }); + } + } + } else if request.height.is_some() { + return Err("display: `height` requires a side window".into()); + } + + // 3. Ordinary target. + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + // 3.i — reuse a visible NON-side window already showing it. An + // ordinary display never selects the panel by coincidence. + if let Some(existing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == request.buffer_id) + }) { + return Ok(Placement { + target: existing, + kind: PlacementKind::Ordinary, + }); + } + // 3.ii — the Q#BP11a candidate, then `iter_ids()` order, skipping + // any window dedicated to a different buffer. + let mut candidates: Vec = Vec::new(); + if let Ok(preferred) = self.non_side_target(fid) { + candidates.push(preferred); + } + candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + for candidate in candidates { + let eligible = self.windows.get(&candidate).is_some_and(|w| { + !w.params.dedicated || w.buffer_id == request.buffer_id + }); + if eligible { + return Ok(Placement { + target: candidate, + kind: PlacementKind::Ordinary, + }); + } + } + Err("display: no eligible document window is available".into()) + } + + /// Create the side window when needed, then install the buffer and + /// reconcile the parameter semantics of Q#BP3. + fn apply_placement( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + placement: &Placement, + ) -> Result<(), String> { + let side = match placement.kind { + PlacementKind::Ordinary => { + // Reaching Ordinary while a side was REQUESTED means the + // request fell back (not panel-capable, or the one slot + // is dedicated elsewhere). A failed placement request may + // never pin or dedicate a document window, so `side`, + // `height`, `dedicated`, and quit bookkeeping are all + // discarded here; only an explicit `select` survives, and + // that is Phase 2's business. + let fell_back = request.side.is_some(); + let same_buffer_redisplay = self + .windows + .get(&placement.target) + .is_some_and(|w| w.buffer_id == request.buffer_id); + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: target window vanished".to_string())?; + match request.dedicated { + Some(dedicated) if !fell_back => window.params.dedicated = dedicated, + // A same-buffer redisplay must not silently unpin a + // window; a genuine replacement starts undedicated. + _ if !same_buffer_redisplay => window.params.dedicated = false, + _ => {} + } + return Ok(()); + } + PlacementKind::Side { created, replacing } => (created, replacing), + }; + let (created, replacing) = side; + let requested_side = request.side.unwrap_or(Side::Bottom); + + if created { + let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; + let origin = self.non_side_target(fid).ok(); + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(request.buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let mut window = Window::new(placement.target, request.buffer_id, text_view); + window.params.side = Some(requested_side); + window.params.fixed_rows = Some(rows); + window.params.dedicated = request.dedicated.unwrap_or(false); + window.params.set_quit_action(Some(QuitAction::Delete)); + window.params.set_origin_document(origin); + self.windows.insert(placement.target, window); + self.views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .layout + .install_side_leaf(placement.target); + return Ok(()); + } + + // Reusing the existing slot. Capture the outgoing presentation + // BEFORE the install resets the window's view state. + let prior = { + let window = self + .windows + .get(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + QuitAction::Restore { + buffer_id: window.buffer_id, + fixed_rows: window.params.fixed_rows.unwrap_or(MIN_WINDOW_OUTER_ROWS), + dedicated: window.params.dedicated, + cursor: window.cursor, + view_top: window.view_top, + goal_col: window.goal_col, + selection: window.selection, + then: Box::new( + window + .params + .quit_action() + .cloned() + .unwrap_or(QuitAction::Delete), + ), + } + }; + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let height = match request.height { + Some(rows) => Some(Self::clamp_panel_rows(rows)?), + None => None, + }; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + if let Some(rows) = height { + window.params.fixed_rows = Some(rows); + } + if replacing { + // A replacement's new presentation defaults to undedicated so + // the one slot stays replaceable; an explicit dedication + // applies only after the OLD presentation already passed + // eligibility, so `dedicated = false` cannot clear-and-bypass + // an existing dedication in the same call. + window.params.dedicated = request.dedicated.unwrap_or(false); + let mut action = prior; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + window.params.set_quit_action(Some(action)); + } else if let Some(dedicated) = request.dedicated { + window.params.dedicated = dedicated; + } + Ok(()) } // ---- selection / region (T M2.12) -------------------------------------- @@ -3070,6 +4433,25 @@ impl EditorCore { } } }; + // Q#BP10a: a side window showing the victim is CLOSED, not + // redirected to `*scratch*`. Redirecting would strand an + // unrelated buffer in the panel slot; the wrapper collapse + // restores the prior root, which by construction holds a leaf. + let doomed_sides: Vec<(FrontendId, WindowId)> = self + .views + .iter() + .filter_map(|(fid, view)| { + let side = view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + })?; + (self.windows.get(&side)?.buffer_id == buffer_id).then_some((*fid, side)) + }) + .collect(); + for (fid, side) in doomed_sides { + self.remove_side_window(fid, side); + } { let reg = self.registry.borrow(); let buf = reg.get(fallback).map_err(|e| e.to_string())?; @@ -3509,6 +4891,9 @@ mod tests { layout: Layout::single(win_id), active: win_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); win_id @@ -3525,7 +4910,7 @@ mod tests { let win2 = attach_frontend(&mut s, fid2); s.active_frontend = fid2; - s.close_others(); + s.close_others().expect("document window may close others"); assert!( s.windows.contains_key(&win2), @@ -4093,8 +5478,10 @@ mod tests { s.active_window_mut().cursor = (i % 10) as u64; s.push_jump(); } + // Bottom-panel arc (Q#BP11c): the cap applies independently to + // each frontend's own vector, with today's oldest-entry eviction. assert_eq!( - s.jump_ring.len(), + s.jump_ring[&FrontendId::LOCAL].len(), EditorCore::JUMP_RING_CAP, "ring must stay bounded at JUMP_RING_CAP" ); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7cf0bc4..a1ea3fc 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -88,6 +88,7 @@ mod diag; mod fold; mod index; mod mcp; +mod window_panel; // Every `pub` item a moved domain owned is re-exported so its prior // `crate::lua_bindings::` path still resolves — the split must not // shrink the public API surface. That includes the `install_*` wiring fns: @@ -647,6 +648,26 @@ impl PackageInstallOverride { } } +/// Resolve an integer setting out of the shared `pmacs.config` registry +/// (bottom-panel arc, Q#BP2 / Q#BP11). +/// +/// The registry lives in Lua app data, so Rust-side consumers — the +/// divider drag, the keyboard resize commands, and side-window creation +/// — reach it here rather than round-tripping through Lua. `fallback` +/// covers a bare core whose runtime never defined the setting (unit-test +/// construction), and a negative or out-of-range stored value. +#[must_use] +pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option, fallback: u32) -> u32 { + let Some(registry) = lua.app_data_ref::() else { + return fallback; + }; + let borrowed = registry.borrow(); + match borrowed.get(name, buffer_id) { + Ok(crate::config_registry::ConfigValue::Int(v)) => u32::try_from(*v).unwrap_or(fallback), + _ => fallback, + } +} + /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) @@ -1572,7 +1593,7 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) { } } -fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { +pub(crate) fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { let snapshot = match lua.app_data_ref::() { Some(hooks) => hooks.borrow().snapshot(name), None => None, @@ -8467,6 +8488,11 @@ fn install_terminal( manager: &crate::terminal::SharedTerminalManager, supervisor: &SharedProcessSupervisor, ) -> mlua::Result<()> { + // Bottom-panel arc (Q#BP2b): the panel-reconciliation transaction + // must be able to RELEASE a hidden panel's terminal controller from a + // Lua-owning context, so the manager joins the LSP manager and the + // process supervisor as app data. + lua.set_app_data(manager.clone()); let pmacs: Table = lua.globals().get("pmacs")?; let terminal = lua.create_table()?; @@ -12166,6 +12192,9 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs { )] fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result { let win = lua.create_table()?; + // Bottom-panel arc (Q#BP11): display policy, side windows, quit, and + // boundary resize live in their own module. + window_panel::install(lua, core, &win)?; { let cc = core.clone(); @@ -12271,8 +12300,9 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut().close_others(); - Ok(()) + cc.borrow_mut() + .close_others() + .map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs new file mode 100644 index 0000000..a28add0 --- /dev/null +++ b/src/lua_bindings/window_panel.rs @@ -0,0 +1,499 @@ +// window_panel.rs --- `pmacs.window` display policy + side windows. + +//! The Lua surface of the bottom-panel arc (Q#BP11): `display`, +//! `display_file`, `quit`, `panel`, `params` / `set_params`, `resize`, +//! and `display_target`. +//! +//! # Where the transaction lives +//! +//! [`crate::editor_core::EditorCore::display_buffer`] is **Phase 1**: it +//! picks a target under Q#BP3, installs the buffer, and reports what must +//! happen next. It contains no Lua. This module is **Phase 2** (Q#BP4): +//! activate the target, fire the lifecycle hook so overlays reattach and +//! saveplace / recentf / syntax / LSP observe the right active window, +//! run panel reconciliation (a hook may resize, close, or replace the +//! target), then **revalidate both window ids** and apply the final-focus +//! matrix. +//! +//! Two corrections that matrix encodes, both of which an earlier revision +//! of the framing got wrong: +//! +//! * `select = true` **keeps the target selected** — restoring the saved +//! window unconditionally would erase the request outright; +//! * `select = false` restores a saved window **even when it is the +//! panel** — a passive display invoked from a focused panel must not +//! blur it. +//! +//! # What Lua may not write +//! +//! `side` is immutable after placement (Q#BP2a), and `quit_action` / +//! `origin_document` are implementation-owned (Q#BP2c): `params` reports +//! them for diagnostics, `set_params` refuses them. Lua therefore cannot +//! forge a window id, a buffer restore chain, or stale cursor state. + +use mlua::{Lua, Table, Value}; + +use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined}; +use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome}; +use crate::protocol::FrontendId; +use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId}; + +/// The frontend a `pmacs.window.*` call acts for. +/// +/// An interactive command carries authenticated origin; a programmatic +/// call falls back to the ambient active frontend, exactly as the +/// terminal surface does. +pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + .unwrap_or_else(|| core.borrow().active_frontend_key()) +} + +/// Run the panel-reconciliation transaction from a Lua-owning context +/// (Q#BP2b). +/// +/// The core half is pure; releasing a terminal controller needs the +/// manager, which the terminal module publishes as Lua app data for +/// exactly this reason. A bare core without one still reconciles — it +/// simply has no controller to release. +pub(crate) fn reconcile_panel_layout(lua: &Lua, core: &SharedCore, fid: FrontendId) { + let outcome = core.borrow_mut().reconcile_panel_layout_core(fid); + let Some(window_id) = outcome.released_terminal else { + return; + }; + let Some(manager) = lua.app_data_ref::() else { + return; + }; + let buffer_id = core + .borrow() + .windows + .get(&window_id) + .map(|window| window.buffer_id); + if let Some(buffer_id) = buffer_id { + let _ = manager + .borrow_mut() + .release_controller(crate::terminal::TerminalViewKey::new( + fid, window_id, buffer_id, + )); + } +} + +/// A window is "visible" for the final-focus matrix when it is live in +/// this frontend's layout and not a derived-hidden panel (Q#BP2b). +fn visible(core: &SharedCore, fid: FrontendId, win: WindowId) -> bool { + let core = core.borrow(); + let Some(view) = core.views.get(&fid) else { + return false; + }; + if !view.layout.iter_ids().contains(&win) { + return false; + } + !(view.panel_hidden + && core + .windows + .get(&win) + .is_some_and(crate::window::Window::is_side)) +} + +/// Phase 2 of the display transaction (Q#BP4). +fn complete_display( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + outcome: DisplayOutcome, + fire: HookKind, +) -> mlua::Result<()> { + core.borrow_mut().focus_window(fid, outcome.target); + match fire { + HookKind::AfterSwitch => { + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + } + HookKind::AfterLoad => { + run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); + } + HookKind::None => {} + } + // A hook may have resized, closed, or replaced the target, so + // reconcile BEFORE the final-focus decision reads visibility. + reconcile_panel_layout(lua, core, fid); + + let target_ok = visible(core, fid, outcome.target); + let saved_ok = visible(core, fid, outcome.saved_active); + let final_focus = match (outcome.select, target_ok, saved_ok) { + (true, true, _) => Some(outcome.target), + (true, false, true) => Some(outcome.saved_active), + (false, _, true) => Some(outcome.saved_active), + (false, true, false) => Some(outcome.target), + // Both ids died with the hook: fall back to the non-side target + // rule rather than leaving focus on a dead window. + _ => None, + }; + let resolved = match final_focus { + Some(win) => win, + None => core + .borrow() + .non_side_target(fid) + .map_err(mlua::Error::runtime)?, + }; + core.borrow_mut().focus_window(fid, resolved); + Ok(()) +} + +/// Parse the shared `{side, window, height, dedicated, select}` option +/// table. +fn parse_request( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, + opts: Option
, +) -> mlua::Result { + let mut request = DisplayRequest::new(buffer_id); + let Some(opts) = opts else { + return Ok(request); + }; + if let Some(side) = opts.get::>("side")? { + request.side = Some(Side::from_name(&side).ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window.display: unsupported side {side:?} (only \"bottom\" ships)" + )) + })?); + } + if let Some(raw) = opts.get::>("window")? { + request.window = Some(lookup_window(core, fid, raw)?); + } + if let Some(height) = opts.get::>("height")? { + request.height = Some(height); + } + if let Some(dedicated) = opts.get::>("dedicated")? { + request.dedicated = Some(dedicated); + } + if let Some(select) = opts.get::>("select")? { + request.select = Some(select); + } + // The setting is resolved against the buffer being displayed, and + // only consumed when the slot is actually CREATED (Q#BP3). + request.default_panel_rows = config_u32( + lua, + "window.panel-height", + Some(buffer_id), + DEFAULT_PANEL_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS); + Ok(request) +} + +/// Resolve a raw Lua window id, refusing one that is not live in the +/// acting frontend's layout (Q#BP11). +fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { + let core = core.borrow(); + let view = core + .views + .get(&fid) + .ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout"))?; + view.layout + .iter_ids() + .into_iter() + .find(|id| id.raw() == raw) + .ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window: window {raw} is not live in this frontend's layout" + )) + }) +} + +/// Install the bottom-panel surface onto the existing `pmacs.window` +/// table. +pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { + { + let cc = core.clone(); + win.set( + "display", + lua.create_function( + move |lua, (buffer, opts): (BufferIdLua, Option
)| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let request = parse_request(lua, &cc, fid, buffer.0, opts)?; + let outcome = cc + .borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime)?; + complete_display(lua, &cc, fid, outcome, HookKind::AfterSwitch)?; + Ok(outcome.target.raw()) + }, + )?, + )?; + } + + { + // Q#BP11b — the target-aware load transaction. `find_or_open` + // switches the ACTIVE window in both branches before firing + // hooks, so a visit to a previously unopened file would replace + // a focused panel before any display policy could help. + let cc = core.clone(); + win.set( + "display_file", + lua.create_function( + move |lua, (path, opts): (String, Option
)| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let path_buf = std::path::PathBuf::from(&path); + let mut explicit_window = None; + let mut select = None; + if let Some(opts) = opts.as_ref() { + if let Some(raw) = opts.get::>("window")? { + explicit_window = Some(lookup_window(&cc, fid, raw)?); + } + select = opts.get::>("select")?; + } + // 1. Side-effect-free dedup: do NOT read the file yet. + let existing = cc.borrow().find_buffer_for_path(&path_buf); + // 2. Resolve the destination BEFORE I/O, so a + // dedicated origin cannot force load-before-failure. + cc.borrow() + .probe_display_target(fid, existing, explicit_window) + .map_err(mlua::Error::runtime)?; + // 3. Load, dedup, or create the path-backed buffer. + let (buffer_id, fire) = cc + .borrow_mut() + .resolve_target_buffer(&path_buf) + .map_err(mlua::Error::runtime)?; + // 4. Enter Q#BP4's transaction, so any hook observes + // the DOCUMENT TARGET as active. + let mut request = DisplayRequest::new(buffer_id); + request.window = explicit_window; + request.select = select; + let outcome = cc + .borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime)?; + complete_display(lua, &cc, fid, outcome, fire)?; + Ok(outcome.target.raw()) + }, + )?, + )?; + } + + { + // Q#BP11a — the non-side target: what an ordinary visit from a + // panel should address. + let cc = core.clone(); + win.set( + "display_target", + lua.create_function(move |lua, ()| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let core = cc.borrow(); + core.non_side_target(fid) + .map(WindowId::raw) + .map_err(mlua::Error::runtime) + })?, + )?; + } + + { + // The acting frontend's side window, or nil. + let cc = core.clone(); + win.set( + "panel", + lua.create_function(move |lua, ()| -> mlua::Result> { + let fid = acting_frontend(lua, &cc); + Ok(cc.borrow().side_window_for(fid).map(WindowId::raw)) + })?, + )?; + } + + { + // Q#BP2c — `window.quit`. A window with no recorded action gets + // a pointed error WITHOUT closing or switching anything. + let cc = core.clone(); + win.set( + "quit", + lua.create_function(move |lua, target: Option| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let target = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc + .borrow() + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| { + mlua::Error::runtime("pmacs.window.quit: no acting frontend view") + })?, + }; + let outcome = cc + .borrow_mut() + .quit_window(fid, target) + .map_err(mlua::Error::runtime)?; + match outcome { + QuitOutcome::Deleted { focus } => { + reconcile_panel_layout(lua, &cc, fid); + if let Some(focus) = focus { + cc.borrow_mut().focus_window(fid, focus); + } + } + QuitOutcome::Restored { target, .. } => { + // Restoring is an ordinary presentation change: + // fire the switch hook so store-backed overlays + // reattach to the reinstated buffer. + cc.borrow_mut().focus_window(fid, target); + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + reconcile_panel_layout(lua, &cc, fid); + if visible(&cc, fid, target) { + cc.borrow_mut().focus_window(fid, target); + } + } + } + Ok(()) + })?, + )?; + } + + { + // Read-only diagnostics over `WindowParams` (Q#BP2c). + let cc = core.clone(); + win.set( + "params", + lua.create_function(move |lua, target: Option| -> mlua::Result
{ + let fid = acting_frontend(lua, &cc); + let id = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc.borrow().active_window_id(), + }; + let core = cc.borrow(); + let window = core + .windows + .get(&id) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.params: window not live"))?; + let table = lua.create_table()?; + table.set("window", id.raw())?; + table.set("side", window.params.side.map(Side::name))?; + table.set("fixed_rows", window.params.fixed_rows)?; + table.set("dedicated", window.params.dedicated)?; + table.set( + "origin_document", + window.params.origin_document().map(WindowId::raw), + )?; + table.set( + "quit_action", + window.params.quit_action().map(|action| match action { + crate::window::QuitAction::Delete => "delete", + crate::window::QuitAction::Restore { .. } => "restore", + }), + )?; + table.set( + "quit_depth", + window + .params + .quit_action() + .map_or(0, crate::window::QuitAction::depth), + )?; + table.set( + "hidden", + window.is_side() && core.panel_hidden_for(fid), + )?; + Ok(table) + })?, + )?; + } + + { + // Only `fixed_rows` and `dedicated` are writable (Q#BP2c). + let cc = core.clone(); + win.set( + "set_params", + lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = lookup_window(&cc, fid, target)?; + for key in ["side", "origin_document", "quit_action"] { + if opts.get::(key)? != Value::Nil { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: `{key}` is not settable" + ))); + } + } + let height = match opts.get::>("fixed_rows")? { + Some(rows) => Some( + crate::editor_core::EditorCore::clamp_panel_rows(rows) + .map_err(mlua::Error::runtime)?, + ), + None => None, + }; + let dedicated = opts.get::>("dedicated")?; + { + let mut core = cc.borrow_mut(); + let window = core.windows.get_mut(&id).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.set_params: window not live") + })?; + if let Some(rows) = height { + // Inert on an ordinary window by construction: + // the fixed map is built from side windows only. + window.params.fixed_rows = Some(rows); + } + if let Some(dedicated) = dedicated { + window.params.dedicated = dedicated; + } + } + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + })?, + )?; + } + + { + // Q#BP5b — `resize(win, delta_rows)` resolves from the SUPPLIED + // window; the `window.enlarge` / `window.shrink` commands are + // implicitly active. + let cc = core.clone(); + win.set( + "resize", + lua.create_function( + move |lua, (target, delta): (Option, i32)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc.borrow().active_window_id(), + }; + let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.resize: this frontend has not declared its geometry yet", + ) + })?; + let minima: std::collections::HashMap = { + let core = cc.borrow(); + core.views + .get(&fid) + .map(|view| { + view.layout + .iter_ids() + .into_iter() + .map(|id| { + let buffer_id = + core.windows.get(&id).map(|w| w.buffer_id); + ( + id, + config_u32( + lua, + "window.min-height", + buffer_id, + MIN_WINDOW_OUTER_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS), + ) + }) + .collect() + }) + .unwrap_or_default() + }; + cc.borrow_mut() + .resize_boundary(fid, id, delta, area_rows, &|id| { + minima.get(&id).copied().unwrap_or(MIN_WINDOW_OUTER_ROWS) + }) + .map_err(mlua::Error::runtime)?; + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + }, + )?, + )?; + } + + Ok(()) +} diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index b16daae..b32195a 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -109,7 +109,12 @@ pub fn paint_other_frontend_overlays( return; } let text_area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(text_area); + // Bottom-panel arc (R5-B1): this pass derives its own text-area + // `Rect` instead of reusing `window_placements`, so it must ask for + // the same fixed extents — otherwise every peer cursor paints at the + // row it would occupy with no panel open. + let fixed = core.panel_fixed_rows(core.active_frontend_key(), text_rows); + let placements = core.active_layout().compute(text_area, &fixed); let registry = core.registry.clone(); let reg = registry.borrow(); diff --git a/src/window.rs b/src/window.rs index e1162c0..2d8afff 100644 --- a/src/window.rs +++ b/src/window.rs @@ -154,6 +154,201 @@ pub fn decimal_digits(mut n: usize) -> u32 { d } +// --------------------------------------------------------------------------- +// Window parameters (bottom-panel arc, Q#BP2) +// --------------------------------------------------------------------------- + +/// Which edge of the frame a *side window* is pinned to. +/// +/// Stage 1 of the bottom-panel arc ships exactly one side. Left / right / +/// top are named deferrals, so the enum stays closed rather than +/// accepting a value no allocator honors: a Lua caller asking for an +/// unsupported side gets a pointed error at the boundary instead of a +/// silently ordinary window. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum Side { + /// Pinned to the bottom of the frame (the panel slot). + Bottom, +} + +impl Side { + /// Parse the Lua-facing spelling. `None` for every unsupported value. + #[must_use] + pub fn from_name(name: &str) -> Option { + match name { + "bottom" => Some(Self::Bottom), + _ => None, + } + } + + /// The Lua-facing spelling. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Bottom => "bottom", + } + } +} + +/// Structural floor for a window's **outer** row extent: one text row +/// plus its mode line (`content = outer - 1`). +/// +/// Every programmatic source of `fixed_rows` clamps a nonzero request up +/// to this floor; a request of `0` is rejected rather than being an +/// invisible "open" (Q#BP2). This is *not* a promise that the layout can +/// never produce a smaller rect — [`Layout::compute`] has always been +/// allowed to hand out zero extents on an intrinsically tiny frame. The +/// bounded promise is narrower: the panel allocator never makes an +/// otherwise satisfiable document tree unsatisfiable. +pub const MIN_WINDOW_OUTER_ROWS: u32 = 2; + +/// Default `window.panel-height`: outer rows a freshly created panel +/// takes when the caller supplies no explicit `height` (Q#BP11). +pub const DEFAULT_PANEL_ROWS: u32 = 12; + +/// How far back [`QuitAction::Restore`] chains may be retained before the +/// oldest retained presentation is truncated to [`QuitAction::Delete`] +/// (Q#BP2c, R4-B6). Repeated panel replacement would otherwise grow the +/// recursive history without bound. +pub const MAX_PANEL_QUIT_DEPTH: usize = 64; + +/// What `window.quit` does to a side window (Q#BP2c). +/// +/// Present only on a side window; ordinary windows and every capability +/// fallback carry `None`. Replacing a side presentation captures the +/// outgoing one in `Restore` so `C → B → A → delete` restores the actual +/// presentations rather than forgetting `A` or leaking `C`'s height and +/// dedication into it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QuitAction { + /// Close the side window and collapse its wrapper. + Delete, + /// Reinstate a previously displayed presentation, then fall back to + /// `then` on the next quit. + Restore { + /// Buffer that was displayed. Revalidated at quit time: a killed + /// buffer degrades the whole entry to [`QuitAction::Delete`]. + buffer_id: BufferId, + /// Requested outer rows of that presentation. + fixed_rows: u32, + /// Whether that presentation was dedicated. + dedicated: bool, + /// Saved cursor, clamped against the buffer's current contents. + cursor: Position, + /// Saved first visible line. + view_top: usize, + /// Saved sticky goal column. + goal_col: Option, + /// Saved region, if one was active. + selection: Option, + /// The action that was in force *before* this presentation + /// replaced its predecessor. + then: Box, + }, +} + +impl QuitAction { + /// Number of retained presentations in this chain, counted + /// iteratively so a long history can never blow the stack. + #[must_use] + pub fn depth(&self) -> usize { + let mut depth = 0usize; + let mut cursor = self; + while let Self::Restore { then, .. } = cursor { + depth += 1; + cursor = then; + } + depth + } + + /// Truncate the oldest retained `Restore` to [`QuitAction::Delete`] + /// so the chain holds at most `cap` presentations. Iterative, like + /// [`Self::depth`]. + pub fn truncate_to(&mut self, cap: usize) { + if cap == 0 { + *self = Self::Delete; + return; + } + let mut kept = 0usize; + let mut cursor = self; + loop { + match cursor { + Self::Delete => return, + Self::Restore { then, .. } => { + kept += 1; + if kept >= cap { + **then = Self::Delete; + return; + } + cursor = then; + } + } + } + } +} + +/// Per-window display-policy parameters (Q#BP2). +/// +/// `side` is immutable after placement; `quit_action` and +/// `origin_document` are implementation-owned bookkeeping that the Lua +/// `set_params` surface refuses to write (Q#BP2c), so Lua cannot forge a +/// window id, a buffer restore chain, or stale cursor state. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WindowParams { + /// Side this window is pinned to, or `None` for an ordinary + /// document window. Immutable after placement (Q#BP2a). + pub side: Option, + /// Requested **outer** rows (including the mode line) when this is a + /// side window. Inert on any other window — the fixed map is built + /// from side windows only. + pub fixed_rows: Option, + /// Whether `display_buffer` may replace this window's buffer. + /// + /// Binds the **policy layer only**: raw `pmacs.window.switch_buffer` + /// and `switch_active_buffer_for` deliberately ignore it, because + /// they are the low-level escape hatch and every existing caller + /// predates this arc (Q#BP2c). + pub dedicated: bool, + /// See [`WindowParams::quit_action`]. + quit_action: Option, + /// See [`WindowParams::origin_document`]. + origin_document: Option, +} + +impl WindowParams { + /// What `window.quit` does here, if anything. + #[must_use] + pub fn quit_action(&self) -> Option<&QuitAction> { + self.quit_action.as_ref() + } + + /// Install (or clear) the quit action. Rust-internal: no Lua path + /// reaches this. + pub fn set_quit_action(&mut self, action: Option) { + self.quit_action = action; + } + + /// The remembered document window this side window was entered + /// from (Q#BP2c). Recorded at panel creation, refreshed on every + /// focus transition from a non-side window into the panel, and + /// revalidated on every use. + #[must_use] + pub fn origin_document(&self) -> Option { + self.origin_document + } + + /// Record (or clear) the remembered document window. Rust-internal. + pub fn set_origin_document(&mut self, origin: Option) { + self.origin_document = origin; + } + + /// True iff this window is pinned to a side. + #[must_use] + pub fn is_side(&self) -> bool { + self.side.is_some() + } +} + /// One leaf of the window tree: a buffer plus per-window state. pub struct Window { /// Unique identifier. @@ -186,6 +381,9 @@ pub struct Window { /// Line-number gutter mode for this window (UX gutter arc). `Off` by /// default → no gutter, no coordinate change. pub line_numbers: LineNumberMode, + /// Display-policy parameters (bottom-panel arc, Q#BP2). Default for + /// every ordinary window: no side, no fixed extent, undedicated. + pub params: WindowParams, } impl Window { @@ -204,9 +402,16 @@ impl Window { goal_col: None, last_visible_rows: 0, line_numbers: LineNumberMode::Off, + params: WindowParams::default(), } } + /// True iff this window is pinned to a side (bottom-panel arc). + #[must_use] + pub fn is_side(&self) -> bool { + self.params.is_side() + } + /// Width in cells this window's line-number gutter occupies, or `0` /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// the renderer caps this against the window width and applies it as a @@ -305,6 +510,23 @@ pub struct Layout { pub root: LayoutNode, } +/// A frontend's last authoritative cell-equivalent frame capacity +/// (Q#BP2b / Q#BP15a). +/// +/// `geometry_epoch` is a monotonically increasing declaration id owned by +/// the frontend. Grid / `LOCAL` views cache their real attach and resize +/// sizes here with an internal epoch; a semantic view stays `None` — +/// **unknown**, never `24×80` — until Stage 2's authenticated +/// `FrontendCellGeometry` fills it. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DeclaredFrameGeometry { + /// Monotonic declaration id. A lower or repeated epoch carrying + /// different data is stale. + pub geometry_epoch: u64, + /// Whole-frame capacity in cells, including the one global status row. + pub total: CellSize, +} + /// T M10.8 — one attached frontend's view of the editor. /// /// Per-frontend state for multi-frontend operation: the split tree @@ -346,6 +568,32 @@ pub struct FrontendView { /// explicitly, so the projection is never inferred from a /// `FrontendId` (**Bet B8**). pub fold_projection: bool, + /// Whether this frontend can *render* a side window (bottom-panel + /// arc, Q#BP13). + /// + /// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId) and + /// every grid session. Stage 1 sets `false` for every semantic + /// session — the GPU band is Stage 2 — so a `display` carrying a + /// `side` falls back to the non-side target and **discards every + /// side-specific parameter** rather than pinning a document window it + /// could not show. Like `fold_projection`, deliberately has no + /// `Default`: every construction site chooses explicitly. + pub panel_capable: bool, + /// This frontend's last authoritative frame capacity, or `None` while + /// it is **unknown** (Q#BP2b). + /// + /// The panel allocator is the only consumer, and it must never guess: + /// a panel requested before a real declaration stays non-presentable + /// rather than being sized against the GPU attach request's permanent + /// `24×80` placeholder. + pub frame_geometry: Option, + /// Cached derived layout state: the side window exists but cannot be + /// satisfied on the current frame (Q#BP2b). + /// + /// Recomputed from authoritative geometry by + /// `EditorState::reconcile_panel_layout`; never persisted, never set + /// from Lua, and never `true` while no side window exists. + pub panel_hidden: bool, } impl Layout { @@ -359,17 +607,75 @@ impl Layout { /// Walk the tree and assign each leaf a viewport rectangle. /// - /// Splits divide proportionally according to their weights. If a - /// child's allocated extent is `0` (terminal too small for the + /// Splits divide proportionally according to their weights, except + /// that a leaf listed in `fixed` takes exactly that many **rows** out + /// of a horizontal split before the remainder is divided (Q#BP2). + /// The map is the *effective* allocation, not the stored request: a + /// hidden panel is passed as `0`, which gives it an empty rect and + /// hands every reclaimed row back to the document subtree. + /// + /// `fixed` is interpreted only on leaves of a **horizontal** split — + /// a vertical split divides columns, where a row count means nothing + /// — and the last flexible child still takes the remainder, so a tree + /// with no fixed leaves computes byte-identically to before this arc. + /// If a child's allocated extent is `0` (terminal too small for the /// split), that child receives an empty rect, and renderers must /// skip it. #[must_use] - pub fn compute(&self, area: Rect) -> HashMap { + pub fn compute(&self, area: Rect, fixed: &HashMap) -> HashMap { let mut out = HashMap::new(); - compute_node(&self.root, area, &mut out); + compute_node(&self.root, area, fixed, &mut out); out } + /// The single side leaf among `sides`, if this layout holds one. + /// + /// `sides` answers "is this window pinned to a side"; the caller owns + /// the `Window` table, so the predicate is injected rather than + /// duplicated here. At most one bottom side leaf exists per + /// `FrontendView` (Q#BP2a). + #[must_use] + pub fn side_leaf(&self, sides: impl Fn(WindowId) -> bool) -> Option { + self.iter_ids().into_iter().find(|id| sides(*id)) + } + + /// The document subtree beneath the root-level panel wrapper. + /// + /// A side window is installed as the final child of a horizontal + /// split wrapping the entire prior root (Q#BP2a), so the document + /// subtree is that wrapper's first child. Returns `None` when the + /// tree does not have that exact shape. + #[must_use] + pub fn document_subtree(&self, side: WindowId) -> Option<&LayoutNode> { + match &self.root { + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } if children.len() == 2 + && matches!(children[1], LayoutNode::Leaf(id) if id == side) => + { + Some(&children[0]) + } + _ => None, + } + } + + /// Wrap the entire current root in a horizontal split whose final + /// child is `side` (Q#BP2a). + /// + /// `fixed_rows` makes the panel's weight inert, so the prior root + /// keeps the flexible remainder and its **structure** — nodes, + /// weights, order, ids — is untouched (Bet B6). + pub fn install_side_leaf(&mut self, side: WindowId) { + let prior = std::mem::replace(&mut self.root, LayoutNode::Leaf(side)); + self.root = LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![prior, LayoutNode::Leaf(side)], + }; + } + /// All [`WindowId`]s in left→right / top→bottom order. #[must_use] pub fn iter_ids(&self) -> Vec { @@ -414,25 +720,211 @@ impl Layout { /// if the layout has only one window. #[must_use] pub fn focus_next(&self, current: WindowId) -> WindowId { - let ids = self.iter_ids(); - match ids.iter().position(|&id| id == current) { - Some(i) => ids[(i + 1) % ids.len()], - None => *ids.first().unwrap_or(¤t), - } + self.focus_step(current, true, &|_| true) } /// Step focus to the previous window. #[must_use] pub fn focus_prev(&self, current: WindowId) -> WindowId { + self.focus_step(current, false, &|_| true) + } + + /// [`Self::focus_next`] / [`Self::focus_prev`] restricted to windows + /// `eligible` accepts (Q#BP6: a hidden panel is never a focus + /// destination, though it becomes one again as soon as it reappears). + /// + /// A currently focused ineligible window can always leave, so the + /// caller can never strand focus: `current` itself is not filtered. + #[must_use] + pub fn focus_step( + &self, + current: WindowId, + forward: bool, + eligible: &impl Fn(WindowId) -> bool, + ) -> WindowId { let ids = self.iter_ids(); - match ids.iter().position(|&id| id == current) { - Some(i) => ids[(i + ids.len() - 1) % ids.len()], - None => *ids.first().unwrap_or(¤t), + if ids.is_empty() { + return current; + } + let Some(start) = ids.iter().position(|&id| id == current) else { + return ids + .iter() + .copied() + .find(|id| eligible(*id)) + .unwrap_or_else(|| *ids.first().unwrap_or(¤t)); + }; + let n = ids.len(); + for step in 1..=n { + let i = if forward { + (start + step) % n + } else { + (start + n - (step % n)) % n + }; + if eligible(ids[i]) { + return ids[i]; + } + } + current + } + + /// Index path from the root to `target`'s leaf, or `None` when the + /// layout does not hold it. + #[must_use] + pub fn path_to(&self, target: WindowId) -> Option> { + let mut path = Vec::new(); + path_to_node(&self.root, target, &mut path).then_some(path) + } + + /// The node at `path`, or `None` when the path does not resolve. + #[must_use] + pub fn node_at(&self, path: &[usize]) -> Option<&LayoutNode> { + let mut node = &self.root; + for &i in path { + match node { + LayoutNode::Split { children, .. } => node = children.get(i)?, + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + /// Mutable [`Self::node_at`]. + pub fn node_at_mut(&mut self, path: &[usize]) -> Option<&mut LayoutNode> { + let mut node = &mut self.root; + for &i in path { + match node { + LayoutNode::Split { children, .. } => node = children.get_mut(i)?, + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + /// The horizontal boundary immediately **below** `target` (Q#BP5b + /// rule 2), or `None` when there is none. + /// + /// Walk up from the leaf to the nearest horizontal-split ancestor at + /// which the path child has a **following sibling**. "Nearest + /// horizontal ancestor" alone is wrong: when the subtree is that + /// ancestor's *final* child there is no boundary below it there, and + /// the real one is further up. This is also the boundary a drag on + /// `target`'s bottom mode-line row moves, so keyboard resize and drag + /// are the same operation (acceptance 31). + #[must_use] + pub fn boundary_below(&self, target: WindowId) -> Option { + let path = self.path_to(target)?; + for depth in (0..path.len()).rev() { + let parent_path = &path[..depth]; + let child_index = path[depth]; + let LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } = self.node_at(parent_path)? + else { + continue; + }; + if child_index + 1 < children.len() { + return Some(SplitBoundary { + path: parent_path.to_vec(), + upper: child_index, + }); + } + } + None + } +} + +/// One horizontal split boundary: the split node plus the index of the +/// child immediately **above** the dividing line (Q#BP5). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitBoundary { + /// Index path from the root to the horizontal split node. + pub path: Vec, + /// Index of the child above the boundary; `upper + 1` is below it. + pub upper: usize, +} + +fn path_to_node(node: &LayoutNode, target: WindowId, path: &mut Vec) -> bool { + match node { + LayoutNode::Leaf(id) => *id == target, + LayoutNode::Split { children, .. } => { + for (i, child) in children.iter().enumerate() { + path.push(i); + if path_to_node(child, target, path) { + return true; + } + path.pop(); + } + false } } } -fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap) { +/// Minimum **outer** rows a subtree needs for every one of its leaves to +/// clear [`MIN_WINDOW_OUTER_ROWS`] (Q#BP2). +/// +/// The recursion is the point: "leave the document tree two rows" is +/// wrong, because two rows at the root does not give each nested leaf two +/// rows. Horizontal splits stack rows, so minima add; vertical splits +/// share rows, so the tallest child governs. +#[must_use] +pub fn subtree_min_rows(node: &LayoutNode) -> u32 { + match node { + LayoutNode::Leaf(_) => MIN_WINDOW_OUTER_ROWS, + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } => children.iter().map(subtree_min_rows).sum(), + LayoutNode::Split { + orientation: Orientation::Vertical, + children, + .. + } => children.iter().map(subtree_min_rows).max().unwrap_or(0), + } +} + +/// The same sum/max recursion over the user's `window.min-height` +/// *preference* (Q#BP2). +/// +/// `per_leaf` resolves the setting against that window's own buffer +/// (buffer-local override → global → default) and is snapshotted once per +/// gesture, before any geometry changes. Only **interactive** resize — +/// drag, keyboard, and the Stage 2 `PanelResizeRows` — consults this; the +/// ordinary layout pass and frame-resize reconciliation use +/// [`subtree_min_rows`] alone, so changing a preference can never +/// invalidate an existing layout. +#[must_use] +pub fn interactive_min_rows(node: &LayoutNode, per_leaf: &impl Fn(WindowId) -> u32) -> u32 { + match node { + LayoutNode::Leaf(id) => per_leaf(*id), + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } => children + .iter() + .map(|child| interactive_min_rows(child, per_leaf)) + .sum(), + LayoutNode::Split { + orientation: Orientation::Vertical, + children, + .. + } => children + .iter() + .map(|child| interactive_min_rows(child, per_leaf)) + .max() + .unwrap_or(0), + } +} + +fn compute_node( + node: &LayoutNode, + area: Rect, + fixed: &HashMap, + out: &mut HashMap, +) { match node { LayoutNode::Leaf(id) => { out.insert(*id, area); @@ -442,18 +934,61 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap weights, children, } => { - let total: u32 = weights.iter().map(|w| (*w).max(1)).sum(); let primary = match orientation { Orientation::Horizontal => area.size.rows, Orientation::Vertical => area.size.cols, }; + // Pass 1 — subtract the fixed children. Only a horizontal + // split divides rows, so `fixed` is inert anywhere else. + let mut extents: Vec> = vec![None; children.len()]; + let mut fixed_total: u32 = 0; + if matches!(orientation, Orientation::Horizontal) { + for (i, child) in children.iter().enumerate() { + if let LayoutNode::Leaf(id) = child + && let Some(rows) = fixed.get(id).copied() + { + // Saturating: a request larger than the frame + // takes what is left rather than wrapping. The + // caller has already clamped against the document + // minimum; this is the last-resort floor. + let take = rows.min(primary.saturating_sub(fixed_total)); + extents[i] = Some(take); + fixed_total += take; + } + } + } + // Pass 2 — divide the remainder by weight among the flexible + // children, preserving last-flexible-takes-the-remainder. + let remainder = primary.saturating_sub(fixed_total); + let total: u32 = children + .iter() + .enumerate() + .filter(|(i, _)| extents[*i].is_none()) + .map(|(i, _)| weights.get(i).copied().unwrap_or(1).max(1)) + .sum(); + let last_flexible = children + .iter() + .enumerate() + .rev() + .find(|(i, _)| extents[*i].is_none()) + .map(|(i, _)| i); + let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { - let w = weights.get(i).copied().unwrap_or(1).max(1); - let extent = if i + 1 == children.len() { - primary - cursor - } else { - primary * w / total + let extent = match extents[i] { + Some(rows) => rows, + None => { + let w = weights.get(i).copied().unwrap_or(1).max(1); + let e = if Some(i) == last_flexible { + remainder - flexible_used + } else if total == 0 { + 0 + } else { + remainder * w / total + }; + flexible_used += e; + e + } }; let child_area = match orientation { Orientation::Horizontal => Rect { @@ -465,13 +1000,21 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap size: CellSize::new(area.size.rows, extent), }, }; - compute_node(child, child_area, out); + compute_node(child, child_area, fixed, out); cursor += extent; } } } } +/// Every [`WindowId`] beneath `node`, in layout order. +#[must_use] +pub fn node_ids(node: &LayoutNode) -> Vec { + let mut out = Vec::new(); + collect_ids(node, &mut out); + out +} + fn collect_ids(node: &LayoutNode, out: &mut Vec) { match node { LayoutNode::Leaf(id) => out.push(*id), @@ -598,7 +1141,7 @@ mod tests { fn single_window_takes_full_area() { let w = id(); let layout = Layout::single(w); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.get(&w), Some(&rect_24x80())); } @@ -608,7 +1151,7 @@ mod tests { let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Vertical, b)); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.rows, 24); @@ -624,7 +1167,7 @@ mod tests { let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Horizontal, b)); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.cols, 80); @@ -644,15 +1187,15 @@ mod tests { } else { panic!("expected split"); } - let p1 = layout.compute(Rect::new(0, 0, 24, 90)); + let p1 = layout.compute(Rect::new(0, 0, 24, 90), &HashMap::new()); assert_eq!(p1[&a].size.cols, 60); assert_eq!(p1[&b].size.cols, 30); // Resize down by 1/3. - let p2 = layout.compute(Rect::new(0, 0, 24, 60)); + let p2 = layout.compute(Rect::new(0, 0, 24, 60), &HashMap::new()); assert_eq!(p2[&a].size.cols, 40); assert_eq!(p2[&b].size.cols, 20); // Resize wide. - let p3 = layout.compute(Rect::new(0, 0, 24, 300)); + let p3 = layout.compute(Rect::new(0, 0, 24, 300), &HashMap::new()); assert_eq!(p3[&a].size.cols, 200); assert_eq!(p3[&b].size.cols, 100); } @@ -681,7 +1224,7 @@ mod tests { } leaves.extend(more); assert_eq!(leaves.len(), 8); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.len(), 8); // Every rect must be non-empty (terminal large enough). for id in &leaves { diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index cbef8cd..79b41c8 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -1516,6 +1516,9 @@ fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> W layout: Layout::single(win_id), active: win_id, fold_projection, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); win_id diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 6a08c41..120ca23 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -465,6 +465,9 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() { layout: pmacs::window::Layout::single(window_id), active: window_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); } diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 8bf44a5..04b2c27 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -82,6 +82,9 @@ fn attach_view( layout: Layout::single(window_id), active: window_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); window_id From 683c9b86aa1e94dc3c6360e025e852fa89127734 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:10:45 -0400 Subject: [PATCH 03/10] feat(window): adopter placement opt-in and the Stage 1 acceptance suite - `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the same strict `display = "current" | "panel"`, validated before any buffer, session, process, or wrapper exists. Omission keeps today's behavior; Stage 3 flips the default. - `listview.quit` / `compile.quit` delegate to `window.quit` only when the buffer really is in a side window, so the presentation is deleted or restored instead of leaving a source buffer stranded in the slot. - LSP `visit_location`, LSP go-to-definition, and compile `visit_error` route through `display_file`, so a visit from a panel lands in the document target and fires its hook with that window active. - `window.quit`'s Delete arm focuses the revalidated remembered origin. - Capability fallback discards an accompanying `height` rather than rejecting the call. - `window.min-height` clamps a below-floor value on read instead of refusing the write. - `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's Stage 1 criteria, including the two production `Layout::compute` callers, the recursive minima, hide/reappear, the final-focus matrix, quit chains at the depth cap, per-frontend jump origins, the divider, and a real-PTY pin of Bet B1. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 34 +- builtin/runtime/listview.lua | 29 +- builtin/runtime/lsp.lua | 10 +- builtin/runtime/window.lua | 7 +- src/daemon.rs | 4 +- src/editor.rs | 72 +- src/editor_core.rs | 137 +- src/lua_bindings/mod.rs | 59 +- src/lua_bindings/window_panel.rs | 212 ++- src/window.rs | 29 +- tests/bottom_panel_stage1_acceptance.rs | 2224 +++++++++++++++++++++++ 11 files changed, 2646 insertions(+), 171 deletions(-) create mode 100644 tests/bottom_panel_stage1_acceptance.rs diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index e364de2..2c00731 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -734,6 +734,16 @@ end) -- has its own worker path. local function start_run(slot, cmdline, opts) opts = opts or {} + -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run + -- supersedes anything, rewrites the buffer, or spawns a process, so + -- an unknown value leaves no half-started run behind. In Stages 1-2 + -- omission means "current"; Stage 3 flips the default. + local display = opts.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "compile.run: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end -- q-target discipline (Q#CM11): capture only when coming from a -- non-generated buffer, so `g` reruns don't re-capture and -- compile → g → q restores the original buffer. @@ -805,7 +815,16 @@ local function start_run(slot, cmdline, opts) -- attach here stacked a duplicate render view per run (round-5 -- finding 1; translation itself is buffer-level and unaffected by -- attachment count). - pmacs.window.switch_buffer(slot.buf) + -- The FIRST display of this run is the side-affine one (Q#BP3): a + -- persistent *compilation* already visible in a document window must + -- not preempt the requested panel. Compile output is passive, so it + -- takes `select = false` explicitly; a recompile simply reuses the + -- panel it is already in. + if display == "panel" then + pmacs.window.display(slot.buf, { side = "bottom", select = false }) + else + pmacs.window.switch_buffer(slot.buf) + end if not ok then emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc))) slot.expected_rev = buf:revision() @@ -866,7 +885,9 @@ local function visit_error(slot, idx) if not e then return end local path = resolve_error_path(slot, e.file) pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the + -- source in the document target, leaving the panel where it is. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then pmacs.editor.jump_back() pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err)) @@ -995,6 +1016,15 @@ pmacs.command.define { fn = function() local slot = slot_for_buffer(pmacs.window.buffer()) if not slot then return end + -- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or + -- restores the PRESENTATION rather than leaving a source buffer + -- stranded in the panel slot. Capability fallback and pre-arc + -- placement keep today's previous-buffer restore below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = slot.prev if not (target and target:is_valid()) then target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 4be4faa..6081587 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -123,7 +123,25 @@ function pmacs.listview.open(spec) p.prev = active end render(p, spec.rows or {}) - pmacs.window.switch_buffer(p.buffer) + -- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and + -- `listview.refresh` are active-window-only, so an interactive panel + -- MUST take `select = true` or it would silently seat the wrong + -- window. In Stages 1-2 omitting `display` keeps today's raw switch; + -- Stage 3 flips the default. An unknown value errors before anything + -- is displayed. + local display = spec.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "listview.open: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end + if display == "panel" then + p.side = true + pmacs.window.display(p.buffer, { side = "bottom", select = true }) + else + p.side = false + pmacs.window.switch_buffer(p.buffer) + end seat_cursor(p, 1) end @@ -160,6 +178,15 @@ pmacs.command.define { fn = function() local p = panel_for_current_buffer() if not p then return end + -- Bottom-panel arc (Q#BP11b): `q` keeps its name and its + -- user-visible behavior, delegating to `window.quit` only when the + -- listview really is in a side window. Capability fallback (and any + -- pre-arc placement) keeps the previous-buffer switch below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = p.prev if not (target and target:is_valid()) then target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d7ab9d9..4181156 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition() return end pmacs.editor.push_jump() - local ok2, oerr = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): the target-aware load. `find_or_open` + -- switches the ACTIVE window, which would replace a focused panel; + -- `display_file` resolves the DOCUMENT target first and fires the + -- load/switch hook with that window active. + local ok2, oerr = pcall(pmacs.window.display_file, path, { select = true }) if not ok2 then -- Open failed: drop the origin we just pushed so M-, isn't -- left pointing at a jump that never happened. @@ -1601,7 +1605,9 @@ local function visit_location(loc) return end pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): a visit FROM a panel must land in the + -- document target and leave the panel intact. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then -- Open failed: drop the origin we just pushed so M-, isn't left -- pointing at a jump that never happened. diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua index cf63443..37b459f 100644 --- a/builtin/runtime/window.lua +++ b/builtin/runtime/window.lua @@ -27,12 +27,17 @@ pmacs.config.define { -- (drag and the commands below) and is deliberately ignored by the -- ordinary layout pass and by frame-resize reconciliation, so raising it -- can never invalidate a layout that already exists. +-- +-- The registry floor is 1 rather than 2 on purpose: a value below the +-- STRUCTURAL floor is clamped when it is read, not rejected when it is +-- written, so a user who asks for a smaller minimum simply gets the +-- smallest one the layout can actually honor. pmacs.config.define { name = "window.min-height", description = "Smallest outer rows interactive resize will leave a window.", type = "integer", default = 2, - min = 2, + min = 1, mutability = "live", } diff --git a/src/daemon.rs b/src/daemon.rs index cb82c3b..44d4b61 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -905,7 +905,7 @@ fn peer_declared_terminal_support( /// from the daemon's own negotiated state, and Stage 2 turns the version /// arm on (`semantic_render && negotiated_protocol_version >= /// PANEL_MIN_VERSION`). -fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { +fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -1808,7 +1808,7 @@ fn handle_session_established( let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, - peer_declared_panel_support(&session_state), + peer_declared_panel_support(session_state), ); { let mut core = editor.core.borrow_mut(); diff --git a/src/editor.rs b/src/editor.rs index d6c6a51..2d2a5e8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -841,14 +841,13 @@ impl EditorState { .get(&window_id) .map(|window| window.buffer_id); if let Some(buffer_id) = buffer_id { - let _ = self - .terminal_manager - .borrow_mut() - .release_controller(crate::terminal::TerminalViewKey { + let _ = self.terminal_manager.borrow_mut().release_controller( + crate::terminal::TerminalViewKey { frontend_id, window_id, buffer_id, - }); + }, + ); } } outcome.changed @@ -1931,7 +1930,8 @@ impl EditorState { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); } - MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, + // Any other event — release, a different button, a + // wheel notch — ends the gesture. _ => self.window_drag = None, } return; @@ -2062,9 +2062,12 @@ impl EditorState { /// Arm a divider drag if `owner`'s bottom row really is an exposed /// segment of a horizontal boundary (Q#BP5). fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { - let is_divider = self.core.borrow().views.get(&frontend_id).is_some_and(|view| { - view.layout.boundary_below(owner).is_some() - }); + let is_divider = self + .core + .borrow() + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.boundary_below(owner).is_some()); self.window_drag = is_divider.then_some(WindowDragState { frontend_id, owner, @@ -2078,14 +2081,22 @@ impl EditorState { /// layout mutation mid-drag cannot move a boundary that no longer /// exists. Motion is applied incrementally and re-anchored each /// event, so the clamp absorbs over-travel instead of accumulating it. - fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { + fn drag_window_boundary( + &mut self, + frontend_id: FrontendId, + cell_row: u32, + term_size: CellSize, + ) { let Some(drag) = self.window_drag else { return; }; if drag.frontend_id != frontend_id { return; } - self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); + self.window_drag = Some(WindowDragState { + last_row: cell_row, + ..drag + }); let delta = i64::from(cell_row) - i64::from(drag.last_row); let Ok(delta) = i32::try_from(delta) else { return; @@ -3126,13 +3137,14 @@ pub fn paint_frame( // upper child is a nested subtree exposes SEVERAL leaf segments along // the same edge, so the root panel divider is full width even when // the document subtree ends in several columns. - let divider_windows: Vec = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { - view.layout - .iter_ids() - .into_iter() - .filter(|id| view.layout.boundary_below(*id).is_some()) - .collect() - }); + let divider_windows: Vec = + core.views.get(&frontend_id).map_or_else(Vec::new, |view| { + view.layout + .iter_ids() + .into_iter() + .filter(|id| view.layout.boundary_below(*id).is_some()) + .collect() + }); let divider_style = theme.face("ui.divider"); // Clear the whole grid first so windows that shrink on resize @@ -7073,22 +7085,14 @@ mod tests { } else { panic!("expected split"); } - let p1 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 90), - &std::collections::HashMap::new(), - ); - let p2 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 60), - &std::collections::HashMap::new(), - ); + let p1 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 90), + &std::collections::HashMap::new(), + ); + let p2 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 60), + &std::collections::HashMap::new(), + ); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 9ab4bca..039ee43 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -882,10 +882,7 @@ impl EditorCore { /// /// # Errors /// Any load failure other than `NotFound`. - pub fn resolve_target_buffer( - &mut self, - path: &Path, - ) -> Result<(BufferId, HookKind), String> { + pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { match self.get_or_load_buffer(path) { Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), @@ -1060,11 +1057,7 @@ impl EditorCore { pub fn jump_back(&mut self) -> bool { let fid = self.active_frontend; loop { - let Some(entry) = self - .jump_ring - .get_mut(&fid) - .and_then(std::vec::Vec::pop) - else { + let Some(entry) = self.jump_ring.get_mut(&fid).and_then(std::vec::Vec::pop) else { return false; }; if !self.registry.borrow().contains(entry.buffer_id) { @@ -1081,12 +1074,20 @@ impl EditorCore { && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { self.set_active_window_id(entry.window_id); - } else if entry.side_origin { - continue; - } else if self.active_buffer_id() != entry.buffer_id - && self.switch_active_buffer(entry.buffer_id).is_err() - { - continue; + } else { + // A stale SIDE origin is skipped outright: switching a + // panel's buffer into the document window is exactly the + // duplicate-presentation corruption this design removes. + // A stale non-side origin keeps today's active-window + // fallback. + if entry.side_origin { + continue; + } + if self.active_buffer_id() != entry.buffer_id + && self.switch_active_buffer(entry.buffer_id).is_err() + { + continue; + } } let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); @@ -2713,10 +2714,8 @@ impl EditorCore { } self.active_layout_mut().close_window(target); self.windows.remove(&target); - if target_is_side { - if let Some(view) = self.views.get_mut(&fid) { - view.panel_hidden = false; - } + if target_is_side && let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; } // Pick an adjacent window as the new focus, preferring a document. let ids = self.active_layout().iter_ids(); @@ -3002,10 +3001,9 @@ impl EditorCore { self.windows.remove(&side); if was_active && let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) { - if let Some(view) = self.views.get_mut(&fid) { - view.active = target; - } + view.active = target; } // A remembered origin pointing at a now-dead window is cleared by // `non_side_target`'s revalidation on next use; nothing else here @@ -3050,14 +3048,34 @@ impl EditorCore { }; match action { QuitAction::Delete => { - let saved_active = self.views.get(&fid).map(|view| view.active); + // Capture the remembered origin BEFORE the window dies: + // executing `Delete` focuses the revalidated origin, not + // merely whatever leaf the wrapper collapse surfaced + // (Q#BP11b). Entering the panel from document window B + // must therefore return focus to B, not to the window + // that happened to create the panel. + let origin = self + .windows + .get(&target) + .and_then(|window| window.params.origin_document()); self.remove_side_window(fid, target); - Ok(QuitOutcome::Deleted { - focus: self - .views + let origin_valid = origin.is_some_and(|origin| { + self.views .get(&fid) - .map(|view| view.active) - .or(saved_active), + .is_some_and(|view| view.layout.iter_ids().contains(&origin)) + && !self + .windows + .get(&origin) + .is_some_and(crate::window::Window::is_side) + }); + if origin_valid + && let Some(origin) = origin + && let Some(view) = self.views.get_mut(&fid) + { + view.active = origin; + } + Ok(QuitOutcome::Deleted { + focus: self.views.get(&fid).map(|view| view.active), }) } QuitAction::Restore { @@ -3073,7 +3091,7 @@ impl EditorCore { self.install_buffer_in_window(target, buffer_id)?; let len = { let reg = self.registry.borrow(); - reg.get(buffer_id).map(Buffer::len).unwrap_or(0) + reg.get(buffer_id).map_or(0, Buffer::len) }; let window = self .windows @@ -3091,10 +3109,7 @@ impl EditorCore { window.view_top = view_top; window.goal_col = goal_col; window.selection = selection.filter(|sel| sel.anchor <= len); - Ok(QuitOutcome::Restored { - target, - buffer_id, - }) + Ok(QuitOutcome::Restored { target, buffer_id }) } } } @@ -3117,8 +3132,7 @@ impl EditorCore { #[must_use] pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { let geometry = self.views.get(&fid)?.frame_geometry?; - (geometry.total.rows >= 2 && geometry.total.cols > 0) - .then(|| geometry.total.rows - 1) + (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } /// Cache a frontend's authoritative frame capacity (Q#BP2b). @@ -3207,6 +3221,10 @@ impl EditorCore { /// # Errors /// When `win` is not live in `fid`'s layout, when the panel is /// hidden, or when no adjustable horizontal boundary exists. + #[allow( + clippy::too_many_lines, + reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" + )] pub fn resize_boundary( &mut self, fid: FrontendId, @@ -3260,9 +3278,9 @@ impl EditorCore { ) } else { ( - view.layout - .boundary_below(win) - .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + view.layout.boundary_below(win).ok_or_else(|| { + "window.resize: no adjustable horizontal boundary".to_string() + })?, false, ) }; @@ -3423,13 +3441,7 @@ impl EditorCore { target: placement.target, saved_active, select, - created_side: matches!( - placement.kind, - PlacementKind::Side { - created: true, - .. - } - ), + created_side: matches!(placement.kind, PlacementKind::Side { created: true, .. }), }) } @@ -3494,7 +3506,12 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); candidates .into_iter() .find(|id| eligible(*id)) @@ -3506,6 +3523,10 @@ impl EditorCore { /// otherwise a persistent `*compilation*` buffer already visible in a /// document window makes `{side = "bottom"}` silently ignore its /// requested placement. + #[allow( + clippy::too_many_lines, + reason = "Q#BP3's precedence ladder reads as one ordered policy" + )] fn resolve_placement( &self, fid: FrontendId, @@ -3594,7 +3615,12 @@ impl EditorCore { }); } } - } else if request.height.is_some() { + } else if request.side.is_none() && request.height.is_some() { + // A freestanding `height` with no side request is a mistake. + // A `height` that arrived WITH a side request and fell + // through (not panel-capable, or the one slot is dedicated + // elsewhere) is discarded, not rejected — capability + // fallback must not turn into an error (Q#BP2c). return Err("display: `height` requires a side window".into()); } @@ -3624,11 +3650,17 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); for candidate in candidates { - let eligible = self.windows.get(&candidate).is_some_and(|w| { - !w.params.dedicated || w.buffer_id == request.buffer_id - }); + let eligible = self + .windows + .get(&candidate) + .is_some_and(|w| !w.params.dedicated || w.buffer_id == request.buffer_id); if eligible { return Ok(Placement { target: candidate, @@ -3681,7 +3713,8 @@ impl EditorCore { let requested_side = request.side.unwrap_or(Side::Bottom); if created { - let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; + let rows = + Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; let origin = self.non_side_target(fid).ok(); let text_view = { let reg = self.registry.borrow(); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index a1ea3fc..3d8a4e9 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8501,8 +8501,8 @@ fn install_terminal( let supervisor = supervisor.clone(); terminal.set( "_open", - lua.create_function(move |lua, spec: Table| -> mlua::Result { - let spec = parse_terminal_spec(&spec)?; + lua.create_function(move |lua, spec_table: Table| -> mlua::Result { + let spec = parse_terminal_spec(&spec_table)?; let core = lua .app_data_ref::() .map(|core| core.clone()) @@ -8515,37 +8515,54 @@ fn install_terminal( "pmacs.terminal.open: target frontend has no active window", )); } + // Bottom-panel arc (Q#BP11b): parse placement BEFORE the + // session, process, buffer, or wrapper exists, so an + // unknown `display` value creates nothing to roll back. + let placement = window_panel::parse_adopter_placement( + &core, + frontend_id, + "pmacs.terminal.open", + spec_table.get::>("display")?.as_deref(), + spec_table.get::>("window")?, + )?; let buffer_id = { let mut manager = manager.borrow_mut(); manager .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) .map_err(mlua::Error::external)? }; - let key = { - let mut core = core.borrow_mut(); - if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { + let outcome = match window_panel::place_adopter_buffer( + lua, + &core, + frontend_id, + buffer_id, + &placement, + true, + ) { + Ok(outcome) => outcome, + Err(error) => { + let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager .borrow_mut() .prune(&mut core, &mut supervisor.borrow_mut()); - return Err(mlua::Error::external(format!( - "pmacs.terminal.open: active-window switch failed: {error}" - ))); + return Err(error); } - crate::terminal::TerminalViewKey::new( - frontend_id, - core.views - .get(&frontend_id) - .expect("checked frontend has active view") - .active, - buffer_id, - ) }; + let key = + crate::terminal::TerminalViewKey::new(frontend_id, outcome.target, buffer_id); let claimed = { let mut manager = manager.borrow_mut(); manager.register_view(key) && manager.claim_controller(key) }; if !claimed { + // Placement failure removes any side wrapper this + // transaction created, BEFORE the existing + // session/buffer rollback completes (Q#BP11b). + if outcome.created_side { + core.borrow_mut() + .remove_side_window(frontend_id, outcome.target); + } let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager @@ -8555,7 +8572,7 @@ fn install_terminal( "pmacs.terminal.open: failed to claim the new terminal view", )); } - run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + window_panel::finish_adopter_placement(lua, &core, frontend_id, outcome)?; Ok(BufferIdLua(buffer_id)) })?, )?; @@ -8717,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result mlua::Result
{ win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut() - .close_others() - .map_err(mlua::Error::runtime) + cc.borrow_mut().close_others().map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index a28add0..d0ee6c0 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -120,10 +120,11 @@ fn complete_display( let target_ok = visible(core, fid, outcome.target); let saved_ok = visible(core, fid, outcome.saved_active); let final_focus = match (outcome.select, target_ok, saved_ok) { - (true, true, _) => Some(outcome.target), - (true, false, true) => Some(outcome.saved_active), - (false, _, true) => Some(outcome.saved_active), - (false, true, false) => Some(outcome.target), + // `select = true` KEEPS the target selected. + (true, true, _) | (false, true, false) => Some(outcome.target), + // `select = false` restores the saved window even when it is the + // panel — a passive display from a focused panel must not blur it. + (true, false, true) | (false, _, true) => Some(outcome.saved_active), // Both ids died with the hook: fall back to the non-side target // rule rather than leaving focus on a dead window. _ => None, @@ -202,8 +203,135 @@ fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result, + window: Option, +) -> mlua::Result { + let display = match display { + None | Some("current") => AdopterPlacement::Current, + Some("panel") => AdopterPlacement::Panel, + Some(other) => { + return Err(mlua::Error::runtime(format!( + "{operation}: unknown display {other:?} (expected \"current\" or \"panel\")" + ))); + } + }; + match (window, &display) { + (Some(_), AdopterPlacement::Panel) => Err(mlua::Error::runtime(format!( + "{operation}: `window` and `display = \"panel\"` are mutually exclusive" + ))), + (Some(raw), _) => Ok(AdopterPlacement::Window(lookup_window(core, fid, raw)?)), + (None, _) => Ok(display), + } +} + +/// Install `buffer_id` per `placement`, returning Phase 1's outcome +/// (Q#BP11b). +/// +/// `Current` keeps the pre-arc raw switch: it is the deliberate escape +/// hatch every existing adopter caller already relies on, and it does not +/// consult display-policy dedication. +/// +/// # Errors +/// Any placement failure. The caller owns its own session/buffer +/// rollback, and inspects `created_side` to remove a wrapper this +/// transaction created. +pub(crate) fn place_adopter_buffer( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, + placement: &AdopterPlacement, + select: bool, +) -> mlua::Result { + if matches!(placement, AdopterPlacement::Current) { + let mut borrowed = core.borrow_mut(); + borrowed + .switch_active_buffer_for(fid, buffer_id) + .map_err(mlua::Error::runtime)?; + let target = borrowed + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| { + mlua::Error::runtime("adopter placement: acting frontend has no active window") + })?; + return Ok(DisplayOutcome { + target, + saved_active: target, + select: true, + created_side: false, + }); + } + let mut request = DisplayRequest::new(buffer_id); + match placement { + AdopterPlacement::Panel => request.side = Some(Side::Bottom), + AdopterPlacement::Window(window) => request.window = Some(*window), + AdopterPlacement::Current => unreachable!("handled above"), + } + request.select = Some(select); + request.default_panel_rows = config_u32( + lua, + "window.panel-height", + Some(buffer_id), + DEFAULT_PANEL_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS); + core.borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime) +} + +/// Phase 2 for an adopter that had to interleave its own work (claiming a +/// terminal controller, seating a cursor) between placement and the hook. +/// +/// # Errors +/// Propagates the final-focus resolution error when both window ids died +/// inside the hook. +pub(crate) fn finish_adopter_placement( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + outcome: DisplayOutcome, +) -> mlua::Result<()> { + complete_display(lua, core, fid, outcome, HookKind::AfterSwitch) +} + /// Install the bottom-panel surface onto the existing `pmacs.window` /// table. +#[allow( + clippy::too_many_lines, + reason = "one flat list of bindings, each following the same \ + acting-frontend / Rc-borrow shape; splitting them fragments \ + a coherent surface" +)] pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { { let cc = core.clone(); @@ -386,10 +514,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .quit_action() .map_or(0, crate::window::QuitAction::depth), )?; - table.set( - "hidden", - window.is_side() && core.panel_hidden_for(fid), - )?; + table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?; Ok(table) })?, )?; @@ -400,41 +525,43 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let cc = core.clone(); win.set( "set_params", - lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { - let fid = acting_frontend(lua, &cc); - let id = lookup_window(&cc, fid, target)?; - for key in ["side", "origin_document", "quit_action"] { - if opts.get::(key)? != Value::Nil { - return Err(mlua::Error::runtime(format!( - "pmacs.window.set_params: `{key}` is not settable" - ))); + lua.create_function( + move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = lookup_window(&cc, fid, target)?; + for key in ["side", "origin_document", "quit_action"] { + if opts.get::(key)? != Value::Nil { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: `{key}` is not settable" + ))); + } } - } - let height = match opts.get::>("fixed_rows")? { - Some(rows) => Some( - crate::editor_core::EditorCore::clamp_panel_rows(rows) - .map_err(mlua::Error::runtime)?, - ), - None => None, - }; - let dedicated = opts.get::>("dedicated")?; - { - let mut core = cc.borrow_mut(); - let window = core.windows.get_mut(&id).ok_or_else(|| { - mlua::Error::runtime("pmacs.window.set_params: window not live") - })?; - if let Some(rows) = height { - // Inert on an ordinary window by construction: - // the fixed map is built from side windows only. - window.params.fixed_rows = Some(rows); + let height = match opts.get::>("fixed_rows")? { + Some(rows) => Some( + crate::editor_core::EditorCore::clamp_panel_rows(rows) + .map_err(mlua::Error::runtime)?, + ), + None => None, + }; + let dedicated = opts.get::>("dedicated")?; + { + let mut core = cc.borrow_mut(); + let window = core.windows.get_mut(&id).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.set_params: window not live") + })?; + if let Some(rows) = height { + // Inert on an ordinary window by construction: + // the fixed map is built from side windows only. + window.params.fixed_rows = Some(rows); + } + if let Some(dedicated) = dedicated { + window.params.dedicated = dedicated; + } } - if let Some(dedicated) = dedicated { - window.params.dedicated = dedicated; - } - } - reconcile_panel_layout(lua, &cc, fid); - Ok(()) - })?, + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + }, + )?, )?; } @@ -466,8 +593,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .iter_ids() .into_iter() .map(|id| { - let buffer_id = - core.windows.get(&id).map(|w| w.buffer_id); + let buffer_id = core.windows.get(&id).map(|w| w.buffer_id); ( id, config_u32( diff --git a/src/window.rs b/src/window.rs index 2d8afff..f38cf53 100644 --- a/src/window.rs +++ b/src/window.rs @@ -975,20 +975,21 @@ fn compute_node( let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { - let extent = match extents[i] { - Some(rows) => rows, - None => { - let w = weights.get(i).copied().unwrap_or(1).max(1); - let e = if Some(i) == last_flexible { - remainder - flexible_used - } else if total == 0 { - 0 - } else { - remainder * w / total - }; - flexible_used += e; - e - } + let extent = if let Some(rows) = extents[i] { + rows + } else { + let w = weights.get(i).copied().unwrap_or(1).max(1); + let e = if Some(i) == last_flexible { + remainder - flexible_used + } else { + remainder + .checked_mul(w) + .unwrap_or(remainder) + .checked_div(total) + .unwrap_or(0) + }; + flexible_used += e; + e }; let child_area = match orientation { Orientation::Horizontal => Rect { diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs new file mode 100644 index 0000000..b89ca15 --- /dev/null +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -0,0 +1,2224 @@ +// bottom_panel_stage1_acceptance.rs --- bottom-panel Stage 1 acceptance +// (docs/bottom-panel-framing.md, acceptance items 1-35). + +//! Window placement + TUI side windows. No wire change. +//! +//! Every claim about geometry is asserted through a **production** +//! caller: `window_placements` (via the real `paint_frame`) or the +//! peer-presence overlay pass, never against `Layout::compute` in +//! isolation — the whole point of R5-B1 is that a second caller derives +//! its own rect and would otherwise keep computing unfixed geometry. +//! Placement, quit, and visit claims run through the real Lua surface +//! and the real adopter entry points. + +use std::collections::HashMap; +use std::time::Duration; + +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use pmacs::buffer::BufferId; +use pmacs::cell::{CellCoord, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::{DisplayRequest, EditorCore}; +use pmacs::protocol::FrontendId; +use pmacs::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Rect, Side, Window, WindowId, subtree_min_rows, +}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Terminal geometry. `paint_frame` reserves the last row for the status +/// line, so the window area is `ROWS - 1`. +const ROWS: u32 = 24; +const COLS: u32 = 60; +const AREA_ROWS: u32 = ROWS - 1; + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + // Geometry is authoritative state, and a grid frontend's real frame + // size IS its declaration. Every test that does not render declares + // it here, before any input. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn try_exec(s: &EditorState, src: &str) -> Result<(), String> { + s.lua_host + .lua() + .load(src.to_string()) + .exec() + .map_err(|e| e.to_string()) +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Render one real frame and return the per-window outer rects keyed by +/// window id, as `window_placements` computed them. +fn render(s: &EditorState) -> HashMap { + render_at(s, CellSize::new(ROWS, COLS)) +} + +fn render_at(s: &EditorState, size: CellSize) -> HashMap { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + placements(s, size) +} + +/// The production placement pass, at `size`. +fn placements(s: &EditorState, size: CellSize) -> HashMap { + let core = s.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view"); + let area = Rect::new(0, 0, size.rows - 1, size.cols); + let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows); + view.layout.compute(area, &fixed) +} + +/// Paint one frame and hand back the grid text, row by row. +fn painted_rows(s: &EditorState, size: CellSize) -> Vec { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + (0..size.rows) + .map(|row| { + (0..size.cols) + .map(|col| match &cells[(row * size.cols + col) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(_) => '?', + Glyph::Continuation => ' ', + }) + .collect() + }) + .collect() +} + +fn side_window(s: &EditorState) -> Option { + s.core.borrow().side_window_for(FrontendId::LOCAL) +} + +fn active_window(s: &EditorState) -> WindowId { + s.core.borrow().active_window_id() +} + +fn fixed_rows_of(s: &EditorState, win: WindowId) -> Option { + s.core.borrow().windows.get(&win)?.params.fixed_rows +} + +fn layout_root(s: &EditorState) -> LayoutNode { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .layout + .root + .clone() +} + +/// Structural fingerprint: node shape, weights, order, and ids — what +/// Bet B6 promises stays byte-identical when a panel opens. +fn structure(node: &LayoutNode) -> String { + match node { + LayoutNode::Leaf(id) => format!("L{}", id.raw()), + LayoutNode::Split { + orientation, + weights, + children, + } => format!( + "S{}{weights:?}({})", + match orientation { + Orientation::Horizontal => "H", + Orientation::Vertical => "V", + }, + children.iter().map(structure).collect::>().join(",") + ), + } +} + +/// Create a panel showing a fresh generated buffer, through the real Lua +/// display surface. +fn open_panel(s: &EditorState, name: &str, height: u32) -> WindowId { + exec( + s, + &format!( + "PANEL_BUF = pmacs.buffer.create({name:?}) + PANEL_WIN = pmacs.window.display(PANEL_BUF, \ + {{ side = \"bottom\", height = {height} }})" + ), + ); + side_window(s).expect("panel exists") +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn mouse(kind: MouseEventKind, row: u16, column: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +/// Register a second frontend with its own single-window layout. +fn attach_frontend(s: &EditorState, fid: FrontendId, panel_capable: bool) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win = WindowId::next(); + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable, + frame_geometry: None, + panel_hidden: false, + }, + ); + drop(core); + if panel_capable { + s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS)); + } + win +} + +// --------------------------------------------------------------------------- +// 1 — fixed extents reach BOTH production callers +// --------------------------------------------------------------------------- + +#[test] +fn acc1_fixed_extent_reaches_both_production_callers() { + let s = editor(); + let document = active_window(&s); + let before = render(&s); + assert_eq!( + before[&document].size.rows, AREA_ROWS, + "one window takes the whole area" + ); + + let panel = open_panel(&s, "*panel*", 6); + let after = render(&s); + assert_eq!( + after[&panel].size.rows, 6, + "the side child gets exactly N rows" + ); + assert_eq!( + after[&document].size.rows, + AREA_ROWS - 6, + "the sibling divides the remainder" + ); + + // The second production caller (`overlay_paint`) derives its OWN + // text-area rect and never routes through `window_placements`. Paint + // a peer cursor into the document window and assert it lands on the + // row the fixed geometry says — the assertion that fails if that + // caller keeps computing unfixed geometry. + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let row_with_panel = peer_cursor_row(&s, document_buffer, 0); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + let row_without_panel = peer_cursor_row(&s, document_buffer, 0); + assert_eq!( + row_with_panel, row_without_panel, + "a peer cursor in the document window paints at the same row \ + whether or not a panel is open" + ); +} + +/// Paint the peer-presence overlay pass and report the grid row the peer +/// cursor landed on. +fn peer_cursor_row(s: &EditorState, buffer_id: BufferId, position: u64) -> u32 { + let size = CellSize::new(ROWS, COLS); + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + let presence = pmacs::overlay_paint::OtherPresence { + frontend_id: FrontendId(7), + color_slot: 0, + snapshot: pmacs::presence::PresenceSnapshot { + buffer_id, + cursor: position, + selection: None, + }, + }; + pmacs::overlay_paint::paint_other_frontend_overlays(s, &mut grid, size, &[presence]); + for row in 0..size.rows { + for col in 0..size.cols { + if cells[(row * size.cols + col) as usize].style.reverse { + return row; + } + } + } + panic!("peer cursor was not painted anywhere"); +} + +// --------------------------------------------------------------------------- +// 2 — opening a panel preserves the document subtree's STRUCTURE (B6) +// --------------------------------------------------------------------------- + +#[test] +fn acc2_opening_a_panel_preserves_document_structure() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = layout_root(&s); + let before_rects = render(&s); + + open_panel(&s, "*panel*", 5); + let after = layout_root(&s); + let LayoutNode::Split { children, .. } = &after else { + panic!("the panel wrapper is a split"); + }; + assert_eq!( + structure(&before), + structure(&children[0]), + "nodes, weights, order and ids of the document subtree are identical" + ); + let after_rects = render(&s); + assert!( + before_rects + .keys() + .any(|id| before_rects[id] != after_rects[id]), + "…while the rectangles necessarily change, being recomputed \ + inside the smaller flexible remainder" + ); +} + +// --------------------------------------------------------------------------- +// 3 — the minimum is RECURSIVE +// --------------------------------------------------------------------------- + +#[test] +fn acc3_subtree_minimum_is_recursive_and_clamps_the_panel() { + // Horizontal inside vertical inside horizontal: four leaves, of + // which three stack rows. + let leaf_a = WindowId::next(); + let leaf_b = WindowId::next(); + let leaf_c = WindowId::next(); + let leaf_d = WindowId::next(); + let nested = LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_a), + LayoutNode::Split { + orientation: Orientation::Vertical, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_b), + LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![LayoutNode::Leaf(leaf_c), LayoutNode::Leaf(leaf_d)], + }, + ], + }, + ], + }; + // Rows add across a horizontal split and the tallest child governs a + // vertical one: 2 + max(2, 2 + 2) = 6. A flat "two rows at the root" + // reading would answer 2. + assert_eq!(subtree_min_rows(&nested), 6); + + // In a live layout the PANEL is clamped, never the document. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical(); \ + pmacs.window.split_horizontal()", + ); + let document_min = { + let core = s.core.borrow(); + subtree_min_rows(&core.views[&FrontendId::LOCAL].layout.root) + }; + let panel = open_panel(&s, "*panel*", AREA_ROWS); + let rects = render(&s); + assert_eq!( + rects[&panel].size.rows, + AREA_ROWS - document_min, + "the panel takes min(requested, area - subtree_min_rows(document))" + ); +} + +// --------------------------------------------------------------------------- +// 4 — clamping, rejection, and saturating arithmetic +// --------------------------------------------------------------------------- + +#[test] +fn acc4_height_requests_clamp_to_the_floor_and_reject_zero() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 1); + assert_eq!( + fixed_rows_of(&s, panel), + Some(MIN_WINDOW_OUTER_ROWS), + "a one-row request clamps up to the structural floor" + ); + assert_eq!(render(&s)[&panel].size.rows, MIN_WINDOW_OUTER_ROWS); + + let zero = try_exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*z*\"), \ + { side = \"bottom\", height = 0 })", + ); + assert!( + zero.is_err(), + "a request of zero is rejected, not an invisible open" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 0 }})", + panel.raw() + ) + ) + .is_err(), + "set_params rejects zero too" + ); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 1 }})", + panel.raw() + ), + ); + assert_eq!(fixed_rows_of(&s, panel), Some(MIN_WINDOW_OUTER_ROWS)); + + // `window.panel-height` is the creation default, clamped the same way. + exec(&s, "pmacs.config.set(\"window.panel-height\", 2)"); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*p2*\"), { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + assert_eq!(fixed_rows_of(&s, panel), Some(2)); + + // An intrinsically tiny frame saturates and hides rather than + // underflowing; a zero-column frame is never presentable. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(3, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, 0)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); +} + +// --------------------------------------------------------------------------- +// 5 — absolute height vs proportional ratio, in ONE layout +// --------------------------------------------------------------------------- + +#[test] +fn acc5_resize_preserves_absolute_panel_height_and_flexible_ratio() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 6); + let ids: Vec = { + let core = s.core.borrow(); + core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .filter(|id| *id != panel) + .collect() + }; + let wide = render_at(&s, CellSize::new(ROWS, COLS)); + assert_eq!(wide[&panel].size.rows, 6); + let ratio_before = f64::from(wide[&ids[0]].size.rows) / f64::from(wide[&ids[1]].size.rows); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 10, COLS)); + let tall = render_at(&s, CellSize::new(ROWS + 10, COLS)); + assert_eq!( + tall[&panel].size.rows, 6, + "the side window keeps its ABSOLUTE height" + ); + let ratio_after = f64::from(tall[&ids[0]].size.rows) / f64::from(tall[&ids[1]].size.rows); + assert!( + (ratio_before - ratio_after).abs() < 0.35, + "the flexible pair keeps its RATIO ({ratio_before} vs {ratio_after})" + ); +} + +// --------------------------------------------------------------------------- +// 6 / 7 / 8 — hiding is a durable transition +// --------------------------------------------------------------------------- + +#[test] +fn acc6_reconciliation_hides_moves_focus_and_releases_before_the_next_key() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "the panel is focused"); + + // Shrink the frame to something that cannot satisfy the panel, then + // dispatch a key in the same burst. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + active_window(&s), + document, + "focus moved out of the invisible panel" + ); + let rects = placements(&s, CellSize::new(4, COLS)); + assert_eq!( + rects[&panel].size.rows, 0, + "a hidden panel has an empty rect" + ); + assert_eq!( + rects[&document].size.rows, 3, + "the document subtree receives every reclaimed row" + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(8), + "the stored request survives hiding" + ); +} + +#[test] +fn acc7_reappearing_restores_the_request_but_not_focus() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + + let before = layout_root(&s); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert_eq!( + structure(&before), + structure(&layout_root(&s)), + "wrapper, ids, weights and order survive hiding" + ); + // While hidden the panel is not a focus destination. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!( + active_window(&s), + document, + "focus_next skips a hidden panel" + ); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!(!s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + render(&s)[&panel].size.rows, + 8, + "restored at the exact request" + ); + assert_eq!( + active_window(&s), + document, + "focus is NOT auto-restored — the user moved on" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "…but C-x o reaches it again"); +} + +#[test] +fn acc8_keys_while_hidden_reach_the_document_window() { + let mut s = editor(); + let document = active_window(&s); + open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let text: String = { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + let buf = reg.get(document_buffer).unwrap(); + let mut bytes = vec![0u8; buf.len() as usize]; + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() + }; + assert!( + text.contains('x'), + "the keystroke landed in the document buffer, not the invisible panel" + ); +} + +// --------------------------------------------------------------------------- +// 9 — window.min-height is an INTERACTIVE preference only +// --------------------------------------------------------------------------- + +#[test] +fn acc9_min_height_constrains_interactive_resize_only() { + let s = editor(); + exec(&s, "pmacs.config.set(\"window.min-height\", 1)"); + let panel = open_panel(&s, "*panel*", 6); + // Below the structural floor: the resolver clamps it back up. + assert_eq!(s.window_min_height(None), MIN_WINDOW_OUTER_ROWS); + + // A value materially above the floor constrains resize recursively + // across a nested document tree. + exec( + &s, + "pmacs.config.set(\"window.min-height\", 5) + pmacs.window.split_horizontal()", + ); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document target"); + // Two document leaves at 5 rows each = 10; the frame area is 23, so + // the panel can never grow past 13. + let _ = s.resize_window_boundary(FrontendId::LOCAL, panel, 100, AREA_ROWS); + assert!( + fixed_rows_of(&s, panel).expect("panel rows") <= AREA_ROWS - 10, + "the recursive interactive minimum bounds the panel" + ); + // Frame-resize layout ignores the preference entirely: an area that + // only satisfies the STRUCTURAL floor still lays out. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(8, COLS)); + let rects = placements(&s, CellSize::new(8, COLS)); + assert!( + rects[&document].size.rows > 0, + "changing a preference never invalidates an existing layout" + ); +} + +// --------------------------------------------------------------------------- +// 10 — closing collapses the wrapper +// --------------------------------------------------------------------------- + +#[test] +fn acc10_closing_the_panel_restores_the_prior_root_exactly() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = structure(&layout_root(&s)); + let panel = open_panel(&s, "*panel*", 5); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + assert_eq!( + before, + structure(&layout_root(&s)), + "the wrapper collapses and the prior root returns unchanged" + ); +} + +// --------------------------------------------------------------------------- +// 11 — parameter write discipline +// --------------------------------------------------------------------------- + +#[test] +fn acc11_parameter_writes_are_restricted_and_ids_are_frontend_scoped() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + for forbidden in [ + "side = \"bottom\"", + "origin_document = 1", + "quit_action = \"delete\"", + ] { + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ {forbidden} }})", + panel.raw() + ) + ) + .is_err(), + "set_params must reject `{forbidden}`" + ); + } + // `params` may REPORT the implementation-owned bookkeeping. + exec(&s, "pmacs.window.focus_next()"); + let origin: Option = eval( + &s, + &format!( + "return pmacs.window.params({}).origin_document", + panel.raw() + ), + ); + assert_eq!(origin, Some(document.raw())); + + // A stray `fixed_rows` on a non-side window is inert. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 4 }})", + document.raw() + ), + ); + let rects = render(&s); + assert_eq!( + rects[&document].size.rows, + AREA_ROWS - 5, + "the fixed map is built from side windows only" + ); + + // Every WindowId-taking operation rejects a live id owned by another + // frontend. + let foreign = attach_frontend(&s, FrontendId(9), true); + for call in [ + format!("pmacs.window.params({})", foreign.raw()), + format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + foreign.raw() + ), + format!("pmacs.window.resize({}, 1)", foreign.raw()), + format!("pmacs.window.quit({})", foreign.raw()), + format!( + "pmacs.window.display(pmacs.buffer.create(\"*f*\"), {{ window = {} }})", + foreign.raw() + ), + ] { + assert!( + try_exec(&s, &call).is_err(), + "a cross-frontend id must be a pointed error: {call}" + ); + } +} + +// --------------------------------------------------------------------------- +// 12 — dedication binds the POLICY layer only +// --------------------------------------------------------------------------- + +#[test] +fn acc12_dedication_binds_display_policy_not_the_raw_switch() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + &format!( + "OTHER = pmacs.buffer.create(\"*other*\") + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + let pinned_buffer = s.core.borrow().windows[&document].buffer_id; + + // The raw escape hatch ignores dedication. + exec(&s, "pmacs.window.switch_buffer(OTHER)"); + assert_ne!( + s.core.borrow().windows[&document].buffer_id, + pinned_buffer, + "raw switch_buffer ignores `dedicated`" + ); + + // The policy layer honors it on every candidate. + exec( + &s, + &format!( + "pmacs.window.switch_buffer(pmacs.buffer.list()[1]) + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec(&s, "pmacs.window.display(OTHER)").is_err(), + "display_buffer refuses to overwrite a dedicated window with no alternative" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ window = {} }})", + document.raw() + ) + ) + .is_err(), + "…and refuses a dedicated EXACT target too" + ); + + // An ordinary display never reuses a matching side window. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + document.raw() + ), + ); + let panel = open_panel(&s, "*shared*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + let target: u64 = eval(&s, "return pmacs.window.display(PANEL_BUF)"); + assert_ne!( + target, + panel.raw(), + "an ordinary display never selects the panel by coincidence" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and leaves the panel's own presentation alone" + ); +} + +// --------------------------------------------------------------------------- +// 13 — side placement affinity + option-valued height/dedication +// --------------------------------------------------------------------------- + +#[test] +fn acc13_side_placement_is_affinity_aware_and_option_valued() { + let s = editor(); + let document = active_window(&s); + // A buffer already visible in a DOCUMENT window must not preempt a + // requested usable side slot. + exec( + &s, + "SHARED = pmacs.buffer.create(\"*shared*\"); pmacs.window.switch_buffer(SHARED)", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(SHARED, { side = \"bottom\", height = 7 })", + ); + let panel = side_window(&s).expect("panel created"); + assert_eq!(target, panel.raw(), "the requested side placement wins"); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + s.core.borrow().windows[&panel].buffer_id + ); + + // Same-buffer redisplay preserves an omitted height, dedication, and + // quit action. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(SHARED, { side = \"bottom\" })"); + assert_eq!(fixed_rows_of(&s, panel), Some(7)); + assert!(s.core.borrow().windows[&panel].params.dedicated); + + // A dedicated side slot never spawns a second one: the request falls + // back after discarding height/dedication/quit state. + exec(&s, "OTHER = pmacs.buffer.create(\"*other*\")"); + let fallback: u64 = eval( + &s, + "return pmacs.window.display(OTHER, { side = \"bottom\", height = 9, dedicated = true })", + ); + assert_ne!(fallback, panel.raw()); + assert_eq!(side_window(&s), Some(panel), "still exactly one side slot"); + { + let core = s.core.borrow(); + let fell_back = core + .windows + .values() + .find(|w| w.id.raw() == fallback) + .expect("fallback window"); + assert!( + !fell_back.params.dedicated, + "a failed request may not dedicate" + ); + assert!(fell_back.params.fixed_rows.is_none(), "…nor pin"); + assert!( + fell_back.params.quit_action().is_none(), + "…nor leave quit state" + ); + } + + // Replacement preserves an omitted (user-resized) height but starts + // undedicated. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(OTHER, { side = \"bottom\" })"); + assert_eq!( + fixed_rows_of(&s, panel), + Some(7), + "the resized height survives" + ); + assert!(!s.core.borrow().windows[&panel].params.dedicated); + + // Mutual exclusion and a freestanding height are pointed errors. + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ side = \"bottom\", window = {} }})", + document.raw() + ) + ) + .is_err() + ); + assert!(try_exec(&s, "pmacs.window.display(OTHER, { height = 4 })").is_err()); + assert!( + try_exec(&s, "pmacs.window.display(OTHER, { side = \"left\" })").is_err(), + "Stage 1 ships only the bottom side" + ); + + // An explicit `dedicated = false` cannot clear-and-bypass an existing + // dedication in the same call. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "THIRD = pmacs.buffer.create(\"*third*\")"); + let bypass: u64 = eval( + &s, + "return pmacs.window.display(THIRD, { side = \"bottom\", dedicated = false })", + ); + assert_ne!( + bypass, + panel.raw(), + "eligibility is checked before the new dedication" + ); +} + +// --------------------------------------------------------------------------- +// 14 — capability fallback +// --------------------------------------------------------------------------- + +#[test] +fn acc14_capability_fallback_discards_every_side_parameter() { + let s = editor(); + let fid = FrontendId(11); + let document = attach_frontend(&s, fid, false); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*panel*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + request.height = Some(9); + request.dedicated = Some(true); + let outcome = s + .core + .borrow_mut() + .display_buffer(fid, &request) + .expect("fallback succeeds"); + assert_eq!(outcome.target, document, "fell back to the document target"); + assert!( + s.core.borrow().side_window_for(fid).is_none(), + "no side window was created" + ); + let core = s.core.borrow(); + let window = &core.windows[&document]; + assert!( + !window.params.dedicated, + "the document target is left undedicated" + ); + assert!(window.params.fixed_rows.is_none(), "…and unpinned"); + assert!(window.params.side.is_none()); + assert!(window.params.quit_action().is_none()); +} + +// --------------------------------------------------------------------------- +// 15 / 16 — the final-focus matrix and the hook-failure arms +// --------------------------------------------------------------------------- + +#[test] +fn acc15_final_focus_matrix_all_six_rows() { + // Row 1 — select = true, target live: the target stays selected. + let s = editor(); + let document = active_window(&s); + exec( + &s, + "P = pmacs.buffer.create(\"*p*\") + pmacs.window.display(P, { side = \"bottom\", height = 5, select = true })", + ); + assert_eq!(active_window(&s), side_window(&s).unwrap()); + + // Row 4 — select = false with a live saved window that IS the panel: + // a passive display invoked from a focused panel must not blur it. + let panel = side_window(&s).unwrap(); + exec( + &s, + "Q = pmacs.buffer.create(\"*q*\") + pmacs.window.display(Q, { select = false })", + ); + assert_eq!( + active_window(&s), + panel, + "select = false restores a SIDE saved_active" + ); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + eval::(&s, "return Q").0, + "…while the buffer really did land in the document window" + ); + + // Row 5 — select = false, saved window died in the hook, target live. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + SAVED = pmacs.window.list()[1] + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_SAVED then KILL_SAVED = nil; pmacs.window.focus_next(); pmacs.window.close() end + end)", + ); + exec(&s, "R = pmacs.buffer.create(\"*r*\")"); + let saved = active_window(&s); + exec(&s, "KILL_SAVED = true"); + let target: u64 = eval(&s, "return pmacs.window.display(R, { select = false })"); + assert!( + !s.core.borrow().windows.contains_key(&saved) || active_window(&s).raw() == target, + "focus falls to the live target when the saved window dies" + ); + + // Rows 2/3/6 — the target dies in the hook. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_TARGET then KILL_TARGET = nil; pmacs.window.close() end + end) + T = pmacs.buffer.create(\"*t*\") + KILL_TARGET = true", + ); + let before = active_window(&s); + exec(&s, "pmacs.window.display(T, { select = true })"); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "focus always lands on a live window" + ); + let _ = before; +} + +#[test] +fn acc16_hook_failure_arms_are_covered_in_both_select_modes() { + for select in ["true", "false"] { + // The hook switches the target's buffer out from under us. + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + if SWAP then SWAP = nil; pmacs.window.switch_buffer(pmacs.buffer.create(\"*swap*\")) end + end) + X = pmacs.buffer.create(\"*x*\") + SWAP = true", + ); + exec( + &s, + &format!("pmacs.window.display(X, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays on a live window after a buffer-switching hook" + ); + + // The hook closes the target. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if CLOSE then CLOSE = nil; pmacs.window.close() end + end) + Y = pmacs.buffer.create(\"*y*\") + CLOSE = true", + ); + exec( + &s, + &format!("pmacs.window.display(Y, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays live after a target-closing hook" + ); + } +} + +// --------------------------------------------------------------------------- +// 17 — a passive display re-attaches overlays +// --------------------------------------------------------------------------- + +#[test] +fn acc17_passive_display_reattaches_overlays() { + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + SEEN_ACTIVE = pmacs.window.list_active and 1 or 1 + HOOK_WINDOW = pmacs.window.current() + end) + Z = pmacs.buffer.create(\"*z*\")", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(Z, { side = \"bottom\", height = 5 })", + ); + let hook_window: u64 = eval(&s, "return HOOK_WINDOW"); + assert_eq!( + hook_window, target, + "the switch hook observes the TARGET window as active, which is \ + what re-attaches store-backed overlays on a passive display" + ); + assert_ne!( + active_window(&s).raw(), + target, + "…while the passive display leaves focus where it was" + ); +} + +// --------------------------------------------------------------------------- +// 18 — display_file +// --------------------------------------------------------------------------- + +#[test] +fn acc18_display_file_targets_the_document_from_a_focused_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("visit.txt"); + std::fs::write(&file, b"hello\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + exec( + &s, + "pmacs.hook.add(\"buffer.after-load\", function() + LOAD_WINDOW = pmacs.window.current() + end)", + ); + let target: u64 = eval( + &s, + &format!("return pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + target, + document.raw(), + "the visit lands in the document target" + ); + assert_eq!( + eval::(&s, "return LOAD_WINDOW"), + document.raw(), + "buffer.after-load fires with the DOCUMENT TARGET active" + ); + assert_eq!(side_window(&s), Some(panel), "the panel is intact"); + + // A dedicated exact target fails WITHOUT loading. + let unopened = dir.path().join("unopened.txt"); + std::fs::write(&unopened, b"nope\n").unwrap(); + let unopened_path = unopened.display().to_string(); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display_file({unopened_path:?}, {{ window = {} }})", + document.raw() + ) + ) + .is_err() + ); + let opened_names: Vec = eval( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out", + ); + assert!( + !opened_names.iter().any(|n| n.contains("unopened")), + "the file must not be loaded when the destination is ineligible" + ); + + // An omitted target skips a dedicated remembered origin and chooses + // the next eligible non-side window — before I/O. + exec(&s, "pmacs.window.split_horizontal()"); + exec(&s, &format!("pmacs.window.display_file({unopened_path:?})")); + assert!( + eval::>( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out" + ) + .iter() + .any(|n| n.contains("unopened")), + "…and succeeds once another eligible window exists" + ); + + // A NotFound path creates a path-backed buffer and fires NO hook. + let s = editor(); + exec( + &s, + "LOADS = 0 + pmacs.hook.add(\"buffer.after-load\", function() LOADS = LOADS + 1 end) + SWITCHES = 0 + pmacs.hook.add(\"buffer.after-switch\", function() SWITCHES = SWITCHES + 1 end)", + ); + let missing = dir.path().join("brand-new.txt").display().to_string(); + exec(&s, &format!("pmacs.window.display_file({missing:?})")); + assert_eq!(eval::(&s, "return LOADS"), 0); + assert_eq!(eval::(&s, "return SWITCHES"), 0); + assert_eq!( + eval::(&s, "return pmacs.window.buffer():path()"), + missing, + "the new buffer is path-backed" + ); +} + +// --------------------------------------------------------------------------- +// 19 — adopters place through their REAL entry points +// --------------------------------------------------------------------------- + +#[test] +fn acc19_adopters_place_side_affinely_through_real_entry_points() { + // listview: pre-seed the persistent panel buffer in a DOCUMENT window + // first, so side-affine placement cannot be vacuous. + let s = editor(); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } } }", + ); + let seeded = active_window(&s); + assert!( + side_window(&s).is_none(), + "the default placement is unchanged" + ); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("listview opened a panel"); + assert_eq!( + active_window(&s), + panel, + "an interactive listview takes select = true" + ); + assert_ne!(panel, seeded); + assert!( + try_exec( + &s, + "pmacs.listview.open { name = \"*bogus*\", rows = {}, display = \"sideways\" }" + ) + .is_err(), + "an unknown display value is a pointed error" + ); + + // compile: same shape, but passive (`select = false`). + let s = editor(); + exec(&s, "pmacs.compile.run(\"true\")"); + assert!(side_window(&s).is_none()); + let document = active_window(&s); + exec(&s, "pmacs.compile.run(\"true\", { display = \"panel\" })"); + let panel = side_window(&s).expect("compile opened a panel"); + assert_eq!( + active_window(&s), + document, + "compile output is passive: select = false" + ); + assert_ne!(panel, document); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec(&s, "pmacs.compile.run(\"true\", { display = \"nope\" })").is_err(), + "an unknown display value fails BEFORE the run starts" + ); + assert_eq!( + s.core.borrow().registry.borrow().ids().len(), + before, + "…and creates no buffer" + ); + + // terminal: the panel opt-in uses select = true. + let s = editor(); + let document = active_window(&s); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec( + &s, + "pmacs.terminal.open { command = \"/bin/sh\", display = \"elsewhere\" }" + ) + .is_err(), + "unknown display fails before session/process/buffer creation" + ); + assert_eq!(s.core.borrow().registry.borrow().ids().len(), before); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal opened a panel"); + assert_eq!(active_window(&s), panel); + assert_ne!(panel, document); +} + +// --------------------------------------------------------------------------- +// 20 / 23 — quit: delete, restore chains, revalidation, and the cap +// --------------------------------------------------------------------------- + +#[test] +fn acc20_quit_deletes_then_restores_each_saved_presentation() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + C = pmacs.buffer.create(\"*C*\") + pmacs.window.display(A, { side = \"bottom\", height = 6, select = true })", + ); + let panel = side_window(&s).expect("panel"); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 9 }})", + panel.raw() + ), + ); + exec( + &s, + "pmacs.window.display(B, { side = \"bottom\", select = true })", + ); + exec( + &s, + "pmacs.window.display(C, { side = \"bottom\", select = true })", + ); + + // C -> B -> A -> delete. + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return B").0 + ); + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return A").0 + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(9), + "the saved (user-resized) height is restored with its presentation" + ); + exec(&s, "pmacs.window.quit()"); + assert!(side_window(&s).is_none(), "the last quit deletes the slot"); + assert_eq!(active_window(&s), document); + + // A window with no quit action is a pointed error that changes nothing. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, "pmacs.window.quit()").is_err()); + assert_eq!(before, structure(&layout_root(&s))); +} + +#[test] +fn acc20b_quit_history_is_bounded_at_max_panel_quit_depth() { + let s = editor(); + exec(&s, "P0 = pmacs.buffer.create(\"*p0*\")"); + exec( + &s, + "pmacs.window.display(P0, { side = \"bottom\", height = 4 })", + ); + let panel = side_window(&s).expect("panel"); + for i in 1..=(MAX_PANEL_QUIT_DEPTH + 20) { + exec( + &s, + &format!( + "pmacs.window.display(pmacs.buffer.create(\"*p{i}*\"), {{ side = \"bottom\" }})" + ), + ); + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert!( + depth <= MAX_PANEL_QUIT_DEPTH, + "depth never grows beyond the cap (saw {depth} at replacement {i})" + ); + } + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert_eq!( + depth, MAX_PANEL_QUIT_DEPTH, + "exactly the newest 64 are retained" + ); + for _ in 0..MAX_PANEL_QUIT_DEPTH { + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + } + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!(side_window(&s).is_none(), "the chain terminates in Delete"); +} + +#[test] +fn acc23_quit_revalidates_a_killed_restore_target() { + let s = editor(); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + pmacs.window.display(A, { side = \"bottom\", height = 5 }) + pmacs.window.display(B, { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + exec(&s, "pmacs.buffer.kill(A)"); + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!( + side_window(&s).is_none(), + "a killed restore target degrades the whole chain to Delete" + ); +} + +// --------------------------------------------------------------------------- +// 21 / 22 — the jump ring +// --------------------------------------------------------------------------- + +#[test] +fn acc21_panel_visit_and_jump_back_returns_to_the_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("src.txt"); + std::fs::write(&file, b"one\ntwo\nthree\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*outline*", 6); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + // Move the panel cursor so the restored row is observable. + s.core.borrow_mut().windows.get_mut(&panel).unwrap().cursor = 0; + + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + &format!("pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + active_window(&s), + document, + "RET visited the document window" + ); + + let jumped: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(jumped); + assert_eq!( + active_window(&s), + panel, + "M-, returns focus to the EXISTING panel, not a duplicate" + ); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 2, + "no duplicate presentation was created" + ); +} + +#[test] +fn acc22_jump_histories_are_per_frontend_and_skip_stale_side_origins() { + let s = editor(); + let fid = FrontendId(21); + let foreign = attach_frontend(&s, fid, true); + + // LOCAL pushes; the foreign frontend must not be able to pop it. + exec(&s, "pmacs.editor.push_jump()"); + s.core.borrow_mut().active_frontend = fid; + assert!( + !s.core.borrow_mut().jump_back(), + "one frontend cannot consume another's navigation trail" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + s.core.borrow_mut().jump_back(), + "LOCAL's own entry survives" + ); + let _ = foreign; + + // A SIDE origin whose buffer was replaced is skipped, not resurrected. + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*new*\"), { side = \"bottom\" })", + ); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + !s.core.borrow_mut().jump_back(), + "a replaced side origin is skipped rather than duplicated into the document" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and the panel keeps its current presentation" + ); +} + +// --------------------------------------------------------------------------- +// 24 / 25 / 26 / 27 — the window guards +// --------------------------------------------------------------------------- + +#[test] +fn acc24_killing_a_panel_buffer_closes_the_side_window() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + exec(&s, "pmacs.buffer.kill(PANEL_BUF)"); + assert!(side_window(&s).is_none(), "the side window closed"); + assert!( + !s.core.borrow().windows.contains_key(&panel), + "…rather than being redirected to *scratch*" + ); + assert!(!s.core.borrow().registry.borrow().contains(panel_buffer)); +} + +#[test] +fn acc25_close_active_refuses_only_the_last_document_window() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + // A document window with only the panel beside it still cannot close. + assert!( + !s.core.borrow_mut().close_active(), + "the last document window is protected" + ); + // The panel itself always may — even as the only other window. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + s.core.borrow_mut().close_active(), + "closing the side window is always legal" + ); + assert!(side_window(&s).is_none()); + assert_eq!(active_window(&s), document); +} + +#[test] +fn acc26_close_others_and_split_respect_the_side_window() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 5); + // From a side window both are pointed errors. + exec(&s, "pmacs.window.focus_next()"); + while active_window(&s) != panel { + exec(&s, "pmacs.window.focus_next()"); + } + assert!(s.core.borrow_mut().close_others().is_err()); + assert!( + s.core + .borrow_mut() + .try_split_active(Orientation::Horizontal, true) + .is_err() + ); + assert!(side_window(&s).is_some(), "nothing was mutated"); + + // From a document window, close_others deletes the panel too. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.core + .borrow_mut() + .close_others() + .expect("document may close others"); + assert!(side_window(&s).is_none()); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 1 + ); +} + +#[test] +fn acc27_traversal_refreshes_the_remembered_document_origin() { + let s = editor(); + let a = active_window(&s); + exec(&s, "pmacs.window.split_horizontal()"); + let b = s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .find(|id| *id != a) + .expect("second document window"); + // Create the panel from A. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + let panel = open_panel(&s, "*panel*", 5); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(a) + ); + // Enter the panel from B: the memory retargets. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, b); + s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(b), + "entering the panel from B retargets the remembered origin" + ); + assert_eq!( + eval::(&s, "return pmacs.window.display_target()"), + b.raw(), + "display_target follows it" + ); + // A Delete-form quit focuses B, not the creation-time window. + exec(&s, "pmacs.window.quit()"); + assert_eq!(active_window(&s), b); +} + +// --------------------------------------------------------------------------- +// 29 — optimistic input is gated per WINDOW, not per buffer +// --------------------------------------------------------------------------- + +#[test] +fn acc29_focused_side_window_gates_dispatch_idle_without_marking_the_buffer() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + s.dispatch_idle_for(FrontendId::LOCAL), + "a document window is idle" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a focused side window turns optimistic apply off" + ); + assert!( + !s.core.borrow().buffer_round_trips(panel_buffer), + "…WITHOUT marking the buffer round-trip" + ); + + // Another frontend showing that same buffer as its DOCUMENT keeps + // optimistic apply. + let other = FrontendId(29); + let other_window = attach_frontend(&s, other, true); + s.core + .borrow_mut() + .install_buffer_in_window(other_window, panel_buffer) + .expect("install"); + assert!( + s.dispatch_idle_for(other), + "the buffer-global set is untouched, so the peer stays optimistic" + ); +} + +// --------------------------------------------------------------------------- +// 30 / 31 — the divider +// --------------------------------------------------------------------------- + +#[test] +fn acc30_divider_drag_writes_fixed_rows_and_weights_and_creates_no_selection() { + let s0 = editor(); + let mut s = s0; + let panel = open_panel(&s, "*panel*", 6); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + let rects = render(&s); + let divider_row = u16::try_from(rects[&document].origin.row + rects[&document].size.rows - 1) + .expect("row fits"); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + assert!( + s.core.borrow().active_window().selection.is_none(), + "a press on the reserved row creates no selection" + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(4), + "dragging the divider DOWN shrinks the side window's fixed rows" + ); + + // A flexible pair writes weights instead. + let mut s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + let rects = render(&s); + let divider_row = + u16::try_from(rects[&top].origin.row + rects[&top].size.rows - 1).expect("row fits"); + let before = rects[&top].size.rows; + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 3, 3), + CellSize::new(ROWS, COLS), + ); + let after = render(&s)[&top].size.rows; + assert_eq!( + after, + before + 3, + "the flexible boundary moved by the drag delta" + ); + // …and the ratio survives a frame resize, which is the whole point of + // writing weights rather than a fixed extent. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS * 2, COLS)); + let doubled = render_at(&s, CellSize::new(ROWS * 2, COLS))[&top].size.rows; + assert!(doubled > after, "the ratio scales with the frame"); +} + +#[test] +fn acc30b_ui_divider_face_resolves_and_paints_every_exposed_segment() { + let s = editor(); + // A boundary whose upper child is a VERTICAL split exposes several + // leaf mode-line segments along the same edge. + exec(&s, "pmacs.window.split_vertical()"); + open_panel(&s, "*panel*", 5); + exec( + &s, + "pmacs.theme.set { [\"ui.divider\"] = { fg = { 255, 0, 255 } } }", + ); + let rows = painted_rows(&s, CellSize::new(ROWS, COLS)); + let boundary_rows: Vec = rows + .iter() + .enumerate() + .filter(|(_, line)| line.contains('⇕')) + .map(|(i, _)| i) + .collect(); + assert_eq!( + boundary_rows.len(), + 1, + "both exposed segments sit on the SAME boundary row" + ); + + // Dragging either segment resolves the same boundary. + let core = s.core.borrow(); + let ids = core.views[&FrontendId::LOCAL].layout.iter_ids(); + let leaves: Vec = ids + .into_iter() + .filter(|id| !core.windows[id].is_side()) + .collect(); + let layout = core.views[&FrontendId::LOCAL].layout.clone(); + drop(core); + assert_eq!(leaves.len(), 2); + assert_eq!( + layout.boundary_below(leaves[0]), + layout.boundary_below(leaves[1]), + "every leaf segment touching the same bottom edge resolves to one boundary" + ); +} + +#[test] +fn acc31_keyboard_resize_matches_the_equivalent_drag_in_a_nested_layout() { + // Build H[ H[A, C], B ] — A's nearest horizontal ancestor is the + // inner split; C's is that same split, but C is its FINAL child, so + // C's boundary is the outer one. The naive "nearest horizontal + // ancestor" reading picks the wrong split for C. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let a = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + exec(&s, "pmacs.window.split_horizontal()"); + let ids = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids(); + assert_eq!(ids.len(), 3); + let (a, c, b) = (ids[0], ids[1], ids[2]); + + let layout = s.core.borrow().views[&FrontendId::LOCAL].layout.clone(); + assert_ne!( + layout.boundary_below(a), + layout.boundary_below(c), + "A owns the INNER boundary; C, as that split's final child, \ + resolves upward to the outer one — the naive \"nearest \ + horizontal ancestor\" reading picks the wrong split for C" + ); + assert_eq!( + layout.boundary_below(c).expect("C has a boundary").path, + Vec::::new(), + "C's boundary is the ROOT split, not its own parent" + ); + assert!( + layout.boundary_below(b).is_none(), + "the last child owns no boundary" + ); + + // The keyboard resize and the equivalent DRAG move the same boundary + // to the same place. `resize(win, delta)` resolves from the SUPPLIED + // window (the Lua entry point is explicit). + let before = render(&s); + exec(&s, &format!("pmacs.window.resize({}, 2)", c.raw())); + let by_command: HashMap = render(&s) + .iter() + .map(|(id, rect)| (*id, rect.size.rows)) + .collect(); + assert!( + by_command[&c] > before[&c].size.rows, + "C grew: {} -> {}", + before[&c].size.rows, + by_command[&c] + ); + + let mut dragged = editor(); + exec(&dragged, "pmacs.window.split_horizontal()"); + let da = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids()[0]; + dragged + .core + .borrow_mut() + .focus_window(FrontendId::LOCAL, da); + exec(&dragged, "pmacs.window.split_horizontal()"); + let dids = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids(); + let dc = dids[1]; + let rects = render(&dragged); + let divider_row = + u16::try_from(rects[&dc].origin.row + rects[&dc].size.rows - 1).expect("row fits"); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + let by_drag = render(&dragged); + assert_eq!( + by_command[&c], by_drag[&dc].size.rows, + "keyboard resize equals the equivalent drag on that window's \ + bottom mode-line row" + ); + + // The no-adjustable-boundary case reports and no-ops. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, &format!("pmacs.window.resize({}, 1)", b.raw())).is_err()); + assert_eq!(before, structure(&layout_root(&s))); + + // The commands act on the ACTIVE window and equal the same move. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, top); + let before = render(&s)[&top].size.rows; + exec(&s, "pmacs.command.invoke(\"window.enlarge\")"); + assert_eq!(render(&s)[&top].size.rows, before + 1); + exec(&s, "pmacs.command.invoke(\"window.shrink\")"); + assert_eq!(render(&s)[&top].size.rows, before); +} + +// --------------------------------------------------------------------------- +// 32 / 33 / 34 — a terminal panel's height changes +// --------------------------------------------------------------------------- + +#[test] +fn acc32_terminal_panel_height_change_is_a_viewport_change() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"for i in $(seq 1 200); do echo line$i; done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + + // Wait for output. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let has_output = s + .terminal_manager + .borrow() + .snapshot(buffer.0) + .is_some_and(|snap| !snap.cells.is_empty()); + if has_output || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back, then change the panel height. `top` is preserved + // verbatim: a height change is a viewport change, never a scroll one. + exec(&s, "pmacs.window.focus_next()"); + let key_before = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + s.terminal_manager + .borrow_mut() + .scroll_view(key_before, CellSize::new(6, COLS), 5); + let offset_before = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 10 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let offset_after = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + assert_eq!( + offset_before, offset_after, + "a scrolled-back terminal panel keeps its top across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// **Bet B1 pin.** Panel-as-window means the terminal controller, the +/// fixed `C-c` escape, and release-on-blur need zero new code: the +/// controller is keyed `(frontend_id, window_id)` and `view.active` +/// already answers "which window", whether or not that window is a side +/// window. +#[test] +fn acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut s = editor(); + exec( + &s, + &format!( + "TERM_BUF = pmacs.terminal.open {{ + command = \"/usr/bin/python3\", + args = {{ \"-c\", {} }}, + rows = 4, cols = 20, + display = \"panel\", + }}", + format_args!("{probe:?}") + ), + ); + let panel = side_window(&s).expect("terminal panel"); + assert_eq!( + active_window(&s), + panel, + "the panel opt-in selects the panel" + ); + assert_eq!( + wait_for_file(&ready_path, Duration::from_secs(5)), + b"1", + "the child in the PANEL reached raw mode" + ); + + // Exactly the Stage 2 vterm contract, unchanged: unescaped bound keys + // reach the child, `C-c` escapes for one key, `C-c C-c` sends one + // literal interrupt. + for ev in [ + key(KeyCode::Char('v'), KeyModifiers::ALT), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('w'), KeyModifiers::ALT), + ] { + s.dispatch_key(FrontendId::LOCAL, ev); + } + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "child input routing through a SIDE window is byte-identical" + ); + + // Release-on-blur still works: leaving the panel drops the controller. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!( + s.terminal_manager + .borrow() + .controller_view_for_frontend(FrontendId::LOCAL) + .is_none(), + "the controller is released when focus leaves the panel" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +fn wait_for_file(path: &std::path::Path, timeout: Duration) -> Vec { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Ok(bytes) = std::fs::read(path) + && !bytes.is_empty() + { + return bytes; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + let view_size = CellSize::new(5, COLS); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let seen = s + .terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, view_size) + .is_some(); + if seen || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back into history and start a selection there. + { + let mut manager = s.terminal_manager.borrow_mut(); + assert!(manager.scroll_view(key_id, view_size, 10)); + assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); + } + let before = s.terminal_manager.borrow_mut().view_status(key_id); + + // Grow the panel enough that it would otherwise reach the live tail. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 20 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let after = s.terminal_manager.borrow_mut().view_status(key_id); + assert_eq!( + before.map(|status| (status.scroll_offset, status.selection)), + after.map(|status| (status.scroll_offset, status.selection)), + "a historical selection freezes the anchor across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +#[test] +fn acc34_only_the_controller_resizes_the_pty() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let controlled = s.terminal_manager.borrow().screen_size(buffer.0); + + // A second frontend that does NOT control the session may hold its + // own panel height without resizing the child. + let other = FrontendId(34); + attach_frontend(&s, other, true); + s.sync_terminal_layout(other, CellSize::new(ROWS, COLS)); + assert_eq!( + s.terminal_manager.borrow().screen_size(buffer.0), + controlled, + "only the controller's height change resizes the PTY" + ); + let _ = panel; + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +// --------------------------------------------------------------------------- +// 35 — the desktop never persists a side window +// --------------------------------------------------------------------------- + +#[test] +fn acc35_desktop_round_trip_omits_the_side_leaf_and_its_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("saved.txt"); + std::fs::write(&file, b"content\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + exec(&s, &format!("pmacs.buffer.find_or_open({path:?})")); + let document_structure = structure(&layout_root(&s)); + exec( + &s, + &format!( + "PANEL_BUF = pmacs.buffer.find_or_open({path:?}) + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = 6 }})" + ), + ); + assert!(side_window(&s).is_some()); + + let snapshot = + pmacs::desktop::snapshot(&s.core.borrow(), "test".into()).expect("a file window survives"); + assert_eq!( + snapshot.version, + pmacs::desktop::DESKTOP_VERSION, + "the desktop format version does not change" + ); + assert!( + matches!(snapshot.root, pmacs::desktop::SavedNode::Leaf(_)), + "neither the side leaf nor its root wrapper is persisted \ + (saw {:?})", + snapshot.root + ); + let _ = document_structure; +} + +// --------------------------------------------------------------------------- +// Core-level invariants that back the above +// --------------------------------------------------------------------------- + +#[test] +fn panel_hidden_never_describes_a_panel_that_no_longer_exists() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 8); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + s.reconcile_panel_layout(FrontendId::LOCAL); + assert!( + !s.core.borrow().views[&FrontendId::LOCAL].panel_hidden, + "reconciliation clears the flag once the window is gone" + ); +} + +#[test] +fn unknown_geometry_is_not_twenty_four_by_eighty() { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fid = FrontendId(77); + attach_frontend(&s, fid, false); + assert!( + s.core.borrow().frontend_area_rows(fid).is_none(), + "a semantic view's geometry is UNKNOWN, never the attach placeholder" + ); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*p*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + let _ = s.core.borrow_mut().display_buffer(fid, &request); + // Not panel-capable in Stage 1, so it fell back; and even a capable + // view with unknown geometry would follow the hidden arm. + assert!(s.core.borrow().side_window_for(fid).is_none()); +} + +#[test] +fn quit_action_truncation_is_iterative_and_bounded() { + let mut action = QuitAction::Delete; + for _ in 0..(MAX_PANEL_QUIT_DEPTH * 3) { + action = QuitAction::Restore { + buffer_id: BufferId::from_raw(1), + fixed_rows: 4, + dedicated: false, + cursor: 0, + view_top: 0, + goal_col: None, + selection: None, + then: Box::new(action), + }; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + assert!(action.depth() <= MAX_PANEL_QUIT_DEPTH); + } +} + +#[test] +fn clamp_panel_rows_rejects_zero_and_lifts_to_the_floor() { + assert!(EditorCore::clamp_panel_rows(0).is_err()); + assert_eq!(EditorCore::clamp_panel_rows(1), Ok(MIN_WINDOW_OUTER_ROWS)); + assert_eq!(EditorCore::clamp_panel_rows(30), Ok(30)); +} + +#[test] +fn cell_coord_helper_is_used() { + // Keeps the CellCoord import honest for grid assertions above. + assert_eq!(CellCoord::new(1, 2).row, 1); +} From 2a9c11461c73ec3ea7368e297689ce10798abd10 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:20:16 -0400 Subject: [PATCH 04/10] docs: record the bottom-panel Stage 1 lane Adds the lane to docs/active-work.md: branch, base, what Stage 1 implemented, the verification run, and the two known local-only test caveats (the parallel-load GPU flake and compile_mode_acceptance's single-thread requirement). The durable handoff snapshot stays untouched until the PR merges, per its own update protocol. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 2b258bd..fe1fb19 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,55 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW + +- Portable branch: `githubsucks/bottom-panel`, worktree + `../pmacs-bottom-panel`, based on `githubsucks/main` @ `ddaa80d`. +- Approved framing: `docs/bottom-panel-framing.md` revision 4, committed + as the branch's first commit (`c27f75a`). +- **Stage 1 implemented; no wire change (protocol stays v20).** What + landed on the branch: + - `src/window.rs`: `WindowParams` (`side` / `fixed_rows` / `dedicated` + + implementation-owned `quit_action` and `origin_document`), `Side`, + a depth-bounded `QuitAction`, `MIN_WINDOW_OUTER_ROWS = 2`, + `Layout::compute(area, fixed)`, the `subtree_min_rows` / + `interactive_min_rows` recursions, `boundary_below`, and the three + new `FrontendView` fields (`panel_capable`, `frame_geometry`, + `panel_hidden`). + - `src/editor_core.rs`: `primary_document_window`, the non-side target + rule, `display_buffer` + the Q#BP3 placement policy, `quit_window`, + `reconcile_panel_layout_core`, `resize_boundary`, per-frontend + `JumpEntry`s, and the shared `resolve_target_buffer` seam that the + #148 initial-target bootstrap now routes through as well. + - `src/editor.rs`: the reconciliation transaction, geometry + declaration, the side-window `dispatch_idle_for` gate, the divider + paint, and the divider drag. + - `src/lua_bindings/window_panel.rs`: the whole `pmacs.window` panel + surface plus the shared adopter-placement helpers; + `builtin/runtime/window.lua` owns `window.panel-height` / + `window.min-height` and the resize commands. + - Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all + take `display = "current" | "panel"` (Stage 1 default `"current"`); + LSP/compile visits route through `display_file`. +- Verification on this branch: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,815 default + 1,992 CRDT library tests; + `bottom_panel_stage1_acceptance` 42/42; M4 121; required GPU 152; + `gpu_initial_target_acceptance` 1 default + 14 CRDT; vterm Stage 2 4 / + Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; + workspace sweep 3,103 passed across 86 suites; `git diff --check` + clean. + - **The sweep's only red was the known parallel-load GPU flake**: + `font_facts_out_of_range_sizes_fail_closed`, + `built_in_only_overwide_readout_…`, and + `statusline_wire_validation_is_atomic_…` fail under a loaded + workspace run (wgpu device contention) and pass both isolated and in + the dedicated `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` gate. + - `compile_mode_acceptance` likewise needs `--test-threads=1` locally; + it is 67/67 there. +- Stage 2 (the GPU panel band, next available protocol version) has its + own re-framing obligation before implementation; Stage 3 is the default + placement flip. + ## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3 From 90fc7a913e0c44fd6986668dc5341cb75e4a4cd7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 15:07:35 -0400 Subject: [PATCH 05/10] fix(window): wire the side-window split guard and scope the divider drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 1. Finding 1 (must fix): `try_split_active` had no production caller — `pmacs.window.split_horizontal` / `split_vertical`, and therefore `C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a focused panel made the root wrapper's final child a split rather than `Leaf(side)`, which both `Layout::compute`'s fixed pass and `document_subtree` key on: the panel band reverted to 1:1 weight division and an ordinary window ended up living inside it. Both bindings now route through the guard, and acc26 asserts through the real Lua path — a direct core call passes with the guard unwired, which is how it survived the first round. Finding 2: the armed-drag early return now checks the arming frontend, so one frontend's in-flight gesture cannot cancel or swallow another's mouse events. New acc30c. Finding 3: `paint_mode_line_graphemes`'s doc block was left heading `paint_divider_segment`; moved back. Finding 4: a recompile carries no `display`, so it took the raw switch and duplicated a panel-placed `*compilation*` into the document window. `start_run` now detects that the buffer already owns the panel slot. `pmacs.window.buffer` gained an optional window argument so an adopter can ask without selecting the panel first. New acc19b. Stage-2 hazard pins the review asked for, both in `src/daemon.rs`: a fresh attach while LOCAL is focused in a panel inherits LOCAL's document buffer, and an initial-target bootstrap whose `after-load` hook creates and selects a panel still reasserts into a document window. Minor: dropped listview's dead `p.side`; documented `focus_window`'s caller-validates contract; `jump_back` restores through `focus_window` so the "every focus change" contract holds; `params` / `resize` default to the acting frontend's selected window rather than the ambient one; widened the flexible-division math to u64 intermediates. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 22 +++- builtin/runtime/listview.lua | 2 - src/daemon.rs | 129 ++++++++++++++++++++++++ src/editor.rs | 16 ++- src/editor_core.rs | 13 ++- src/lua_bindings/mod.rs | 53 ++++++++-- src/lua_bindings/window_panel.rs | 17 +++- src/window.rs | 12 ++- tests/bottom_panel_stage1_acceptance.rs | 127 ++++++++++++++++++++--- 9 files changed, 354 insertions(+), 37 deletions(-) diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 2c00731..2e894d2 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -732,6 +732,17 @@ end) -- Start a run in `slot`. Shared by compile and shell-command; grep -- has its own worker path. +-- Whether `buf` is currently the acting frontend's side-window buffer +-- (bottom-panel arc). Used so a recompile re-displays into the panel it +-- is already in rather than duplicating itself into the document window. +local function already_in_panel(buf) + if not buf then return false end + local panel = pmacs.window.panel() + if not panel then return false end + local ok, shown = pcall(pmacs.window.buffer, panel) + return ok and shown == buf +end + local function start_run(slot, cmdline, opts) opts = opts or {} -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run @@ -818,9 +829,14 @@ local function start_run(slot, cmdline, opts) -- The FIRST display of this run is the side-affine one (Q#BP3): a -- persistent *compilation* already visible in a document window must -- not preempt the requested panel. Compile output is passive, so it - -- takes `select = false` explicitly; a recompile simply reuses the - -- panel it is already in. - if display == "panel" then + -- takes `select = false` explicitly. + -- + -- A recompile reaches here with NO `display` (only cmdline/cwd are + -- stored in `_last`), so the raw switch below would put this buffer in + -- the selected DOCUMENT window while the panel still shows it — the + -- duplicate presentation this arc removes elsewhere. Detect that the + -- buffer already owns the panel slot and keep it there. + if display == "panel" or already_in_panel(slot.buf) then pmacs.window.display(slot.buf, { side = "bottom", select = false }) else pmacs.window.switch_buffer(slot.buf) diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 6081587..6a6d717 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -136,10 +136,8 @@ function pmacs.listview.open(spec) tostring(display))) end if display == "panel" then - p.side = true pmacs.window.display(p.buffer, { side = "bottom", select = true }) else - p.side = false pmacs.window.switch_buffer(p.buffer) end seat_cursor(p, 1) diff --git a/src/daemon.rs b/src/daemon.rs index 44d4b61..5af71d0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -4190,4 +4190,133 @@ mod tests { "key must self-insert into the displayed buffer, not the attach-time scratch" ); } + + /// Bottom-panel arc, §1.3 #22 (framing acceptance 51's Stage-1 half). + /// + /// A fresh no-target attach clones `LOCAL`'s **primary document** + /// buffer, not `local_view.active`. Stage 1 makes a TUI panel a real + /// focus target, so `LOCAL` can legitimately own focus in a panel at + /// attach time — and panel content must never become a newly attached + /// frontend's full-window document. + #[test] + fn fresh_attach_inherits_locals_document_buffer_not_its_focused_panel() { + let mut editor = EditorState::new(); + let document_buffer = editor.core.borrow().active_buffer_id(); + let panel_buffer = editor.core.borrow().registry.borrow_mut().create("*panel*"); + // Open a bottom panel on LOCAL and focus it. + let panel = { + let mut core = editor.core.borrow_mut(); + let mut request = crate::editor_core::DisplayRequest::new(panel_buffer); + request.side = Some(crate::window::Side::Bottom); + request.height = Some(5); + request.select = Some(true); + let outcome = core + .display_buffer(FrontendId::LOCAL, &request) + .expect("panel placement"); + core.focus_window(FrontendId::LOCAL, outcome.target); + outcome.target + }; + assert_eq!( + editor.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "LOCAL really is focused in the panel" + ); + + let fid = FrontendId(123); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .expect("fresh view window") + .buffer_id, + document_buffer, + "the new frontend inherited LOCAL's DOCUMENT buffer; inheriting \ + `local_view.active` would have made the panel its document" + ); + assert_ne!(document_buffer, panel_buffer); + } + + /// Bottom-panel arc, Q#BP11b / R4-B4 (framing acceptance 55's + /// Stage-1 half). + /// + /// Stage 1 lets a startup hook create and select a side window. The + /// initial-target bootstrap must still reassert the requested buffer + /// in — and activate — a **non-side** document window, rather than + /// overwriting the panel merely because it became `view.active`. + #[test] + fn initial_target_reasserts_a_document_window_when_a_hook_selects_a_panel() { + use std::os::unix::ffi::OsStrExt as _; + + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.txt"); + std::fs::write(&target, b"target contents\n").expect("write target"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load( + r#" + pmacs.lsp.config = {} + pmacs.hook.add("buffer.after-load", function() + if HOOK_RAN then return end + HOOK_RAN = true + HOOK_PANEL = pmacs.window.display( + pmacs.buffer.create("*hook-panel*"), + { side = "bottom", height = 4, select = true }) + end) + "#, + ) + .exec() + .expect("install hook"); + + // A GRID session (panel-capable), which is the realistic shape + // for a hook-created panel in Stage 1 — and real geometry, so + // the panel is genuinely VISIBLE and focused when the reassert + // runs. Without the declaration, reconciliation would hide the + // panel and move focus out on its own, and the assertions below + // would pass without exercising the reassert at all. + let fid = FrontendId(124); + let view = build_fresh_frontend_view(&mut editor, true, true); + editor.core.borrow_mut().register_frontend_view(fid, view); + editor.sync_frame_geometry(fid, CellSize::new(24, 80)); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: target.as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("bootstrap succeeds despite the panel-creating hook"); + + let core = editor.core.borrow(); + assert!( + !core.views[&fid].panel_hidden, + "the hook's panel is visible, so focus really was on it when \ + the reassert ran" + ); + let active = core.views[&fid].active; + let active_window = core.windows.get(&active).expect("active window live"); + assert!( + !active_window.is_side(), + "bootstrap activated a DOCUMENT window, not the hook's panel" + ); + assert_eq!( + active_window.buffer_id, opened.buffer_id, + "…showing the requested target" + ); + let panel = core + .side_window_for(fid) + .expect("the hook's panel survived"); + assert_ne!( + core.windows[&panel].buffer_id, opened.buffer_id, + "the panel was not overwritten with the target" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 2d2a5e8..246a575 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1925,7 +1925,15 @@ impl EditorState { // pointer for the whole gesture, INCLUDING rows outside any // window — otherwise tracking would stop the moment the pointer // crossed the frame's status row. - if self.window_drag.is_some() { + // + // Scoped to the ARMING frontend. The daemon routes every attached + // grid frontend through this same dispatcher, so an unscoped + // check would let one frontend's in-flight drag cancel and + // swallow another frontend's clicks. + if self + .window_drag + .is_some_and(|drag| drag.frontend_id == frontend_id) + { match ev.kind { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); @@ -3860,9 +3868,6 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { graphemes.iter().map(|grapheme| grapheme.width).sum() } -/// Paint complete graphemes at a logical signed origin. A grapheme that -/// straddles either clip edge is omitted wholesale, so a wide glyph can never -/// leave a dangling half-cell at a window or left/right collision boundary. /// Restyle one exposed segment of a horizontal split boundary and stamp /// its grip (Q#BP5a). /// @@ -3890,6 +3895,9 @@ fn paint_divider_segment( cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH); } +/// Paint complete graphemes at a logical signed origin. A grapheme that +/// straddles either clip edge is omitted wholesale, so a wide glyph can never +/// leave a dangling half-cell at a window or left/right collision boundary. fn paint_mode_line_graphemes( grid: &mut crate::cell::CellGrid<'_>, rect: &crate::window::Rect, diff --git a/src/editor_core.rs b/src/editor_core.rs index 039ee43..cfb2bcb 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -1073,7 +1073,12 @@ impl EditorCore { .is_some_and(|window| window.buffer_id == entry.buffer_id) && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { - self.set_active_window_id(entry.window_id); + // Through `focus_window`, not `set_active_window_id`: + // returning INTO a panel from a document window is a + // focus transition like any other, so it refreshes + // `origin_document` and a later `window.quit` returns to + // the window the jump came from. + self.focus_window(fid, entry.window_id); } else { // A stale SIDE origin is skipped outright: switching a // panel's buffer into the document window is exactly the @@ -2667,6 +2672,12 @@ impl EditorCore { /// Focus an explicit window in the acting frontend, refreshing the /// panel's remembered document origin on the way (Q#BP2c). + /// + /// **The caller must have validated `target`** — that it is live and + /// belongs to `fid`'s layout. Every Lua path does so through + /// `lookup_window` or the display transaction's own revalidation; + /// this function only `debug_assert!`s it, so a release-mode caller + /// passing a foreign or dead id would leave `view.active` dangling. pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { let Some(view) = self.views.get_mut(&fid) else { return; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3d8a4e9..959edbe 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12218,14 +12218,20 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ window_panel::install(lua, core, &win)?; { + // Bottom-panel arc (Q#BP6): `try_split_active` refuses a side + // window. This binding is what `C-x 2` reaches, so the refusal + // has to live on THIS path — splitting the panel leaf would make + // the root wrapper's final child a split rather than + // `Leaf(side)`, and both `Layout::compute`'s fixed pass and + // `document_subtree` key on exactly that shape. let cc = core.clone(); win.set( "split_horizontal", lua.create_function(move |_, ()| { - let new_id = cc - .borrow_mut() - .split_active(crate::window::Orientation::Horizontal, true); - Ok(new_id.raw()) + cc.borrow_mut() + .try_split_active(crate::window::Orientation::Horizontal, true) + .map(crate::window::WindowId::raw) + .map_err(mlua::Error::runtime) })?, )?; } @@ -12235,10 +12241,10 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ win.set( "split_vertical", lua.create_function(move |_, ()| { - let new_id = cc - .borrow_mut() - .split_active(crate::window::Orientation::Vertical, true); - Ok(new_id.raw()) + cc.borrow_mut() + .try_split_active(crate::window::Orientation::Vertical, true) + .map(crate::window::WindowId::raw) + .map_err(mlua::Error::runtime) })?, )?; } @@ -12351,9 +12357,38 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); + // With no argument: the selected window's buffer (unchanged). + // With an explicit window id: that window's buffer, validated + // against the acting frontend's layout like every other + // `WindowId`-taking operation (bottom-panel arc, Q#BP11) — an + // adopter has to be able to ask "is my buffer the one in the + // panel" without first selecting the panel. win.set( "buffer", - lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?, + lua.create_function(move |lua, target: Option| -> mlua::Result { + let Some(raw) = target else { + return Ok(BufferIdLua(cc.borrow().active_buffer_id())); + }; + let fid = window_panel::acting_frontend(lua, &cc); + let core = cc.borrow(); + let view = core.views.get(&fid).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.buffer: acting frontend has no layout") + })?; + let id = view + .layout + .iter_ids() + .into_iter() + .find(|id| id.raw() == raw) + .ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window.buffer: window {raw} is not live in this frontend's layout" + )) + })?; + core.windows + .get(&id) + .map(|window| BufferIdLua(window.buffer_id)) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) + })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index d0ee6c0..068f6c8 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -184,6 +184,19 @@ fn parse_request( Ok(request) } +/// The ACTING frontend's selected window. +/// +/// Not `active_window_id()`, which resolves through the ambient active +/// frontend: every other id in this module is `fid`-scoped, and the two +/// only coincide because dispatch happens to set `active_frontend` first. +fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { + core.borrow() + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout")) +} + /// Resolve a raw Lua window id, refusing one that is not live in the /// acting frontend's layout (Q#BP11). fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { @@ -484,7 +497,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let fid = acting_frontend(lua, &cc); let id = match target { Some(raw) => lookup_window(&cc, fid, raw)?, - None => cc.borrow().active_window_id(), + None => selected_window(&cc, fid)?, }; let core = cc.borrow(); let window = core @@ -577,7 +590,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let fid = acting_frontend(lua, &cc); let id = match target { Some(raw) => lookup_window(&cc, fid, raw)?, - None => cc.borrow().active_window_id(), + None => selected_window(&cc, fid)?, }; let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| { mlua::Error::runtime( diff --git a/src/window.rs b/src/window.rs index f38cf53..b66499e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -979,14 +979,18 @@ fn compute_node( rows } else { let w = weights.get(i).copied().unwrap_or(1).max(1); + // u64 intermediates: `remainder * w` is the only + // place this arithmetic could overflow a u32, and a + // saturating fallback there would hand a non-last + // child the whole remainder and underflow the last + // one. Widening deletes the case outright. let e = if Some(i) == last_flexible { remainder - flexible_used + } else if total == 0 { + 0 } else { - remainder - .checked_mul(w) + u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total)) .unwrap_or(remainder) - .checked_div(total) - .unwrap_or(0) }; flexible_used += e; e diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index b89ca15..73b3986 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1300,6 +1300,55 @@ fn acc19_adopters_place_side_affinely_through_real_entry_points() { assert_ne!(panel, document); } +/// A recompile carries no `display` (only cmdline/cwd are stored), so +/// the raw switch would put `*compilation*` in the selected DOCUMENT +/// window while the panel still shows it — the duplicate presentation +/// this arc removes elsewhere. +#[test] +fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + exec(&s, "pmacs.compile.run(\"true\", { display = \"panel\" })"); + let panel = side_window(&s).expect("compile opened a panel"); + let compilation = s.core.borrow().windows[&panel].buffer_id; + + // Focus a document window, then recompile — which reaches + // `start_run` with no `display` at all. + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + let document_buffer = s.core.borrow().windows[&document].buffer_id; + exec(&s, "pmacs.command.invoke(\"compile.recompile\")"); + + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + compilation, + "the recompile stayed in the panel" + ); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + document_buffer, + "…and did not duplicate itself into the document window" + ); + + // A compilation that is NOT in a panel keeps the pre-arc raw switch. + let s = editor(); + exec(&s, "pmacs.compile.run(\"true\")"); + assert!(side_window(&s).is_none()); + let target = active_window(&s); + exec(&s, "pmacs.command.invoke(\"compile.recompile\")"); + assert_eq!(active_window(&s), target); + assert!( + side_window(&s).is_none(), + "no panel is created out of nowhere" + ); +} + // --------------------------------------------------------------------------- // 20 / 23 — quit: delete, restore chains, revalidation, and the cap // --------------------------------------------------------------------------- @@ -1549,27 +1598,30 @@ fn acc26_close_others_and_split_respect_the_side_window() { let s = editor(); exec(&s, "pmacs.window.split_horizontal()"); let panel = open_panel(&s, "*panel*", 5); - // From a side window both are pointed errors. + // From a side window both are pointed errors — asserted through the + // REAL Lua bindings, which is what `C-x 1` / `C-x 2` / `C-x 3` + // reach. A direct `core.try_split_active(..)` call would pass even + // with the guard unwired, which is exactly how an unwired guard + // survives review. exec(&s, "pmacs.window.focus_next()"); while active_window(&s) != panel { exec(&s, "pmacs.window.focus_next()"); } - assert!(s.core.borrow_mut().close_others().is_err()); - assert!( - s.core - .borrow_mut() - .try_split_active(Orientation::Horizontal, true) - .is_err() - ); + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, "pmacs.window.close_others()").is_err()); + assert!(try_exec(&s, "pmacs.window.split_horizontal()").is_err()); + assert!(try_exec(&s, "pmacs.window.split_vertical()").is_err()); assert!(side_window(&s).is_some(), "nothing was mutated"); + assert_eq!( + before, + structure(&layout_root(&s)), + "the wrapper's final child is still Leaf(side)" + ); // From a document window, close_others deletes the panel too. exec(&s, "pmacs.window.focus_next()"); assert_ne!(active_window(&s), panel); - s.core - .borrow_mut() - .close_others() - .expect("document may close others"); + exec(&s, "pmacs.window.close_others()"); assert!(side_window(&s).is_none()); assert_eq!( s.core.borrow().views[&FrontendId::LOCAL] @@ -1728,6 +1780,57 @@ fn acc30_divider_drag_writes_fixed_rows_and_weights_and_creates_no_selection() { assert!(doubled > after, "the ratio scales with the frame"); } +/// An armed drag owns the pointer for its OWN frontend only. The daemon +/// routes every attached grid frontend through one `dispatch_mouse`, so +/// an unscoped guard would let one frontend's in-flight gesture cancel +/// and swallow another frontend's clicks. +#[test] +fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { + let mut s = editor(); + let panel = open_panel(&s, "*panel*", 6); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + let other = FrontendId(30); + let other_window = attach_frontend(&s, other, true); + + let rects = render(&s); + let divider_row = u16::try_from(rects[&document].origin.row + rects[&document].size.rows - 1) + .expect("row fits"); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + let armed_rows = fixed_rows_of(&s, panel); + + // A click from the OTHER frontend must be dispatched normally… + s.dispatch_mouse( + other, + mouse(MouseEventKind::Down(MouseButton::Left), 1, 2), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + s.core.borrow().views[&other].active, + other_window, + "the peer's click reached its own window instead of being swallowed" + ); + + // …and LOCAL's gesture must still be armed and still work. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(armed_rows.expect("armed rows") - 2), + "the peer's event did not cancel LOCAL's in-flight drag" + ); +} + #[test] fn acc30b_ui_divider_face_resolves_and_paints_every_exposed_segment() { let s = editor(); From 7e1bfb6dc59a438a8dcae7721e2bc4edc95d9385 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 15:21:48 -0400 Subject: [PATCH 06/10] test(window): pin the terminal anchor, not the tail-relative offset `TerminalViewStatus.scroll_offset` is the retained rows between the VIEWPORT and the live tail, so it necessarily tracks viewport height: an assertion that it survives a panel height change unchanged is either vacuous or wrong, and it went red once under a loaded sweep for exactly that reason. Q#BP7's invariant is that the ANCHOR is frozen, so acc32 and acc33 now compare the first visible row's text across the change, and additionally pin the follow behavior that distinguishes them: a shrink never re-arms follow, growth reaching the tail does, and growth with a frozen selection does not. Both also wait for the child's last line before sampling, so neither races further output. Also records the round in docs/active-work.md. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 52 +++++-- tests/bottom_panel_stage1_acceptance.rs | 193 ++++++++++++++++++------ 2 files changed, 188 insertions(+), 57 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index fe1fb19..4e493af 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -84,21 +84,49 @@ If it does not, stop and repair the remote/fetch configuration. - Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all take `display = "current" | "panel"` (Stage 1 default `"current"`); LSP/compile visits route through `display_file`. +- **Review round 1 addressed.** The load-bearing finding: the Q#BP6 + side-window split guard (`try_split_active`) had **no production + caller** — `pmacs.window.split_horizontal` / `split_vertical`, and so + `C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a + focused panel made the root wrapper's final child a split rather than + `Leaf(side)`, which both `Layout::compute`'s fixed pass and + `document_subtree` key on. It survived the first round because the + acceptance test called the core method **directly**; it now goes + through the real Lua binding. This is the folding-arc round-2 lesson + repeating exactly: *after wiring a guard into a production hook, pin it + through the real path — a direct-call test misses the wiring.* + Also fixed: the armed divider drag was not scoped to its arming + frontend (it could cancel and swallow a peer's mouse events); a + recompile carries no `display` and duplicated a panel-placed + `*compilation*` into the document window; and + `paint_mode_line_graphemes` had lost its doc block to an insertion. + Five bite-verified fixes (three via `scripts/bite`, two by manual + revert since their tests share `src/daemon.rs` with the production + code). +- Two Stage-2 hazard pins now exist in `src/daemon.rs`, closing the gap + the review named: a fresh attach while `LOCAL` is focused in a panel + inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap + whose `after-load` hook creates and selects a panel still reasserts + into a document window. - Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,815 default + 1,992 CRDT library tests; - `bottom_panel_stage1_acceptance` 42/42; M4 121; required GPU 152; - `gpu_initial_target_acceptance` 1 default + 14 CRDT; vterm Stage 2 4 / + workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; + `bottom_panel_stage1_acceptance` 44/44; M4 121; required GPU 152; + `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; - workspace sweep 3,103 passed across 86 suites; `git diff --check` + **workspace sweep 3,128 passed, zero failures**; `git diff --check` clean. - - **The sweep's only red was the known parallel-load GPU flake**: - `font_facts_out_of_range_sizes_fail_closed`, - `built_in_only_overwide_readout_…`, and - `statusline_wire_validation_is_atomic_…` fail under a loaded - workspace run (wgpu device contention) and pass both isolated and in - the dedicated `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` gate. - - `compile_mode_acceptance` likewise needs `--test-threads=1` locally; - it is 67/67 there. + - Durable test lesson from this round: `TerminalViewStatus.scroll_offset` + is documented as the retained rows between **this viewport** and the + live tail, so it necessarily tracks the viewport height. Asserting it + constant across a panel height change is either vacuous or wrong — + the invariant Q#BP7 actually states is that the **anchor** is frozen, + which the acceptance now pins by comparing the first visible row's + text, plus `at_bottom` for the follow re-arm. + - `compile_mode_acceptance` needs `--test-threads=1` locally; it is + 67/67 there. The `pmacs-gpu` bin tests have historically gone red + under a loaded sweep (wgpu device contention) — they were green in + the final run, but rerun isolated before treating one as a + regression. - Stage 2 (the GPU panel band, next available protocol version) has its own re-framing obligation before implementation; Stage 3 is the default placement flip. diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index 73b3986..e413394 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1989,33 +1989,20 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); render(&s); - // Wait for output. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - s.tick_processes(); - let has_output = s - .terminal_manager - .borrow() - .snapshot(buffer.0) - .is_some_and(|snap| !snap.cells.is_empty()); - if has_output || std::time::Instant::now() > deadline { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + // Wait for the child's LAST line: `scroll_offset` is tail-relative, + // so comparing it across a height change is only meaningful once the + // tail has stopped moving. + wait_for_terminal_text(&mut s, buffer.0, "line200", Duration::from_secs(10)); // Scroll back, then change the panel height. `top` is preserved // verbatim: a height change is a viewport change, never a scroll one. exec(&s, "pmacs.window.focus_next()"); let key_before = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + let before_size = CellSize::new(11, COLS); s.terminal_manager .borrow_mut() - .scroll_view(key_before, CellSize::new(6, COLS), 5); - let offset_before = s - .terminal_manager - .borrow_mut() - .view_status(key_before) - .map(|status| status.scroll_offset); + .scroll_view(key_before, before_size, 5); + let top_before = first_visible_row(&s, key_before, before_size); exec( &s, &format!( @@ -2025,15 +2012,47 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); - let offset_after = s - .terminal_manager - .borrow_mut() - .view_status(key_before) - .map(|status| status.scroll_offset); + // The ANCHOR is the invariant. `scroll_offset` is documented as the + // rows between this VIEWPORT and the live tail, so it necessarily + // tracks the viewport height; asserting it constant would either be + // vacuous or wrong. The first visible row is `top` itself. assert_eq!( - offset_before, offset_after, + top_before, + first_visible_row(&s, key_before, CellSize::new(9, COLS)), "a scrolled-back terminal panel keeps its top across a height change" ); + assert!( + !s.terminal_manager + .borrow_mut() + .view_status(key_before) + .expect("view status") + .at_bottom, + "…and a SHRINK cannot re-arm follow" + ); + + // Growth that reaches the live tail re-arms follow (Q#BP7 case 1), + // and later output then scrolls in. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 23 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_before, CellSize::new(22, COLS)) + .expect("snapshot at the grown size"); + assert!( + s.terminal_manager + .borrow_mut() + .view_status(key_before) + .expect("view status") + .at_bottom, + "growth reaching the live tail re-arms follow when no selection is frozen" + ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } @@ -2132,6 +2151,46 @@ fn wait_for_file(path: &std::path::Path, timeout: Duration) -> Vec { } } +/// Tick until the child's screen contains `needle`, so a test that +/// compares tail-relative state is not racing further output. +/// +/// `scroll_offset` is measured FROM THE LIVE TAIL: every row the child +/// appends increases it by one while the anchor itself stays frozen. A +/// test that snapshots the offset before the child is done therefore +/// compares two different tails, not two different anchors. +fn wait_for_terminal_text(s: &mut EditorState, buffer: BufferId, needle: &str, timeout: Duration) { + let deadline = std::time::Instant::now() + timeout; + loop { + s.tick_processes(); + let seen = s + .terminal_manager + .borrow() + .snapshot(buffer) + .is_some_and(|snapshot| { + let text: String = snapshot + .cells + .iter() + .filter_map(|cell| match &cell.glyph { + Glyph::Char(ch) => Some(*ch), + Glyph::Cluster(_) => Some('?'), + Glyph::Continuation => None, + }) + .collect(); + text.contains(needle) + }); + if seen { + // One more drain so nothing is left in flight. + s.tick_processes(); + return; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {needle:?} on the terminal screen" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { let mut s = editor(); @@ -2147,19 +2206,13 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); let view_size = CellSize::new(5, COLS); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - s.tick_processes(); - let seen = s - .terminal_manager - .borrow_mut() - .snapshot_for_view(key_id, view_size) - .is_some(); - if seen || std::time::Instant::now() > deadline { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + // Wait for the child's LAST line, so the tail is stable before the + // before/after comparison below. + wait_for_terminal_text(&mut s, buffer.0, "row59", Duration::from_secs(10)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, view_size) + .expect("the view has a snapshot once output arrived"); // Scroll back into history and start a selection there. { @@ -2167,9 +2220,9 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { assert!(manager.scroll_view(key_id, view_size, 10)); assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); } - let before = s.terminal_manager.borrow_mut().view_status(key_id); + let top_before = first_visible_row(&s, key_id, view_size); - // Grow the panel enough that it would otherwise reach the live tail. + // Grow the panel enough that following the tail WOULD reach it. exec( &s, &format!( @@ -2179,15 +2232,65 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); - let after = s.terminal_manager.borrow_mut().view_status(key_id); + + let grown = CellSize::new(19, COLS); + let after = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("view status"); + // The anchor is what freezes — `scroll_offset` is documented as + // "physical retained rows between this VIEWPORT and the live tail", + // so it moves with the viewport height by construction even when + // `top` is preserved verbatim. Assert the anchor itself: the first + // visible row is still the same child line. assert_eq!( - before.map(|status| (status.scroll_offset, status.selection)), - after.map(|status| (status.scroll_offset, status.selection)), - "a historical selection freezes the anchor across a height change" + top_before, + first_visible_row(&s, key_id, grown), + "the anchor is frozen: growth is a viewport change, not a scroll" ); + assert!(after.selection, "the historical selection survived"); + assert!( + !after.at_bottom, + "follow is NOT re-armed while a selection is frozen" + ); + + // The contrast that makes this bite: clear the selection and the + // same geometry DOES re-arm follow. + s.terminal_manager.borrow_mut().clear_selection(key_id); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, grown) + .expect("snapshot after clearing"); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } +/// Text of the view's first visible row — the anchor, read through the +/// same per-view projection the painter uses. +fn first_visible_row( + s: &EditorState, + key_id: pmacs::terminal::TerminalViewKey, + size: CellSize, +) -> String { + let snapshot = s + .terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, size) + .expect("view snapshot"); + snapshot + .cells + .iter() + .take(size.cols as usize) + .filter_map(|cell| match &cell.glyph { + Glyph::Char(ch) => Some(*ch), + Glyph::Cluster(_) => Some('?'), + Glyph::Continuation => None, + }) + .collect::() + .trim_end() + .to_owned() +} + #[test] fn acc34_only_the_controller_resizes_the_pty() { let mut s = editor(); From 4d44be5d7ba9886894b404080039d56c53cc7179 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:32:23 -0400 Subject: [PATCH 07/10] fix(terminal): implement the Q#BP7 growth re-arm and pin it honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 2. Finding 1 (must fix): Q#BP7 item 1 — "growth reaching the live tail re-arms follow (top -> None), only when no selection is active" — was never implemented. `at_bottom` is the instantaneous geometric readout `scroll_offset == 0`, which a still-anchored view satisfies whenever it happens to be tall enough to reach the tail, so the round-1 assertion could not see the gap: the next rows the child printed pushed the anchored view back into history. `rearm_follow_on_growth` now clears `top` when a viewport-size declaration makes the view cover the tail and no selection is frozen, and every size-declaring path (`snapshot_for_view`, `record_view_size`, `view_status_for_size`) routes through one `declare_view_size` helper so grid and semantic declarations cannot disagree. `scroll_view` and `begin_selection` deliberately stay out: they write `top` themselves, and `scroll_view` already owns the scroll-driven arm. New acc32b is the pin the review asked for: scroll into history, grow past the tail, then release a SECOND burst of child output through a filesystem gate and assert the view moved with it. Finding 2: the PTY fixtures emitted LF-only output, which staircases rightward until every row clips to blanks past the viewport width — so the round-1 anchor assertions compared "" with "" and could not fail. Both fixtures now emit CRLF, and each anchor comparison is guarded by `assert!(!top_before.is_empty())`. Finding 3: acc33's contrast case asserted nothing, and the behavior it claimed was false as coded. With the re-arm in place it is true and now asserted: clearing the selection at the same geometry re-arms follow and leaves the frozen anchor. Finding 4: `start_run` gated the panel branch on `display == "panel" or already_in_panel(..)`, so an explicit `display = "current"` lost to the inference — and that value is the documented user-facing opt-out from the Stage 3 default flip. Now gated on OMISSION. acc19b gains the explicit-"current" case. Finding 5: `window_drag` is a `HashMap`, so a peer's mode-line press can no longer steal or clear another frontend's in-flight gesture, and concurrent drags are legal. Cleared on detach. acc30c gains the mode-line-press case. Minor: `pmacs.window.buffer()` resolves both arms through the acting frontend using the shared `lookup_window` / `selected_window` validators rather than re-implementing them beside an ambient `active_buffer_id()`. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 8 +- src/editor.rs | 65 +++++---- src/lua_bindings/mod.rs | 41 +++--- src/lua_bindings/window_panel.rs | 8 +- src/terminal/view.rs | 59 +++++++- tests/bottom_panel_stage1_acceptance.rs | 170 +++++++++++++++++++++--- 6 files changed, 276 insertions(+), 75 deletions(-) diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 2e894d2..544c1d7 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -836,7 +836,13 @@ local function start_run(slot, cmdline, opts) -- the selected DOCUMENT window while the panel still shows it — the -- duplicate presentation this arc removes elsewhere. Detect that the -- buffer already owns the panel slot and keep it there. - if display == "panel" or already_in_panel(slot.buf) then + -- + -- Gated on OMISSION, never on an explicit value: `display = "current"` + -- is the documented user-facing opt-out from the Stage 3 default flip, + -- so it must reach the raw switch even when the previous run was + -- panel-placed. The duplicate presentation that produces is the + -- escape hatch's documented cost (R3-rp2). + if display == "panel" or (display == nil and already_in_panel(slot.buf)) then pmacs.window.display(slot.buf, { side = "bottom", select = false }) else pmacs.window.switch_buffer(slot.buf) diff --git a/src/editor.rs b/src/editor.rs index 246a575..79f1225 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,11 +161,16 @@ pub struct EditorState { /// Last left-button down event, used to synthesize terminal double /// clicks from crossterm's plain Down/Up mouse event stream. mouse_click: Option, - /// In-progress split-boundary drag (bottom-panel arc, Q#BP5), armed + /// In-progress split-boundary drags (bottom-panel arc, Q#BP5), armed /// by a left press on a mode-line row that is an exposed segment of a - /// horizontal boundary. Lives beside `mouse_click`; selection is - /// untouched for the whole gesture. - window_drag: Option, + /// horizontal boundary. Selection is untouched for the whole gesture. + /// + /// Keyed by frontend, unlike the older global `mouse_click` slot: the + /// daemon routes every attached grid frontend through one + /// `dispatch_mouse`, so a single slot would let one frontend's press + /// steal or clear another's in-flight gesture, and concurrent drags + /// are perfectly legal. + window_drag: HashMap, } #[derive(Default)] @@ -220,7 +225,6 @@ struct MouseClickState { /// mutation mid-drag cannot move a boundary that no longer exists. #[derive(Copy, Clone)] struct WindowDragState { - frontend_id: FrontendId, owner: WindowId, last_row: u32, } @@ -619,7 +623,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, - window_drag: None, + window_drag: HashMap::new(), } } @@ -875,6 +879,9 @@ impl EditorState { /// Drop one detached frontend's pending key and terminal escape state. pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { self.dispatchers.remove(&frontend_id); + // A detached frontend cannot finish a divider gesture, and its + // `owner` window is about to stop being live (Q#BP5). + self.window_drag.remove(&frontend_id); self.terminal_manager .borrow_mut() .detach_frontend(frontend_id); @@ -1930,17 +1937,16 @@ impl EditorState { // grid frontend through this same dispatcher, so an unscoped // check would let one frontend's in-flight drag cancel and // swallow another frontend's clicks. - if self - .window_drag - .is_some_and(|drag| drag.frontend_id == frontend_id) - { + if self.window_drag.contains_key(&frontend_id) { match ev.kind { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); } // Any other event — release, a different button, a - // wheel notch — ends the gesture. - _ => self.window_drag = None, + // wheel notch — ends THIS frontend's gesture only. + _ => { + self.window_drag.remove(&frontend_id); + } } return; } @@ -2076,11 +2082,20 @@ impl EditorState { .views .get(&frontend_id) .is_some_and(|view| view.layout.boundary_below(owner).is_some()); - self.window_drag = is_divider.then_some(WindowDragState { - frontend_id, - owner, - last_row: cell_row, - }); + // Only this frontend's slot is written, and only its own press + // can clear it — a peer pressing some other window's mode line + // must not disarm an in-flight gesture here. + if is_divider { + self.window_drag.insert( + frontend_id, + WindowDragState { + owner, + last_row: cell_row, + }, + ); + } else { + self.window_drag.remove(&frontend_id); + } } /// Continue an armed divider drag (Q#BP5). @@ -2095,16 +2110,16 @@ impl EditorState { cell_row: u32, term_size: CellSize, ) { - let Some(drag) = self.window_drag else { + let Some(drag) = self.window_drag.get(&frontend_id).copied() else { return; }; - if drag.frontend_id != frontend_id { - return; - } - self.window_drag = Some(WindowDragState { - last_row: cell_row, - ..drag - }); + self.window_drag.insert( + frontend_id, + WindowDragState { + last_row: cell_row, + ..drag + }, + ); let delta = i64::from(cell_row) - i64::from(drag.last_row); let Ok(delta) = i32::try_from(delta) else { return; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 959edbe..0d585ea 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12365,30 +12365,23 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ // panel" without first selecting the panel. win.set( "buffer", - lua.create_function(move |lua, target: Option| -> mlua::Result { - let Some(raw) = target else { - return Ok(BufferIdLua(cc.borrow().active_buffer_id())); - }; - let fid = window_panel::acting_frontend(lua, &cc); - let core = cc.borrow(); - let view = core.views.get(&fid).ok_or_else(|| { - mlua::Error::runtime("pmacs.window.buffer: acting frontend has no layout") - })?; - let id = view - .layout - .iter_ids() - .into_iter() - .find(|id| id.raw() == raw) - .ok_or_else(|| { - mlua::Error::runtime(format!( - "pmacs.window.buffer: window {raw} is not live in this frontend's layout" - )) - })?; - core.windows - .get(&id) - .map(|window| BufferIdLua(window.buffer_id)) - .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) - })?, + lua.create_function( + move |lua, target: Option| -> mlua::Result { + // Both arms resolve through the ACTING frontend, using + // the same validator the rest of the window surface + // does — no ambient `active_buffer_id()` asymmetry. + let fid = window_panel::acting_frontend(lua, &cc); + let id = match target { + Some(raw) => window_panel::lookup_window(&cc, fid, raw)?, + None => window_panel::selected_window(&cc, fid)?, + }; + cc.borrow() + .windows + .get(&id) + .map(|window| BufferIdLua(window.buffer_id)) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) + }, + )?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index 068f6c8..f4833ef 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -189,7 +189,7 @@ fn parse_request( /// Not `active_window_id()`, which resolves through the ambient active /// frontend: every other id in this module is `fid`-scoped, and the two /// only coincide because dispatch happens to set `active_frontend` first. -fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { +pub(crate) fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { core.borrow() .views .get(&fid) @@ -199,7 +199,11 @@ fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result /// Resolve a raw Lua window id, refusing one that is not live in the /// acting frontend's layout (Q#BP11). -fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { +pub(crate) fn lookup_window( + core: &SharedCore, + fid: FrontendId, + raw: u64, +) -> mlua::Result { let core = core.borrow(); let view = core .views diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5d9d311..1c0957d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -132,8 +132,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); Some(project_snapshot( key.buffer_id, viewport_size, @@ -234,8 +233,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); Some(TerminalViewStatus { @@ -289,8 +287,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); true } @@ -595,6 +592,56 @@ fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option< .then(|| row_lead(first)) } +/// The shared viewport-size declaration path (bottom-panel arc, Q#BP7). +/// +/// Normalize, then re-arm live-tail following when the newly declared +/// viewport reaches the tail, then record the size. Every path that +/// *declares* a size routes through here so grid and semantic +/// declarations cannot disagree; `scroll_view` and `begin_selection` +/// deliberately do not, because they write `top` themselves. +fn declare_view_size( + state: &mut TerminalViewState, + projection: BorrowedScreenProjection<'_>, + viewport_size: CellSize, +) { + normalize_state(state, projection); + rearm_follow_on_growth(state, projection, viewport_size.rows); + state.viewport_size = Some(viewport_size); +} + +/// Q#BP7 item 1: **growth reaching the live tail re-arms follow.** +/// +/// A height change is a viewport change, never a scroll change — `top` +/// is preserved verbatim — but once a taller viewport covers the tail, +/// staying anchored would leave the view frozen just short of the live +/// output while `at_bottom` reported `true`: `at_bottom` is the +/// instantaneous geometric readout `scroll_offset == 0`, so it cannot +/// distinguish "following" from "anchored, and currently tall enough to +/// reach". The next rows the child prints would then push the anchored +/// view back into history with nothing to explain it. +/// +/// **Only when no selection is active** (R1-8): a historical selection +/// froze this anchor on purpose, and growth must not yank the user's +/// region out from under them. `scroll_view` already handles the +/// scroll-driven arm (`next == tail_start`), so during ordinary +/// scrolling `scroll_offset == 0` implies follow is already armed — +/// which makes this rule fire on exactly the growth (and shrink-back) +/// case it names, and be idempotent everywhere else. +fn rearm_follow_on_growth( + state: &mut TerminalViewState, + projection: BorrowedScreenProjection<'_>, + viewport_rows: u32, +) { + if state.top.is_none() || state.selection.is_some() || viewport_rows == 0 { + return; + } + let rows = retained_rows(projection); + if view_geometry(&rows, state, viewport_rows).scroll_offset == 0 { + state.top = None; + state.selection_froze_top = false; + } +} + fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) { if state .alternate_active diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index e413394..f9acea1 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1336,6 +1336,26 @@ fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() "…and did not duplicate itself into the document window" ); + // An EXPLICIT `display = "current"` still wins over the inference: + // it is the documented user-facing opt-out from the Stage 3 default + // flip, so it must reach the raw switch even while the panel holds + // this buffer. The resulting duplicate presentation is the escape + // hatch's documented cost (R3-rp2). + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + exec(&s, "pmacs.compile.run(\"true\", { display = \"current\" })"); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + compilation, + "explicit \"current\" reached the raw switch" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + compilation, + "…and the panel still holds it too — the escape hatch's cost" + ); + // A compilation that is NOT in a panel keeps the pre-arc raw switch. let s = editor(); exec(&s, "pmacs.compile.run(\"true\")"); @@ -1818,6 +1838,15 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { "the peer's click reached its own window instead of being swallowed" ); + // …a peer press on a MODE-LINE row must not steal or clear the slot + // either — that press reaches the arming path, which a single global + // slot would let it overwrite. + s.dispatch_mouse( + other, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 4), + CellSize::new(ROWS, COLS), + ); + // …and LOCAL's gesture must still be armed and still work. s.dispatch_mouse( FrontendId::LOCAL, @@ -1827,7 +1856,7 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { assert_eq!( fixed_rows_of(&s, panel), Some(armed_rows.expect("armed rows") - 2), - "the peer's event did not cancel LOCAL's in-flight drag" + "the peer's events did not cancel or steal LOCAL's in-flight drag" ); } @@ -1981,8 +2010,14 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let mut s = editor(); exec( &s, + // `printf '...\\r\\n'`, not `echo`: a PTY in the default mode + // does not translate LF to CRLF for us, so LF-only output + // staircases rightward and every row past the viewport width + // clips to blanks — which would make the anchor assertions below + // compare "" with "" and pass for any regression. "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ - args = { \"-c\", \"for i in $(seq 1 200); do echo line$i; done; sleep 30\" }, \ + args = { \"-c\", \"i=1; while [ $i -le 200 ]; do printf 'line%d\\\\r\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }, \ display = \"panel\" }", ); let panel = side_window(&s).expect("terminal panel"); @@ -2001,8 +2036,13 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let before_size = CellSize::new(11, COLS); s.terminal_manager .borrow_mut() - .scroll_view(key_before, before_size, 5); + .scroll_view(key_before, before_size, 30); let top_before = first_visible_row(&s, key_before, before_size); + assert!( + !top_before.is_empty(), + "the anchor row must carry real text, or the equality below \ + cannot fail for the regression it names" + ); exec( &s, &format!( @@ -2029,9 +2069,65 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { .at_bottom, "…and a SHRINK cannot re-arm follow" ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} - // Growth that reaches the live tail re-arms follow (Q#BP7 case 1), - // and later output then scrolls in. +/// Q#BP7 item 1 proper: **growth reaching the live tail re-arms follow**, +/// so later output scrolls in. +/// +/// `at_bottom` alone cannot pin this — it is the instantaneous geometric +/// readout `scroll_offset == 0`, which a still-anchored view satisfies +/// whenever it happens to be tall enough to reach the tail. The pin has +/// to feed the child MORE output after the growth and assert the view +/// moved with it. +#[test] +fn acc32b_growth_reaching_the_tail_re_arms_follow_and_later_output_scrolls_in() { + let dir = tempfile::tempdir().expect("tempdir"); + let gate = dir.path().join("gate"); + // Inserted bare into the shell word: `tempfile` paths carry no + // spaces or quotes, and wrapping it would terminate the Lua string. + let gate_path = gate.display().to_string(); + let mut s = editor(); + // Two bursts with a filesystem gate between them, so "more output + // after the growth" is deterministic rather than a race. + exec( + &s, + &format!( + "TERM_BUF = pmacs.terminal.open {{ command = \"/bin/sh\", \ + args = {{ \"-c\", \"i=1; while [ $i -le 60 ]; do printf 'first%02d\\\\r\\\\n' $i; \ + i=$((i+1)); done; \ + while [ ! -f {gate_path} ]; do sleep 0.02; done; \ + i=1; while [ $i -le 40 ]; do printf 'second%02d\\\\r\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }}, \ + display = \"panel\" }}" + ), + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + render(&s); + wait_for_terminal_text(&mut s, buffer.0, "first60", Duration::from_secs(10)); + + // Scroll back into history at a short viewport. + let short = CellSize::new(6, COLS); + assert!( + s.terminal_manager + .borrow_mut() + .scroll_view(key_id, short, 20) + ); + let anchored = first_visible_row(&s, key_id, short); + assert!(!anchored.is_empty(), "the anchor row carries real text"); + assert!( + s.terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("status") + .scroll_offset + > 0, + "the view really is anchored in history" + ); + + // Grow the panel until the viewport covers the tail. exec( &s, &format!( @@ -2041,17 +2137,36 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let grown = CellSize::new(40, COLS); s.terminal_manager .borrow_mut() - .snapshot_for_view(key_before, CellSize::new(22, COLS)) + .snapshot_for_view(key_id, grown) .expect("snapshot at the grown size"); - assert!( - s.terminal_manager - .borrow_mut() - .view_status(key_before) - .expect("view status") - .at_bottom, - "growth reaching the live tail re-arms follow when no selection is frozen" + + // Release the second burst. A view that merely LOOKS at-bottom while + // still anchored gets pushed back into history here; a re-armed one + // follows. + std::fs::write(&gate, b"go").expect("open the gate"); + wait_for_terminal_text(&mut s, buffer.0, "second40", Duration::from_secs(10)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, grown) + .expect("snapshot after the second burst"); + + let status = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("status"); + assert_eq!( + status.scroll_offset, 0, + "the view followed the live tail through the new output" + ); + assert!(status.at_bottom); + assert_ne!( + anchored, + first_visible_row(&s, key_id, grown), + "…and its first visible row moved off the old anchor" ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } @@ -2197,7 +2312,7 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { exec( &s, "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ - args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\n' $i; \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\r\\\\n' $i; \ i=$((i+1)); done; sleep 30\" }, \ display = \"panel\" }", ); @@ -2221,6 +2336,11 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); } let top_before = first_visible_row(&s, key_id, view_size); + assert!( + !top_before.is_empty(), + "the anchor row must carry real text, or the equality below \ + cannot fail for the regression it names" + ); // Grow the panel enough that following the tail WOULD reach it. exec( @@ -2255,13 +2375,29 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { "follow is NOT re-armed while a selection is frozen" ); - // The contrast that makes this bite: clear the selection and the - // same geometry DOES re-arm follow. - s.terminal_manager.borrow_mut().clear_selection(key_id); + // The contrast that makes this bite: the freeze is owed to the + // SELECTION, so clearing it lets the next size declaration re-arm + // follow at the very same geometry. Without this, "no re-arm while + // selected" would also hold if the re-arm simply did not exist. + assert!(s.terminal_manager.borrow_mut().clear_selection(key_id)); s.terminal_manager .borrow_mut() .snapshot_for_view(key_id, grown) .expect("snapshot after clearing"); + let cleared = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("view status after clearing"); + assert!( + cleared.at_bottom && cleared.scroll_offset == 0, + "clearing the selection re-arms follow at the same geometry" + ); + assert_ne!( + top_before, + first_visible_row(&s, key_id, grown), + "…and the view left the frozen anchor" + ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } From 52e7598da03603cf7ab61eadb51e1acb016d859c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:37:10 -0400 Subject: [PATCH 08/10] test(window): press the peer's own mode line in acc30c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-2 peer press landed in the peer's CONTENT area, so it never reached `arm_window_drag` — the exact path Finding 5 names — and the case bit nothing. It now presses the peer's own mode-line row, where a single global drag slot is overwritten (and, since that lone window owns no boundary, cleared outright). Co-Authored-By: Claude Opus 5 (1M context) --- tests/bottom_panel_stage1_acceptance.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index f9acea1..e358351 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1838,12 +1838,15 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { "the peer's click reached its own window instead of being swallowed" ); - // …a peer press on a MODE-LINE row must not steal or clear the slot - // either — that press reaches the arming path, which a single global - // slot would let it overwrite. + // …a peer press on ITS OWN mode-line row must not steal or clear the + // slot either. That press reaches `arm_window_drag`, which a single + // global slot lets it overwrite — and the peer's lone window owns no + // boundary, so the write is an outright clear. The peer's mode line + // is the last row of its own single-window layout. + let peer_mode_line = u16::try_from(AREA_ROWS - 1).expect("row fits"); s.dispatch_mouse( other, - mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 4), + mouse(MouseEventKind::Down(MouseButton::Left), peer_mode_line, 4), CellSize::new(ROWS, COLS), ); From bbe41520482b9a59afcc57ca93414353ac4df62e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:42:04 -0400 Subject: [PATCH 09/10] docs: record bottom-panel review round 2 Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4e493af..f462c16 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -108,9 +108,38 @@ If it does not, stop and repair the remote/fetch configuration. inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap whose `after-load` hook creates and selects a panel still reasserts into a document window. +- **Review round 2 addressed.** The load-bearing finding: **Q#BP7 item 1 + — "growth reaching the live tail re-arms follow" — was never + implemented.** `at_bottom` is the instantaneous geometric readout + `scroll_offset == 0`, which a still-anchored view satisfies whenever it + is momentarily tall enough to reach the tail, so the round-1 assertion + could not see the gap: the next rows the child printed pushed the + anchored view back into history. `src/terminal/view.rs` now has + `rearm_follow_on_growth`, reached by one shared `declare_view_size` + helper from every size-declaring path (`snapshot_for_view`, + `record_view_size`, `view_status_for_size`) so grid and semantic + declarations cannot disagree. + Also fixed: the PTY fixtures emitted LF-only output, which staircases + until every row clips to blanks — so the anchor assertions compared + `""` with `""` and could not fail (now CRLF, each guarded by + `assert!(!top_before.is_empty())`); acc33's contrast case asserted + nothing; `start_run` let `already_in_panel` override an **explicit** + `display = "current"`, which is the documented opt-out from the Stage 3 + flip (now gated on omission); and `window_drag` was a daemon-global + slot that a peer's mode-line press could clear. +- Durable test lessons from this round, both the same class: + 1. **A geometric readout is not a state predicate.** `at_bottom` says + "the viewport currently reaches the tail", not "this view follows + the tail". Pinning follow requires feeding MORE output and asserting + the view moved (acc32b uses a filesystem gate between two bursts). + 2. **A PTY in the default mode does not translate LF to CRLF.** An + `echo`-driven fixture staircases rightward and clips to blanks past + the viewport width, so any text equality over it is vacuously true. + Emit `\r\n`, and guard text comparisons with a non-empty assertion + the way the daemon pin guards on `!panel_hidden`. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; - `bottom_panel_stage1_acceptance` 44/44; M4 121; required GPU 152; + `bottom_panel_stage1_acceptance` 45/45; vterm Stage 1 9; M4 121; required GPU 152; `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; **workspace sweep 3,128 passed, zero failures**; `git diff --check` @@ -123,9 +152,12 @@ If it does not, stop and repair the remote/fetch configuration. which the acceptance now pins by comparing the first visible row's text, plus `at_bottom` for the follow re-arm. - `compile_mode_acceptance` needs `--test-threads=1` locally; it is - 67/67 there. The `pmacs-gpu` bin tests have historically gone red - under a loaded sweep (wgpu device contention) — they were green in - the final run, but rerun isolated before treating one as a + 67/67 there. Under default parallelism it fails roughly 1 run in 3, + with a *different* test each time (acc14/acc25a, then acc24) — + **verified pre-existing** by swapping in `githubsucks/main`'s + `builtin/runtime/compile.lua` and reproducing the same rate. The + `pmacs-gpu` bin tests have historically gone red under a loaded sweep + (wgpu device contention). Rerun isolated before treating either as a regression. - Stage 2 (the GPU panel band, next available protocol version) has its own re-framing obligation before implementation; Stage 3 is the default From 9110f9f32cf9adef4f3afb732db4db0127b8ec70 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 20:05:51 -0400 Subject: [PATCH 10/10] fix(window): keep pmacs.window.buffer() infallible with no argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 2, self-review of the round-2 commit. The round-2 change labelled "minor" — resolving both arms of pmacs.window.buffer() through the acting frontend for uniformity — made the NO-ARGUMENT arm fallible. `acting_frontend` follows the interactive origin, which can name a frontend that has no registered view: a bare `dispatch_key` from an unattached peer does exactly that. `selected_window` then raises "acting frontend has no layout" instead of answering. Nothing surfaced that error, because the runtime callers do not pcall it. killring, syntax, autosave, pair, indent and comment all read pmacs.window.buffer() on ordinary edits, so the raise silently dropped the operation: kill_ring_acceptance went 30/30 to 25/5, with frontend_detached_drops_per_frontend_state reporting only "B has kill state". main is 30/30, and reverting this one file restored it. The no-arg arm is back on ambient active_buffer_id() and now documents why that is deliberate rather than an oversight: dispatch sets active_frontend to the acting frontend before running a command, so the two agree on every real path, while only the ambient resolver has the fallback that makes it total. The explicit-window arm keeps its Q#BP11 layout validation, which is what the arc actually needed. acc19c pins it through the real path — a buffer.after-edit subscriber reading pmacs.window.buffer() during a viewless peer's dispatch_key — rather than by calling the binding directly. Bite-verified: scripts/bite bbe4152 src/lua_bindings/mod.rs --test bottom_panel_stage1_acceptance -- acc19c goes red with the exact "acting frontend has no layout" traceback. The ledger also records two gating facts found on the way: the workspace sweep must run with an isolated XDG_CONFIG_HOME, because the real user init.lua installs a local package and the losing race leaks a status message into painted-frame comparisons; and a latent pre-existing main bug in the buffer CRDT undo path, which is not this branch's and whose proptest seed is deliberately not committed here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012j4omtTMn9v1UfmHQb9ap6 --- docs/active-work.md | 49 ++++++++++++++++++++--- src/lua_bindings/mod.rs | 30 +++++++++----- tests/bottom_panel_stage1_acceptance.rs | 52 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f462c16..fb835a6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -137,13 +137,52 @@ If it does not, stop and repair the remote/fetch configuration. the viewport width, so any text equality over it is vacuously true. Emit `\r\n`, and guard text comparisons with a non-empty assertion the way the daemon pin guards on `!panel_hidden`. +- **Round-2 self-review caught a regression the round-2 commit + introduced**, in the change it labelled "minor": routing + `pmacs.window.buffer()`'s **no-argument** arm through the fid-scoped + `selected_window` validator made it **fallible**, and + `acting_frontend` can name a frontend with **no registered view** (a + bare `dispatch_key` from an unattached peer does exactly that). The + runtime calls that function on ordinary edits from `killring`, + `syntax`, `autosave`, `pair`, `indent` and `comment` **without + `pcall`**, so the raise never surfaced as an error — it silently + dropped the operation. `kill_ring_acceptance` went 30/30 → 25/5 + (`frontend_detached_drops_per_frontend_state`: "B has kill state"). + The no-arg arm is back on ambient `active_buffer_id()` and documented + as deliberately infallible; the explicit-window arm keeps its Q#BP11 + validation. New **acc19c** pins it through the real path (a + `buffer.after-edit` subscriber during a viewless peer's `dispatch_key`) + and bites against the regressing commit. + Generalizes: **a "uniformity" cleanup that changes a function's + fallibility is not minor** — check every caller's error discipline + first, and remember that an ambient resolver's fallback IS its + contract. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; - `bottom_panel_stage1_acceptance` 45/45; vterm Stage 1 9; M4 121; required GPU 152; - `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / - Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; - **workspace sweep 3,128 passed, zero failures**; `git diff --check` - clean. + `bottom_panel_stage1_acceptance` 46/46; kill ring 30 default + 30 CRDT; + vterm Stage 1 9 default + 10 CRDT; M4 121; required GPU 152; + compile 67; vterm Stage 2 4 / Stage 3 5 (7 CRDT); folding Stage 2 48; + statusline 7; listview 6; + **isolated-config workspace sweep 3,130 passed across 89 suites, zero + failures**; `git diff --check` clean. + - **Run the sweep with an isolated `XDG_CONFIG_HOME`.** The real + `~/.config/pmacs/init.lua` on this desktop calls + `pmacs.packages.install_local(...)`, so every editor the sweep builds + races on one shared install root; a losing race sets a status message + that leaks into the mode line and breaks + `folding_stage2_acceptance::unfolded_frame_is_identical_to_the_pre_folding_baseline`, + which compares whole painted frames. Standalone it is 48/48. This + generalizes the known `compile_mode_acceptance` real-config trap: + any suite that paints the status area inherits it. + - **A latent pre-existing `main` bug surfaced while gating and is NOT + this branch's**: `buffer::tests::proptests::rope_matches_crdt_projection_after_arbitrary_edits` + fails on `main` @ `352bf0b` with `ops = [Insert(0,"a"), + Insert(0,"aaa"), Replace(0,1,"a"), Undo]` — undo of a textually-null + `Replace` returns a no-op edit result still carrying `crdt_op = + Some`, violating the suite's own shape invariant. `src/buffer.rs` is + byte-identical here, and the seed was deliberately **not** committed + (it would make an unrelated failure deterministically red on this + PR). Needs its own lane. - Durable test lesson from this round: `TerminalViewStatus.scroll_offset` is documented as the retained rows between **this viewport** and the live tail, so it necessarily tracks the viewport height. Asserting it diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 0d585ea..29e3b47 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12357,9 +12357,9 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); - // With no argument: the selected window's buffer (unchanged). - // With an explicit window id: that window's buffer, validated - // against the acting frontend's layout like every other + // With no argument: the ambient active buffer, exactly as before + // this arc. With an explicit window id: that window's buffer, + // validated against the acting frontend's layout like every other // `WindowId`-taking operation (bottom-panel arc, Q#BP11) — an // adopter has to be able to ask "is my buffer the one in the // panel" without first selecting the panel. @@ -12367,14 +12367,24 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ "buffer", lua.create_function( move |lua, target: Option| -> mlua::Result { - // Both arms resolve through the ACTING frontend, using - // the same validator the rest of the window surface - // does — no ambient `active_buffer_id()` asymmetry. - let fid = window_panel::acting_frontend(lua, &cc); - let id = match target { - Some(raw) => window_panel::lookup_window(&cc, fid, raw)?, - None => window_panel::selected_window(&cc, fid)?, + // The no-arg arm deliberately stays on ambient + // `active_buffer_id()`, and stays INFALLIBLE. This is not + // the asymmetry it looks like: dispatch sets + // `active_frontend` to the acting frontend before running a + // command, so the two agree on every real path — while + // `acting_frontend` can additionally name a frontend that + // has no registered view, where a `views`-keyed lookup + // raises instead of answering. `killring`, `syntax`, + // `autosave`, `pair`, `indent` and `comment` all call this + // on ordinary edits without `pcall`, so a raise here does + // not surface as an error — it silently drops the + // operation (it lost a whole kill in `kill_ring_acceptance` + // when this arm was routed through `selected_window`). + let Some(raw) = target else { + return Ok(BufferIdLua(cc.borrow().active_buffer_id())); }; + let fid = window_panel::acting_frontend(lua, &cc); + let id = window_panel::lookup_window(&cc, fid, raw)?; cc.borrow() .windows .get(&id) diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index e358351..fb3a4ad 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1369,6 +1369,58 @@ fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() ); } +/// `pmacs.window.buffer()` with NO argument must stay **infallible**. +/// +/// The optional window argument this arc added is validated against the +/// acting frontend's layout, and it is tempting to make the no-arg arm +/// symmetric by resolving it the same way. That silently breaks the +/// runtime: `acting_frontend` follows the interactive origin, which can +/// name a frontend with **no registered view** (as a bare +/// `dispatch_key` from a peer does), where a `views`-keyed lookup raises +/// instead of answering — and `killring`, `syntax`, `autosave`, `pair`, +/// `indent` and `comment` all call this on ordinary edits without +/// `pcall`, so the raise does not surface as an error, it just drops the +/// operation. Routing it through `selected_window` lost an entire kill in +/// `kill_ring_acceptance`. +#[test] +fn acc19c_window_buffer_stays_infallible_for_an_acting_frontend_without_a_view() { + let mut s = editor(); + let ambient = s.core.borrow().active_buffer_id(); + exec( + &s, + // A `buffer.after-edit` subscriber is the real shape: this is + // where syntax.lua, pair.lua and comment.lua each call + // `pmacs.window.buffer()` on every ordinary edit. + "SEEN = nil; ERR = nil \ + pmacs.hook.add(\"buffer.after-edit\", function() \ + local ok, got = pcall(pmacs.window.buffer) \ + if ok then SEEN = got else ERR = tostring(got) end \ + end)", + ); + + // A peer that never registered a view — the shape `dispatch_key` + // produces for an unattached frontend, and what the kill-ring suite + // drives with `ctrl_as`. + let viewless = FrontendId(9); + assert!( + !s.core.borrow().views.contains_key(&viewless), + "the premise: this frontend really has no view" + ); + s.dispatch_key(viewless, key(KeyCode::Char('z'), KeyModifiers::NONE)); + + let err: Option = eval(&s, "return ERR"); + assert_eq!( + err, None, + "pmacs.window.buffer() must not raise for a viewless acting frontend" + ); + let seen: Option = eval(&s, "return SEEN"); + assert_eq!( + seen.expect("the command observed a buffer").0, + ambient, + "…it answers with the ambient active buffer" + ); +} + // --------------------------------------------------------------------------- // 20 / 23 — quit: delete, restore chains, revalidation, and the cap // ---------------------------------------------------------------------------