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.
local function start_run(slot, cmdline, opts)
opts = opts or {}
-- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run
-- supersedes anything, rewrites the buffer, or spawns a process, so
-- an unknown value leaves no half-started run behind. In Stages 1-2
-- omission means "current"; Stage 3 flips the default.
local display = opts.display
if display ~= nil and display ~= "current" and display ~= "panel" then
error(string.format(
"compile.run: unknown display %q (expected \"current\" or \"panel\")",
tostring(display)))
end
-- q-target discipline (Q#CM11): capture only when coming from a
-- non-generated buffer, so `g` reruns don't re-capture and
-- compile → g → q restores the original buffer.
@ -805,7 +815,16 @@ local function start_run(slot, cmdline, opts)
-- attach here stacked a duplicate render view per run (round-5
-- finding 1; translation itself is buffer-level and unaffected by
-- attachment count).
pmacs.window.switch_buffer(slot.buf)
-- The FIRST display of this run is the side-affine one (Q#BP3): a
-- persistent *compilation* already visible in a document window must
-- not preempt the requested panel. Compile output is passive, so it
-- takes `select = false` explicitly; a recompile simply reuses the
-- panel it is already in.
if display == "panel" then
pmacs.window.display(slot.buf, { side = "bottom", select = false })
else
pmacs.window.switch_buffer(slot.buf)
end
if not ok then
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
slot.expected_rev = buf:revision()
@ -866,7 +885,9 @@ local function visit_error(slot, idx)
if not e then return end
local path = resolve_error_path(slot, e.file)
pmacs.editor.push_jump()
local ok, err = pcall(pmacs.buffer.find_or_open, path)
-- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the
-- source in the document target, leaving the panel where it is.
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
if not ok then
pmacs.editor.jump_back()
pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err))
@ -995,6 +1016,15 @@ pmacs.command.define {
fn = function()
local slot = slot_for_buffer(pmacs.window.buffer())
if not slot then return end
-- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or
-- restores the PRESENTATION rather than leaving a source buffer
-- stranded in the panel slot. Capability fallback and pre-arc
-- placement keep today's previous-buffer restore below.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = slot.prev
if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")

View File

@ -123,7 +123,25 @@ function pmacs.listview.open(spec)
p.prev = active
end
render(p, spec.rows or {})
pmacs.window.switch_buffer(p.buffer)
-- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and
-- `listview.refresh` are active-window-only, so an interactive panel
-- MUST take `select = true` or it would silently seat the wrong
-- window. In Stages 1-2 omitting `display` keeps today's raw switch;
-- Stage 3 flips the default. An unknown value errors before anything
-- is displayed.
local display = spec.display
if display ~= nil and display ~= "current" and display ~= "panel" then
error(string.format(
"listview.open: unknown display %q (expected \"current\" or \"panel\")",
tostring(display)))
end
if display == "panel" then
p.side = true
pmacs.window.display(p.buffer, { side = "bottom", select = true })
else
p.side = false
pmacs.window.switch_buffer(p.buffer)
end
seat_cursor(p, 1)
end
@ -160,6 +178,15 @@ pmacs.command.define {
fn = function()
local p = panel_for_current_buffer()
if not p then return end
-- Bottom-panel arc (Q#BP11b): `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when the
-- listview really is in a side window. Capability fallback (and any
-- pre-arc placement) keeps the previous-buffer switch below.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = p.prev
if not (target and target:is_valid()) then
target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*")

View File

@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition()
return
end
pmacs.editor.push_jump()
local ok2, oerr = pcall(pmacs.buffer.find_or_open, path)
-- Bottom-panel arc (Q#BP11b): the target-aware load. `find_or_open`
-- switches the ACTIVE window, which would replace a focused panel;
-- `display_file` resolves the DOCUMENT target first and fires the
-- load/switch hook with that window active.
local ok2, oerr = pcall(pmacs.window.display_file, path, { select = true })
if not ok2 then
-- Open failed: drop the origin we just pushed so M-, isn't
-- left pointing at a jump that never happened.
@ -1601,7 +1605,9 @@ local function visit_location(loc)
return
end
pmacs.editor.push_jump()
local ok, err = pcall(pmacs.buffer.find_or_open, path)
-- Bottom-panel arc (Q#BP11b): a visit FROM a panel must land in the
-- document target and leave the panel intact.
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
if not ok then
-- Open failed: drop the origin we just pushed so M-, isn't left
-- pointing at a jump that never happened.

View File

@ -27,12 +27,17 @@ pmacs.config.define {
-- (drag and the commands below) and is deliberately ignored by the
-- ordinary layout pass and by frame-resize reconciliation, so raising it
-- can never invalidate a layout that already exists.
--
-- The registry floor is 1 rather than 2 on purpose: a value below the
-- STRUCTURAL floor is clamped when it is read, not rejected when it is
-- written, so a user who asks for a smaller minimum simply gets the
-- smallest one the layout can actually honor.
pmacs.config.define {
name = "window.min-height",
description = "Smallest outer rows interactive resize will leave a window.",
type = "integer",
default = 2,
min = 2,
min = 1,
mutability = "live",
}

View File

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

View File

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

View File

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

View File

@ -8501,8 +8501,8 @@ fn install_terminal(
let supervisor = supervisor.clone();
terminal.set(
"_open",
lua.create_function(move |lua, spec: Table| -> mlua::Result<BufferIdLua> {
let spec = parse_terminal_spec(&spec)?;
lua.create_function(move |lua, spec_table: Table| -> mlua::Result<BufferIdLua> {
let spec = parse_terminal_spec(&spec_table)?;
let core = lua
.app_data_ref::<SharedCore>()
.map(|core| core.clone())
@ -8515,37 +8515,54 @@ fn install_terminal(
"pmacs.terminal.open: target frontend has no active window",
));
}
// Bottom-panel arc (Q#BP11b): parse placement BEFORE the
// session, process, buffer, or wrapper exists, so an
// unknown `display` value creates nothing to roll back.
let placement = window_panel::parse_adopter_placement(
&core,
frontend_id,
"pmacs.terminal.open",
spec_table.get::<Option<String>>("display")?.as_deref(),
spec_table.get::<Option<u64>>("window")?,
)?;
let buffer_id = {
let mut manager = manager.borrow_mut();
manager
.open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut())
.map_err(mlua::Error::external)?
};
let key = {
let mut core = core.borrow_mut();
if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) {
let outcome = match window_panel::place_adopter_buffer(
lua,
&core,
frontend_id,
buffer_id,
&placement,
true,
) {
Ok(outcome) => outcome,
Err(error) => {
let mut core = core.borrow_mut();
let _ = core.registry.borrow_mut().remove(buffer_id);
manager
.borrow_mut()
.prune(&mut core, &mut supervisor.borrow_mut());
return Err(mlua::Error::external(format!(
"pmacs.terminal.open: active-window switch failed: {error}"
)));
return Err(error);
}
crate::terminal::TerminalViewKey::new(
frontend_id,
core.views
.get(&frontend_id)
.expect("checked frontend has active view")
.active,
buffer_id,
)
};
let key =
crate::terminal::TerminalViewKey::new(frontend_id, outcome.target, buffer_id);
let claimed = {
let mut manager = manager.borrow_mut();
manager.register_view(key) && manager.claim_controller(key)
};
if !claimed {
// Placement failure removes any side wrapper this
// transaction created, BEFORE the existing
// session/buffer rollback completes (Q#BP11b).
if outcome.created_side {
core.borrow_mut()
.remove_side_window(frontend_id, outcome.target);
}
let mut core = core.borrow_mut();
let _ = core.registry.borrow_mut().remove(buffer_id);
manager
@ -8555,7 +8572,7 @@ fn install_terminal(
"pmacs.terminal.open: failed to claim the new terminal view",
));
}
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
window_panel::finish_adopter_placement(lua, &core, frontend_id, outcome)?;
Ok(BufferIdLua(buffer_id))
})?,
)?;
@ -8717,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result<crate::terminal::TerminalS
"rows",
"cols",
"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;
table.clone().for_each(|key: Value, _: Value| {
@ -12300,9 +12321,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
win.set(
"close_others",
lua.create_function(move |_, ()| {
cc.borrow_mut()
.close_others()
.map_err(mlua::Error::runtime)
cc.borrow_mut().close_others().map_err(mlua::Error::runtime)
})?,
)?;
}

View File

@ -120,10 +120,11 @@ fn complete_display(
let target_ok = visible(core, fid, outcome.target);
let saved_ok = visible(core, fid, outcome.saved_active);
let final_focus = match (outcome.select, target_ok, saved_ok) {
(true, true, _) => Some(outcome.target),
(true, false, true) => Some(outcome.saved_active),
(false, _, true) => Some(outcome.saved_active),
(false, true, false) => Some(outcome.target),
// `select = true` KEEPS the target selected.
(true, true, _) | (false, true, false) => Some(outcome.target),
// `select = false` restores the saved window even when it is the
// panel — a passive display from a focused panel must not blur it.
(true, false, true) | (false, _, true) => Some(outcome.saved_active),
// Both ids died with the hook: fall back to the non-side target
// rule rather than leaving focus on a dead window.
_ => None,
@ -202,8 +203,135 @@ fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result<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`
/// table.
#[allow(
clippy::too_many_lines,
reason = "one flat list of bindings, each following the same \
acting-frontend / Rc-borrow shape; splitting them fragments \
a coherent surface"
)]
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
{
let cc = core.clone();
@ -386,10 +514,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
.quit_action()
.map_or(0, crate::window::QuitAction::depth),
)?;
table.set(
"hidden",
window.is_side() && core.panel_hidden_for(fid),
)?;
table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?;
Ok(table)
})?,
)?;
@ -400,41 +525,43 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
let cc = core.clone();
win.set(
"set_params",
lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> {
let fid = acting_frontend(lua, &cc);
let id = lookup_window(&cc, fid, target)?;
for key in ["side", "origin_document", "quit_action"] {
if opts.get::<Value>(key)? != Value::Nil {
return Err(mlua::Error::runtime(format!(
"pmacs.window.set_params: `{key}` is not settable"
)));
lua.create_function(
move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> {
let fid = acting_frontend(lua, &cc);
let id = lookup_window(&cc, fid, target)?;
for key in ["side", "origin_document", "quit_action"] {
if opts.get::<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);
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;
}
}
if let Some(dedicated) = dedicated {
window.params.dedicated = dedicated;
}
}
reconcile_panel_layout(lua, &cc, fid);
Ok(())
})?,
reconcile_panel_layout(lua, &cc, fid);
Ok(())
},
)?,
)?;
}
@ -466,8 +593,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
.iter_ids()
.into_iter()
.map(|id| {
let buffer_id =
core.windows.get(&id).map(|w| w.buffer_id);
let buffer_id = core.windows.get(&id).map(|w| w.buffer_id);
(
id,
config_u32(

View File

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

File diff suppressed because it is too large Load Diff