feat(window): window parameters, fixed extents, and the display policy
Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md). - `WindowParams` (side / fixed_rows / dedicated + implementation-owned quit action and remembered document origin), `Side`, `QuitAction` with a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor. - `Layout::compute(area, fixed)` allocates fixed rows before dividing the remainder by weight; both production callers feed the same shared map, including the peer-presence overlay pass that derives its own rect. - `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and `boundary_below` for the shared drag / keyboard resize boundary rule. - `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived `panel_hidden`, each spelled explicitly at every construction site. - `EditorCore`: `primary_document_window`, the non-side target rule, `display_buffer` + placement policy, `quit_window`, side-window removal on `kill_buffer`, per-frontend jump entries with origin windows, and the shared resolve/load-without-switch seam the initial-target bootstrap now uses too. - `EditorState`: the panel reconciliation transaction, geometry declaration, the side-window `dispatch_idle_for` gate, divider paint, and divider drag. - `pmacs.window.display / display_file / quit / panel / params / set_params / resize / display_target`, plus `builtin/runtime/window.lua` with `window.panel-height`, `window.min-height`, and the resize commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c27f75a1b2
commit
6c8a76e235
|
|
@ -0,0 +1,58 @@
|
|||
-- window.lua --- side-window settings, quit, and keyboard resize.
|
||||
--
|
||||
-- The Lua half of the bottom-panel arc's window surface. The placement
|
||||
-- policy itself is Rust (`pmacs.window.display` / `display_file` /
|
||||
-- `quit` / `resize`); this module owns the two settings those paths
|
||||
-- resolve, plus the interactive commands and their Emacs bindings.
|
||||
--
|
||||
-- Both settings are read against the window's OWN buffer (buffer-local
|
||||
-- override -> global -> default), so a project or a mode hook can pin a
|
||||
-- taller panel for one buffer with `pmacs.config.set_local`.
|
||||
--
|
||||
-- Framing: docs/bottom-panel-framing.md (Q#BP2, Q#BP5b, Q#BP11).
|
||||
|
||||
-- Outer rows (text + mode line) a freshly created panel takes when the
|
||||
-- caller supplies no explicit `height`. Only consulted at CREATION: a
|
||||
-- replacement preserves whatever height the user dragged the slot to.
|
||||
pmacs.config.define {
|
||||
name = "window.panel-height",
|
||||
description = "Outer rows a newly created bottom panel occupies.",
|
||||
type = "integer",
|
||||
default = 12,
|
||||
min = 2,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
-- A preference, not a structural rule: it constrains INTERACTIVE resize
|
||||
-- (drag and the commands below) and is deliberately ignored by the
|
||||
-- ordinary layout pass and by frame-resize reconciliation, so raising it
|
||||
-- can never invalidate a layout that already exists.
|
||||
pmacs.config.define {
|
||||
name = "window.min-height",
|
||||
description = "Smallest outer rows interactive resize will leave a window.",
|
||||
type = "integer",
|
||||
default = 2,
|
||||
min = 2,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.quit",
|
||||
description = "Quit the selected side window: restore or delete it",
|
||||
fn = function() pmacs.window.quit() end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.enlarge",
|
||||
description = "Make the selected window one row taller",
|
||||
fn = function() pmacs.window.resize(nil, 1) end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.shrink",
|
||||
description = "Make the selected window one row shorter",
|
||||
fn = function() pmacs.window.resize(nil, -1) end,
|
||||
}
|
||||
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x ^", command = "window.enlarge" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x C-^", command = "window.shrink" }
|
||||
143
src/daemon.rs
143
src/daemon.rs
|
|
@ -894,6 +894,21 @@ fn peer_declared_terminal_support(
|
|||
.is_some_and(|state| state.negotiated_protocol_version >= 19)
|
||||
}
|
||||
|
||||
/// Whether a session can **render** a side window (bottom-panel arc,
|
||||
/// Q#BP13).
|
||||
///
|
||||
/// Grid sessions paint the whole cell grid the daemon composes, so a side
|
||||
/// window is just another leaf for them. A semantic session needs the
|
||||
/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1
|
||||
/// answers `false` for every semantic peer, whatever it declares. No
|
||||
/// client-asserted standalone boolean is trusted: the answer is derived
|
||||
/// from the daemon's own negotiated state, and Stage 2 turns the version
|
||||
/// arm on (`semantic_render && negotiated_protocol_version >=
|
||||
/// PANEL_MIN_VERSION`).
|
||||
fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool {
|
||||
!session_state.negotiated_capabilities.semantic_render
|
||||
}
|
||||
|
||||
/// The same belt-and-braces write-loop gate for the additive
|
||||
/// protocol-v19 terminal frame. The semantic producer skips construction
|
||||
/// for an older peer; this filter independently prevents an unknown
|
||||
|
|
@ -1628,38 +1643,40 @@ fn open_initial_target(
|
|||
target: InitialTarget,
|
||||
) -> Result<OpenedInitialTarget, String> {
|
||||
let path = resolve_initial_target(target);
|
||||
let display_path = path.display().to_string();
|
||||
let (buffer_id, newly_loaded, newly_created) = {
|
||||
// Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's
|
||||
// ORIGINAL document window before any I/O. A startup hook may now
|
||||
// create and select a side window, and bootstrap must reassert the
|
||||
// requested buffer in a document window rather than overwriting a
|
||||
// panel merely because it became `view.active`.
|
||||
let (origin_window, buffer_id, fire) = {
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_frontend = frontend_id;
|
||||
let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) {
|
||||
Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
let buffer_id = core.registry.borrow_mut().create(display_path.clone());
|
||||
core.set_buffer_path(buffer_id, Some(path.clone()));
|
||||
"[new file]".clone_into(&mut core.status);
|
||||
(buffer_id, false, true)
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!("cannot open {}: {error}", path.display()));
|
||||
}
|
||||
};
|
||||
core.switch_active_buffer_for(frontend_id, buffer_id)
|
||||
let origin_window = core
|
||||
.primary_document_window(frontend_id)
|
||||
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
|
||||
let (buffer_id, fire) = core.resolve_target_buffer(&path)?;
|
||||
core.install_buffer_in_window(origin_window, buffer_id)
|
||||
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
|
||||
(buffer_id, newly_loaded, newly_created)
|
||||
core.focus_window(frontend_id, origin_window);
|
||||
(origin_window, buffer_id, fire)
|
||||
};
|
||||
|
||||
if newly_loaded {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||||
} else if !newly_created {
|
||||
match fire {
|
||||
crate::editor_core::HookKind::AfterLoad => {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||||
}
|
||||
// Dedup is a logical switch even when the fresh view already shares
|
||||
// this BufferId; configuration must observe it exactly once.
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-switch", mlua::MultiValue::new());
|
||||
crate::editor_core::HookKind::AfterSwitch => {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-switch", mlua::MultiValue::new());
|
||||
}
|
||||
crate::editor_core::HookKind::None => {}
|
||||
}
|
||||
editor.reconcile_panel_layout(frontend_id);
|
||||
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_frontend = frontend_id;
|
||||
|
|
@ -1669,11 +1686,28 @@ fn open_initial_target(
|
|||
path.display()
|
||||
));
|
||||
}
|
||||
core.switch_active_buffer_for(frontend_id, buffer_id)
|
||||
// Reassert into the original document window when it is still live;
|
||||
// if a hook closed it, rehome to an eligible non-side window in the
|
||||
// same frontend WITHOUT firing a second hook.
|
||||
let destination = if core
|
||||
.views
|
||||
.get(&frontend_id)
|
||||
.is_some_and(|view| view.layout.iter_ids().contains(&origin_window))
|
||||
{
|
||||
origin_window
|
||||
} else {
|
||||
core.non_side_target(frontend_id)
|
||||
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?
|
||||
};
|
||||
core.install_buffer_in_window(destination, buffer_id)
|
||||
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?;
|
||||
core.focus_window(frontend_id, destination);
|
||||
Ok(OpenedInitialTarget {
|
||||
buffer_id,
|
||||
publish_to_replicas: newly_loaded || newly_created,
|
||||
publish_to_replicas: matches!(
|
||||
fire,
|
||||
crate::editor_core::HookKind::AfterLoad | crate::editor_core::HookKind::None
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1766,9 +1800,15 @@ fn handle_session_established(
|
|||
// `RenderState` vs a `SemanticRenderState` below — a grid session
|
||||
// collapses folds, a semantic one keeps raw-line reckoning until
|
||||
// Stage 3.
|
||||
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
|
||||
// negotiated bit in this same transaction. Stage 1 ships the TUI
|
||||
// side windows only, so a semantic session is not panel-capable and
|
||||
// a `side` request falls back to its document target with every
|
||||
// side-specific parameter discarded.
|
||||
let fresh_view = build_fresh_frontend_view(
|
||||
editor,
|
||||
!session_state.negotiated_capabilities.semantic_render,
|
||||
peer_declared_panel_support(&session_state),
|
||||
);
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
|
|
@ -1850,6 +1890,14 @@ fn handle_session_established(
|
|||
}
|
||||
streams.insert(frontend_id, write_stream);
|
||||
term_sizes.insert(frontend_id, initial_size);
|
||||
// Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its
|
||||
// authoritative geometry declaration, cached BEFORE any input can
|
||||
// reach it. A semantic session deliberately stays UNKNOWN — Stage 2's
|
||||
// authenticated `FrontendCellGeometry` fills it, and the permanent
|
||||
// 24x80 attach placeholder is never consulted for panel layout.
|
||||
if editor.core.borrow().panel_capable_for(frontend_id) {
|
||||
editor.sync_frame_geometry(frontend_id, initial_size);
|
||||
}
|
||||
|
||||
if let Some(opened) = opened_target {
|
||||
last_active_buffer_sent.insert(frontend_id, opened.buffer_id);
|
||||
|
|
@ -1933,6 +1981,13 @@ fn handle_dispatcher_event(
|
|||
if let Some(ts) = term_sizes.get_mut(&source) {
|
||||
*ts = size;
|
||||
}
|
||||
// Bottom-panel arc (Q#BP2b): a frame that can no
|
||||
// longer satisfy the panel hides it, moves focus out,
|
||||
// and releases its terminal controller here — before
|
||||
// the next drained event dispatches.
|
||||
if editor.core.borrow().panel_capable_for(source) {
|
||||
editor.sync_frame_geometry(source, size);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "crdt")]
|
||||
FrontendEvent::CrdtOp {
|
||||
|
|
@ -2938,6 +2993,10 @@ fn build_fresh_frontend_view(
|
|||
// collapses folds. Passed explicitly from the negotiated
|
||||
// selected-render bit at the call site — never inferred here.
|
||||
fold_projection: bool,
|
||||
// Bottom-panel arc (Q#BP13): whether this session can RENDER a side
|
||||
// window. Same explicit-at-the-call-site discipline as
|
||||
// `fold_projection`; never inferred from a `FrontendId` here.
|
||||
panel_capable: bool,
|
||||
) -> crate::window::FrontendView {
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{FrontendView, Layout, Window, WindowId};
|
||||
|
|
@ -2946,16 +3005,14 @@ fn build_fresh_frontend_view(
|
|||
// scratch). M10.8's fresh-scratch behavior made overlays
|
||||
// never fire because attaching frontends were in distinct
|
||||
// buffers.
|
||||
let local_view = core
|
||||
.views
|
||||
.get(&FrontendId::LOCAL)
|
||||
.expect("LOCAL view present");
|
||||
let local_active_win_id = local_view.active;
|
||||
//
|
||||
// Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT
|
||||
// buffer, not `local_view.active`. A TUI panel may own focus at
|
||||
// attach time, and panel content must never become a newly attached
|
||||
// frontend's full-window document.
|
||||
let buffer_id = core
|
||||
.windows
|
||||
.get(&local_active_win_id)
|
||||
.expect("LOCAL's active window present in core.windows")
|
||||
.buffer_id;
|
||||
.primary_document_buffer(FrontendId::LOCAL)
|
||||
.expect("LOCAL always retains a document window");
|
||||
let text_view = {
|
||||
let reg = core.registry.borrow();
|
||||
let buf = reg.get(buffer_id).expect("shared buffer present");
|
||||
|
|
@ -2968,6 +3025,13 @@ fn build_fresh_frontend_view(
|
|||
layout: Layout::single(id),
|
||||
active: id,
|
||||
fold_projection,
|
||||
panel_capable,
|
||||
// Grid sessions cache their real attach/resize size; a semantic
|
||||
// session stays UNKNOWN until Stage 2's authenticated
|
||||
// declaration, and must never be sized against the attach
|
||||
// request's permanent 24×80 placeholder (Q#BP15a).
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3233,7 +3297,7 @@ mod tests {
|
|||
let semantic_peer = FrontendId(20);
|
||||
let live_grid_peer = FrontendId(21);
|
||||
let dead_grid_peer = FrontendId(22);
|
||||
let semantic_view = build_fresh_frontend_view(&mut editor, false);
|
||||
let semantic_view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
|
|
@ -3885,6 +3949,9 @@ mod tests {
|
|||
layout: Layout::single(wid),
|
||||
active: wid,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -4017,7 +4084,7 @@ mod tests {
|
|||
let fid = FrontendId(99);
|
||||
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
|
||||
// projection until Stage 3).
|
||||
let view = build_fresh_frontend_view(&mut editor, false);
|
||||
let view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
|
||||
let before = editor
|
||||
|
|
@ -4080,7 +4147,7 @@ mod tests {
|
|||
let fid = FrontendId(99);
|
||||
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
|
||||
// projection until Stage 3).
|
||||
let view = build_fresh_frontend_view(&mut editor, false);
|
||||
let view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
assert_eq!(
|
||||
editor
|
||||
|
|
|
|||
|
|
@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop>
|
|||
|
||||
let resolve = |wid: WindowId| -> Option<SavedLeaf> {
|
||||
let win = core.windows.get(&wid)?;
|
||||
// Bottom-panel arc (Q#BP10): side windows are transient display
|
||||
// policy, never desktop state. Dropping the leaf here makes the
|
||||
// existing single-surviving-child collapse remove the root
|
||||
// wrapper too, so the saved tree is the document tree exactly —
|
||||
// no `SavedLeaf` shape change and no `DESKTOP_VERSION` bump.
|
||||
if win.is_side() {
|
||||
return None;
|
||||
}
|
||||
let path = reg.get(win.buffer_id).ok()?.file_path()?;
|
||||
Some(SavedLeaf {
|
||||
path: path.display().to_string(),
|
||||
|
|
@ -437,6 +445,12 @@ pub fn restore_into(
|
|||
active,
|
||||
// Desktop restore rebuilds LOCAL's grid view (Q#FD21).
|
||||
fold_projection: true,
|
||||
// …which renders side windows natively (Q#BP13). Every
|
||||
// field is spelled explicitly, preserving folding's
|
||||
// non-`Default` discipline.
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
active
|
||||
|
|
|
|||
317
src/editor.rs
317
src/editor.rs
|
|
@ -161,6 +161,11 @@ pub struct EditorState {
|
|||
/// Last left-button down event, used to synthesize terminal double
|
||||
/// clicks from crossterm's plain Down/Up mouse event stream.
|
||||
mouse_click: Option<MouseClickState>,
|
||||
/// In-progress split-boundary drag (bottom-panel arc, Q#BP5), armed
|
||||
/// by a left press on a mode-line row that is an exposed segment of a
|
||||
/// horizontal boundary. Lives beside `mouse_click`; selection is
|
||||
/// untouched for the whole gesture.
|
||||
window_drag: Option<WindowDragState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -208,8 +213,26 @@ struct MouseClickState {
|
|||
at: Instant,
|
||||
}
|
||||
|
||||
/// An armed split-boundary drag (Q#BP5).
|
||||
///
|
||||
/// `owner` is the window whose bottom mode-line row was pressed; the
|
||||
/// boundary it resolves to is recomputed on every motion, so a layout
|
||||
/// mutation mid-drag cannot move a boundary that no longer exists.
|
||||
#[derive(Copy, Clone)]
|
||||
struct WindowDragState {
|
||||
frontend_id: FrontendId,
|
||||
owner: WindowId,
|
||||
last_row: u32,
|
||||
}
|
||||
|
||||
const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Grip glyph stamped at the right end of a divider segment (Q#BP5a).
|
||||
///
|
||||
/// It lands on the mode line's protected trailing blank, so it adds no
|
||||
/// column and clobbers no information.
|
||||
const DIVIDER_HANDLE_GLYPH: char = '⇕';
|
||||
|
||||
impl EditorState {
|
||||
/// Construct a fresh editor for an unnamed scratch buffer.
|
||||
///
|
||||
|
|
@ -488,6 +511,16 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/indent.lua"),
|
||||
)
|
||||
.expect("load indent builtin chunk");
|
||||
// Bottom-panel arc: `window.panel-height` / `window.min-height`
|
||||
// plus the quit and keyboard-resize commands. Must load BEFORE
|
||||
// listview/compile/terminal, which resolve `window.panel-height`
|
||||
// when they open a panel.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/window.lua"),
|
||||
include_str!("../builtin/runtime/window.lua"),
|
||||
)
|
||||
.expect("load window builtin chunk");
|
||||
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
|
||||
// compile.lua must load AFTER lsp.lua. It takes over
|
||||
// `M-g n` / `M-g p` for the unified error dispatchers, and
|
||||
|
|
@ -586,6 +619,7 @@ impl EditorState {
|
|||
snippets,
|
||||
statusline_registry,
|
||||
mouse_click: None,
|
||||
window_drag: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -763,9 +797,74 @@ impl EditorState {
|
|||
&& !core.search_active()
|
||||
&& !core.query_replace_active()
|
||||
&& !core.menu_is_open()
|
||||
&& core
|
||||
.active_window_for(frontend_id)
|
||||
.is_some_and(|window| !core.buffer_round_trips(window.buffer_id))
|
||||
&& core.active_window_for(frontend_id).is_some_and(|window| {
|
||||
// Bottom-panel arc (Q#BP14a): a focused SIDE window turns
|
||||
// optimistic apply off for this frontend, independently
|
||||
// of the buffer-global round-trip set.
|
||||
//
|
||||
// Marking the panel's BUFFER round-trip instead would be
|
||||
// wrong twice: `round_trip_buffers` is keyed by
|
||||
// `BufferId` across every frontend and window, so it
|
||||
// would disable optimistic input for another frontend
|
||||
// editing the same buffer as its document; and an opt-out
|
||||
// would be unsafe, because the GPU would optimistically
|
||||
// edit its document mirror while daemon input targets the
|
||||
// panel — every resulting op then fails remote-op
|
||||
// validation and the mirror silently diverges.
|
||||
!window.is_side() && !core.buffer_round_trips(window.buffer_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// The idempotent panel-reconciliation transaction (Q#BP2b).
|
||||
///
|
||||
/// Runs after attach / resize / display / split / close, after any
|
||||
/// `fixed_rows` or setting change, after any Lua hook or callback
|
||||
/// transaction that can mutate the layout, and **defensively** before
|
||||
/// final-focus resolution, input dispatch, terminal sync, and paint.
|
||||
/// Two events drained in one burst therefore cannot route the second
|
||||
/// to a panel the first made invisible, and a render callback cannot
|
||||
/// leave stale panel geometry for the painter.
|
||||
pub fn reconcile_panel_layout(&self, frontend_id: FrontendId) -> bool {
|
||||
let outcome = self
|
||||
.core
|
||||
.borrow_mut()
|
||||
.reconcile_panel_layout_core(frontend_id);
|
||||
if let Some(window_id) = outcome.released_terminal {
|
||||
// Hiding is a DURABLE transition: the terminal resize path
|
||||
// merely returns on zero content without releasing the
|
||||
// controller, so an invisible panel would otherwise keep
|
||||
// owning its child.
|
||||
let buffer_id = self
|
||||
.core
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.map(|window| window.buffer_id);
|
||||
if let Some(buffer_id) = buffer_id {
|
||||
let _ = self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.release_controller(crate::terminal::TerminalViewKey {
|
||||
frontend_id,
|
||||
window_id,
|
||||
buffer_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
outcome.changed
|
||||
}
|
||||
|
||||
/// Cache one frontend's authoritative frame capacity and reconcile
|
||||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// The single seam for grid and `LOCAL` views, whose real attach and
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this
|
||||
/// in Stage 1; its geometry stays **unknown**.
|
||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.declare_frame_geometry(frontend_id, total);
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
}
|
||||
|
||||
/// Local-frontend compatibility wrapper.
|
||||
|
|
@ -800,6 +899,10 @@ impl EditorState {
|
|||
// Authenticate every path through this input event, including modal
|
||||
// callbacks such as M-x minibuffer acceptance.
|
||||
let _origin = self.interactive_origin.enter(frontend_id);
|
||||
// Bottom-panel arc (Q#BP2b): reconcile defensively before input
|
||||
// dispatch, so two events drained in one burst cannot route the
|
||||
// second to a panel the first made invisible.
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
let chord = key_event_to_chord(key);
|
||||
{
|
||||
let mut core = self.core.borrow_mut();
|
||||
|
|
@ -1084,6 +1187,10 @@ impl EditorState {
|
|||
///
|
||||
/// This is called before process drain and paint, never from rendering.
|
||||
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
|
||||
// Bottom-panel arc (Q#BP2b): a panel that just became
|
||||
// unsatisfiable must have released its controller before this
|
||||
// runs, or the child would be resized against a dead rect.
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
let Some(key) = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
|
|
@ -1815,6 +1922,21 @@ impl EditorState {
|
|||
return;
|
||||
}
|
||||
|
||||
// Bottom-panel arc (Q#BP5): an armed divider drag owns the
|
||||
// pointer for the whole gesture, INCLUDING rows outside any
|
||||
// window — otherwise tracking would stop the moment the pointer
|
||||
// crossed the frame's status row.
|
||||
if self.window_drag.is_some() {
|
||||
match ev.kind {
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
self.drag_window_boundary(frontend_id, cell_row, term_size);
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => self.window_drag = None,
|
||||
_ => self.window_drag = None,
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((win_id, rect)) = window_at_cell(
|
||||
&self.core.borrow(),
|
||||
frontend_id,
|
||||
|
|
@ -1826,6 +1948,15 @@ impl EditorState {
|
|||
};
|
||||
let inner_rows = rect.size.rows.saturating_sub(1);
|
||||
let local_row = cell_row.saturating_sub(rect.origin.row);
|
||||
// A press on a mode-line row that is an exposed segment of a
|
||||
// horizontal boundary arms a divider drag, ahead of the terminal
|
||||
// router: a document terminal above the panel owns a boundary
|
||||
// too. Selection is untouched, so this click still creates none.
|
||||
if matches!(ev.kind, MouseEventKind::Down(MouseButton::Left)) && local_row >= inner_rows {
|
||||
self.mouse_click = None;
|
||||
self.arm_window_drag(frontend_id, win_id, cell_row);
|
||||
return;
|
||||
}
|
||||
let buffer_id = self.core.borrow().windows[&win_id].buffer_id;
|
||||
if self.terminal_manager.borrow().is_terminal(buffer_id) {
|
||||
let content_size = CellSize::new(inner_rows, rect.size.cols);
|
||||
|
|
@ -1928,6 +2059,112 @@ 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()
|
||||
});
|
||||
self.window_drag = is_divider.then_some(WindowDragState {
|
||||
frontend_id,
|
||||
owner,
|
||||
last_row: cell_row,
|
||||
});
|
||||
}
|
||||
|
||||
/// Continue an armed divider drag (Q#BP5).
|
||||
///
|
||||
/// The boundary is re-resolved from `owner` on every motion, so a
|
||||
/// layout mutation mid-drag cannot move a boundary that no longer
|
||||
/// exists. Motion is applied incrementally and re-anchored each
|
||||
/// event, so the clamp absorbs over-travel instead of accumulating it.
|
||||
fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) {
|
||||
let Some(drag) = self.window_drag else {
|
||||
return;
|
||||
};
|
||||
if drag.frontend_id != frontend_id {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
};
|
||||
if delta == 0 || term_size.rows < 2 {
|
||||
return;
|
||||
}
|
||||
// A drag that runs into the clamp is a no-op, not an error to
|
||||
// surface: the pointer simply cannot move the boundary further.
|
||||
let _ = self.resize_window_boundary(frontend_id, drag.owner, delta, term_size.rows - 1);
|
||||
}
|
||||
|
||||
/// Move the boundary `win` owns by `delta_rows`, growing `win`
|
||||
/// (Q#BP5 / Q#BP5b), under the interactive `window.min-height`
|
||||
/// preference snapshotted before any geometry changes.
|
||||
///
|
||||
/// Returns the core's pointed error, if any; a `no adjustable
|
||||
/// horizontal boundary` result is a no-op by construction.
|
||||
pub fn resize_window_boundary(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
win: WindowId,
|
||||
delta_rows: i32,
|
||||
area_rows: u32,
|
||||
) -> Result<(), String> {
|
||||
// One gesture, one set of minima: resolved against each leaf's
|
||||
// CURRENT buffer (buffer-local override → global → default)
|
||||
// before the geometry moves.
|
||||
let minima: HashMap<WindowId, u32> = {
|
||||
let core = self.core.borrow();
|
||||
core.views
|
||||
.get(&frontend_id)
|
||||
.map(|view| {
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let buffer_id = core.windows.get(&id).map(|w| w.buffer_id);
|
||||
(id, self.window_min_height(buffer_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let result = self.core.borrow_mut().resize_boundary(
|
||||
frontend_id,
|
||||
win,
|
||||
delta_rows,
|
||||
area_rows,
|
||||
&|id| {
|
||||
minima
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or(crate::window::MIN_WINDOW_OUTER_ROWS)
|
||||
},
|
||||
);
|
||||
if result.is_ok() {
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve the `window.min-height` preference for a buffer, clamped
|
||||
/// into `[MIN_WINDOW_OUTER_ROWS, …]` (Q#BP2).
|
||||
///
|
||||
/// A core with no Lua host — or one whose runtime has not defined the
|
||||
/// setting — falls back to the structural floor, so the preference
|
||||
/// can never make an existing layout invalid.
|
||||
#[must_use]
|
||||
pub fn window_min_height(&self, buffer_id: Option<crate::buffer::BufferId>) -> u32 {
|
||||
crate::lua_bindings::config_u32(
|
||||
self.lua_host.lua(),
|
||||
"window.min-height",
|
||||
buffer_id,
|
||||
crate::window::MIN_WINDOW_OUTER_ROWS,
|
||||
)
|
||||
.max(crate::window::MIN_WINDOW_OUTER_ROWS)
|
||||
}
|
||||
|
||||
fn dispatch_terminal_mouse(
|
||||
&mut self,
|
||||
key: TerminalViewKey,
|
||||
|
|
@ -2368,8 +2605,12 @@ pub(crate) fn window_placements(
|
|||
return HashMap::new();
|
||||
};
|
||||
let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols);
|
||||
// Bottom-panel arc (Q#BP2, R5-B1): both production `Layout::compute`
|
||||
// callers feed in the SAME shared fixed map, so a side window's rows
|
||||
// are identical in the placement pass and the peer-overlay pass.
|
||||
let fixed = core.panel_fixed_rows(frontend_id, area.size.rows);
|
||||
view.layout
|
||||
.compute(area)
|
||||
.compute(area, &fixed)
|
||||
.into_iter()
|
||||
.map(|(window_id, outer)| {
|
||||
let content = Rect::new(
|
||||
|
|
@ -2834,6 +3075,13 @@ pub fn paint_frame(
|
|||
if term_size.rows < 2 || term_size.cols == 0 {
|
||||
return None;
|
||||
}
|
||||
// Bottom-panel arc (Q#BP2b/Q#BP15a): a grid frontend's real frame
|
||||
// size IS its authoritative geometry declaration. Declaring and
|
||||
// reconciling here — before the statusline fan-out and before the
|
||||
// long mutable borrow — means the painter never sees stale panel
|
||||
// geometry, and a panel the frame can no longer satisfy has already
|
||||
// surrendered focus and its terminal controller.
|
||||
state.sync_frame_geometry(frontend_id, term_size);
|
||||
// Statusline callbacks may call arbitrary editor APIs. Evaluate the
|
||||
// complete visible-window fan-out before the long mutable core borrow
|
||||
// below, then paint only the transactionally validated owned results.
|
||||
|
|
@ -2871,6 +3119,21 @@ pub fn paint_frame(
|
|||
|
||||
let placements = window_placements(core, frontend_id, term_size);
|
||||
let active = core.views.get(&frontend_id)?.active;
|
||||
// Bottom-panel arc (Q#BP5a): the divider IS the upper subtree's
|
||||
// existing mode-line row — no row is added or consumed, and
|
||||
// `fixed_rows` excludes it. Resolved once per frame, before the
|
||||
// mutable per-window loop borrows `core.windows`. A boundary whose
|
||||
// upper child is a nested subtree exposes SEVERAL leaf segments along
|
||||
// the same edge, so the root panel divider is full width even when
|
||||
// the document subtree ends in several columns.
|
||||
let divider_windows: Vec<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
|
||||
// don't leak the old contents.
|
||||
|
|
@ -3094,6 +3357,12 @@ pub fn paint_frame(
|
|||
}
|
||||
drop(reg);
|
||||
|
||||
for id in ÷r_windows {
|
||||
if let Some(placement) = placements.get(id) {
|
||||
paint_divider_segment(grid, &placement.outer, divider_style);
|
||||
}
|
||||
}
|
||||
|
||||
paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme);
|
||||
|
||||
// An active isearch owns the bottom row (its prompt + match
|
||||
|
|
@ -3582,6 +3851,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 {
|
|||
/// Paint complete graphemes at a logical signed origin. A grapheme that
|
||||
/// straddles either clip edge is omitted wholesale, so a wide glyph can never
|
||||
/// leave a dangling half-cell at a window or left/right collision boundary.
|
||||
/// Restyle one exposed segment of a horizontal split boundary and stamp
|
||||
/// its grip (Q#BP5a).
|
||||
///
|
||||
/// The segment is the window's own mode-line row: the glyphs the mode
|
||||
/// line already painted are preserved, only the *surface* changes, and
|
||||
/// the grip lands on the protected suffix's trailing blank. `ui.divider`
|
||||
/// resolves through the ordinary `ui.*` face walk, so an unset face
|
||||
/// leaves today's mode-line surface untouched and the affordance is the
|
||||
/// grip alone.
|
||||
fn paint_divider_segment(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
rect: &crate::window::Rect,
|
||||
style: Option<crate::cell::Style>,
|
||||
) {
|
||||
if rect.size.rows == 0 || rect.size.cols == 0 {
|
||||
return;
|
||||
}
|
||||
let row = rect.origin.row + rect.size.rows - 1;
|
||||
if let Some(style) = style {
|
||||
for col in 0..rect.size.cols {
|
||||
grid.at(CellCoord::new(row, rect.origin.col + col)).style = style;
|
||||
}
|
||||
}
|
||||
let cell = grid.at(CellCoord::new(row, rect.origin.col + rect.size.cols - 1));
|
||||
cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH);
|
||||
}
|
||||
|
||||
fn paint_mode_line_graphemes(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
rect: &crate::window::Rect,
|
||||
|
|
@ -6360,7 +6656,8 @@ mod tests {
|
|||
let core = s.core.borrow();
|
||||
assert_eq!(core.windows.len(), 8);
|
||||
let area = crate::window::Rect::new(0, 0, 40, 120);
|
||||
let placements = core.active_layout().compute(area);
|
||||
let fixed = core.panel_fixed_rows(core.active_frontend_key(), area.size.rows);
|
||||
let placements = core.active_layout().compute(area, &fixed);
|
||||
assert_eq!(placements.len(), 8);
|
||||
for r in placements.values() {
|
||||
assert!(!r.is_empty(), "rect was empty: {r:?}");
|
||||
|
|
@ -6780,12 +7077,18 @@ mod tests {
|
|||
.core
|
||||
.borrow()
|
||||
.active_layout()
|
||||
.compute(crate::window::Rect::new(0, 0, 24, 90));
|
||||
.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));
|
||||
.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();
|
||||
|
|
|
|||
1459
src/editor_core.rs
1459
src/editor_core.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -88,6 +88,7 @@ mod diag;
|
|||
mod fold;
|
||||
mod index;
|
||||
mod mcp;
|
||||
mod window_panel;
|
||||
// Every `pub` item a moved domain owned is re-exported so its prior
|
||||
// `crate::lua_bindings::<item>` path still resolves — the split must not
|
||||
// shrink the public API surface. That includes the `install_*` wiring fns:
|
||||
|
|
@ -647,6 +648,26 @@ impl PackageInstallOverride {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve an integer setting out of the shared `pmacs.config` registry
|
||||
/// (bottom-panel arc, Q#BP2 / Q#BP11).
|
||||
///
|
||||
/// The registry lives in Lua app data, so Rust-side consumers — the
|
||||
/// divider drag, the keyboard resize commands, and side-window creation
|
||||
/// — reach it here rather than round-tripping through Lua. `fallback`
|
||||
/// covers a bare core whose runtime never defined the setting (unit-test
|
||||
/// construction), and a negative or out-of-range stored value.
|
||||
#[must_use]
|
||||
pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback: u32) -> u32 {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
|
||||
return fallback;
|
||||
};
|
||||
let borrowed = registry.borrow();
|
||||
match borrowed.get(name, buffer_id) {
|
||||
Ok(crate::config_registry::ConfigValue::Int(v)) => u32::try_from(*v).unwrap_or(fallback),
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-circuit a binding when the init phase has completed.
|
||||
///
|
||||
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
|
||||
|
|
@ -1572,7 +1593,7 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) {
|
||||
pub(crate) fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) {
|
||||
let snapshot = match lua.app_data_ref::<SharedHookRegistry>() {
|
||||
Some(hooks) => hooks.borrow().snapshot(name),
|
||||
None => None,
|
||||
|
|
@ -8467,6 +8488,11 @@ fn install_terminal(
|
|||
manager: &crate::terminal::SharedTerminalManager,
|
||||
supervisor: &SharedProcessSupervisor,
|
||||
) -> mlua::Result<()> {
|
||||
// Bottom-panel arc (Q#BP2b): the panel-reconciliation transaction
|
||||
// must be able to RELEASE a hidden panel's terminal controller from a
|
||||
// Lua-owning context, so the manager joins the LSP manager and the
|
||||
// process supervisor as app data.
|
||||
lua.set_app_data(manager.clone());
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let terminal = lua.create_table()?;
|
||||
|
||||
|
|
@ -12166,6 +12192,9 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs {
|
|||
)]
|
||||
fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
||||
let win = lua.create_table()?;
|
||||
// Bottom-panel arc (Q#BP11): display policy, side windows, quit, and
|
||||
// boundary resize live in their own module.
|
||||
window_panel::install(lua, core, &win)?;
|
||||
|
||||
{
|
||||
let cc = core.clone();
|
||||
|
|
@ -12271,8 +12300,9 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
win.set(
|
||||
"close_others",
|
||||
lua.create_function(move |_, ()| {
|
||||
cc.borrow_mut().close_others();
|
||||
Ok(())
|
||||
cc.borrow_mut()
|
||||
.close_others()
|
||||
.map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
// window_panel.rs --- `pmacs.window` display policy + side windows.
|
||||
|
||||
//! The Lua surface of the bottom-panel arc (Q#BP11): `display`,
|
||||
//! `display_file`, `quit`, `panel`, `params` / `set_params`, `resize`,
|
||||
//! and `display_target`.
|
||||
//!
|
||||
//! # Where the transaction lives
|
||||
//!
|
||||
//! [`crate::editor_core::EditorCore::display_buffer`] is **Phase 1**: it
|
||||
//! picks a target under Q#BP3, installs the buffer, and reports what must
|
||||
//! happen next. It contains no Lua. This module is **Phase 2** (Q#BP4):
|
||||
//! activate the target, fire the lifecycle hook so overlays reattach and
|
||||
//! saveplace / recentf / syntax / LSP observe the right active window,
|
||||
//! run panel reconciliation (a hook may resize, close, or replace the
|
||||
//! target), then **revalidate both window ids** and apply the final-focus
|
||||
//! matrix.
|
||||
//!
|
||||
//! Two corrections that matrix encodes, both of which an earlier revision
|
||||
//! of the framing got wrong:
|
||||
//!
|
||||
//! * `select = true` **keeps the target selected** — restoring the saved
|
||||
//! window unconditionally would erase the request outright;
|
||||
//! * `select = false` restores a saved window **even when it is the
|
||||
//! panel** — a passive display invoked from a focused panel must not
|
||||
//! blur it.
|
||||
//!
|
||||
//! # What Lua may not write
|
||||
//!
|
||||
//! `side` is immutable after placement (Q#BP2a), and `quit_action` /
|
||||
//! `origin_document` are implementation-owned (Q#BP2c): `params` reports
|
||||
//! them for diagnostics, `set_params` refuses them. Lua therefore cannot
|
||||
//! forge a window id, a buffer restore chain, or stale cursor state.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined};
|
||||
use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome};
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId};
|
||||
|
||||
/// The frontend a `pmacs.window.*` call acts for.
|
||||
///
|
||||
/// An interactive command carries authenticated origin; a programmatic
|
||||
/// call falls back to the ambient active frontend, exactly as the
|
||||
/// terminal surface does.
|
||||
pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId {
|
||||
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
|
||||
.and_then(|origin| origin.current())
|
||||
.unwrap_or_else(|| core.borrow().active_frontend_key())
|
||||
}
|
||||
|
||||
/// Run the panel-reconciliation transaction from a Lua-owning context
|
||||
/// (Q#BP2b).
|
||||
///
|
||||
/// The core half is pure; releasing a terminal controller needs the
|
||||
/// manager, which the terminal module publishes as Lua app data for
|
||||
/// exactly this reason. A bare core without one still reconciles — it
|
||||
/// simply has no controller to release.
|
||||
pub(crate) fn reconcile_panel_layout(lua: &Lua, core: &SharedCore, fid: FrontendId) {
|
||||
let outcome = core.borrow_mut().reconcile_panel_layout_core(fid);
|
||||
let Some(window_id) = outcome.released_terminal else {
|
||||
return;
|
||||
};
|
||||
let Some(manager) = lua.app_data_ref::<crate::terminal::SharedTerminalManager>() else {
|
||||
return;
|
||||
};
|
||||
let buffer_id = core
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.map(|window| window.buffer_id);
|
||||
if let Some(buffer_id) = buffer_id {
|
||||
let _ = manager
|
||||
.borrow_mut()
|
||||
.release_controller(crate::terminal::TerminalViewKey::new(
|
||||
fid, window_id, buffer_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A window is "visible" for the final-focus matrix when it is live in
|
||||
/// this frontend's layout and not a derived-hidden panel (Q#BP2b).
|
||||
fn visible(core: &SharedCore, fid: FrontendId, win: WindowId) -> bool {
|
||||
let core = core.borrow();
|
||||
let Some(view) = core.views.get(&fid) else {
|
||||
return false;
|
||||
};
|
||||
if !view.layout.iter_ids().contains(&win) {
|
||||
return false;
|
||||
}
|
||||
!(view.panel_hidden
|
||||
&& core
|
||||
.windows
|
||||
.get(&win)
|
||||
.is_some_and(crate::window::Window::is_side))
|
||||
}
|
||||
|
||||
/// Phase 2 of the display transaction (Q#BP4).
|
||||
fn complete_display(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
outcome: DisplayOutcome,
|
||||
fire: HookKind,
|
||||
) -> mlua::Result<()> {
|
||||
core.borrow_mut().focus_window(fid, outcome.target);
|
||||
match fire {
|
||||
HookKind::AfterSwitch => {
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
}
|
||||
HookKind::AfterLoad => {
|
||||
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||
}
|
||||
HookKind::None => {}
|
||||
}
|
||||
// A hook may have resized, closed, or replaced the target, so
|
||||
// reconcile BEFORE the final-focus decision reads visibility.
|
||||
reconcile_panel_layout(lua, core, fid);
|
||||
|
||||
let target_ok = visible(core, fid, outcome.target);
|
||||
let saved_ok = visible(core, fid, outcome.saved_active);
|
||||
let final_focus = match (outcome.select, target_ok, saved_ok) {
|
||||
(true, true, _) => Some(outcome.target),
|
||||
(true, false, true) => Some(outcome.saved_active),
|
||||
(false, _, true) => Some(outcome.saved_active),
|
||||
(false, true, false) => Some(outcome.target),
|
||||
// Both ids died with the hook: fall back to the non-side target
|
||||
// rule rather than leaving focus on a dead window.
|
||||
_ => None,
|
||||
};
|
||||
let resolved = match final_focus {
|
||||
Some(win) => win,
|
||||
None => core
|
||||
.borrow()
|
||||
.non_side_target(fid)
|
||||
.map_err(mlua::Error::runtime)?,
|
||||
};
|
||||
core.borrow_mut().focus_window(fid, resolved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the shared `{side, window, height, dedicated, select}` option
|
||||
/// table.
|
||||
fn parse_request(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
opts: Option<Table>,
|
||||
) -> mlua::Result<DisplayRequest> {
|
||||
let mut request = DisplayRequest::new(buffer_id);
|
||||
let Some(opts) = opts else {
|
||||
return Ok(request);
|
||||
};
|
||||
if let Some(side) = opts.get::<Option<String>>("side")? {
|
||||
request.side = Some(Side::from_name(&side).ok_or_else(|| {
|
||||
mlua::Error::runtime(format!(
|
||||
"pmacs.window.display: unsupported side {side:?} (only \"bottom\" ships)"
|
||||
))
|
||||
})?);
|
||||
}
|
||||
if let Some(raw) = opts.get::<Option<u64>>("window")? {
|
||||
request.window = Some(lookup_window(core, fid, raw)?);
|
||||
}
|
||||
if let Some(height) = opts.get::<Option<u32>>("height")? {
|
||||
request.height = Some(height);
|
||||
}
|
||||
if let Some(dedicated) = opts.get::<Option<bool>>("dedicated")? {
|
||||
request.dedicated = Some(dedicated);
|
||||
}
|
||||
if let Some(select) = opts.get::<Option<bool>>("select")? {
|
||||
request.select = Some(select);
|
||||
}
|
||||
// The setting is resolved against the buffer being displayed, and
|
||||
// only consumed when the slot is actually CREATED (Q#BP3).
|
||||
request.default_panel_rows = config_u32(
|
||||
lua,
|
||||
"window.panel-height",
|
||||
Some(buffer_id),
|
||||
DEFAULT_PANEL_ROWS,
|
||||
)
|
||||
.max(MIN_WINDOW_OUTER_ROWS);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Resolve a raw Lua window id, refusing one that is not live in the
|
||||
/// acting frontend's layout (Q#BP11).
|
||||
fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result<WindowId> {
|
||||
let core = core.borrow();
|
||||
let view = core
|
||||
.views
|
||||
.get(&fid)
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout"))?;
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.find(|id| id.raw() == raw)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime(format!(
|
||||
"pmacs.window: window {raw} is not live in this frontend's layout"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Install the bottom-panel surface onto the existing `pmacs.window`
|
||||
/// table.
|
||||
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display",
|
||||
lua.create_function(
|
||||
move |lua, (buffer, opts): (BufferIdLua, Option<Table>)| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let request = parse_request(lua, &cc, fid, buffer.0, opts)?;
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.display_buffer(fid, &request)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
complete_display(lua, &cc, fid, outcome, HookKind::AfterSwitch)?;
|
||||
Ok(outcome.target.raw())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP11b — the target-aware load transaction. `find_or_open`
|
||||
// switches the ACTIVE window in both branches before firing
|
||||
// hooks, so a visit to a previously unopened file would replace
|
||||
// a focused panel before any display policy could help.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display_file",
|
||||
lua.create_function(
|
||||
move |lua, (path, opts): (String, Option<Table>)| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
let mut explicit_window = None;
|
||||
let mut select = None;
|
||||
if let Some(opts) = opts.as_ref() {
|
||||
if let Some(raw) = opts.get::<Option<u64>>("window")? {
|
||||
explicit_window = Some(lookup_window(&cc, fid, raw)?);
|
||||
}
|
||||
select = opts.get::<Option<bool>>("select")?;
|
||||
}
|
||||
// 1. Side-effect-free dedup: do NOT read the file yet.
|
||||
let existing = cc.borrow().find_buffer_for_path(&path_buf);
|
||||
// 2. Resolve the destination BEFORE I/O, so a
|
||||
// dedicated origin cannot force load-before-failure.
|
||||
cc.borrow()
|
||||
.probe_display_target(fid, existing, explicit_window)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
// 3. Load, dedup, or create the path-backed buffer.
|
||||
let (buffer_id, fire) = cc
|
||||
.borrow_mut()
|
||||
.resolve_target_buffer(&path_buf)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
// 4. Enter Q#BP4's transaction, so any hook observes
|
||||
// the DOCUMENT TARGET as active.
|
||||
let mut request = DisplayRequest::new(buffer_id);
|
||||
request.window = explicit_window;
|
||||
request.select = select;
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.display_buffer(fid, &request)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
complete_display(lua, &cc, fid, outcome, fire)?;
|
||||
Ok(outcome.target.raw())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP11a — the non-side target: what an ordinary visit from a
|
||||
// panel should address.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display_target",
|
||||
lua.create_function(move |lua, ()| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let core = cc.borrow();
|
||||
core.non_side_target(fid)
|
||||
.map(WindowId::raw)
|
||||
.map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// The acting frontend's side window, or nil.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"panel",
|
||||
lua.create_function(move |lua, ()| -> mlua::Result<Option<u64>> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
Ok(cc.borrow().side_window_for(fid).map(WindowId::raw))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP2c — `window.quit`. A window with no recorded action gets
|
||||
// a pointed error WITHOUT closing or switching anything.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"quit",
|
||||
lua.create_function(move |lua, target: Option<u64>| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let target = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => cc
|
||||
.borrow()
|
||||
.views
|
||||
.get(&fid)
|
||||
.map(|view| view.active)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime("pmacs.window.quit: no acting frontend view")
|
||||
})?,
|
||||
};
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.quit_window(fid, target)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
match outcome {
|
||||
QuitOutcome::Deleted { focus } => {
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
if let Some(focus) = focus {
|
||||
cc.borrow_mut().focus_window(fid, focus);
|
||||
}
|
||||
}
|
||||
QuitOutcome::Restored { target, .. } => {
|
||||
// Restoring is an ordinary presentation change:
|
||||
// fire the switch hook so store-backed overlays
|
||||
// reattach to the reinstated buffer.
|
||||
cc.borrow_mut().focus_window(fid, target);
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
if visible(&cc, fid, target) {
|
||||
cc.borrow_mut().focus_window(fid, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Read-only diagnostics over `WindowParams` (Q#BP2c).
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"params",
|
||||
lua.create_function(move |lua, target: Option<u64>| -> mlua::Result<Table> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => cc.borrow().active_window_id(),
|
||||
};
|
||||
let core = cc.borrow();
|
||||
let window = core
|
||||
.windows
|
||||
.get(&id)
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window.params: window not live"))?;
|
||||
let table = lua.create_table()?;
|
||||
table.set("window", id.raw())?;
|
||||
table.set("side", window.params.side.map(Side::name))?;
|
||||
table.set("fixed_rows", window.params.fixed_rows)?;
|
||||
table.set("dedicated", window.params.dedicated)?;
|
||||
table.set(
|
||||
"origin_document",
|
||||
window.params.origin_document().map(WindowId::raw),
|
||||
)?;
|
||||
table.set(
|
||||
"quit_action",
|
||||
window.params.quit_action().map(|action| match action {
|
||||
crate::window::QuitAction::Delete => "delete",
|
||||
crate::window::QuitAction::Restore { .. } => "restore",
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
"quit_depth",
|
||||
window
|
||||
.params
|
||||
.quit_action()
|
||||
.map_or(0, crate::window::QuitAction::depth),
|
||||
)?;
|
||||
table.set(
|
||||
"hidden",
|
||||
window.is_side() && core.panel_hidden_for(fid),
|
||||
)?;
|
||||
Ok(table)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Only `fixed_rows` and `dedicated` are writable (Q#BP2c).
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"set_params",
|
||||
lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = lookup_window(&cc, fid, target)?;
|
||||
for key in ["side", "origin_document", "quit_action"] {
|
||||
if opts.get::<Value>(key)? != Value::Nil {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"pmacs.window.set_params: `{key}` is not settable"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let height = match opts.get::<Option<u32>>("fixed_rows")? {
|
||||
Some(rows) => Some(
|
||||
crate::editor_core::EditorCore::clamp_panel_rows(rows)
|
||||
.map_err(mlua::Error::runtime)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let dedicated = opts.get::<Option<bool>>("dedicated")?;
|
||||
{
|
||||
let mut core = cc.borrow_mut();
|
||||
let window = core.windows.get_mut(&id).ok_or_else(|| {
|
||||
mlua::Error::runtime("pmacs.window.set_params: window not live")
|
||||
})?;
|
||||
if let Some(rows) = height {
|
||||
// Inert on an ordinary window by construction:
|
||||
// the fixed map is built from side windows only.
|
||||
window.params.fixed_rows = Some(rows);
|
||||
}
|
||||
if let Some(dedicated) = dedicated {
|
||||
window.params.dedicated = dedicated;
|
||||
}
|
||||
}
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP5b — `resize(win, delta_rows)` resolves from the SUPPLIED
|
||||
// window; the `window.enlarge` / `window.shrink` commands are
|
||||
// implicitly active.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"resize",
|
||||
lua.create_function(
|
||||
move |lua, (target, delta): (Option<u64>, i32)| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => cc.borrow().active_window_id(),
|
||||
};
|
||||
let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| {
|
||||
mlua::Error::runtime(
|
||||
"pmacs.window.resize: this frontend has not declared its geometry yet",
|
||||
)
|
||||
})?;
|
||||
let minima: std::collections::HashMap<WindowId, u32> = {
|
||||
let core = cc.borrow();
|
||||
core.views
|
||||
.get(&fid)
|
||||
.map(|view| {
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let buffer_id =
|
||||
core.windows.get(&id).map(|w| w.buffer_id);
|
||||
(
|
||||
id,
|
||||
config_u32(
|
||||
lua,
|
||||
"window.min-height",
|
||||
buffer_id,
|
||||
MIN_WINDOW_OUTER_ROWS,
|
||||
)
|
||||
.max(MIN_WINDOW_OUTER_ROWS),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
cc.borrow_mut()
|
||||
.resize_boundary(fid, id, delta, area_rows, &|id| {
|
||||
minima.get(&id).copied().unwrap_or(MIN_WINDOW_OUTER_ROWS)
|
||||
})
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -109,7 +109,12 @@ pub fn paint_other_frontend_overlays(
|
|||
return;
|
||||
}
|
||||
let text_area = Rect::new(0, 0, text_rows, term_size.cols);
|
||||
let placements = core.active_layout().compute(text_area);
|
||||
// Bottom-panel arc (R5-B1): this pass derives its own text-area
|
||||
// `Rect` instead of reusing `window_placements`, so it must ask for
|
||||
// the same fixed extents — otherwise every peer cursor paints at the
|
||||
// row it would occupy with no panel open.
|
||||
let fixed = core.panel_fixed_rows(core.active_frontend_key(), text_rows);
|
||||
let placements = core.active_layout().compute(text_area, &fixed);
|
||||
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
|
|||
597
src/window.rs
597
src/window.rs
|
|
@ -154,6 +154,201 @@ pub fn decimal_digits(mut n: usize) -> u32 {
|
|||
d
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window parameters (bottom-panel arc, Q#BP2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which edge of the frame a *side window* is pinned to.
|
||||
///
|
||||
/// Stage 1 of the bottom-panel arc ships exactly one side. Left / right /
|
||||
/// top are named deferrals, so the enum stays closed rather than
|
||||
/// accepting a value no allocator honors: a Lua caller asking for an
|
||||
/// unsupported side gets a pointed error at the boundary instead of a
|
||||
/// silently ordinary window.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Side {
|
||||
/// Pinned to the bottom of the frame (the panel slot).
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Parse the Lua-facing spelling. `None` for every unsupported value.
|
||||
#[must_use]
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"bottom" => Some(Self::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-facing spelling.
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structural floor for a window's **outer** row extent: one text row
|
||||
/// plus its mode line (`content = outer - 1`).
|
||||
///
|
||||
/// Every programmatic source of `fixed_rows` clamps a nonzero request up
|
||||
/// to this floor; a request of `0` is rejected rather than being an
|
||||
/// invisible "open" (Q#BP2). This is *not* a promise that the layout can
|
||||
/// never produce a smaller rect — [`Layout::compute`] has always been
|
||||
/// allowed to hand out zero extents on an intrinsically tiny frame. The
|
||||
/// bounded promise is narrower: the panel allocator never makes an
|
||||
/// otherwise satisfiable document tree unsatisfiable.
|
||||
pub const MIN_WINDOW_OUTER_ROWS: u32 = 2;
|
||||
|
||||
/// Default `window.panel-height`: outer rows a freshly created panel
|
||||
/// takes when the caller supplies no explicit `height` (Q#BP11).
|
||||
pub const DEFAULT_PANEL_ROWS: u32 = 12;
|
||||
|
||||
/// How far back [`QuitAction::Restore`] chains may be retained before the
|
||||
/// oldest retained presentation is truncated to [`QuitAction::Delete`]
|
||||
/// (Q#BP2c, R4-B6). Repeated panel replacement would otherwise grow the
|
||||
/// recursive history without bound.
|
||||
pub const MAX_PANEL_QUIT_DEPTH: usize = 64;
|
||||
|
||||
/// What `window.quit` does to a side window (Q#BP2c).
|
||||
///
|
||||
/// Present only on a side window; ordinary windows and every capability
|
||||
/// fallback carry `None`. Replacing a side presentation captures the
|
||||
/// outgoing one in `Restore` so `C → B → A → delete` restores the actual
|
||||
/// presentations rather than forgetting `A` or leaking `C`'s height and
|
||||
/// dedication into it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum QuitAction {
|
||||
/// Close the side window and collapse its wrapper.
|
||||
Delete,
|
||||
/// Reinstate a previously displayed presentation, then fall back to
|
||||
/// `then` on the next quit.
|
||||
Restore {
|
||||
/// Buffer that was displayed. Revalidated at quit time: a killed
|
||||
/// buffer degrades the whole entry to [`QuitAction::Delete`].
|
||||
buffer_id: BufferId,
|
||||
/// Requested outer rows of that presentation.
|
||||
fixed_rows: u32,
|
||||
/// Whether that presentation was dedicated.
|
||||
dedicated: bool,
|
||||
/// Saved cursor, clamped against the buffer's current contents.
|
||||
cursor: Position,
|
||||
/// Saved first visible line.
|
||||
view_top: usize,
|
||||
/// Saved sticky goal column.
|
||||
goal_col: Option<u32>,
|
||||
/// Saved region, if one was active.
|
||||
selection: Option<Selection>,
|
||||
/// The action that was in force *before* this presentation
|
||||
/// replaced its predecessor.
|
||||
then: Box<QuitAction>,
|
||||
},
|
||||
}
|
||||
|
||||
impl QuitAction {
|
||||
/// Number of retained presentations in this chain, counted
|
||||
/// iteratively so a long history can never blow the stack.
|
||||
#[must_use]
|
||||
pub fn depth(&self) -> usize {
|
||||
let mut depth = 0usize;
|
||||
let mut cursor = self;
|
||||
while let Self::Restore { then, .. } = cursor {
|
||||
depth += 1;
|
||||
cursor = then;
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
/// Truncate the oldest retained `Restore` to [`QuitAction::Delete`]
|
||||
/// so the chain holds at most `cap` presentations. Iterative, like
|
||||
/// [`Self::depth`].
|
||||
pub fn truncate_to(&mut self, cap: usize) {
|
||||
if cap == 0 {
|
||||
*self = Self::Delete;
|
||||
return;
|
||||
}
|
||||
let mut kept = 0usize;
|
||||
let mut cursor = self;
|
||||
loop {
|
||||
match cursor {
|
||||
Self::Delete => return,
|
||||
Self::Restore { then, .. } => {
|
||||
kept += 1;
|
||||
if kept >= cap {
|
||||
**then = Self::Delete;
|
||||
return;
|
||||
}
|
||||
cursor = then;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-window display-policy parameters (Q#BP2).
|
||||
///
|
||||
/// `side` is immutable after placement; `quit_action` and
|
||||
/// `origin_document` are implementation-owned bookkeeping that the Lua
|
||||
/// `set_params` surface refuses to write (Q#BP2c), so Lua cannot forge a
|
||||
/// window id, a buffer restore chain, or stale cursor state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WindowParams {
|
||||
/// Side this window is pinned to, or `None` for an ordinary
|
||||
/// document window. Immutable after placement (Q#BP2a).
|
||||
pub side: Option<Side>,
|
||||
/// Requested **outer** rows (including the mode line) when this is a
|
||||
/// side window. Inert on any other window — the fixed map is built
|
||||
/// from side windows only.
|
||||
pub fixed_rows: Option<u32>,
|
||||
/// Whether `display_buffer` may replace this window's buffer.
|
||||
///
|
||||
/// Binds the **policy layer only**: raw `pmacs.window.switch_buffer`
|
||||
/// and `switch_active_buffer_for` deliberately ignore it, because
|
||||
/// they are the low-level escape hatch and every existing caller
|
||||
/// predates this arc (Q#BP2c).
|
||||
pub dedicated: bool,
|
||||
/// See [`WindowParams::quit_action`].
|
||||
quit_action: Option<QuitAction>,
|
||||
/// See [`WindowParams::origin_document`].
|
||||
origin_document: Option<WindowId>,
|
||||
}
|
||||
|
||||
impl WindowParams {
|
||||
/// What `window.quit` does here, if anything.
|
||||
#[must_use]
|
||||
pub fn quit_action(&self) -> Option<&QuitAction> {
|
||||
self.quit_action.as_ref()
|
||||
}
|
||||
|
||||
/// Install (or clear) the quit action. Rust-internal: no Lua path
|
||||
/// reaches this.
|
||||
pub fn set_quit_action(&mut self, action: Option<QuitAction>) {
|
||||
self.quit_action = action;
|
||||
}
|
||||
|
||||
/// The remembered document window this side window was entered
|
||||
/// from (Q#BP2c). Recorded at panel creation, refreshed on every
|
||||
/// focus transition from a non-side window into the panel, and
|
||||
/// revalidated on every use.
|
||||
#[must_use]
|
||||
pub fn origin_document(&self) -> Option<WindowId> {
|
||||
self.origin_document
|
||||
}
|
||||
|
||||
/// Record (or clear) the remembered document window. Rust-internal.
|
||||
pub fn set_origin_document(&mut self, origin: Option<WindowId>) {
|
||||
self.origin_document = origin;
|
||||
}
|
||||
|
||||
/// True iff this window is pinned to a side.
|
||||
#[must_use]
|
||||
pub fn is_side(&self) -> bool {
|
||||
self.side.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// One leaf of the window tree: a buffer plus per-window state.
|
||||
pub struct Window {
|
||||
/// Unique identifier.
|
||||
|
|
@ -186,6 +381,9 @@ pub struct Window {
|
|||
/// Line-number gutter mode for this window (UX gutter arc). `Off` by
|
||||
/// default → no gutter, no coordinate change.
|
||||
pub line_numbers: LineNumberMode,
|
||||
/// Display-policy parameters (bottom-panel arc, Q#BP2). Default for
|
||||
/// every ordinary window: no side, no fixed extent, undedicated.
|
||||
pub params: WindowParams,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
|
|
@ -204,9 +402,16 @@ impl Window {
|
|||
goal_col: None,
|
||||
last_visible_rows: 0,
|
||||
line_numbers: LineNumberMode::Off,
|
||||
params: WindowParams::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this window is pinned to a side (bottom-panel arc).
|
||||
#[must_use]
|
||||
pub fn is_side(&self) -> bool {
|
||||
self.params.is_side()
|
||||
}
|
||||
|
||||
/// Width in cells this window's line-number gutter occupies, or `0`
|
||||
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
|
||||
/// the renderer caps this against the window width and applies it as a
|
||||
|
|
@ -305,6 +510,23 @@ pub struct Layout {
|
|||
pub root: LayoutNode,
|
||||
}
|
||||
|
||||
/// A frontend's last authoritative cell-equivalent frame capacity
|
||||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// `geometry_epoch` is a monotonically increasing declaration id owned by
|
||||
/// the frontend. Grid / `LOCAL` views cache their real attach and resize
|
||||
/// sizes here with an internal epoch; a semantic view stays `None` —
|
||||
/// **unknown**, never `24×80` — until Stage 2's authenticated
|
||||
/// `FrontendCellGeometry` fills it.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeclaredFrameGeometry {
|
||||
/// Monotonic declaration id. A lower or repeated epoch carrying
|
||||
/// different data is stale.
|
||||
pub geometry_epoch: u64,
|
||||
/// Whole-frame capacity in cells, including the one global status row.
|
||||
pub total: CellSize,
|
||||
}
|
||||
|
||||
/// T M10.8 — one attached frontend's view of the editor.
|
||||
///
|
||||
/// Per-frontend state for multi-frontend operation: the split tree
|
||||
|
|
@ -346,6 +568,32 @@ pub struct FrontendView {
|
|||
/// explicitly, so the projection is never inferred from a
|
||||
/// `FrontendId` (**Bet B8**).
|
||||
pub fold_projection: bool,
|
||||
/// Whether this frontend can *render* a side window (bottom-panel
|
||||
/// arc, Q#BP13).
|
||||
///
|
||||
/// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId) and
|
||||
/// every grid session. Stage 1 sets `false` for every semantic
|
||||
/// session — the GPU band is Stage 2 — so a `display` carrying a
|
||||
/// `side` falls back to the non-side target and **discards every
|
||||
/// side-specific parameter** rather than pinning a document window it
|
||||
/// could not show. Like `fold_projection`, deliberately has no
|
||||
/// `Default`: every construction site chooses explicitly.
|
||||
pub panel_capable: bool,
|
||||
/// This frontend's last authoritative frame capacity, or `None` while
|
||||
/// it is **unknown** (Q#BP2b).
|
||||
///
|
||||
/// The panel allocator is the only consumer, and it must never guess:
|
||||
/// a panel requested before a real declaration stays non-presentable
|
||||
/// rather than being sized against the GPU attach request's permanent
|
||||
/// `24×80` placeholder.
|
||||
pub frame_geometry: Option<DeclaredFrameGeometry>,
|
||||
/// Cached derived layout state: the side window exists but cannot be
|
||||
/// satisfied on the current frame (Q#BP2b).
|
||||
///
|
||||
/// Recomputed from authoritative geometry by
|
||||
/// `EditorState::reconcile_panel_layout`; never persisted, never set
|
||||
/// from Lua, and never `true` while no side window exists.
|
||||
pub panel_hidden: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
|
|
@ -359,17 +607,75 @@ impl Layout {
|
|||
|
||||
/// Walk the tree and assign each leaf a viewport rectangle.
|
||||
///
|
||||
/// Splits divide proportionally according to their weights. If a
|
||||
/// child's allocated extent is `0` (terminal too small for the
|
||||
/// Splits divide proportionally according to their weights, except
|
||||
/// that a leaf listed in `fixed` takes exactly that many **rows** out
|
||||
/// of a horizontal split before the remainder is divided (Q#BP2).
|
||||
/// The map is the *effective* allocation, not the stored request: a
|
||||
/// hidden panel is passed as `0`, which gives it an empty rect and
|
||||
/// hands every reclaimed row back to the document subtree.
|
||||
///
|
||||
/// `fixed` is interpreted only on leaves of a **horizontal** split —
|
||||
/// a vertical split divides columns, where a row count means nothing
|
||||
/// — and the last flexible child still takes the remainder, so a tree
|
||||
/// with no fixed leaves computes byte-identically to before this arc.
|
||||
/// If a child's allocated extent is `0` (terminal too small for the
|
||||
/// split), that child receives an empty rect, and renderers must
|
||||
/// skip it.
|
||||
#[must_use]
|
||||
pub fn compute(&self, area: Rect) -> HashMap<WindowId, Rect> {
|
||||
pub fn compute(&self, area: Rect, fixed: &HashMap<WindowId, u32>) -> HashMap<WindowId, Rect> {
|
||||
let mut out = HashMap::new();
|
||||
compute_node(&self.root, area, &mut out);
|
||||
compute_node(&self.root, area, fixed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The single side leaf among `sides`, if this layout holds one.
|
||||
///
|
||||
/// `sides` answers "is this window pinned to a side"; the caller owns
|
||||
/// the `Window` table, so the predicate is injected rather than
|
||||
/// duplicated here. At most one bottom side leaf exists per
|
||||
/// `FrontendView` (Q#BP2a).
|
||||
#[must_use]
|
||||
pub fn side_leaf(&self, sides: impl Fn(WindowId) -> bool) -> Option<WindowId> {
|
||||
self.iter_ids().into_iter().find(|id| sides(*id))
|
||||
}
|
||||
|
||||
/// The document subtree beneath the root-level panel wrapper.
|
||||
///
|
||||
/// A side window is installed as the final child of a horizontal
|
||||
/// split wrapping the entire prior root (Q#BP2a), so the document
|
||||
/// subtree is that wrapper's first child. Returns `None` when the
|
||||
/// tree does not have that exact shape.
|
||||
#[must_use]
|
||||
pub fn document_subtree(&self, side: WindowId) -> Option<&LayoutNode> {
|
||||
match &self.root {
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} if children.len() == 2
|
||||
&& matches!(children[1], LayoutNode::Leaf(id) if id == side) =>
|
||||
{
|
||||
Some(&children[0])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the entire current root in a horizontal split whose final
|
||||
/// child is `side` (Q#BP2a).
|
||||
///
|
||||
/// `fixed_rows` makes the panel's weight inert, so the prior root
|
||||
/// keeps the flexible remainder and its **structure** — nodes,
|
||||
/// weights, order, ids — is untouched (Bet B6).
|
||||
pub fn install_side_leaf(&mut self, side: WindowId) {
|
||||
let prior = std::mem::replace(&mut self.root, LayoutNode::Leaf(side));
|
||||
self.root = LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
weights: vec![1, 1],
|
||||
children: vec![prior, LayoutNode::Leaf(side)],
|
||||
};
|
||||
}
|
||||
|
||||
/// All [`WindowId`]s in left→right / top→bottom order.
|
||||
#[must_use]
|
||||
pub fn iter_ids(&self) -> Vec<WindowId> {
|
||||
|
|
@ -414,25 +720,211 @@ impl Layout {
|
|||
/// if the layout has only one window.
|
||||
#[must_use]
|
||||
pub fn focus_next(&self, current: WindowId) -> WindowId {
|
||||
let ids = self.iter_ids();
|
||||
match ids.iter().position(|&id| id == current) {
|
||||
Some(i) => ids[(i + 1) % ids.len()],
|
||||
None => *ids.first().unwrap_or(¤t),
|
||||
}
|
||||
self.focus_step(current, true, &|_| true)
|
||||
}
|
||||
|
||||
/// Step focus to the previous window.
|
||||
#[must_use]
|
||||
pub fn focus_prev(&self, current: WindowId) -> WindowId {
|
||||
self.focus_step(current, false, &|_| true)
|
||||
}
|
||||
|
||||
/// [`Self::focus_next`] / [`Self::focus_prev`] restricted to windows
|
||||
/// `eligible` accepts (Q#BP6: a hidden panel is never a focus
|
||||
/// destination, though it becomes one again as soon as it reappears).
|
||||
///
|
||||
/// A currently focused ineligible window can always leave, so the
|
||||
/// caller can never strand focus: `current` itself is not filtered.
|
||||
#[must_use]
|
||||
pub fn focus_step(
|
||||
&self,
|
||||
current: WindowId,
|
||||
forward: bool,
|
||||
eligible: &impl Fn(WindowId) -> bool,
|
||||
) -> WindowId {
|
||||
let ids = self.iter_ids();
|
||||
match ids.iter().position(|&id| id == current) {
|
||||
Some(i) => ids[(i + ids.len() - 1) % ids.len()],
|
||||
None => *ids.first().unwrap_or(¤t),
|
||||
if ids.is_empty() {
|
||||
return current;
|
||||
}
|
||||
let Some(start) = ids.iter().position(|&id| id == current) else {
|
||||
return ids
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| eligible(*id))
|
||||
.unwrap_or_else(|| *ids.first().unwrap_or(¤t));
|
||||
};
|
||||
let n = ids.len();
|
||||
for step in 1..=n {
|
||||
let i = if forward {
|
||||
(start + step) % n
|
||||
} else {
|
||||
(start + n - (step % n)) % n
|
||||
};
|
||||
if eligible(ids[i]) {
|
||||
return ids[i];
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Index path from the root to `target`'s leaf, or `None` when the
|
||||
/// layout does not hold it.
|
||||
#[must_use]
|
||||
pub fn path_to(&self, target: WindowId) -> Option<Vec<usize>> {
|
||||
let mut path = Vec::new();
|
||||
path_to_node(&self.root, target, &mut path).then_some(path)
|
||||
}
|
||||
|
||||
/// The node at `path`, or `None` when the path does not resolve.
|
||||
#[must_use]
|
||||
pub fn node_at(&self, path: &[usize]) -> Option<&LayoutNode> {
|
||||
let mut node = &self.root;
|
||||
for &i in path {
|
||||
match node {
|
||||
LayoutNode::Split { children, .. } => node = children.get(i)?,
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Mutable [`Self::node_at`].
|
||||
pub fn node_at_mut(&mut self, path: &[usize]) -> Option<&mut LayoutNode> {
|
||||
let mut node = &mut self.root;
|
||||
for &i in path {
|
||||
match node {
|
||||
LayoutNode::Split { children, .. } => node = children.get_mut(i)?,
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// The horizontal boundary immediately **below** `target` (Q#BP5b
|
||||
/// rule 2), or `None` when there is none.
|
||||
///
|
||||
/// Walk up from the leaf to the nearest horizontal-split ancestor at
|
||||
/// which the path child has a **following sibling**. "Nearest
|
||||
/// horizontal ancestor" alone is wrong: when the subtree is that
|
||||
/// ancestor's *final* child there is no boundary below it there, and
|
||||
/// the real one is further up. This is also the boundary a drag on
|
||||
/// `target`'s bottom mode-line row moves, so keyboard resize and drag
|
||||
/// are the same operation (acceptance 31).
|
||||
#[must_use]
|
||||
pub fn boundary_below(&self, target: WindowId) -> Option<SplitBoundary> {
|
||||
let path = self.path_to(target)?;
|
||||
for depth in (0..path.len()).rev() {
|
||||
let parent_path = &path[..depth];
|
||||
let child_index = path[depth];
|
||||
let LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} = self.node_at(parent_path)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if child_index + 1 < children.len() {
|
||||
return Some(SplitBoundary {
|
||||
path: parent_path.to_vec(),
|
||||
upper: child_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal split boundary: the split node plus the index of the
|
||||
/// child immediately **above** the dividing line (Q#BP5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SplitBoundary {
|
||||
/// Index path from the root to the horizontal split node.
|
||||
pub path: Vec<usize>,
|
||||
/// Index of the child above the boundary; `upper + 1` is below it.
|
||||
pub upper: usize,
|
||||
}
|
||||
|
||||
fn path_to_node(node: &LayoutNode, target: WindowId, path: &mut Vec<usize>) -> bool {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => *id == target,
|
||||
LayoutNode::Split { children, .. } => {
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
path.push(i);
|
||||
if path_to_node(child, target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>) {
|
||||
/// Minimum **outer** rows a subtree needs for every one of its leaves to
|
||||
/// clear [`MIN_WINDOW_OUTER_ROWS`] (Q#BP2).
|
||||
///
|
||||
/// The recursion is the point: "leave the document tree two rows" is
|
||||
/// wrong, because two rows at the root does not give each nested leaf two
|
||||
/// rows. Horizontal splits stack rows, so minima add; vertical splits
|
||||
/// share rows, so the tallest child governs.
|
||||
#[must_use]
|
||||
pub fn subtree_min_rows(node: &LayoutNode) -> u32 {
|
||||
match node {
|
||||
LayoutNode::Leaf(_) => MIN_WINDOW_OUTER_ROWS,
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} => children.iter().map(subtree_min_rows).sum(),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Vertical,
|
||||
children,
|
||||
..
|
||||
} => children.iter().map(subtree_min_rows).max().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same sum/max recursion over the user's `window.min-height`
|
||||
/// *preference* (Q#BP2).
|
||||
///
|
||||
/// `per_leaf` resolves the setting against that window's own buffer
|
||||
/// (buffer-local override → global → default) and is snapshotted once per
|
||||
/// gesture, before any geometry changes. Only **interactive** resize —
|
||||
/// drag, keyboard, and the Stage 2 `PanelResizeRows` — consults this; the
|
||||
/// ordinary layout pass and frame-resize reconciliation use
|
||||
/// [`subtree_min_rows`] alone, so changing a preference can never
|
||||
/// invalidate an existing layout.
|
||||
#[must_use]
|
||||
pub fn interactive_min_rows(node: &LayoutNode, per_leaf: &impl Fn(WindowId) -> u32) -> u32 {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => per_leaf(*id),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} => children
|
||||
.iter()
|
||||
.map(|child| interactive_min_rows(child, per_leaf))
|
||||
.sum(),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Vertical,
|
||||
children,
|
||||
..
|
||||
} => children
|
||||
.iter()
|
||||
.map(|child| interactive_min_rows(child, per_leaf))
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_node(
|
||||
node: &LayoutNode,
|
||||
area: Rect,
|
||||
fixed: &HashMap<WindowId, u32>,
|
||||
out: &mut HashMap<WindowId, Rect>,
|
||||
) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => {
|
||||
out.insert(*id, area);
|
||||
|
|
@ -442,18 +934,61 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
|
|||
weights,
|
||||
children,
|
||||
} => {
|
||||
let total: u32 = weights.iter().map(|w| (*w).max(1)).sum();
|
||||
let primary = match orientation {
|
||||
Orientation::Horizontal => area.size.rows,
|
||||
Orientation::Vertical => area.size.cols,
|
||||
};
|
||||
// Pass 1 — subtract the fixed children. Only a horizontal
|
||||
// split divides rows, so `fixed` is inert anywhere else.
|
||||
let mut extents: Vec<Option<u32>> = vec![None; children.len()];
|
||||
let mut fixed_total: u32 = 0;
|
||||
if matches!(orientation, Orientation::Horizontal) {
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
if let LayoutNode::Leaf(id) = child
|
||||
&& let Some(rows) = fixed.get(id).copied()
|
||||
{
|
||||
// Saturating: a request larger than the frame
|
||||
// takes what is left rather than wrapping. The
|
||||
// caller has already clamped against the document
|
||||
// minimum; this is the last-resort floor.
|
||||
let take = rows.min(primary.saturating_sub(fixed_total));
|
||||
extents[i] = Some(take);
|
||||
fixed_total += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pass 2 — divide the remainder by weight among the flexible
|
||||
// children, preserving last-flexible-takes-the-remainder.
|
||||
let remainder = primary.saturating_sub(fixed_total);
|
||||
let total: u32 = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| extents[*i].is_none())
|
||||
.map(|(i, _)| weights.get(i).copied().unwrap_or(1).max(1))
|
||||
.sum();
|
||||
let last_flexible = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(i, _)| extents[*i].is_none())
|
||||
.map(|(i, _)| i);
|
||||
let mut flexible_used: u32 = 0;
|
||||
let mut cursor: u32 = 0;
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let w = weights.get(i).copied().unwrap_or(1).max(1);
|
||||
let extent = if i + 1 == children.len() {
|
||||
primary - cursor
|
||||
} else {
|
||||
primary * w / total
|
||||
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 child_area = match orientation {
|
||||
Orientation::Horizontal => Rect {
|
||||
|
|
@ -465,13 +1000,21 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
|
|||
size: CellSize::new(area.size.rows, extent),
|
||||
},
|
||||
};
|
||||
compute_node(child, child_area, out);
|
||||
compute_node(child, child_area, fixed, out);
|
||||
cursor += extent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every [`WindowId`] beneath `node`, in layout order.
|
||||
#[must_use]
|
||||
pub fn node_ids(node: &LayoutNode) -> Vec<WindowId> {
|
||||
let mut out = Vec::new();
|
||||
collect_ids(node, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_ids(node: &LayoutNode, out: &mut Vec<WindowId>) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => out.push(*id),
|
||||
|
|
@ -598,7 +1141,7 @@ mod tests {
|
|||
fn single_window_takes_full_area() {
|
||||
let w = id();
|
||||
let layout = Layout::single(w);
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
assert_eq!(placements.get(&w), Some(&rect_24x80()));
|
||||
}
|
||||
|
||||
|
|
@ -608,7 +1151,7 @@ mod tests {
|
|||
let b = id();
|
||||
let mut layout = Layout::single(a);
|
||||
assert!(layout.split_window(a, Orientation::Vertical, b));
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
let ra = placements[&a];
|
||||
let rb = placements[&b];
|
||||
assert_eq!(ra.size.rows, 24);
|
||||
|
|
@ -624,7 +1167,7 @@ mod tests {
|
|||
let b = id();
|
||||
let mut layout = Layout::single(a);
|
||||
assert!(layout.split_window(a, Orientation::Horizontal, b));
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
let ra = placements[&a];
|
||||
let rb = placements[&b];
|
||||
assert_eq!(ra.size.cols, 80);
|
||||
|
|
@ -644,15 +1187,15 @@ mod tests {
|
|||
} else {
|
||||
panic!("expected split");
|
||||
}
|
||||
let p1 = layout.compute(Rect::new(0, 0, 24, 90));
|
||||
let p1 = layout.compute(Rect::new(0, 0, 24, 90), &HashMap::new());
|
||||
assert_eq!(p1[&a].size.cols, 60);
|
||||
assert_eq!(p1[&b].size.cols, 30);
|
||||
// Resize down by 1/3.
|
||||
let p2 = layout.compute(Rect::new(0, 0, 24, 60));
|
||||
let p2 = layout.compute(Rect::new(0, 0, 24, 60), &HashMap::new());
|
||||
assert_eq!(p2[&a].size.cols, 40);
|
||||
assert_eq!(p2[&b].size.cols, 20);
|
||||
// Resize wide.
|
||||
let p3 = layout.compute(Rect::new(0, 0, 24, 300));
|
||||
let p3 = layout.compute(Rect::new(0, 0, 24, 300), &HashMap::new());
|
||||
assert_eq!(p3[&a].size.cols, 200);
|
||||
assert_eq!(p3[&b].size.cols, 100);
|
||||
}
|
||||
|
|
@ -681,7 +1224,7 @@ mod tests {
|
|||
}
|
||||
leaves.extend(more);
|
||||
assert_eq!(leaves.len(), 8);
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
assert_eq!(placements.len(), 8);
|
||||
// Every rect must be non-empty (terminal large enough).
|
||||
for id in &leaves {
|
||||
|
|
|
|||
|
|
@ -1516,6 +1516,9 @@ fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> W
|
|||
layout: Layout::single(win_id),
|
||||
active: win_id,
|
||||
fold_projection,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
win_id
|
||||
|
|
|
|||
|
|
@ -465,6 +465,9 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() {
|
|||
layout: pmacs::window::Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ fn attach_view(
|
|||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
|
|
|
|||
Loading…
Reference in New Issue