feat(window): adopter placement opt-in and the Stage 1 acceptance suite

- `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the
  same strict `display = "current" | "panel"`, validated before any
  buffer, session, process, or wrapper exists. Omission keeps today's
  behavior; Stage 3 flips the default.
- `listview.quit` / `compile.quit` delegate to `window.quit` only when
  the buffer really is in a side window, so the presentation is deleted
  or restored instead of leaving a source buffer stranded in the slot.
- LSP `visit_location`, LSP go-to-definition, and compile `visit_error`
  route through `display_file`, so a visit from a panel lands in the
  document target and fires its hook with that window active.
- `window.quit`'s Delete arm focuses the revalidated remembered origin.
- Capability fallback discards an accompanying `height` rather than
  rejecting the call.
- `window.min-height` clamps a below-floor value on read instead of
  refusing the write.
- `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's
  Stage 1 criteria, including the two production `Layout::compute`
  callers, the recursive minima, hide/reappear, the final-focus matrix,
  quit chains at the depth cap, per-frontend jump origins, the divider,
  and a real-PTY pin of Bet B1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 14:10:45 -04:00
parent 6c8a76e235
commit 683c9b86aa
11 changed files with 2646 additions and 171 deletions

View File

@ -734,6 +734,16 @@ end)
-- has its own worker path. -- has its own worker path.
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 +815,16 @@ 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 simply reuses the
-- panel it is already in.
if display == "panel" then
pmacs.window.display(slot.buf, { side = "bottom", select = false })
else
pmacs.window.switch_buffer(slot.buf)
end
if not ok then 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 +885,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 +1016,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,25 @@ 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
p.side = true
pmacs.window.display(p.buffer, { side = "bottom", select = true })
else
p.side = false
pmacs.window.switch_buffer(p.buffer)
end
seat_cursor(p, 1) seat_cursor(p, 1)
end end
@ -160,6 +178,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

@ -27,12 +27,17 @@ pmacs.config.define {
-- (drag and the commands below) and is deliberately ignored by the -- (drag and the commands below) and is deliberately ignored by the
-- ordinary layout pass and by frame-resize reconciliation, so raising it -- ordinary layout pass and by frame-resize reconciliation, so raising it
-- can never invalidate a layout that already exists. -- 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 { pmacs.config.define {
name = "window.min-height", name = "window.min-height",
description = "Smallest outer rows interactive resize will leave a window.", description = "Smallest outer rows interactive resize will leave a window.",
type = "integer", type = "integer",
default = 2, default = 2,
min = 2, min = 1,
mutability = "live", mutability = "live",
} }

View File

@ -905,7 +905,7 @@ fn peer_declared_terminal_support(
/// from the daemon's own negotiated state, and Stage 2 turns the version /// from the daemon's own negotiated state, and Stage 2 turns the version
/// arm on (`semantic_render && negotiated_protocol_version >= /// arm on (`semantic_render && negotiated_protocol_version >=
/// PANEL_MIN_VERSION`). /// PANEL_MIN_VERSION`).
fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
!session_state.negotiated_capabilities.semantic_render !session_state.negotiated_capabilities.semantic_render
} }
@ -1808,7 +1808,7 @@ fn handle_session_established(
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), peer_declared_panel_support(session_state),
); );
{ {
let mut core = editor.core.borrow_mut(); let mut core = editor.core.borrow_mut();

View File

@ -841,14 +841,13 @@ impl EditorState {
.get(&window_id) .get(&window_id)
.map(|window| window.buffer_id); .map(|window| window.buffer_id);
if let Some(buffer_id) = buffer_id { if let Some(buffer_id) = buffer_id {
let _ = self let _ = self.terminal_manager.borrow_mut().release_controller(
.terminal_manager crate::terminal::TerminalViewKey {
.borrow_mut()
.release_controller(crate::terminal::TerminalViewKey {
frontend_id, frontend_id,
window_id, window_id,
buffer_id, buffer_id,
}); },
);
} }
} }
outcome.changed outcome.changed
@ -1931,7 +1930,8 @@ impl EditorState {
MouseEventKind::Drag(MouseButton::Left) => { MouseEventKind::Drag(MouseButton::Left) => {
self.drag_window_boundary(frontend_id, cell_row, term_size); self.drag_window_boundary(frontend_id, cell_row, term_size);
} }
MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, // Any other event — release, a different button, a
// wheel notch — ends the gesture.
_ => self.window_drag = None, _ => self.window_drag = None,
} }
return; return;
@ -2062,9 +2062,12 @@ impl EditorState {
/// Arm a divider drag if `owner`'s bottom row really is an exposed /// Arm a divider drag if `owner`'s bottom row really is an exposed
/// segment of a horizontal boundary (Q#BP5). /// segment of a horizontal boundary (Q#BP5).
fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { 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| { let is_divider = self
view.layout.boundary_below(owner).is_some() .core
}); .borrow()
.views
.get(&frontend_id)
.is_some_and(|view| view.layout.boundary_below(owner).is_some());
self.window_drag = is_divider.then_some(WindowDragState { self.window_drag = is_divider.then_some(WindowDragState {
frontend_id, frontend_id,
owner, owner,
@ -2078,14 +2081,22 @@ impl EditorState {
/// layout mutation mid-drag cannot move a boundary that no longer /// layout mutation mid-drag cannot move a boundary that no longer
/// exists. Motion is applied incrementally and re-anchored each /// exists. Motion is applied incrementally and re-anchored each
/// event, so the clamp absorbs over-travel instead of accumulating it. /// event, so the clamp absorbs over-travel instead of accumulating it.
fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { fn drag_window_boundary(
&mut self,
frontend_id: FrontendId,
cell_row: u32,
term_size: CellSize,
) {
let Some(drag) = self.window_drag else { let Some(drag) = self.window_drag else {
return; return;
}; };
if drag.frontend_id != frontend_id { if drag.frontend_id != frontend_id {
return; return;
} }
self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); self.window_drag = Some(WindowDragState {
last_row: cell_row,
..drag
});
let delta = i64::from(cell_row) - i64::from(drag.last_row); let delta = i64::from(cell_row) - i64::from(drag.last_row);
let Ok(delta) = i32::try_from(delta) else { let Ok(delta) = i32::try_from(delta) else {
return; return;
@ -3126,13 +3137,14 @@ pub fn paint_frame(
// upper child is a nested subtree exposes SEVERAL leaf segments along // 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 same edge, so the root panel divider is full width even when
// the document subtree ends in several columns. // the document subtree ends in several columns.
let divider_windows: Vec<WindowId> = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { let divider_windows: Vec<WindowId> =
view.layout core.views.get(&frontend_id).map_or_else(Vec::new, |view| {
.iter_ids() view.layout
.into_iter() .iter_ids()
.filter(|id| view.layout.boundary_below(*id).is_some()) .into_iter()
.collect() .filter(|id| view.layout.boundary_below(*id).is_some())
}); .collect()
});
let divider_style = theme.face("ui.divider"); 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
@ -7073,22 +7085,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( let p2 = s.core.borrow().active_layout().compute(
crate::window::Rect::new(0, 0, 24, 90), crate::window::Rect::new(0, 0, 24, 60),
&std::collections::HashMap::new(), &std::collections::HashMap::new(),
); );
let p2 = s
.core
.borrow()
.active_layout()
.compute(
crate::window::Rect::new(0, 0, 24, 60),
&std::collections::HashMap::new(),
);
// Both should preserve the 2:1 ratio. Find the two windows // 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();

View File

@ -882,10 +882,7 @@ impl EditorCore {
/// ///
/// # Errors /// # Errors
/// Any load failure other than `NotFound`. /// Any load failure other than `NotFound`.
pub fn resolve_target_buffer( pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> {
&mut self,
path: &Path,
) -> Result<(BufferId, HookKind), String> {
match self.get_or_load_buffer(path) { match self.get_or_load_buffer(path) {
Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)),
Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)),
@ -1060,11 +1057,7 @@ impl EditorCore {
pub fn jump_back(&mut self) -> bool { pub fn jump_back(&mut self) -> bool {
let fid = self.active_frontend; let fid = self.active_frontend;
loop { loop {
let Some(entry) = self let Some(entry) = self.jump_ring.get_mut(&fid).and_then(std::vec::Vec::pop) else {
.jump_ring
.get_mut(&fid)
.and_then(std::vec::Vec::pop)
else {
return false; return false;
}; };
if !self.registry.borrow().contains(entry.buffer_id) { if !self.registry.borrow().contains(entry.buffer_id) {
@ -1081,12 +1074,20 @@ impl EditorCore {
&& !self.side_window_is_hidden(fid, entry.window_id); && !self.side_window_is_hidden(fid, entry.window_id);
if origin_valid { if origin_valid {
self.set_active_window_id(entry.window_id); self.set_active_window_id(entry.window_id);
} else if entry.side_origin { } else {
continue; // A stale SIDE origin is skipped outright: switching a
} else if self.active_buffer_id() != entry.buffer_id // panel's buffer into the document window is exactly the
&& self.switch_active_buffer(entry.buffer_id).is_err() // duplicate-presentation corruption this design removes.
{ // A stale non-side origin keeps today's active-window
continue; // fallback.
if entry.side_origin {
continue;
}
if self.active_buffer_id() != entry.buffer_id
&& self.switch_active_buffer(entry.buffer_id).is_err()
{
continue;
}
} }
let clamped = entry.position.min(self.active_buffer_len()); let clamped = entry.position.min(self.active_buffer_len());
let aw = self.active_window_mut(); let aw = self.active_window_mut();
@ -2713,10 +2714,8 @@ impl EditorCore {
} }
self.active_layout_mut().close_window(target); self.active_layout_mut().close_window(target);
self.windows.remove(&target); self.windows.remove(&target);
if target_is_side { if target_is_side && let Some(view) = self.views.get_mut(&fid) {
if let Some(view) = self.views.get_mut(&fid) { view.panel_hidden = false;
view.panel_hidden = false;
}
} }
// Pick an adjacent window as the new focus, preferring a document. // Pick an adjacent window as the new focus, preferring a document.
let ids = self.active_layout().iter_ids(); let ids = self.active_layout().iter_ids();
@ -3002,10 +3001,9 @@ impl EditorCore {
self.windows.remove(&side); self.windows.remove(&side);
if was_active if was_active
&& let Ok(target) = self.non_side_target(fid) && let Ok(target) = self.non_side_target(fid)
&& let Some(view) = self.views.get_mut(&fid)
{ {
if let Some(view) = self.views.get_mut(&fid) { view.active = target;
view.active = target;
}
} }
// A remembered origin pointing at a now-dead window is cleared by // A remembered origin pointing at a now-dead window is cleared by
// `non_side_target`'s revalidation on next use; nothing else here // `non_side_target`'s revalidation on next use; nothing else here
@ -3050,14 +3048,34 @@ impl EditorCore {
}; };
match action { match action {
QuitAction::Delete => { QuitAction::Delete => {
let saved_active = self.views.get(&fid).map(|view| view.active); // Capture the remembered origin BEFORE the window dies:
// executing `Delete` focuses the revalidated origin, not
// merely whatever leaf the wrapper collapse surfaced
// (Q#BP11b). Entering the panel from document window B
// must therefore return focus to B, not to the window
// that happened to create the panel.
let origin = self
.windows
.get(&target)
.and_then(|window| window.params.origin_document());
self.remove_side_window(fid, target); self.remove_side_window(fid, target);
Ok(QuitOutcome::Deleted { let origin_valid = origin.is_some_and(|origin| {
focus: self self.views
.views
.get(&fid) .get(&fid)
.map(|view| view.active) .is_some_and(|view| view.layout.iter_ids().contains(&origin))
.or(saved_active), && !self
.windows
.get(&origin)
.is_some_and(crate::window::Window::is_side)
});
if origin_valid
&& let Some(origin) = origin
&& let Some(view) = self.views.get_mut(&fid)
{
view.active = origin;
}
Ok(QuitOutcome::Deleted {
focus: self.views.get(&fid).map(|view| view.active),
}) })
} }
QuitAction::Restore { QuitAction::Restore {
@ -3073,7 +3091,7 @@ impl EditorCore {
self.install_buffer_in_window(target, buffer_id)?; self.install_buffer_in_window(target, buffer_id)?;
let len = { let len = {
let reg = self.registry.borrow(); let reg = self.registry.borrow();
reg.get(buffer_id).map(Buffer::len).unwrap_or(0) reg.get(buffer_id).map_or(0, Buffer::len)
}; };
let window = self let window = self
.windows .windows
@ -3091,10 +3109,7 @@ impl EditorCore {
window.view_top = view_top; window.view_top = view_top;
window.goal_col = goal_col; window.goal_col = goal_col;
window.selection = selection.filter(|sel| sel.anchor <= len); window.selection = selection.filter(|sel| sel.anchor <= len);
Ok(QuitOutcome::Restored { Ok(QuitOutcome::Restored { target, buffer_id })
target,
buffer_id,
})
} }
} }
} }
@ -3117,8 +3132,7 @@ impl EditorCore {
#[must_use] #[must_use]
pub fn frontend_area_rows(&self, fid: FrontendId) -> Option<u32> { pub fn frontend_area_rows(&self, fid: FrontendId) -> Option<u32> {
let geometry = self.views.get(&fid)?.frame_geometry?; let geometry = self.views.get(&fid)?.frame_geometry?;
(geometry.total.rows >= 2 && geometry.total.cols > 0) (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1)
.then(|| geometry.total.rows - 1)
} }
/// Cache a frontend's authoritative frame capacity (Q#BP2b). /// Cache a frontend's authoritative frame capacity (Q#BP2b).
@ -3207,6 +3221,10 @@ impl EditorCore {
/// # Errors /// # Errors
/// When `win` is not live in `fid`'s layout, when the panel is /// When `win` is not live in `fid`'s layout, when the panel is
/// hidden, or when no adjustable horizontal boundary exists. /// hidden, or when no adjustable horizontal boundary exists.
#[allow(
clippy::too_many_lines,
reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back"
)]
pub fn resize_boundary( pub fn resize_boundary(
&mut self, &mut self,
fid: FrontendId, fid: FrontendId,
@ -3260,9 +3278,9 @@ impl EditorCore {
) )
} else { } else {
( (
view.layout view.layout.boundary_below(win).ok_or_else(|| {
.boundary_below(win) "window.resize: no adjustable horizontal boundary".to_string()
.ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, })?,
false, false,
) )
}; };
@ -3423,13 +3441,7 @@ impl EditorCore {
target: placement.target, target: placement.target,
saved_active, saved_active,
select, select,
created_side: matches!( created_side: matches!(placement.kind, PlacementKind::Side { created: true, .. }),
placement.kind,
PlacementKind::Side {
created: true,
..
}
),
}) })
} }
@ -3494,7 +3506,12 @@ impl EditorCore {
if let Ok(preferred) = self.non_side_target(fid) { if let Ok(preferred) = self.non_side_target(fid) {
candidates.push(preferred); candidates.push(preferred);
} }
candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); candidates.extend(
view.layout
.iter_ids()
.into_iter()
.filter(|id| !is_side(*id)),
);
candidates candidates
.into_iter() .into_iter()
.find(|id| eligible(*id)) .find(|id| eligible(*id))
@ -3506,6 +3523,10 @@ impl EditorCore {
/// otherwise a persistent `*compilation*` buffer already visible in a /// otherwise a persistent `*compilation*` buffer already visible in a
/// document window makes `{side = "bottom"}` silently ignore its /// document window makes `{side = "bottom"}` silently ignore its
/// requested placement. /// requested placement.
#[allow(
clippy::too_many_lines,
reason = "Q#BP3's precedence ladder reads as one ordered policy"
)]
fn resolve_placement( fn resolve_placement(
&self, &self,
fid: FrontendId, fid: FrontendId,
@ -3594,7 +3615,12 @@ impl EditorCore {
}); });
} }
} }
} else if request.height.is_some() { } else if request.side.is_none() && request.height.is_some() {
// A freestanding `height` with no side request is a mistake.
// A `height` that arrived WITH a side request and fell
// through (not panel-capable, or the one slot is dedicated
// elsewhere) is discarded, not rejected — capability
// fallback must not turn into an error (Q#BP2c).
return Err("display: `height` requires a side window".into()); return Err("display: `height` requires a side window".into());
} }
@ -3624,11 +3650,17 @@ impl EditorCore {
if let Ok(preferred) = self.non_side_target(fid) { if let Ok(preferred) = self.non_side_target(fid) {
candidates.push(preferred); candidates.push(preferred);
} }
candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); candidates.extend(
view.layout
.iter_ids()
.into_iter()
.filter(|id| !is_side(*id)),
);
for candidate in candidates { for candidate in candidates {
let eligible = self.windows.get(&candidate).is_some_and(|w| { let eligible = self
!w.params.dedicated || w.buffer_id == request.buffer_id .windows
}); .get(&candidate)
.is_some_and(|w| !w.params.dedicated || w.buffer_id == request.buffer_id);
if eligible { if eligible {
return Ok(Placement { return Ok(Placement {
target: candidate, target: candidate,
@ -3681,7 +3713,8 @@ impl EditorCore {
let requested_side = request.side.unwrap_or(Side::Bottom); let requested_side = request.side.unwrap_or(Side::Bottom);
if created { if created {
let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; let rows =
Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?;
let origin = self.non_side_target(fid).ok(); let origin = self.non_side_target(fid).ok();
let text_view = { let text_view = {
let reg = self.registry.borrow(); let reg = self.registry.borrow();

View File

@ -8501,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())
@ -8515,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
@ -8555,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))
})?, })?,
)?; )?;
@ -8717,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| {
@ -12300,9 +12321,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() cc.borrow_mut().close_others().map_err(mlua::Error::runtime)
.close_others()
.map_err(mlua::Error::runtime)
})?, })?,
)?; )?;
} }

View File

@ -120,10 +120,11 @@ fn complete_display(
let target_ok = visible(core, fid, outcome.target); let target_ok = visible(core, fid, outcome.target);
let saved_ok = visible(core, fid, outcome.saved_active); let saved_ok = visible(core, fid, outcome.saved_active);
let final_focus = match (outcome.select, target_ok, saved_ok) { let final_focus = match (outcome.select, target_ok, saved_ok) {
(true, true, _) => Some(outcome.target), // `select = true` KEEPS the target selected.
(true, false, true) => Some(outcome.saved_active), (true, true, _) | (false, true, false) => Some(outcome.target),
(false, _, true) => Some(outcome.saved_active), // `select = false` restores the saved window even when it is the
(false, true, false) => Some(outcome.target), // 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 // Both ids died with the hook: fall back to the non-side target
// rule rather than leaving focus on a dead window. // rule rather than leaving focus on a dead window.
_ => None, _ => None,
@ -202,8 +203,135 @@ fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result<W
}) })
} }
/// 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` /// Install the bottom-panel surface onto the existing `pmacs.window`
/// table. /// 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<()> { pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
{ {
let cc = core.clone(); let cc = core.clone();
@ -386,10 +514,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
.quit_action() .quit_action()
.map_or(0, crate::window::QuitAction::depth), .map_or(0, crate::window::QuitAction::depth),
)?; )?;
table.set( table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?;
"hidden",
window.is_side() && core.panel_hidden_for(fid),
)?;
Ok(table) Ok(table)
})?, })?,
)?; )?;
@ -400,41 +525,43 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
let cc = core.clone(); let cc = core.clone();
win.set( win.set(
"set_params", "set_params",
lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { lua.create_function(
let fid = acting_frontend(lua, &cc); move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> {
let id = lookup_window(&cc, fid, target)?; let fid = acting_frontend(lua, &cc);
for key in ["side", "origin_document", "quit_action"] { let id = lookup_window(&cc, fid, target)?;
if opts.get::<Value>(key)? != Value::Nil { for key in ["side", "origin_document", "quit_action"] {
return Err(mlua::Error::runtime(format!( if opts.get::<Value>(key)? != Value::Nil {
"pmacs.window.set_params: `{key}` is not settable" return Err(mlua::Error::runtime(format!(
))); "pmacs.window.set_params: `{key}` is not settable"
)));
}
} }
} let height = match opts.get::<Option<u32>>("fixed_rows")? {
let height = match opts.get::<Option<u32>>("fixed_rows")? { Some(rows) => Some(
Some(rows) => Some( crate::editor_core::EditorCore::clamp_panel_rows(rows)
crate::editor_core::EditorCore::clamp_panel_rows(rows) .map_err(mlua::Error::runtime)?,
.map_err(mlua::Error::runtime)?, ),
), None => None,
None => None, };
}; let dedicated = opts.get::<Option<bool>>("dedicated")?;
let dedicated = opts.get::<Option<bool>>("dedicated")?; {
{ let mut core = cc.borrow_mut();
let mut core = cc.borrow_mut(); let window = core.windows.get_mut(&id).ok_or_else(|| {
let window = core.windows.get_mut(&id).ok_or_else(|| { mlua::Error::runtime("pmacs.window.set_params: window not live")
mlua::Error::runtime("pmacs.window.set_params: window not live") })?;
})?; if let Some(rows) = height {
if let Some(rows) = height { // Inert on an ordinary window by construction:
// Inert on an ordinary window by construction: // the fixed map is built from side windows only.
// the fixed map is built from side windows only. window.params.fixed_rows = Some(rows);
window.params.fixed_rows = Some(rows); }
if let Some(dedicated) = dedicated {
window.params.dedicated = dedicated;
}
} }
if let Some(dedicated) = dedicated { reconcile_panel_layout(lua, &cc, fid);
window.params.dedicated = dedicated; Ok(())
} },
} )?,
reconcile_panel_layout(lua, &cc, fid);
Ok(())
})?,
)?; )?;
} }
@ -466,8 +593,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
.iter_ids() .iter_ids()
.into_iter() .into_iter()
.map(|id| { .map(|id| {
let buffer_id = let buffer_id = core.windows.get(&id).map(|w| w.buffer_id);
core.windows.get(&id).map(|w| w.buffer_id);
( (
id, id,
config_u32( config_u32(

View File

@ -975,20 +975,21 @@ fn compute_node(
let mut flexible_used: u32 = 0; 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 extent = match extents[i] { let extent = if let Some(rows) = extents[i] {
Some(rows) => rows, rows
None => { } else {
let w = weights.get(i).copied().unwrap_or(1).max(1); let w = weights.get(i).copied().unwrap_or(1).max(1);
let e = if Some(i) == last_flexible { let e = if Some(i) == last_flexible {
remainder - flexible_used remainder - flexible_used
} else if total == 0 { } else {
0 remainder
} else { .checked_mul(w)
remainder * w / total .unwrap_or(remainder)
}; .checked_div(total)
flexible_used += e; .unwrap_or(0)
e };
} flexible_used += e;
e
}; };
let child_area = match orientation { let child_area = match orientation {
Orientation::Horizontal => Rect { Orientation::Horizontal => Rect {

File diff suppressed because it is too large Load Diff