diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua new file mode 100644 index 0000000..cf63443 --- /dev/null +++ b/builtin/runtime/window.lua @@ -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" } diff --git a/src/daemon.rs b/src/daemon.rs index 98a688a..cb82c3b 100644 --- a/src/daemon.rs +++ b/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 { 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 diff --git a/src/desktop.rs b/src/desktop.rs index 7f44524..16c15cc 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option let resolve = |wid: WindowId| -> Option { 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 diff --git a/src/editor.rs b/src/editor.rs index 3cf59e6..d6c6a51 100644 --- a/src/editor.rs +++ b/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, + /// 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, } #[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 = { + 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) -> 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 = 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, +) { + 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(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 37cdabf..9ab4bca 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -33,7 +33,10 @@ use crate::rope::Edit; use crate::rope::{Position, Range}; use crate::text_view::TextView; use crate::view::{DisplayCoord, View}; -use crate::window::{FrontendView, Layout, Orientation, Window, WindowId}; +use crate::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Side, Window, WindowId, subtree_min_rows, +}; /// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op. /// @@ -57,6 +60,157 @@ pub enum CrdtOpOrigin { DaemonKey, } +/// One recorded jump origin (bottom-panel arc, Q#BP11c). +/// +/// `window_id` and `side_origin` are what make `M-,` correct once a panel +/// can be a separate window: restoring into the recorded window keeps the +/// document window untouched, and a *side* origin that no longer +/// revalidates is **skipped** rather than degrading to an active-window +/// switch — that degradation is exactly the duplicate-panel corruption +/// this design removes. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct JumpEntry { + /// Window the origin was recorded in. + pub window_id: WindowId, + /// Buffer displayed there at the time. + pub buffer_id: BufferId, + /// Cursor position to restore. + pub position: Position, + /// Whether `window_id` was a side window when recorded. + pub side_origin: bool, +} + +/// Which lifecycle hook Phase 2 of the display transaction must fire +/// **with the target window active** (Q#BP4 / Q#BP11b). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum HookKind { + /// `buffer.after-switch` — a reuse, including a same-buffer no-op. + AfterSwitch, + /// `buffer.after-load` — a fresh load. saveplace, recentf, syntax + /// and LSP all require the document target to be active for this. + AfterLoad, + /// Nothing to fire (a newly created path-backed buffer for a + /// `NotFound` path, matching initial-target / local-startup). + None, +} + +/// A `display_buffer` request (Q#BP3). +/// +/// `height` and `dedicated` are deliberately option-valued at the policy +/// boundary: omission is **not** silently equivalent to an explicit +/// zero/false, which is what lets a user-resized panel keep its height as +/// compile and listview replace one another. +#[derive(Clone, Debug)] +pub struct DisplayRequest { + /// Buffer to display. + pub buffer_id: BufferId, + /// Exact target window. Mutually exclusive with `side`. + pub window: Option, + /// Requested side. Mutually exclusive with `window`. + pub side: Option, + /// Explicit requested outer rows for a side placement. + pub height: Option, + /// Explicit dedication for the installed presentation. + pub dedicated: Option, + /// Explicit final-focus request. Omission defaults to `false` for an + /// actual side target and `true` for an ordinary one; an explicit + /// value survives fallback unchanged. + pub select: Option, + /// The caller's resolved `window.panel-height`, used only when a side + /// slot is **created** with no explicit `height`. + pub default_panel_rows: u32, +} + +impl DisplayRequest { + /// A bare ordinary-placement request for `buffer_id`. + #[must_use] + pub fn new(buffer_id: BufferId) -> Self { + Self { + buffer_id, + window: None, + side: None, + height: None, + dedicated: None, + select: None, + default_panel_rows: crate::window::DEFAULT_PANEL_ROWS, + } + } +} + +/// What Phase 1 of the display transaction decided (Q#BP4). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DisplayOutcome { + /// Window the buffer was installed in. + pub target: WindowId, + /// The frontend's focused window before Phase 1 ran. + pub saved_active: WindowId, + /// Resolved final-focus request. + pub select: bool, + /// Whether this call created the side window — the adopter rollback + /// hook (a terminal whose session fails to start must remove the + /// wrapper it just created). + pub created_side: bool, +} + +/// What [`EditorCore::reconcile_panel_layout_core`] resolved (Q#BP2b). +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct PanelReconciliation { + /// The panel's effective visibility after the transaction. + pub hidden: bool, + /// Whether `hidden` changed in this transaction — Stage 2 keys its + /// authoritative `PanelFrame::Absent` / fresh `Present` on this. + pub changed: bool, + /// A side window whose terminal controller the caller must release, + /// because focus just left an invisible panel. + pub released_terminal: Option, +} + +/// Row extent of an arbitrary subtree, derived from its leaves' computed +/// rects: leaves tile their parent, so the union's height is the node's. +fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { + let ids = crate::window::node_ids(node); + let mut lo = u32::MAX; + let mut hi = 0u32; + for id in ids { + let Some(rect) = placements.get(&id) else { + continue; + }; + lo = lo.min(rect.origin.row); + hi = hi.max(rect.origin.row + rect.size.rows); + } + if lo == u32::MAX { 0 } else { hi - lo } +} + +/// What Phase 1 of `window.quit` did (Q#BP2c). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum QuitOutcome { + /// The side window was closed and its wrapper collapsed. + Deleted { + /// Where focus landed, when the frontend still has a view. + focus: Option, + }, + /// A saved presentation was reinstalled; Phase 2 must fire the + /// ordinary switch hook so overlays reattach. + Restored { + /// The window that was restored. + target: WindowId, + /// The buffer now displayed there. + buffer_id: BufferId, + }, +} + +#[derive(Copy, Clone, Debug)] +struct Placement { + target: WindowId, + kind: PlacementKind, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PlacementKind { + Ordinary, + Side { created: bool, replacing: bool }, +} + /// Live state of an in-progress incremental search (Q#SR5). /// /// Present only while an isearch is running (`EditorCore::search`); @@ -285,7 +439,16 @@ pub struct EditorCore { /// this without limit. Entries naming a now-removed buffer are /// skipped on pop (stale-handle safe, mirrors the registry's /// `Missing` contract). - pub jump_ring: Vec<(BufferId, Position)>, + /// + /// **Per frontend** (bottom-panel arc, Q#BP11c), matching + /// `command_history`. Once a panel is a separate window, an entry + /// must remember *which window* it was recorded in — otherwise `M-,` + /// from a source file would switch the **document** window to the + /// panel's buffer while the panel stays open, duplicating the + /// presentation. Keying the whole ring by frontend additionally + /// stops one frontend consuming or destroying another's navigation + /// trail; detach purges the vector. + pub jump_ring: HashMap>, /// In-buffer incremental search store (Q#SR1). Per-buffer query + /// matches + active index, written by the search session / /// `search.*` commands and read by the decorations producer @@ -388,6 +551,11 @@ impl EditorCore { active: id, // LOCAL is the in-process grid editor (Q#FD21). fold_projection: true, + // …and it renders side windows natively (Q#BP13). + panel_capable: true, + // Real geometry arrives with the first render/resize. + frame_geometry: None, + panel_hidden: false, }, ); Self { @@ -400,7 +568,7 @@ impl EditorCore { minibuffer: Minibuffer::new(), active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), - jump_ring: Vec::new(), + jump_ring: HashMap::new(), search_store: crate::search::make_shared_store(), theme: None, search: None, @@ -595,6 +763,10 @@ impl EditorCore { /// closing a window left others intact). pub fn unregister_frontend_view(&mut self, fid: FrontendId) { self.views.remove(&fid); + // Bottom-panel arc (Q#BP11c): a detached frontend's navigation + // trail dies with its view — its `WindowId`s are gone, and no + // other frontend may pop or destroy those entries. + self.jump_ring.remove(&fid); if self.active_frontend == fid { self.active_frontend = FrontendId::LOCAL; } @@ -668,10 +840,10 @@ impl EditorCore { /// Propagates a load failure (e.g. a since-deleted file) so restore /// can skip that leaf rather than abort. pub fn get_or_load_buffer(&mut self, path: &Path) -> std::io::Result<(BufferId, bool)> { - let normalized = normalize_buffer_path(path.to_path_buf()); - if let Some(id) = self.registry.borrow().find_by_path(&normalized) { + if let Some(id) = self.find_buffer_for_path(path) { return Ok((id, false)); } + let normalized = normalize_buffer_path(path.to_path_buf()); let (bytes, meta) = crate::file_io::load_file(path)?; let display_name = path.display().to_string(); let id = self @@ -683,6 +855,51 @@ impl EditorCore { Ok((id, true)) } + /// The buffer already bound to `path`, under the same normalization + /// [`Self::get_or_load_buffer`] uses — **side-effect free**, so a + /// target-aware display can resolve its destination *before* any I/O + /// (Q#BP11b step 1: an ineligible destination must fail without + /// loading the file). + #[must_use] + pub fn find_buffer_for_path(&self, path: &Path) -> Option { + let normalized = normalize_buffer_path(path.to_path_buf()); + self.registry.borrow().find_by_path(&normalized) + } + + /// The shared resolve/load-without-switch primitive behind both + /// `pmacs.window.display_file` and the daemon's initial-target + /// bootstrap (Q#BP11b). + /// + /// Returns the buffer plus the hook Phase 2 must fire **with the + /// destination window active**: `AfterSwitch` for a dedup hit + /// (including a same-buffer no-op), `AfterLoad` for a fresh load, and + /// `None` for a path that does not exist yet — a `NotFound` path + /// becomes an empty path-backed buffer and fires nothing, matching + /// the initial-target and local-startup contract. + /// + /// One primitive, so two path-normalization, dedup, and hook + /// transactions cannot drift apart. + /// + /// # Errors + /// Any load failure other than `NotFound`. + 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)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let display_path = path.display().to_string(); + let buffer_id = self.registry.borrow_mut().create(display_path); + self.set_buffer_path(buffer_id, Some(path.to_path_buf())); + "[new file]".clone_into(&mut self.status); + Ok((buffer_id, HookKind::None)) + } + Err(error) => Err(format!("cannot open {}: {error}", path.display())), + } + } + /// Cursor of the active window (compatibility shim for callers /// migrated from pre-M2.8 code). #[must_use] @@ -796,11 +1013,27 @@ impl EditorCore { /// origin is evicted (front drop) — the user keeps the most /// recent trail, which is the one they're likely to unwind. pub fn push_jump(&mut self) { - let entry = (self.active_buffer_id(), self.cursor()); - if self.jump_ring.len() >= Self::JUMP_RING_CAP { - self.jump_ring.remove(0); + let fid = self.active_frontend; + let window_id = self.active_window_id(); + let entry = JumpEntry { + window_id, + buffer_id: self.active_buffer_id(), + position: self.cursor(), + side_origin: self + .windows + .get(&window_id) + .is_some_and(crate::window::Window::is_side), + }; + let ring = self.jump_ring.entry(fid).or_default(); + if ring.len() >= Self::JUMP_RING_CAP { + ring.remove(0); } - self.jump_ring.push(entry); + ring.push(entry); + } + + /// Drop one detached frontend's navigation trail (Q#BP11c). + pub fn purge_jump_ring(&mut self, fid: FrontendId) { + self.jump_ring.remove(&fid); } /// Pop the most recent jump origin and move there. Returns @@ -811,21 +1044,66 @@ impl EditorCore { /// it finds a live target or the ring empties), so a jump-back /// never lands on a missing buffer. The restored cursor is /// clamped to the (possibly now shorter) buffer length. + /// + /// # Origin windows (Q#BP11c) + /// + /// The entry restores into its **origin window** when that window is + /// live, belongs to the acting frontend's layout, is not a hidden + /// side window, and **still shows the recorded buffer**. A live panel + /// that has since been replaced does not resurrect its old buffer. + /// + /// When revalidation fails the entry degrades differently by origin + /// kind. A **non-side** origin falls back to today's active-window + /// switch. A **side** origin is *skipped*: switching a panel's buffer + /// into the document window is precisely the duplicate-presentation + /// corruption this design removes. pub fn jump_back(&mut self) -> bool { - while let Some((bid, pos)) = self.jump_ring.pop() { - if !self.registry.borrow().contains(bid) { + let fid = self.active_frontend; + loop { + 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) { continue; } - if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() { + let origin_valid = self + .views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&entry.window_id)) + && self + .windows + .get(&entry.window_id) + .is_some_and(|window| window.buffer_id == entry.buffer_id) + && !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; } - let clamped = pos.min(self.active_buffer_len()); + let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); aw.cursor = clamped; aw.goal_col = None; return true; } - false + } + + /// True when `win` is a side window on `fid` and that frontend's + /// panel is currently derived-hidden (Q#BP2b). + #[must_use] + fn side_window_is_hidden(&self, fid: FrontendId, win: WindowId) -> bool { + self.windows + .get(&win) + .is_some_and(crate::window::Window::is_side) + && self.views.get(&fid).is_some_and(|view| view.panel_hidden) } // ---- incremental search (Q#SR5) ---------------------------------------- @@ -2296,6 +2574,27 @@ impl EditorCore { // ---- window operations ------------------------------------------------- + /// [`Self::split_active`], refusing a side window (Q#BP6): the panel + /// is a leaf of the root-level wrapper, so splitting it would produce + /// a second, unallocatable side slot. + /// + /// # Errors + /// When the active window is a side window. + pub fn try_split_active( + &mut self, + orientation: Orientation, + same_buffer: bool, + ) -> Result { + if self + .windows + .get(&self.active_window_id()) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.split: not available in a side window".into()); + } + Ok(self.split_active(orientation, same_buffer)) + } + /// Split the active window. Returns the new window's id. /// `same_buffer` controls whether the new window opens on the /// active buffer (Emacs default) or a fresh `*scratch*` buffer. @@ -2334,52 +2633,129 @@ impl EditorCore { /// Move focus to the next window in iteration order. pub fn focus_next(&mut self) { - let active = self.active_window_id(); - let next = self.active_layout().focus_next(active); - self.set_active_window_id(next); + self.focus_step(true); } /// Move focus to the previous window in iteration order. pub fn focus_prev(&mut self) { - let active = self.active_window_id(); - let prev = self.active_layout().focus_prev(active); - self.set_active_window_id(prev); + self.focus_step(false); } - /// Close the active window (unless it's the only one in this - /// frontend). Returns false if the active frontend's layout has a - /// single window. + /// Shared `C-x o` traversal, skipping a **hidden** side window + /// (Q#BP6): keys must never route to an invisible panel, and once it + /// reappears traversal reaches it normally again. + /// + /// Also the seam that refreshes `origin_document` (Q#BP2c): entering + /// the panel from document window B must retarget `display_target`, + /// panel visits, and a `Delete`-form `window.quit` at B rather than + /// at whichever window happened to create the panel. + fn focus_step(&mut self, forward: bool) { + let fid = self.active_frontend_key(); + let active = self.active_window_id(); + let hidden_panel = if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + self.side_window_for(fid) + } else { + None + }; + let next = self + .active_layout() + .focus_step(active, forward, &|id| Some(id) != hidden_panel); + self.set_active_window_id(next); + self.note_focus_transition(fid, active, next); + } + + /// Focus an explicit window in the acting frontend, refreshing the + /// panel's remembered document origin on the way (Q#BP2c). + pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + let previous = view.active; + view.active = target; + self.note_focus_transition(fid, previous, target); + } + + /// Close the active window. Returns false when the layout would be + /// left with no **document** window. + /// + /// Q#BP6 narrows the pre-arc "unless it's the only one" rule: a side + /// window is never load-bearing, so closing the panel itself is + /// always legal — including when it is the only other window — while + /// closing the last *non-side* window is always refused. pub fn close_active(&mut self) -> bool { // Per-frontend: gate on the *active frontend's* window count, not // the global `self.windows` set. Every attached frontend keeps its // own windows in `self.windows`, so a global `<= 1` check let a // multi-frontend session close a frontend's last window and then // panic picking a successor from the now-empty layout. - if self.active_layout().iter_ids().len() <= 1 { - return false; - } + let fid = self.active_frontend_key(); let target = self.active_window_id(); + let target_is_side = self + .windows + .get(&target) + .is_some_and(crate::window::Window::is_side); + if !target_is_side { + let remaining_documents = self + .active_layout() + .iter_ids() + .into_iter() + .filter(|id| { + *id != target + && !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .count(); + if remaining_documents == 0 { + return false; + } + } self.active_layout_mut().close_window(target); self.windows.remove(&target); - // Pick an adjacent window as the new focus. - let next = *self - .active_layout() - .iter_ids() - .first() - .expect("at least one window remains"); + if target_is_side { + if 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(); + let next = ids + .iter() + .copied() + .find(|id| { + !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .unwrap_or_else(|| *ids.first().expect("at least one window remains")); + let previous = self.active_window_id(); self.set_active_window_id(next); + self.note_focus_transition(fid, previous, next); true } /// Close every window except the active one, *within the active - /// frontend*. - pub fn close_others(&mut self) { + /// frontend* — including the panel (Q#BP6). + /// + /// # Errors + /// From a side window: a panel cannot swallow the document tree. + pub fn close_others(&mut self) -> Result<(), String> { // Per-frontend: only prune the active frontend's own layout. The // global `self.windows` set holds every frontend's windows, so a // global `retain(|id| id == keep)` deleted OTHER frontends' // windows — leaving their `view.active` dangling and panicking the // next `active_window()` (the multi-frontend close-others crash). let keep = self.active_window_id(); + if self + .windows + .get(&keep) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.close-others: not available in a side window".into()); + } + let fid = self.active_frontend_key(); let doomed: Vec = self .active_layout() .iter_ids() @@ -2390,6 +2766,993 @@ impl EditorCore { for id in doomed { self.windows.remove(&id); } + if let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; + } + Ok(()) + } + + /// The `views` key the active-frontend accessors resolve to. + #[must_use] + pub fn active_frontend_key(&self) -> FrontendId { + if self.views.contains_key(&self.active_frontend) { + self.active_frontend + } else { + FrontendId::LOCAL + } + } + + // ---- side windows + display policy (bottom-panel arc) ------------------ + + /// The one side leaf in `fid`'s layout, if it has one (Q#BP2a). + #[must_use] + pub fn side_window_for(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }) + } + + /// Whether `fid`'s side window exists but is currently hidden. + #[must_use] + pub fn panel_hidden_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_hidden) + && self.side_window_for(fid).is_some() + } + + /// Whether `fid` can render a side window at all (Q#BP13). + #[must_use] + pub fn panel_capable_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_capable) + } + + /// **The** primary document window for `fid` (Q#BP14). + /// + /// The frontend's active window when it is non-side, else its + /// non-side target. Every consumer classified *Projection* in the + /// framing's §1.3 census routes through this rather than through + /// `active_window_for` / `active_buffer_id`, so focusing a panel + /// re-sends no snapshot, suppresses no document, swaps no mirror, + /// and cannot leak into a newly attached frontend's document view. + #[must_use] + pub fn primary_document_window(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + if !self + .windows + .get(&view.active) + .is_some_and(crate::window::Window::is_side) + { + return Some(view.active); + } + self.non_side_target(fid).ok() + } + + /// [`Self::primary_document_window`]'s buffer, falling back to the + /// focused window's when the layout is degenerate. + #[must_use] + pub fn primary_document_buffer(&self, fid: FrontendId) -> Option { + let win = self.primary_document_window(fid)?; + self.windows.get(&win).map(|window| window.buffer_id) + } + + /// The non-side target rule (Q#BP11a). + /// + /// 1. the selected window when it is **not** a side window + /// (byte-identical to pre-arc behavior), + /// 2. else the remembered `origin_document`, when it revalidates, + /// 3. else the first non-side window in `iter_ids()` order, + /// 4. else a pointed error. There is no document leaf from which a + /// valid fallback could be fabricated, and Q#BP6 forbids this as + /// a resting state, so the broken invariant is asserted rather + /// than papered over. + /// + /// # Errors + /// When `fid` has no view, or its layout holds no non-side window. + pub fn non_side_target(&self, fid: FrontendId) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if !is_side(view.active) { + return Ok(view.active); + } + if let Some(origin) = self + .windows + .get(&view.active) + .and_then(|w| w.params.origin_document()) + && view.layout.iter_ids().contains(&origin) + && !is_side(origin) + { + return Ok(origin); + } + if let Some(first) = view.layout.iter_ids().into_iter().find(|id| !is_side(*id)) { + return Ok(first); + } + debug_assert!( + false, + "invariant (Q#BP6): a frontend layout always retains at least one non-side window" + ); + Err("no document window is available".into()) + } + + /// Record the document window a focus transition into the panel came + /// from (Q#BP2c). + /// + /// Called on every focus change. Only a **non-side → side** + /// transition refreshes the memory: panel→panel redisplay and + /// passive display must not overwrite it, and a creation-only + /// origin would go stale the moment the user entered the panel from + /// a different document split. + pub fn note_focus_transition(&mut self, fid: FrontendId, from: WindowId, to: WindowId) { + if from == to { + return; + } + debug_assert!( + self.views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&to)), + "focus transition target must belong to the acting frontend's layout" + ); + let from_side = self + .windows + .get(&from) + .is_some_and(crate::window::Window::is_side); + let to_side = self + .windows + .get(&to) + .is_some_and(crate::window::Window::is_side); + if from_side || !to_side { + return; + } + if let Some(window) = self.windows.get_mut(&to) { + window.params.set_origin_document(Some(from)); + } + } + + /// Minimum outer rows the document subtree beneath `fid`'s panel + /// wrapper needs (Q#BP2). Falls back to the whole root when the tree + /// does not have the wrapper shape. + #[must_use] + fn document_min_rows(&self, fid: FrontendId) -> u32 { + let Some(view) = self.views.get(&fid) else { + return MIN_WINDOW_OUTER_ROWS; + }; + let node = self + .side_window_for(fid) + .and_then(|side| view.layout.document_subtree(side)) + .unwrap_or(&view.layout.root); + subtree_min_rows(node) + } + + /// The panel's **effective** row allocation on a frame whose window + /// area is `area_rows` (Q#BP2), or `None` when it cannot be + /// satisfied and must be hidden. + /// + /// `min(requested, area_rows - subtree_min_rows(document_root))`, then + /// the structural floor. This is the whole bounded promise: the panel + /// allocator never makes an otherwise satisfiable document tree + /// unsatisfiable, and what the frame does to a document tree that + /// could not fit anyway is unchanged behavior. + #[must_use] + pub fn panel_allocation(&self, fid: FrontendId, area_rows: u32) -> Option { + let side = self.side_window_for(fid)?; + let requested = self.windows.get(&side)?.params.fixed_rows?; + let allowed = area_rows.saturating_sub(self.document_min_rows(fid)); + let alloc = requested.min(allowed); + (alloc >= MIN_WINDOW_OUTER_ROWS).then_some(alloc) + } + + /// The fixed-extent map both [`crate::window::Layout::compute`] + /// production callers feed in (Q#BP2, R5-B1). + /// + /// Derived by this one shared helper rather than assembled at each + /// call site: `window_placements` and the peer-presence overlay pass + /// build different areas, and leaving the second on unfixed geometry + /// would paint every peer cursor at the row it would occupy with no + /// panel open. + /// + /// A hidden panel maps to `0`, which is Q#BP2's exact effective + /// geometry for that state: the side leaf gets an empty rect, the + /// document subtree receives every reclaimed row, and the stored + /// request, wrapper, ids, weights, and order all stay intact. + #[must_use] + pub fn panel_fixed_rows(&self, fid: FrontendId, area_rows: u32) -> HashMap { + let mut fixed = HashMap::new(); + let Some(side) = self.side_window_for(fid) else { + return fixed; + }; + if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + fixed.insert(side, 0); + return fixed; + } + fixed.insert(side, self.panel_allocation(fid, area_rows).unwrap_or(0)); + fixed + } + + /// Delete `side` from `fid`'s layout, collapsing the root-level + /// wrapper and rehoming focus (Q#BP2a). + /// + /// Idempotent and safe to call from `kill_buffer`: the wrapper + /// collapse is `Layout::close_window`'s existing + /// `collapse_single_child_splits` pass, so no new tree code runs. + pub fn remove_side_window(&mut self, fid: FrontendId, side: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if !view.layout.close_window(side) { + return; + } + view.panel_hidden = false; + let was_active = view.active == side; + if was_active { + let fallback = *view + .layout + .iter_ids() + .first() + .expect("Q#BP6: a document leaf always survives the wrapper collapse"); + view.active = fallback; + } + self.windows.remove(&side); + if was_active + && let Ok(target) = self.non_side_target(fid) + { + if let Some(view) = self.views.get_mut(&fid) { + 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 + // may reference the removed id. + for window in self.windows.values_mut() { + if window.params.origin_document() == Some(side) { + window.params.set_origin_document(None); + } + } + } + + /// Phase 1 of `window.quit` (Q#BP2c / Q#BP11b). + /// + /// Executes the window's recorded [`QuitAction`], returning the + /// Phase-2 transaction Q#BP4 owns. A `Restore` whose buffer has been + /// killed fails closed to `Delete`, dropping the unusable chain. + /// + /// # Errors + /// A window with no recorded action returns a pointed error **without + /// closing or switching anything** — non-side adopter fallbacks call + /// their own existing restore path instead. + pub fn quit_window( + &mut self, + fid: FrontendId, + target: WindowId, + ) -> Result { + let action = self + .windows + .get(&target) + .ok_or_else(|| format!("window {} is not live", target.raw()))? + .params + .quit_action() + .cloned() + .ok_or_else(|| "window.quit: this window has no quit action".to_string())?; + let action = match action { + QuitAction::Restore { buffer_id, .. } + if !self.registry.borrow().contains(buffer_id) => + { + QuitAction::Delete + } + other => other, + }; + match action { + QuitAction::Delete => { + let saved_active = self.views.get(&fid).map(|view| view.active); + self.remove_side_window(fid, target); + Ok(QuitOutcome::Deleted { + focus: self + .views + .get(&fid) + .map(|view| view.active) + .or(saved_active), + }) + } + QuitAction::Restore { + buffer_id, + fixed_rows, + dedicated, + cursor, + view_top, + goal_col, + selection, + then, + } => { + 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) + }; + let window = self + .windows + .get_mut(&target) + .ok_or_else(|| "window.quit: target vanished".to_string())?; + window.params.fixed_rows = Some(fixed_rows.max(MIN_WINDOW_OUTER_ROWS)); + window.params.dedicated = dedicated; + window.params.set_quit_action(Some(*then)); + // Clamp saved positions against the buffer's CURRENT + // contents: it may have shrunk while the panel showed + // something else. Derived `last_visible_rows` and + // trait-object overlays are deliberately not snapshotted — + // the switch hook reattaches overlays. + window.cursor = cursor.min(len); + window.view_top = view_top; + window.goal_col = goal_col; + window.selection = selection.filter(|sel| sel.anchor <= len); + Ok(QuitOutcome::Restored { + target, + buffer_id, + }) + } + } + } + + /// Clamp a programmatic `fixed_rows` request (Q#BP2). + /// + /// # Errors + /// A request of `0` is rejected rather than being an invisible + /// "open". + pub fn clamp_panel_rows(rows: u32) -> Result { + if rows == 0 { + return Err("panel height must be at least 1 row".into()); + } + Ok(rows.max(MIN_WINDOW_OUTER_ROWS)) + } + + /// The window area a frontend's layout is computed into: the whole + /// declared frame minus the one global status row, matching + /// `window_placements`. `None` while geometry is **unknown**. + #[must_use] + pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { + let geometry = self.views.get(&fid)?.frame_geometry?; + (geometry.total.rows >= 2 && geometry.total.cols > 0) + .then(|| geometry.total.rows - 1) + } + + /// Cache a frontend's authoritative frame capacity (Q#BP2b). + /// + /// Grid / `LOCAL` views call this from their real attach and resize + /// sizes with an internally minted epoch; a semantic view stays + /// `None` until Stage 2's authenticated declaration. A repeated + /// identical size is not a new declaration. + pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if view + .frame_geometry + .is_some_and(|geometry| geometry.total == total) + { + return; + } + let next = view + .frame_geometry + .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); + view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { + geometry_epoch: next, + total, + }); + } + + /// Core half of the idempotent panel-reconciliation transaction + /// (Q#BP2b). The caller owns the terminal manager, so releasing a + /// controller is reported rather than performed. + /// + /// Hiding is a **durable state transition**, not a per-frame effect: + /// a render-time dodge would still route keys to an invisible window + /// and would leave the terminal controller claimed, because the + /// resize path merely returns on zero content without releasing it. + pub fn reconcile_panel_layout_core(&mut self, fid: FrontendId) -> PanelReconciliation { + let mut result = PanelReconciliation::default(); + let Some(side) = self.side_window_for(fid) else { + // `panel_hidden` never describes a panel that no longer + // exists. + if let Some(view) = self.views.get_mut(&fid) { + result.changed = view.panel_hidden; + view.panel_hidden = false; + } + return result; + }; + let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); + // Unknown geometry (a semantic view before Stage 2's declaration) + // and a zero-column frame are both non-presentable, and follow the + // hidden arm rather than being sized against a placeholder. + let satisfiable = self + .frontend_area_rows(fid) + .and_then(|rows| self.panel_allocation(fid, rows)) + .is_some(); + let Some(view) = self.views.get_mut(&fid) else { + return result; + }; + view.panel_hidden = !satisfiable; + result.hidden = !satisfiable; + result.changed = was_hidden != result.hidden; + if satisfiable { + // Focus is deliberately NOT restored when the panel + // reappears — the user moved on; `C-x o` returns. + return result; + } + if view.active == side { + // Durable transition: move focus out and tell the caller to + // release the terminal controller for this view key. + result.released_terminal = Some(side); + if let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) + { + view.active = target; + } + } + result + } + + /// Move the horizontal boundary that `win` owns by `delta_rows`, + /// growing `win` (Q#BP5 / Q#BP5b). + /// + /// `min_for` resolves each leaf's `window.min-height` preference; it + /// is snapshotted by the caller **before** any geometry changes, so + /// one gesture uses one set of minima. + /// + /// # Errors + /// When `win` is not live in `fid`'s layout, when the panel is + /// hidden, or when no adjustable horizontal boundary exists. + pub fn resize_boundary( + &mut self, + fid: FrontendId, + win: WindowId, + delta_rows: i32, + area_rows: u32, + min_for: &impl Fn(WindowId) -> u32, + ) -> Result<(), String> { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + if !view.layout.iter_ids().contains(&win) { + return Err(format!( + "window {} does not belong to this frontend", + win.raw() + )); + } + let win_is_side = self + .windows + .get(&win) + .is_some_and(crate::window::Window::is_side); + if win_is_side && view.panel_hidden { + return Err("window.resize: the panel is not currently visible".into()); + } + // Q#BP5b rule 1: a side window resolves to its OWN fixed + // boundary; rule 2: any other window resolves to the nearest + // horizontal ancestor at which its path child has a following + // sibling — the same boundary a drag on its bottom mode-line row + // moves. + let (boundary, lower_grows) = if win_is_side { + let side = self + .side_window_for(fid) + .ok_or_else(|| "window.resize: no side window".to_string())?; + let path = view + .layout + .path_to(side) + .ok_or_else(|| "window.resize: side window is not in the layout".to_string())?; + let (&last, parent) = path + .split_last() + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?; + if last == 0 { + return Err("window.resize: no adjustable horizontal boundary".into()); + } + ( + crate::window::SplitBoundary { + path: parent.to_vec(), + upper: last - 1, + }, + true, + ) + } else { + ( + view.layout + .boundary_below(win) + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + false, + ) + }; + + let placements = view.layout.compute( + crate::window::Rect::new(0, 0, area_rows, 1), + &self.panel_fixed_rows(fid, area_rows), + ); + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let LayoutNode::Split { children, .. } = view + .layout + .node_at(&boundary.path) + .ok_or_else(|| "window.resize: boundary vanished".to_string())? + else { + return Err("window.resize: boundary is not a split".into()); + }; + let upper_node = &children[boundary.upper]; + let lower_node = &children[boundary.upper + 1]; + let upper_rows = node_row_extent(upper_node, &placements); + let lower_rows = node_row_extent(lower_node, &placements); + let total = upper_rows + lower_rows; + let min_upper = crate::window::interactive_min_rows(upper_node, min_for); + let min_lower = crate::window::interactive_min_rows(lower_node, min_for); + // Preserve the preferred minimum on BOTH sides when the frame can + // satisfy it; when it is already smaller, the motion may not make + // either side worse than it already is. + let floor_upper = min_upper.min(upper_rows); + let floor_lower = min_lower.min(lower_rows); + let boundary_delta = if lower_grows { -delta_rows } else { delta_rows }; + let proposed = i64::from(upper_rows) + i64::from(boundary_delta); + let lo = i64::from(floor_upper); + let hi = i64::from(total.saturating_sub(floor_lower)); + if hi < lo { + return Err("window.resize: no room to move this boundary".into()); + } + let new_upper = u32::try_from(proposed.clamp(lo, hi)) + .map_err(|_| "window.resize: boundary out of range".to_string())?; + let new_lower = total - new_upper; + + // A side window writes `fixed_rows` (its ABSOLUTE height survives + // a terminal resize); a flexible pair writes weights (its RATIO + // survives). That difference is the point. + let lower_id = match lower_node { + LayoutNode::Leaf(id) => Some(*id), + LayoutNode::Split { .. } => None, + }; + let lower_is_side = lower_id.is_some_and(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }); + if lower_is_side { + let id = lower_id.expect("checked above"); + if let Some(window) = self.windows.get_mut(&id) { + window.params.fixed_rows = Some(new_lower.max(MIN_WINDOW_OUTER_ROWS)); + } + return Ok(()); + } + // Rewrite every flexible child's weight as its current row + // extent, with the two adjacent children replaced. Untouched + // siblings therefore keep the extents they already had. + let extents: Vec = children + .iter() + .enumerate() + .map(|(i, child)| { + if i == boundary.upper { + new_upper + } else if i == boundary.upper + 1 { + new_lower + } else { + node_row_extent(child, &placements) + } + }) + .collect(); + let fixed = self.panel_fixed_rows(fid, area_rows); + let view = self + .views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let Some(LayoutNode::Split { + weights, children, .. + }) = view.layout.node_at_mut(&boundary.path) + else { + return Err("window.resize: boundary vanished".into()); + }; + weights.resize(children.len(), 1); + for (i, child) in children.iter().enumerate() { + let pinned = matches!(child, LayoutNode::Leaf(id) if fixed.contains_key(id)); + if !pinned { + weights[i] = extents[i].max(1); + } + } + Ok(()) + } + + /// Install `buffer_id` in an explicit window, resetting its view + /// state exactly as [`Self::switch_active_buffer_for`] does — except + /// that redisplaying the buffer a window **already shows** is a no-op + /// on cursor, viewport, selection, and overlays. + /// + /// # Errors + /// Unknown window or buffer. + pub fn install_buffer_in_window( + &mut self, + window_id: WindowId, + buffer_id: BufferId, + ) -> Result<(), String> { + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let window = self + .windows + .get_mut(&window_id) + .ok_or_else(|| format!("window {window_id:?} is not live"))?; + if window.buffer_id == buffer_id { + return Ok(()); + } + window.buffer_id = buffer_id; + window.text_view = text_view; + window.overlays.clear(); + window.cursor = 0; + window.selection = None; + window.view_top = 0; + window.goal_col = None; + Ok(()) + } + + /// Phase 1 of the display transaction (Q#BP4): choose a target, + /// install the buffer, and report what Phase 2 must do. + /// + /// Contains **no** Lua: the hook fan-out, the reconciliation, and the + /// final-focus matrix all belong to the layer that owns the Lua host. + /// + /// # Errors + /// An unusable exact target, an unsatisfiable placement request, or a + /// layout with no eligible document window. + pub fn display_buffer( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + let saved_active = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .active; + let placement = self.resolve_placement(fid, request)?; + self.apply_placement(fid, request, &placement)?; + let select = request + .select + .unwrap_or(!matches!(placement.kind, PlacementKind::Side { .. })); + Ok(DisplayOutcome { + target: placement.target, + saved_active, + select, + created_side: matches!( + placement.kind, + PlacementKind::Side { + created: true, + .. + } + ), + }) + } + + /// Answer "is there a usable destination for this visit?" **without + /// loading anything** (Q#BP11b step 2, R3-B17). + /// + /// `existing` is the side-effect-free dedup result: `None` means the + /// file is not open yet, in which case an eligible destination must + /// not be dedicated to *any* buffer — otherwise a dedicated origin + /// could force a load that then has nowhere to go. + /// + /// # Errors + /// An exact target that is dead, foreign, or dedicated; or a layout + /// with no eligible document window. + pub fn probe_display_target( + &self, + fid: FrontendId, + existing: Option, + window: Option, + ) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let eligible = |id: WindowId| { + self.windows.get(&id).is_some_and(|w| { + !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) + }) + }; + if let Some(target) = window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display_file: window {} does not belong to this frontend", + target.raw() + )); + } + if !eligible(target) { + return Err(format!( + "display_file: window {} is dedicated to another buffer", + target.raw() + )); + } + return Ok(target); + } + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if let Some(buffer_id) = existing + && let Some(showing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == buffer_id) + }) + { + return Ok(showing); + } + let mut candidates: Vec = Vec::new(); + 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 + .into_iter() + .find(|id| eligible(*id)) + .ok_or_else(|| "display_file: no eligible document window is available".into()) + } + + /// Q#BP3's precedence: exact target, then side affinity, then + /// ordinary reuse. Placement affinity precedes generic reuse — + /// otherwise a persistent `*compilation*` buffer already visible in a + /// document window makes `{side = "bottom"}` silently ignore its + /// requested placement. + fn resolve_placement( + &self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + if request.window.is_some() && request.side.is_some() { + return Err("display: `window` and `side` are mutually exclusive".into()); + } + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + + // 1. Exact target. + if let Some(target) = request.window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display: window {} does not belong to this frontend", + target.raw() + )); + } + let window = self + .windows + .get(&target) + .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; + if window.params.dedicated && window.buffer_id != request.buffer_id { + return Err(format!( + "display: window {} is dedicated to another buffer", + target.raw() + )); + } + if request.height.is_some() && !window.is_side() { + return Err("display: `height` requires a side window".into()); + } + return Ok(Placement { + target, + kind: if window.is_side() { + PlacementKind::Side { + created: false, + replacing: window.buffer_id != request.buffer_id, + } + } else { + PlacementKind::Ordinary + }, + }); + } + + // 2. Side target — only on a panel-capable frontend. + if request.side.is_some() && view.panel_capable { + match self.side_window_for(fid) { + Some(side) => { + let window = self + .windows + .get(&side) + .ok_or_else(|| "display: side window is not live".to_string())?; + if window.buffer_id == request.buffer_id { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: false, + }, + }); + } + if !window.params.dedicated { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: true, + }, + }); + } + // The one side slot is dedicated to another buffer. + // Never create a second one: fall through to the + // ordinary policy, discarding every side-specific + // parameter (Q#BP3 2.iii). + } + None => { + return Ok(Placement { + target: WindowId::next(), + kind: PlacementKind::Side { + created: true, + replacing: false, + }, + }); + } + } + } else if request.height.is_some() { + return Err("display: `height` requires a side window".into()); + } + + // 3. Ordinary target. + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + // 3.i — reuse a visible NON-side window already showing it. An + // ordinary display never selects the panel by coincidence. + if let Some(existing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == request.buffer_id) + }) { + return Ok(Placement { + target: existing, + kind: PlacementKind::Ordinary, + }); + } + // 3.ii — the Q#BP11a candidate, then `iter_ids()` order, skipping + // any window dedicated to a different buffer. + let mut candidates: Vec = Vec::new(); + 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))); + for candidate in candidates { + 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, + kind: PlacementKind::Ordinary, + }); + } + } + Err("display: no eligible document window is available".into()) + } + + /// Create the side window when needed, then install the buffer and + /// reconcile the parameter semantics of Q#BP3. + fn apply_placement( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + placement: &Placement, + ) -> Result<(), String> { + let side = match placement.kind { + PlacementKind::Ordinary => { + // Reaching Ordinary while a side was REQUESTED means the + // request fell back (not panel-capable, or the one slot + // is dedicated elsewhere). A failed placement request may + // never pin or dedicate a document window, so `side`, + // `height`, `dedicated`, and quit bookkeeping are all + // discarded here; only an explicit `select` survives, and + // that is Phase 2's business. + let fell_back = request.side.is_some(); + let same_buffer_redisplay = self + .windows + .get(&placement.target) + .is_some_and(|w| w.buffer_id == request.buffer_id); + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: target window vanished".to_string())?; + match request.dedicated { + Some(dedicated) if !fell_back => window.params.dedicated = dedicated, + // A same-buffer redisplay must not silently unpin a + // window; a genuine replacement starts undedicated. + _ if !same_buffer_redisplay => window.params.dedicated = false, + _ => {} + } + return Ok(()); + } + PlacementKind::Side { created, replacing } => (created, replacing), + }; + let (created, replacing) = side; + 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 origin = self.non_side_target(fid).ok(); + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(request.buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let mut window = Window::new(placement.target, request.buffer_id, text_view); + window.params.side = Some(requested_side); + window.params.fixed_rows = Some(rows); + window.params.dedicated = request.dedicated.unwrap_or(false); + window.params.set_quit_action(Some(QuitAction::Delete)); + window.params.set_origin_document(origin); + self.windows.insert(placement.target, window); + self.views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .layout + .install_side_leaf(placement.target); + return Ok(()); + } + + // Reusing the existing slot. Capture the outgoing presentation + // BEFORE the install resets the window's view state. + let prior = { + let window = self + .windows + .get(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + QuitAction::Restore { + buffer_id: window.buffer_id, + fixed_rows: window.params.fixed_rows.unwrap_or(MIN_WINDOW_OUTER_ROWS), + dedicated: window.params.dedicated, + cursor: window.cursor, + view_top: window.view_top, + goal_col: window.goal_col, + selection: window.selection, + then: Box::new( + window + .params + .quit_action() + .cloned() + .unwrap_or(QuitAction::Delete), + ), + } + }; + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let height = match request.height { + Some(rows) => Some(Self::clamp_panel_rows(rows)?), + None => None, + }; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + if let Some(rows) = height { + window.params.fixed_rows = Some(rows); + } + if replacing { + // A replacement's new presentation defaults to undedicated so + // the one slot stays replaceable; an explicit dedication + // applies only after the OLD presentation already passed + // eligibility, so `dedicated = false` cannot clear-and-bypass + // an existing dedication in the same call. + window.params.dedicated = request.dedicated.unwrap_or(false); + let mut action = prior; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + window.params.set_quit_action(Some(action)); + } else if let Some(dedicated) = request.dedicated { + window.params.dedicated = dedicated; + } + Ok(()) } // ---- selection / region (T M2.12) -------------------------------------- @@ -3070,6 +4433,25 @@ impl EditorCore { } } }; + // Q#BP10a: a side window showing the victim is CLOSED, not + // redirected to `*scratch*`. Redirecting would strand an + // unrelated buffer in the panel slot; the wrapper collapse + // restores the prior root, which by construction holds a leaf. + let doomed_sides: Vec<(FrontendId, WindowId)> = self + .views + .iter() + .filter_map(|(fid, view)| { + let side = view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + })?; + (self.windows.get(&side)?.buffer_id == buffer_id).then_some((*fid, side)) + }) + .collect(); + for (fid, side) in doomed_sides { + self.remove_side_window(fid, side); + } { let reg = self.registry.borrow(); let buf = reg.get(fallback).map_err(|e| e.to_string())?; @@ -3509,6 +4891,9 @@ mod tests { layout: Layout::single(win_id), active: win_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); win_id @@ -3525,7 +4910,7 @@ mod tests { let win2 = attach_frontend(&mut s, fid2); s.active_frontend = fid2; - s.close_others(); + s.close_others().expect("document window may close others"); assert!( s.windows.contains_key(&win2), @@ -4093,8 +5478,10 @@ mod tests { s.active_window_mut().cursor = (i % 10) as u64; s.push_jump(); } + // Bottom-panel arc (Q#BP11c): the cap applies independently to + // each frontend's own vector, with today's oldest-entry eviction. assert_eq!( - s.jump_ring.len(), + s.jump_ring[&FrontendId::LOCAL].len(), EditorCore::JUMP_RING_CAP, "ring must stay bounded at JUMP_RING_CAP" ); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7cf0bc4..a1ea3fc 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -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::` 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, fallback: u32) -> u32 { + let Some(registry) = lua.app_data_ref::() 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::() { 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 { 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
{ win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut().close_others(); - Ok(()) + cc.borrow_mut() + .close_others() + .map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs new file mode 100644 index 0000000..a28add0 --- /dev/null +++ b/src/lua_bindings/window_panel.rs @@ -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::() + .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::() 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
, +) -> mlua::Result { + let mut request = DisplayRequest::new(buffer_id); + let Some(opts) = opts else { + return Ok(request); + }; + if let Some(side) = opts.get::>("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::>("window")? { + request.window = Some(lookup_window(core, fid, raw)?); + } + if let Some(height) = opts.get::>("height")? { + request.height = Some(height); + } + if let Some(dedicated) = opts.get::>("dedicated")? { + request.dedicated = Some(dedicated); + } + if let Some(select) = opts.get::>("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 { + 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
)| -> mlua::Result { + 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
)| -> mlua::Result { + 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::>("window")? { + explicit_window = Some(lookup_window(&cc, fid, raw)?); + } + select = opts.get::>("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 { + 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> { + 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| -> 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| -> 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 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::(key)? != Value::Nil { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: `{key}` is not settable" + ))); + } + } + let height = match opts.get::>("fixed_rows")? { + Some(rows) => Some( + crate::editor_core::EditorCore::clamp_panel_rows(rows) + .map_err(mlua::Error::runtime)?, + ), + None => None, + }; + let dedicated = opts.get::>("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, 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 = { + 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(()) +} diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index b16daae..b32195a 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -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(); diff --git a/src/window.rs b/src/window.rs index e1162c0..2d8afff 100644 --- a/src/window.rs +++ b/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 { + 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, + /// Saved region, if one was active. + selection: Option, + /// The action that was in force *before* this presentation + /// replaced its predecessor. + then: Box, + }, +} + +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, + /// 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, + /// 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, + /// See [`WindowParams::origin_document`]. + origin_document: Option, +} + +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) { + 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 { + self.origin_document + } + + /// Record (or clear) the remembered document window. Rust-internal. + pub fn set_origin_document(&mut self, origin: Option) { + 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, + /// 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 { + pub fn compute(&self, area: Rect, fixed: &HashMap) -> HashMap { 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 { + 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 { @@ -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> { + 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 { + 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, + /// 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) -> 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) { +/// 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, + out: &mut HashMap, +) { match node { LayoutNode::Leaf(id) => { out.insert(*id, area); @@ -442,18 +934,61 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap 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> = 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 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 { + let mut out = Vec::new(); + collect_ids(node, &mut out); + out +} + fn collect_ids(node: &LayoutNode, out: &mut Vec) { 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 { diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index cbef8cd..79b41c8 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -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 diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 6a08c41..120ca23 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -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, }, ); } diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 8bf44a5..04b2c27 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -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