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:
parent
6c8a76e235
commit
683c9b86aa
|
|
@ -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*")
|
||||
|
|
|
|||
|
|
@ -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*")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 1–2 omission means
|
||||
/// `"current"`; Stage 3 flips omission to `"panel"`. Explicit
|
||||
/// `"current"` always preserves the adopter's pre-arc selected-window
|
||||
/// behavior and is the user-facing opt-out from that flip.
|
||||
pub(crate) enum AdopterPlacement {
|
||||
/// Today's behavior: the raw switch into the frontend's active
|
||||
/// window, deliberately bypassing display-policy dedication.
|
||||
Current,
|
||||
/// The bottom panel.
|
||||
Panel,
|
||||
/// An exact target window.
|
||||
Window(WindowId),
|
||||
}
|
||||
|
||||
/// Parse an adopter's placement **before** it creates a buffer, session,
|
||||
/// process, or wrapper — so an unknown value leaves nothing to roll back.
|
||||
///
|
||||
/// # Errors
|
||||
/// An unknown `display` value, a `window` combined with
|
||||
/// `display = "panel"`, or a window id that is not live in the acting
|
||||
/// frontend's layout.
|
||||
pub(crate) fn parse_adopter_placement(
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
operation: &str,
|
||||
display: Option<&str>,
|
||||
window: Option<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(
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue