diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index e364de2..544c1d7 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -732,8 +732,29 @@ 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 + -- 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 +826,27 @@ 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 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. + -- + -- 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) + 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 +907,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 +1038,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..6a6d717 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -123,7 +123,23 @@ 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 + pmacs.window.display(p.buffer, { side = "bottom", select = true }) + else + pmacs.window.switch_buffer(p.buffer) + end seat_cursor(p, 1) end @@ -160,6 +176,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 new file mode 100644 index 0000000..37b459f --- /dev/null +++ b/builtin/runtime/window.lua @@ -0,0 +1,63 @@ +-- 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. +-- +-- 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 = 1, + 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/docs/active-work.md b/docs/active-work.md index 2b258bd..fb835a6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,154 @@ 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`. +- **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. +- **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`. +- **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` 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 + 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. 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 + 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 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. diff --git a/src/daemon.rs b/src/daemon.rs index 98a688a..5af71d0 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 @@ -4123,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/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..79f1225 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,6 +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 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. 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)] @@ -208,8 +218,25 @@ 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 { + 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 +515,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 +623,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, + window_drag: HashMap::new(), } } @@ -763,9 +801,73 @@ 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. @@ -777,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); @@ -800,6 +905,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 +1193,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 +1928,29 @@ 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. + // + // 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.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 THIS frontend's gesture only. + _ => { + self.window_drag.remove(&frontend_id); + } + } + return; + } + let Some((win_id, rect)) = window_at_cell( &self.core.borrow(), frontend_id, @@ -1826,6 +1962,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 +2073,132 @@ 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()); + // 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). + /// + /// 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.get(&frontend_id).copied() else { + return; + }; + 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; + }; + 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 +2639,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 +3109,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 +3153,22 @@ 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 +3392,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 @@ -3579,6 +3883,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { graphemes.iter().map(|grapheme| grapheme.width).sum() } +/// 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); +} + /// 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. @@ -6360,7 +6691,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:?}"); @@ -6776,16 +7108,14 @@ mod tests { } else { panic!("expected split"); } - let p1 = s - .core - .borrow() - .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 90)); - let p2 = s - .core - .borrow() - .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 60)); + 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 37cdabf..cfb2bcb 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,48 @@ 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 +1010,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 +1041,75 @@ 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() { - continue; + 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 { + // 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 + // 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 = 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 +2580,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 +2639,133 @@ 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). + /// + /// **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; + }; + 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 && 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 +2776,1027 @@ 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) + && 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 => { + // 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); + let origin_valid = origin.is_some_and(|origin| { + self.views + .get(&fid) + .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 { + 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_or(0, Buffer::len) + }; + 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. + #[allow( + clippy::too_many_lines, + reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" + )] + 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. + #[allow( + clippy::too_many_lines, + reason = "Q#BP3's precedence ladder reads as one ordered policy" + )] + 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.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()); + } + + // 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 +4477,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 +4935,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 +4954,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 +5522,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..29e3b47 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()?; @@ -8475,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()) @@ -8489,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 @@ -8529,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)) })?, )?; @@ -8691,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result 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)?; { + // 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) })?, )?; } @@ -12185,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) })?, )?; } @@ -12271,8 +12327,7 @@ 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) })?, )?; } @@ -12302,9 +12357,41 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); + // 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. win.set( "buffer", - lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?, + lua.create_function( + move |lua, target: Option| -> mlua::Result { + // 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) + .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 new file mode 100644 index 0000000..f4833ef --- /dev/null +++ b/src/lua_bindings/window_panel.rs @@ -0,0 +1,642 @@ +// 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) { + // `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, + }; + 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) +} + +/// 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. +pub(crate) 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). +pub(crate) 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" + )) + }) +} + +/// A parsed adopter placement request (Q#BP11b). +/// +/// `listview`, compile, and terminal all take the same strict +/// `display = "current" | "panel"` value. In Stages 1–2 omission means +/// `"current"`; Stage 3 flips omission to `"panel"`. Explicit +/// `"current"` always preserves the adopter's pre-arc selected-window +/// behavior and is the user-facing opt-out from that flip. +pub(crate) enum AdopterPlacement { + /// Today's behavior: the raw switch into the frontend's active + /// window, deliberately bypassing display-policy dedication. + Current, + /// The bottom panel. + Panel, + /// An exact target window. + Window(WindowId), +} + +/// Parse an adopter's placement **before** it creates a buffer, session, +/// process, or wrapper — so an unknown value leaves nothing to roll back. +/// +/// # Errors +/// An unknown `display` value, a `window` combined with +/// `display = "panel"`, or a window id that is not live in the acting +/// frontend's layout. +pub(crate) fn parse_adopter_placement( + core: &SharedCore, + fid: FrontendId, + operation: &str, + display: Option<&str>, + 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(); + 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 => selected_window(&cc, fid)?, + }; + 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 => selected_window(&cc, fid)?, + }; + 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/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/src/window.rs b/src/window.rs index e1162c0..b66499e 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,66 @@ 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 + let extent = if let Some(rows) = extents[i] { + rows } else { - primary * w / total + 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 { + u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total)) + .unwrap_or(remainder) + }; + flexible_used += e; + e }; let child_area = match orientation { Orientation::Horizontal => Rect { @@ -465,13 +1005,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 +1146,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 +1156,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 +1172,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 +1192,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 +1229,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/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs new file mode 100644 index 0000000..fb3a4ad --- /dev/null +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -0,0 +1,2621 @@ +// 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); +} + +/// 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" + ); + + // 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\")"); + 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" + ); +} + +/// `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 +// --------------------------------------------------------------------------- + +#[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 — 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()"); + } + 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); + exec(&s, "pmacs.window.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"); +} + +/// 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" + ); + + // …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), peer_mode_line, 4), + CellSize::new(ROWS, COLS), + ); + + // …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 events did not cancel or steal LOCAL's in-flight drag" + ); +} + +#[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, + // `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\", \"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"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + + // 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, 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!( + "pmacs.window.set_params({}, {{ fixed_rows = 10 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + // 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!( + 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" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// 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!( + "pmacs.window.set_params({}, {{ fixed_rows = 23 }})", + panel.raw() + ), + ); + 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_id, grown) + .expect("snapshot at the grown size"); + + // 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)"); +} + +/// **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)); + } +} + +/// 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(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%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); + let view_size = CellSize::new(5, COLS); + + // 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. + { + 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 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( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 20 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + + 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!( + 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: 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)"); +} + +/// 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(); + 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); +} 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