Merge pull request #155 from levineuwirth/bottom-panel

Bottom panel Stage 1: window placement + TUI side windows
This commit is contained in:
Levi Neuwirth 2026-07-25 00:21:07 +00:00 committed by GitHub
commit e74506879f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 8199 additions and 157 deletions

View File

@ -732,8 +732,29 @@ end)
-- Start a run in `slot`. Shared by compile and shell-command; grep -- Start a run in `slot`. Shared by compile and shell-command; grep
-- has its own worker path. -- 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) local function start_run(slot, cmdline, opts)
opts = opts or {} 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 -- q-target discipline (Q#CM11): capture only when coming from a
-- non-generated buffer, so `g` reruns don't re-capture and -- non-generated buffer, so `g` reruns don't re-capture and
-- compile → g → q restores the original buffer. -- 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 -- attach here stacked a duplicate render view per run (round-5
-- finding 1; translation itself is buffer-level and unaffected by -- finding 1; translation itself is buffer-level and unaffected by
-- attachment count). -- 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 if not ok then
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc))) emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
slot.expected_rev = buf:revision() slot.expected_rev = buf:revision()
@ -866,7 +907,9 @@ local function visit_error(slot, idx)
if not e then return end if not e then return end
local path = resolve_error_path(slot, e.file) local path = resolve_error_path(slot, e.file)
pmacs.editor.push_jump() 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 if not ok then
pmacs.editor.jump_back() pmacs.editor.jump_back()
pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err)) pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err))
@ -995,6 +1038,15 @@ pmacs.command.define {
fn = function() fn = function()
local slot = slot_for_buffer(pmacs.window.buffer()) local slot = slot_for_buffer(pmacs.window.buffer())
if not slot then return end 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 local target = slot.prev
if not (target and target:is_valid()) then if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")

View File

@ -123,7 +123,23 @@ function pmacs.listview.open(spec)
p.prev = active p.prev = active
end end
render(p, spec.rows or {}) 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) seat_cursor(p, 1)
end end
@ -160,6 +176,15 @@ pmacs.command.define {
fn = function() fn = function()
local p = panel_for_current_buffer() local p = panel_for_current_buffer()
if not p then return end 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 local target = p.prev
if not (target and target:is_valid()) then if not (target and target:is_valid()) then
target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*")

View File

@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition()
return return
end end
pmacs.editor.push_jump() 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 if not ok2 then
-- Open failed: drop the origin we just pushed so M-, isn't -- Open failed: drop the origin we just pushed so M-, isn't
-- left pointing at a jump that never happened. -- left pointing at a jump that never happened.
@ -1601,7 +1605,9 @@ local function visit_location(loc)
return return
end end
pmacs.editor.push_jump() 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 if not ok then
-- Open failed: drop the origin we just pushed so M-, isn't left -- Open failed: drop the origin we just pushed so M-, isn't left
-- pointing at a jump that never happened. -- pointing at a jump that never happened.

View File

@ -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" }

View File

@ -54,6 +54,154 @@ git status --short --branch
The `git log` command must expose `0dd16a5` or a newer intentional main. The `git log` command must expose `0dd16a5` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration. If it does not, stop and repair the remote/fetch configuration.
## 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 ## 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 Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3

1818
docs/bottom-panel-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -894,6 +894,21 @@ fn peer_declared_terminal_support(
.is_some_and(|state| state.negotiated_protocol_version >= 19) .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 /// The same belt-and-braces write-loop gate for the additive
/// protocol-v19 terminal frame. The semantic producer skips construction /// protocol-v19 terminal frame. The semantic producer skips construction
/// for an older peer; this filter independently prevents an unknown /// for an older peer; this filter independently prevents an unknown
@ -1628,38 +1643,40 @@ fn open_initial_target(
target: InitialTarget, target: InitialTarget,
) -> Result<OpenedInitialTarget, String> { ) -> Result<OpenedInitialTarget, String> {
let path = resolve_initial_target(target); let path = resolve_initial_target(target);
let display_path = path.display().to_string(); // Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's
let (buffer_id, newly_loaded, newly_created) = { // 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(); let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id; core.active_frontend = frontend_id;
let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) { let origin_window = core
Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false), .primary_document_window(frontend_id)
Err(error) if error.kind() == ErrorKind::NotFound => { .ok_or_else(|| "attaching frontend has no document window".to_string())?;
let buffer_id = core.registry.borrow_mut().create(display_path.clone()); let (buffer_id, fire) = core.resolve_target_buffer(&path)?;
core.set_buffer_path(buffer_id, Some(path.clone())); core.install_buffer_in_window(origin_window, buffer_id)
"[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)
.map_err(|error| format!("cannot select {}: {error}", path.display()))?; .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 { match fire {
editor crate::editor_core::HookKind::AfterLoad => {
.lua_host editor
.run_hook("buffer.after-load", mlua::MultiValue::new()); .lua_host
} else if !newly_created { .run_hook("buffer.after-load", mlua::MultiValue::new());
}
// Dedup is a logical switch even when the fresh view already shares // Dedup is a logical switch even when the fresh view already shares
// this BufferId; configuration must observe it exactly once. // this BufferId; configuration must observe it exactly once.
editor crate::editor_core::HookKind::AfterSwitch => {
.lua_host editor
.run_hook("buffer.after-switch", mlua::MultiValue::new()); .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(); let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id; core.active_frontend = frontend_id;
@ -1669,11 +1686,28 @@ fn open_initial_target(
path.display() 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()))?; .map_err(|error| format!("cannot reselect {}: {error}", path.display()))?;
core.focus_window(frontend_id, destination);
Ok(OpenedInitialTarget { Ok(OpenedInitialTarget {
buffer_id, 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 // `RenderState` vs a `SemanticRenderState` below — a grid session
// collapses folds, a semantic one keeps raw-line reckoning until // collapses folds, a semantic one keeps raw-line reckoning until
// Stage 3. // 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( let fresh_view = build_fresh_frontend_view(
editor, editor,
!session_state.negotiated_capabilities.semantic_render, !session_state.negotiated_capabilities.semantic_render,
peer_declared_panel_support(session_state),
); );
{ {
let mut core = editor.core.borrow_mut(); let mut core = editor.core.borrow_mut();
@ -1850,6 +1890,14 @@ fn handle_session_established(
} }
streams.insert(frontend_id, write_stream); streams.insert(frontend_id, write_stream);
term_sizes.insert(frontend_id, initial_size); 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 { if let Some(opened) = opened_target {
last_active_buffer_sent.insert(frontend_id, opened.buffer_id); 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) { if let Some(ts) = term_sizes.get_mut(&source) {
*ts = size; *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")] #[cfg(feature = "crdt")]
FrontendEvent::CrdtOp { FrontendEvent::CrdtOp {
@ -2938,6 +2993,10 @@ fn build_fresh_frontend_view(
// collapses folds. Passed explicitly from the negotiated // collapses folds. Passed explicitly from the negotiated
// selected-render bit at the call site — never inferred here. // selected-render bit at the call site — never inferred here.
fold_projection: bool, 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 { ) -> crate::window::FrontendView {
use crate::text_view::TextView; use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, Window, WindowId}; use crate::window::{FrontendView, Layout, Window, WindowId};
@ -2946,16 +3005,14 @@ fn build_fresh_frontend_view(
// scratch). M10.8's fresh-scratch behavior made overlays // scratch). M10.8's fresh-scratch behavior made overlays
// never fire because attaching frontends were in distinct // never fire because attaching frontends were in distinct
// buffers. // buffers.
let local_view = core //
.views // Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT
.get(&FrontendId::LOCAL) // buffer, not `local_view.active`. A TUI panel may own focus at
.expect("LOCAL view present"); // attach time, and panel content must never become a newly attached
let local_active_win_id = local_view.active; // frontend's full-window document.
let buffer_id = core let buffer_id = core
.windows .primary_document_buffer(FrontendId::LOCAL)
.get(&local_active_win_id) .expect("LOCAL always retains a document window");
.expect("LOCAL's active window present in core.windows")
.buffer_id;
let text_view = { let text_view = {
let reg = core.registry.borrow(); let reg = core.registry.borrow();
let buf = reg.get(buffer_id).expect("shared buffer present"); let buf = reg.get(buffer_id).expect("shared buffer present");
@ -2968,6 +3025,13 @@ fn build_fresh_frontend_view(
layout: Layout::single(id), layout: Layout::single(id),
active: id, active: id,
fold_projection, 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 semantic_peer = FrontendId(20);
let live_grid_peer = FrontendId(21); let live_grid_peer = FrontendId(21);
let dead_grid_peer = FrontendId(22); let dead_grid_peer = FrontendId(22);
let semantic_view = build_fresh_frontend_view(&mut editor, false); let semantic_view = build_fresh_frontend_view(&mut editor, false, false);
editor editor
.core .core
.borrow_mut() .borrow_mut()
@ -3885,6 +3949,9 @@ mod tests {
layout: Layout::single(wid), layout: Layout::single(wid),
active: wid, active: wid,
fold_projection: true, fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
}, },
); );
} }
@ -4017,7 +4084,7 @@ mod tests {
let fid = FrontendId(99); let fid = FrontendId(99);
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold // Both these fixtures model a SEMANTIC session (Q#FD21: no fold
// projection until Stage 3). // 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); editor.core.borrow_mut().register_frontend_view(fid, view);
let before = editor let before = editor
@ -4080,7 +4147,7 @@ mod tests {
let fid = FrontendId(99); let fid = FrontendId(99);
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold // Both these fixtures model a SEMANTIC session (Q#FD21: no fold
// projection until Stage 3). // 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); editor.core.borrow_mut().register_frontend_view(fid, view);
assert_eq!( assert_eq!(
editor editor
@ -4123,4 +4190,133 @@ mod tests {
"key must self-insert into the displayed buffer, not the attach-time scratch" "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"
);
}
} }

View File

@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop>
let resolve = |wid: WindowId| -> Option<SavedLeaf> { let resolve = |wid: WindowId| -> Option<SavedLeaf> {
let win = core.windows.get(&wid)?; 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()?; let path = reg.get(win.buffer_id).ok()?.file_path()?;
Some(SavedLeaf { Some(SavedLeaf {
path: path.display().to_string(), path: path.display().to_string(),
@ -437,6 +445,12 @@ pub fn restore_into(
active, active,
// Desktop restore rebuilds LOCAL's grid view (Q#FD21). // Desktop restore rebuilds LOCAL's grid view (Q#FD21).
fold_projection: true, 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 active

View File

@ -161,6 +161,16 @@ pub struct EditorState {
/// Last left-button down event, used to synthesize terminal double /// Last left-button down event, used to synthesize terminal double
/// clicks from crossterm's plain Down/Up mouse event stream. /// clicks from crossterm's plain Down/Up mouse event stream.
mouse_click: Option<MouseClickState>, mouse_click: Option<MouseClickState>,
/// 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<FrontendId, WindowDragState>,
} }
#[derive(Default)] #[derive(Default)]
@ -208,8 +218,25 @@ struct MouseClickState {
at: Instant, 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); 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 { impl EditorState {
/// Construct a fresh editor for an unnamed scratch buffer. /// Construct a fresh editor for an unnamed scratch buffer.
/// ///
@ -488,6 +515,16 @@ impl EditorState {
include_str!("../builtin/runtime/indent.lua"), include_str!("../builtin/runtime/indent.lua"),
) )
.expect("load indent builtin chunk"); .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-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over // compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and // `M-g n` / `M-g p` for the unified error dispatchers, and
@ -586,6 +623,7 @@ impl EditorState {
snippets, snippets,
statusline_registry, statusline_registry,
mouse_click: None, mouse_click: None,
window_drag: HashMap::new(),
} }
} }
@ -763,9 +801,73 @@ impl EditorState {
&& !core.search_active() && !core.search_active()
&& !core.query_replace_active() && !core.query_replace_active()
&& !core.menu_is_open() && !core.menu_is_open()
&& core && core.active_window_for(frontend_id).is_some_and(|window| {
.active_window_for(frontend_id) // Bottom-panel arc (Q#BP14a): a focused SIDE window turns
.is_some_and(|window| !core.buffer_round_trips(window.buffer_id)) // 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. /// Local-frontend compatibility wrapper.
@ -777,6 +879,9 @@ impl EditorState {
/// Drop one detached frontend's pending key and terminal escape state. /// Drop one detached frontend's pending key and terminal escape state.
pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) {
self.dispatchers.remove(&frontend_id); 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 self.terminal_manager
.borrow_mut() .borrow_mut()
.detach_frontend(frontend_id); .detach_frontend(frontend_id);
@ -800,6 +905,10 @@ impl EditorState {
// Authenticate every path through this input event, including modal // Authenticate every path through this input event, including modal
// callbacks such as M-x minibuffer acceptance. // callbacks such as M-x minibuffer acceptance.
let _origin = self.interactive_origin.enter(frontend_id); 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 chord = key_event_to_chord(key);
{ {
let mut core = self.core.borrow_mut(); let mut core = self.core.borrow_mut();
@ -1084,6 +1193,10 @@ impl EditorState {
/// ///
/// This is called before process drain and paint, never from rendering. /// 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 { 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 let Some(key) = self
.terminal_manager .terminal_manager
.borrow() .borrow()
@ -1815,6 +1928,29 @@ impl EditorState {
return; 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( let Some((win_id, rect)) = window_at_cell(
&self.core.borrow(), &self.core.borrow(),
frontend_id, frontend_id,
@ -1826,6 +1962,15 @@ impl EditorState {
}; };
let inner_rows = rect.size.rows.saturating_sub(1); let inner_rows = rect.size.rows.saturating_sub(1);
let local_row = cell_row.saturating_sub(rect.origin.row); 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; let buffer_id = self.core.borrow().windows[&win_id].buffer_id;
if self.terminal_manager.borrow().is_terminal(buffer_id) { if self.terminal_manager.borrow().is_terminal(buffer_id) {
let content_size = CellSize::new(inner_rows, rect.size.cols); 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<WindowId, u32> = {
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<crate::buffer::BufferId>) -> 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( fn dispatch_terminal_mouse(
&mut self, &mut self,
key: TerminalViewKey, key: TerminalViewKey,
@ -2368,8 +2639,12 @@ pub(crate) fn window_placements(
return HashMap::new(); return HashMap::new();
}; };
let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); 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 view.layout
.compute(area) .compute(area, &fixed)
.into_iter() .into_iter()
.map(|(window_id, outer)| { .map(|(window_id, outer)| {
let content = Rect::new( let content = Rect::new(
@ -2834,6 +3109,13 @@ pub fn paint_frame(
if term_size.rows < 2 || term_size.cols == 0 { if term_size.rows < 2 || term_size.cols == 0 {
return None; 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 // Statusline callbacks may call arbitrary editor APIs. Evaluate the
// complete visible-window fan-out before the long mutable core borrow // complete visible-window fan-out before the long mutable core borrow
// below, then paint only the transactionally validated owned results. // 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 placements = window_placements(core, frontend_id, term_size);
let active = core.views.get(&frontend_id)?.active; 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<WindowId> =
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 // Clear the whole grid first so windows that shrink on resize
// don't leak the old contents. // don't leak the old contents.
@ -3094,6 +3392,12 @@ pub fn paint_frame(
} }
drop(reg); drop(reg);
for id in &divider_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); paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme);
// An active isearch owns the bottom row (its prompt + match // 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() 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<crate::cell::Style>,
) {
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 /// Paint complete graphemes at a logical signed origin. A grapheme that
/// straddles either clip edge is omitted wholesale, so a wide glyph can never /// 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. /// leave a dangling half-cell at a window or left/right collision boundary.
@ -6360,7 +6691,8 @@ mod tests {
let core = s.core.borrow(); let core = s.core.borrow();
assert_eq!(core.windows.len(), 8); assert_eq!(core.windows.len(), 8);
let area = crate::window::Rect::new(0, 0, 40, 120); 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); assert_eq!(placements.len(), 8);
for r in placements.values() { for r in placements.values() {
assert!(!r.is_empty(), "rect was empty: {r:?}"); assert!(!r.is_empty(), "rect was empty: {r:?}");
@ -6776,16 +7108,14 @@ mod tests {
} else { } else {
panic!("expected split"); panic!("expected split");
} }
let p1 = s let p1 = s.core.borrow().active_layout().compute(
.core crate::window::Rect::new(0, 0, 24, 90),
.borrow() &std::collections::HashMap::new(),
.active_layout() );
.compute(crate::window::Rect::new(0, 0, 24, 90)); let p2 = s.core.borrow().active_layout().compute(
let p2 = s crate::window::Rect::new(0, 0, 24, 60),
.core &std::collections::HashMap::new(),
.borrow() );
.active_layout()
.compute(crate::window::Rect::new(0, 0, 24, 60));
// Both should preserve the 2:1 ratio. Find the two windows // Both should preserve the 2:1 ratio. Find the two windows
// and verify the larger:smaller ratio is 2:1 in both. // and verify the larger:smaller ratio is 2:1 in both.
let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); let wider1 = p1.values().map(|r| r.size.cols).max().unwrap();

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,7 @@ mod diag;
mod fold; mod fold;
mod index; mod index;
mod mcp; mod mcp;
mod window_panel;
// Every `pub` item a moved domain owned is re-exported so its prior // Every `pub` item a moved domain owned is re-exported so its prior
// `crate::lua_bindings::<item>` path still resolves — the split must not // `crate::lua_bindings::<item>` path still resolves — the split must not
// shrink the public API surface. That includes the `install_*` wiring fns: // 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<BufferId>, fallback: u32) -> u32 {
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() 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. /// Short-circuit a binding when the init phase has completed.
/// ///
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) /// 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::<SharedHookRegistry>() { let snapshot = match lua.app_data_ref::<SharedHookRegistry>() {
Some(hooks) => hooks.borrow().snapshot(name), Some(hooks) => hooks.borrow().snapshot(name),
None => None, None => None,
@ -8467,6 +8488,11 @@ fn install_terminal(
manager: &crate::terminal::SharedTerminalManager, manager: &crate::terminal::SharedTerminalManager,
supervisor: &SharedProcessSupervisor, supervisor: &SharedProcessSupervisor,
) -> mlua::Result<()> { ) -> 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 pmacs: Table = lua.globals().get("pmacs")?;
let terminal = lua.create_table()?; let terminal = lua.create_table()?;
@ -8475,8 +8501,8 @@ fn install_terminal(
let supervisor = supervisor.clone(); let supervisor = supervisor.clone();
terminal.set( terminal.set(
"_open", "_open",
lua.create_function(move |lua, spec: Table| -> mlua::Result<BufferIdLua> { lua.create_function(move |lua, spec_table: Table| -> mlua::Result<BufferIdLua> {
let spec = parse_terminal_spec(&spec)?; let spec = parse_terminal_spec(&spec_table)?;
let core = lua let core = lua
.app_data_ref::<SharedCore>() .app_data_ref::<SharedCore>()
.map(|core| core.clone()) .map(|core| core.clone())
@ -8489,37 +8515,54 @@ fn install_terminal(
"pmacs.terminal.open: target frontend has no active window", "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::<Option<String>>("display")?.as_deref(),
spec_table.get::<Option<u64>>("window")?,
)?;
let buffer_id = { let buffer_id = {
let mut manager = manager.borrow_mut(); let mut manager = manager.borrow_mut();
manager manager
.open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut())
.map_err(mlua::Error::external)? .map_err(mlua::Error::external)?
}; };
let key = { let outcome = match window_panel::place_adopter_buffer(
let mut core = core.borrow_mut(); lua,
if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { &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); let _ = core.registry.borrow_mut().remove(buffer_id);
manager manager
.borrow_mut() .borrow_mut()
.prune(&mut core, &mut supervisor.borrow_mut()); .prune(&mut core, &mut supervisor.borrow_mut());
return Err(mlua::Error::external(format!( return Err(error);
"pmacs.terminal.open: active-window switch failed: {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 claimed = {
let mut manager = manager.borrow_mut(); let mut manager = manager.borrow_mut();
manager.register_view(key) && manager.claim_controller(key) manager.register_view(key) && manager.claim_controller(key)
}; };
if !claimed { 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 mut core = core.borrow_mut();
let _ = core.registry.borrow_mut().remove(buffer_id); let _ = core.registry.borrow_mut().remove(buffer_id);
manager manager
@ -8529,7 +8572,7 @@ fn install_terminal(
"pmacs.terminal.open: failed to claim the new terminal view", "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)) Ok(BufferIdLua(buffer_id))
})?, })?,
)?; )?;
@ -8691,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result<crate::terminal::TerminalS
"rows", "rows",
"cols", "cols",
"scrollback_rows", "scrollback_rows",
// Bottom-panel arc (Q#BP11b): placement, parsed separately by
// `_open` and never part of the child's `TerminalSpec`.
"display",
"window",
]; ];
let mut unknown = None; let mut unknown = None;
table.clone().for_each(|key: Value, _: Value| { table.clone().for_each(|key: Value, _: Value| {
@ -12166,16 +12213,25 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs {
)] )]
fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> { fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
let win = lua.create_table()?; 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(); let cc = core.clone();
win.set( win.set(
"split_horizontal", "split_horizontal",
lua.create_function(move |_, ()| { lua.create_function(move |_, ()| {
let new_id = cc cc.borrow_mut()
.borrow_mut() .try_split_active(crate::window::Orientation::Horizontal, true)
.split_active(crate::window::Orientation::Horizontal, true); .map(crate::window::WindowId::raw)
Ok(new_id.raw()) .map_err(mlua::Error::runtime)
})?, })?,
)?; )?;
} }
@ -12185,10 +12241,10 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
win.set( win.set(
"split_vertical", "split_vertical",
lua.create_function(move |_, ()| { lua.create_function(move |_, ()| {
let new_id = cc cc.borrow_mut()
.borrow_mut() .try_split_active(crate::window::Orientation::Vertical, true)
.split_active(crate::window::Orientation::Vertical, true); .map(crate::window::WindowId::raw)
Ok(new_id.raw()) .map_err(mlua::Error::runtime)
})?, })?,
)?; )?;
} }
@ -12271,8 +12327,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
win.set( win.set(
"close_others", "close_others",
lua.create_function(move |_, ()| { lua.create_function(move |_, ()| {
cc.borrow_mut().close_others(); cc.borrow_mut().close_others().map_err(mlua::Error::runtime)
Ok(())
})?, })?,
)?; )?;
} }
@ -12302,9 +12357,41 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
{ {
let cc = core.clone(); 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( win.set(
"buffer", "buffer",
lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?, lua.create_function(
move |lua, target: Option<u64>| -> mlua::Result<BufferIdLua> {
// 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"))
},
)?,
)?; )?;
} }

View File

@ -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::<crate::editor::InteractiveCommandOrigin>()
.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::<crate::terminal::SharedTerminalManager>() 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<Table>,
) -> mlua::Result<DisplayRequest> {
let mut request = DisplayRequest::new(buffer_id);
let Some(opts) = opts else {
return Ok(request);
};
if let Some(side) = opts.get::<Option<String>>("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::<Option<u64>>("window")? {
request.window = Some(lookup_window(core, fid, raw)?);
}
if let Some(height) = opts.get::<Option<u32>>("height")? {
request.height = Some(height);
}
if let Some(dedicated) = opts.get::<Option<bool>>("dedicated")? {
request.dedicated = Some(dedicated);
}
if let Some(select) = opts.get::<Option<bool>>("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<WindowId> {
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<WindowId> {
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 12 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<u64>,
) -> mlua::Result<AdopterPlacement> {
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<DisplayOutcome> {
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<Table>)| -> mlua::Result<u64> {
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<Table>)| -> mlua::Result<u64> {
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::<Option<u64>>("window")? {
explicit_window = Some(lookup_window(&cc, fid, raw)?);
}
select = opts.get::<Option<bool>>("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<u64> {
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<Option<u64>> {
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<u64>| -> 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<u64>| -> mlua::Result<Table> {
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::<Value>(key)? != Value::Nil {
return Err(mlua::Error::runtime(format!(
"pmacs.window.set_params: `{key}` is not settable"
)));
}
}
let height = match opts.get::<Option<u32>>("fixed_rows")? {
Some(rows) => Some(
crate::editor_core::EditorCore::clamp_panel_rows(rows)
.map_err(mlua::Error::runtime)?,
),
None => None,
};
let dedicated = opts.get::<Option<bool>>("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<u64>, 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<WindowId, u32> = {
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(())
}

View File

@ -109,7 +109,12 @@ pub fn paint_other_frontend_overlays(
return; return;
} }
let text_area = Rect::new(0, 0, text_rows, term_size.cols); 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 registry = core.registry.clone();
let reg = registry.borrow(); let reg = registry.borrow();

View File

@ -132,8 +132,7 @@ impl TerminalManager {
last_bell_count: bell_count, last_bell_count: bell_count,
..TerminalViewState::default() ..TerminalViewState::default()
}); });
normalize_state(state, projection); declare_view_size(state, projection, viewport_size);
state.viewport_size = Some(viewport_size);
Some(project_snapshot( Some(project_snapshot(
key.buffer_id, key.buffer_id,
viewport_size, viewport_size,
@ -234,8 +233,7 @@ impl TerminalManager {
last_bell_count: bell_count, last_bell_count: bell_count,
..TerminalViewState::default() ..TerminalViewState::default()
}); });
normalize_state(state, projection); declare_view_size(state, projection, viewport_size);
state.viewport_size = Some(viewport_size);
let rows = retained_rows(projection); let rows = retained_rows(projection);
let geometry = view_geometry(&rows, state, viewport_size.rows); let geometry = view_geometry(&rows, state, viewport_size.rows);
Some(TerminalViewStatus { Some(TerminalViewStatus {
@ -289,8 +287,7 @@ impl TerminalManager {
last_bell_count: bell_count, last_bell_count: bell_count,
..TerminalViewState::default() ..TerminalViewState::default()
}); });
normalize_state(state, projection); declare_view_size(state, projection, viewport_size);
state.viewport_size = Some(viewport_size);
true true
} }
@ -595,6 +592,56 @@ fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<
.then(|| row_lead(first)) .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<'_>) { fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) {
if state if state
.alternate_active .alternate_active

View File

@ -154,6 +154,201 @@ pub fn decimal_digits(mut n: usize) -> u32 {
d 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<Self> {
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<u32>,
/// Saved region, if one was active.
selection: Option<Selection>,
/// The action that was in force *before* this presentation
/// replaced its predecessor.
then: Box<QuitAction>,
},
}
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<Side>,
/// 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<u32>,
/// 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<QuitAction>,
/// See [`WindowParams::origin_document`].
origin_document: Option<WindowId>,
}
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<QuitAction>) {
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<WindowId> {
self.origin_document
}
/// Record (or clear) the remembered document window. Rust-internal.
pub fn set_origin_document(&mut self, origin: Option<WindowId>) {
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. /// One leaf of the window tree: a buffer plus per-window state.
pub struct Window { pub struct Window {
/// Unique identifier. /// Unique identifier.
@ -186,6 +381,9 @@ pub struct Window {
/// Line-number gutter mode for this window (UX gutter arc). `Off` by /// Line-number gutter mode for this window (UX gutter arc). `Off` by
/// default → no gutter, no coordinate change. /// default → no gutter, no coordinate change.
pub line_numbers: LineNumberMode, 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 { impl Window {
@ -204,9 +402,16 @@ impl Window {
goal_col: None, goal_col: None,
last_visible_rows: 0, last_visible_rows: 0,
line_numbers: LineNumberMode::Off, 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` /// Width in cells this window's line-number gutter occupies, or `0`
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
/// the renderer caps this against the window width and applies it as a /// the renderer caps this against the window width and applies it as a
@ -305,6 +510,23 @@ pub struct Layout {
pub root: LayoutNode, 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. /// T M10.8 — one attached frontend's view of the editor.
/// ///
/// Per-frontend state for multi-frontend operation: the split tree /// 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 /// explicitly, so the projection is never inferred from a
/// `FrontendId` (**Bet B8**). /// `FrontendId` (**Bet B8**).
pub fold_projection: bool, 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<DeclaredFrameGeometry>,
/// 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 { impl Layout {
@ -359,17 +607,75 @@ impl Layout {
/// Walk the tree and assign each leaf a viewport rectangle. /// Walk the tree and assign each leaf a viewport rectangle.
/// ///
/// Splits divide proportionally according to their weights. If a /// Splits divide proportionally according to their weights, except
/// child's allocated extent is `0` (terminal too small for the /// 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 /// split), that child receives an empty rect, and renderers must
/// skip it. /// skip it.
#[must_use] #[must_use]
pub fn compute(&self, area: Rect) -> HashMap<WindowId, Rect> { pub fn compute(&self, area: Rect, fixed: &HashMap<WindowId, u32>) -> HashMap<WindowId, Rect> {
let mut out = HashMap::new(); let mut out = HashMap::new();
compute_node(&self.root, area, &mut out); compute_node(&self.root, area, fixed, &mut out);
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<WindowId> {
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. /// All [`WindowId`]s in left→right / top→bottom order.
#[must_use] #[must_use]
pub fn iter_ids(&self) -> Vec<WindowId> { pub fn iter_ids(&self) -> Vec<WindowId> {
@ -414,25 +720,211 @@ impl Layout {
/// if the layout has only one window. /// if the layout has only one window.
#[must_use] #[must_use]
pub fn focus_next(&self, current: WindowId) -> WindowId { pub fn focus_next(&self, current: WindowId) -> WindowId {
let ids = self.iter_ids(); self.focus_step(current, true, &|_| true)
match ids.iter().position(|&id| id == current) {
Some(i) => ids[(i + 1) % ids.len()],
None => *ids.first().unwrap_or(&current),
}
} }
/// Step focus to the previous window. /// Step focus to the previous window.
#[must_use] #[must_use]
pub fn focus_prev(&self, current: WindowId) -> WindowId { 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(); let ids = self.iter_ids();
match ids.iter().position(|&id| id == current) { if ids.is_empty() {
Some(i) => ids[(i + ids.len() - 1) % ids.len()], return current;
None => *ids.first().unwrap_or(&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(&current));
};
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<Vec<usize>> {
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<SplitBoundary> {
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<usize>,
/// 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<usize>) -> 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<WindowId, Rect>) { /// 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<WindowId, u32>,
out: &mut HashMap<WindowId, Rect>,
) {
match node { match node {
LayoutNode::Leaf(id) => { LayoutNode::Leaf(id) => {
out.insert(*id, area); out.insert(*id, area);
@ -442,18 +934,66 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
weights, weights,
children, children,
} => { } => {
let total: u32 = weights.iter().map(|w| (*w).max(1)).sum();
let primary = match orientation { let primary = match orientation {
Orientation::Horizontal => area.size.rows, Orientation::Horizontal => area.size.rows,
Orientation::Vertical => area.size.cols, 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<Option<u32>> = 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; let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() { for (i, child) in children.iter().enumerate() {
let w = weights.get(i).copied().unwrap_or(1).max(1); let extent = if let Some(rows) = extents[i] {
let extent = if i + 1 == children.len() { rows
primary - cursor
} else { } 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 { let child_area = match orientation {
Orientation::Horizontal => Rect { Orientation::Horizontal => Rect {
@ -465,13 +1005,21 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
size: CellSize::new(area.size.rows, extent), size: CellSize::new(area.size.rows, extent),
}, },
}; };
compute_node(child, child_area, out); compute_node(child, child_area, fixed, out);
cursor += extent; cursor += extent;
} }
} }
} }
} }
/// Every [`WindowId`] beneath `node`, in layout order.
#[must_use]
pub fn node_ids(node: &LayoutNode) -> Vec<WindowId> {
let mut out = Vec::new();
collect_ids(node, &mut out);
out
}
fn collect_ids(node: &LayoutNode, out: &mut Vec<WindowId>) { fn collect_ids(node: &LayoutNode, out: &mut Vec<WindowId>) {
match node { match node {
LayoutNode::Leaf(id) => out.push(*id), LayoutNode::Leaf(id) => out.push(*id),
@ -598,7 +1146,7 @@ mod tests {
fn single_window_takes_full_area() { fn single_window_takes_full_area() {
let w = id(); let w = id();
let layout = Layout::single(w); 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())); assert_eq!(placements.get(&w), Some(&rect_24x80()));
} }
@ -608,7 +1156,7 @@ mod tests {
let b = id(); let b = id();
let mut layout = Layout::single(a); let mut layout = Layout::single(a);
assert!(layout.split_window(a, Orientation::Vertical, b)); 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 ra = placements[&a];
let rb = placements[&b]; let rb = placements[&b];
assert_eq!(ra.size.rows, 24); assert_eq!(ra.size.rows, 24);
@ -624,7 +1172,7 @@ mod tests {
let b = id(); let b = id();
let mut layout = Layout::single(a); let mut layout = Layout::single(a);
assert!(layout.split_window(a, Orientation::Horizontal, b)); 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 ra = placements[&a];
let rb = placements[&b]; let rb = placements[&b];
assert_eq!(ra.size.cols, 80); assert_eq!(ra.size.cols, 80);
@ -644,15 +1192,15 @@ mod tests {
} else { } else {
panic!("expected split"); 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[&a].size.cols, 60);
assert_eq!(p1[&b].size.cols, 30); assert_eq!(p1[&b].size.cols, 30);
// Resize down by 1/3. // 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[&a].size.cols, 40);
assert_eq!(p2[&b].size.cols, 20); assert_eq!(p2[&b].size.cols, 20);
// Resize wide. // 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[&a].size.cols, 200);
assert_eq!(p3[&b].size.cols, 100); assert_eq!(p3[&b].size.cols, 100);
} }
@ -681,7 +1229,7 @@ mod tests {
} }
leaves.extend(more); leaves.extend(more);
assert_eq!(leaves.len(), 8); assert_eq!(leaves.len(), 8);
let placements = layout.compute(rect_24x80()); let placements = layout.compute(rect_24x80(), &HashMap::new());
assert_eq!(placements.len(), 8); assert_eq!(placements.len(), 8);
// Every rect must be non-empty (terminal large enough). // Every rect must be non-empty (terminal large enough).
for id in &leaves { for id in &leaves {

File diff suppressed because it is too large Load Diff

View File

@ -1516,6 +1516,9 @@ fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> W
layout: Layout::single(win_id), layout: Layout::single(win_id),
active: win_id, active: win_id,
fold_projection, fold_projection,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
}, },
); );
win_id win_id

View File

@ -465,6 +465,9 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() {
layout: pmacs::window::Layout::single(window_id), layout: pmacs::window::Layout::single(window_id),
active: window_id, active: window_id,
fold_projection: true, fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
}, },
); );
} }

View File

@ -82,6 +82,9 @@ fn attach_view(
layout: Layout::single(window_id), layout: Layout::single(window_id),
active: window_id, active: window_id,
fold_projection: true, fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
}, },
); );
window_id window_id