diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index e364de2..2c00731 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -734,6 +734,16 @@ end) -- has its own worker path. local function start_run(slot, cmdline, opts) opts = opts or {} + -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run + -- supersedes anything, rewrites the buffer, or spawns a process, so + -- an unknown value leaves no half-started run behind. In Stages 1-2 + -- omission means "current"; Stage 3 flips the default. + local display = opts.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "compile.run: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end -- q-target discipline (Q#CM11): capture only when coming from a -- non-generated buffer, so `g` reruns don't re-capture and -- compile → g → q restores the original buffer. @@ -805,7 +815,16 @@ local function start_run(slot, cmdline, opts) -- attach here stacked a duplicate render view per run (round-5 -- finding 1; translation itself is buffer-level and unaffected by -- attachment count). - pmacs.window.switch_buffer(slot.buf) + -- The FIRST display of this run is the side-affine one (Q#BP3): a + -- persistent *compilation* already visible in a document window must + -- not preempt the requested panel. Compile output is passive, so it + -- takes `select = false` explicitly; a recompile simply reuses the + -- panel it is already in. + if display == "panel" then + pmacs.window.display(slot.buf, { side = "bottom", select = false }) + else + pmacs.window.switch_buffer(slot.buf) + end if not ok then emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc))) slot.expected_rev = buf:revision() @@ -866,7 +885,9 @@ local function visit_error(slot, idx) if not e then return end local path = resolve_error_path(slot, e.file) pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the + -- source in the document target, leaving the panel where it is. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then pmacs.editor.jump_back() pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err)) @@ -995,6 +1016,15 @@ pmacs.command.define { fn = function() local slot = slot_for_buffer(pmacs.window.buffer()) if not slot then return end + -- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or + -- restores the PRESENTATION rather than leaving a source buffer + -- stranded in the panel slot. Capability fallback and pre-arc + -- placement keep today's previous-buffer restore below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = slot.prev if not (target and target:is_valid()) then target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 4be4faa..6081587 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -123,7 +123,25 @@ function pmacs.listview.open(spec) p.prev = active end render(p, spec.rows or {}) - pmacs.window.switch_buffer(p.buffer) + -- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and + -- `listview.refresh` are active-window-only, so an interactive panel + -- MUST take `select = true` or it would silently seat the wrong + -- window. In Stages 1-2 omitting `display` keeps today's raw switch; + -- Stage 3 flips the default. An unknown value errors before anything + -- is displayed. + local display = spec.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "listview.open: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end + if display == "panel" then + p.side = true + pmacs.window.display(p.buffer, { side = "bottom", select = true }) + else + p.side = false + pmacs.window.switch_buffer(p.buffer) + end seat_cursor(p, 1) end @@ -160,6 +178,15 @@ pmacs.command.define { fn = function() local p = panel_for_current_buffer() if not p then return end + -- Bottom-panel arc (Q#BP11b): `q` keeps its name and its + -- user-visible behavior, delegating to `window.quit` only when the + -- listview really is in a side window. Capability fallback (and any + -- pre-arc placement) keeps the previous-buffer switch below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = p.prev if not (target and target:is_valid()) then target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d7ab9d9..4181156 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition() return end pmacs.editor.push_jump() - local ok2, oerr = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): the target-aware load. `find_or_open` + -- switches the ACTIVE window, which would replace a focused panel; + -- `display_file` resolves the DOCUMENT target first and fires the + -- load/switch hook with that window active. + local ok2, oerr = pcall(pmacs.window.display_file, path, { select = true }) if not ok2 then -- Open failed: drop the origin we just pushed so M-, isn't -- left pointing at a jump that never happened. @@ -1601,7 +1605,9 @@ local function visit_location(loc) return end pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): a visit FROM a panel must land in the + -- document target and leave the panel intact. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then -- Open failed: drop the origin we just pushed so M-, isn't left -- pointing at a jump that never happened. diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua index cf63443..37b459f 100644 --- a/builtin/runtime/window.lua +++ b/builtin/runtime/window.lua @@ -27,12 +27,17 @@ pmacs.config.define { -- (drag and the commands below) and is deliberately ignored by the -- ordinary layout pass and by frame-resize reconciliation, so raising it -- can never invalidate a layout that already exists. +-- +-- The registry floor is 1 rather than 2 on purpose: a value below the +-- STRUCTURAL floor is clamped when it is read, not rejected when it is +-- written, so a user who asks for a smaller minimum simply gets the +-- smallest one the layout can actually honor. pmacs.config.define { name = "window.min-height", description = "Smallest outer rows interactive resize will leave a window.", type = "integer", default = 2, - min = 2, + min = 1, mutability = "live", } diff --git a/src/daemon.rs b/src/daemon.rs index cb82c3b..44d4b61 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -905,7 +905,7 @@ fn peer_declared_terminal_support( /// from the daemon's own negotiated state, and Stage 2 turns the version /// arm on (`semantic_render && negotiated_protocol_version >= /// PANEL_MIN_VERSION`). -fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { +fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -1808,7 +1808,7 @@ fn handle_session_established( let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, - peer_declared_panel_support(&session_state), + peer_declared_panel_support(session_state), ); { let mut core = editor.core.borrow_mut(); diff --git a/src/editor.rs b/src/editor.rs index d6c6a51..2d2a5e8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -841,14 +841,13 @@ impl EditorState { .get(&window_id) .map(|window| window.buffer_id); if let Some(buffer_id) = buffer_id { - let _ = self - .terminal_manager - .borrow_mut() - .release_controller(crate::terminal::TerminalViewKey { + let _ = self.terminal_manager.borrow_mut().release_controller( + crate::terminal::TerminalViewKey { frontend_id, window_id, buffer_id, - }); + }, + ); } } outcome.changed @@ -1931,7 +1930,8 @@ impl EditorState { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); } - MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, + // Any other event — release, a different button, a + // wheel notch — ends the gesture. _ => self.window_drag = None, } return; @@ -2062,9 +2062,12 @@ impl EditorState { /// Arm a divider drag if `owner`'s bottom row really is an exposed /// segment of a horizontal boundary (Q#BP5). fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { - let is_divider = self.core.borrow().views.get(&frontend_id).is_some_and(|view| { - view.layout.boundary_below(owner).is_some() - }); + let is_divider = self + .core + .borrow() + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.boundary_below(owner).is_some()); self.window_drag = is_divider.then_some(WindowDragState { frontend_id, owner, @@ -2078,14 +2081,22 @@ impl EditorState { /// layout mutation mid-drag cannot move a boundary that no longer /// exists. Motion is applied incrementally and re-anchored each /// event, so the clamp absorbs over-travel instead of accumulating it. - fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { + fn drag_window_boundary( + &mut self, + frontend_id: FrontendId, + cell_row: u32, + term_size: CellSize, + ) { let Some(drag) = self.window_drag else { return; }; if drag.frontend_id != frontend_id { return; } - self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); + self.window_drag = Some(WindowDragState { + last_row: cell_row, + ..drag + }); let delta = i64::from(cell_row) - i64::from(drag.last_row); let Ok(delta) = i32::try_from(delta) else { return; @@ -3126,13 +3137,14 @@ pub fn paint_frame( // upper child is a nested subtree exposes SEVERAL leaf segments along // the same edge, so the root panel divider is full width even when // the document subtree ends in several columns. - let divider_windows: Vec = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { - view.layout - .iter_ids() - .into_iter() - .filter(|id| view.layout.boundary_below(*id).is_some()) - .collect() - }); + let divider_windows: Vec = + core.views.get(&frontend_id).map_or_else(Vec::new, |view| { + view.layout + .iter_ids() + .into_iter() + .filter(|id| view.layout.boundary_below(*id).is_some()) + .collect() + }); let divider_style = theme.face("ui.divider"); // Clear the whole grid first so windows that shrink on resize @@ -7073,22 +7085,14 @@ mod tests { } else { panic!("expected split"); } - let p1 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 90), - &std::collections::HashMap::new(), - ); - let p2 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 60), - &std::collections::HashMap::new(), - ); + let p1 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 90), + &std::collections::HashMap::new(), + ); + let p2 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 60), + &std::collections::HashMap::new(), + ); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 9ab4bca..039ee43 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -882,10 +882,7 @@ impl EditorCore { /// /// # Errors /// Any load failure other than `NotFound`. - pub fn resolve_target_buffer( - &mut self, - path: &Path, - ) -> Result<(BufferId, HookKind), String> { + pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { match self.get_or_load_buffer(path) { Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), @@ -1060,11 +1057,7 @@ impl EditorCore { pub fn jump_back(&mut self) -> bool { let fid = self.active_frontend; loop { - let Some(entry) = self - .jump_ring - .get_mut(&fid) - .and_then(std::vec::Vec::pop) - else { + let Some(entry) = self.jump_ring.get_mut(&fid).and_then(std::vec::Vec::pop) else { return false; }; if !self.registry.borrow().contains(entry.buffer_id) { @@ -1081,12 +1074,20 @@ impl EditorCore { && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { self.set_active_window_id(entry.window_id); - } else if entry.side_origin { - continue; - } else if self.active_buffer_id() != entry.buffer_id - && self.switch_active_buffer(entry.buffer_id).is_err() - { - continue; + } else { + // A stale SIDE origin is skipped outright: switching a + // panel's buffer into the document window is exactly the + // duplicate-presentation corruption this design removes. + // A stale non-side origin keeps today's active-window + // fallback. + if entry.side_origin { + continue; + } + if self.active_buffer_id() != entry.buffer_id + && self.switch_active_buffer(entry.buffer_id).is_err() + { + continue; + } } let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); @@ -2713,10 +2714,8 @@ impl EditorCore { } self.active_layout_mut().close_window(target); self.windows.remove(&target); - if target_is_side { - if let Some(view) = self.views.get_mut(&fid) { - view.panel_hidden = false; - } + if target_is_side && let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; } // Pick an adjacent window as the new focus, preferring a document. let ids = self.active_layout().iter_ids(); @@ -3002,10 +3001,9 @@ impl EditorCore { self.windows.remove(&side); if was_active && let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) { - if let Some(view) = self.views.get_mut(&fid) { - view.active = target; - } + view.active = target; } // A remembered origin pointing at a now-dead window is cleared by // `non_side_target`'s revalidation on next use; nothing else here @@ -3050,14 +3048,34 @@ impl EditorCore { }; match action { QuitAction::Delete => { - let saved_active = self.views.get(&fid).map(|view| view.active); + // Capture the remembered origin BEFORE the window dies: + // executing `Delete` focuses the revalidated origin, not + // merely whatever leaf the wrapper collapse surfaced + // (Q#BP11b). Entering the panel from document window B + // must therefore return focus to B, not to the window + // that happened to create the panel. + let origin = self + .windows + .get(&target) + .and_then(|window| window.params.origin_document()); self.remove_side_window(fid, target); - Ok(QuitOutcome::Deleted { - focus: self - .views + let origin_valid = origin.is_some_and(|origin| { + self.views .get(&fid) - .map(|view| view.active) - .or(saved_active), + .is_some_and(|view| view.layout.iter_ids().contains(&origin)) + && !self + .windows + .get(&origin) + .is_some_and(crate::window::Window::is_side) + }); + if origin_valid + && let Some(origin) = origin + && let Some(view) = self.views.get_mut(&fid) + { + view.active = origin; + } + Ok(QuitOutcome::Deleted { + focus: self.views.get(&fid).map(|view| view.active), }) } QuitAction::Restore { @@ -3073,7 +3091,7 @@ impl EditorCore { self.install_buffer_in_window(target, buffer_id)?; let len = { let reg = self.registry.borrow(); - reg.get(buffer_id).map(Buffer::len).unwrap_or(0) + reg.get(buffer_id).map_or(0, Buffer::len) }; let window = self .windows @@ -3091,10 +3109,7 @@ impl EditorCore { window.view_top = view_top; window.goal_col = goal_col; window.selection = selection.filter(|sel| sel.anchor <= len); - Ok(QuitOutcome::Restored { - target, - buffer_id, - }) + Ok(QuitOutcome::Restored { target, buffer_id }) } } } @@ -3117,8 +3132,7 @@ impl EditorCore { #[must_use] pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { let geometry = self.views.get(&fid)?.frame_geometry?; - (geometry.total.rows >= 2 && geometry.total.cols > 0) - .then(|| geometry.total.rows - 1) + (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } /// Cache a frontend's authoritative frame capacity (Q#BP2b). @@ -3207,6 +3221,10 @@ impl EditorCore { /// # Errors /// When `win` is not live in `fid`'s layout, when the panel is /// hidden, or when no adjustable horizontal boundary exists. + #[allow( + clippy::too_many_lines, + reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" + )] pub fn resize_boundary( &mut self, fid: FrontendId, @@ -3260,9 +3278,9 @@ impl EditorCore { ) } else { ( - view.layout - .boundary_below(win) - .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + view.layout.boundary_below(win).ok_or_else(|| { + "window.resize: no adjustable horizontal boundary".to_string() + })?, false, ) }; @@ -3423,13 +3441,7 @@ impl EditorCore { target: placement.target, saved_active, select, - created_side: matches!( - placement.kind, - PlacementKind::Side { - created: true, - .. - } - ), + created_side: matches!(placement.kind, PlacementKind::Side { created: true, .. }), }) } @@ -3494,7 +3506,12 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); candidates .into_iter() .find(|id| eligible(*id)) @@ -3506,6 +3523,10 @@ impl EditorCore { /// otherwise a persistent `*compilation*` buffer already visible in a /// document window makes `{side = "bottom"}` silently ignore its /// requested placement. + #[allow( + clippy::too_many_lines, + reason = "Q#BP3's precedence ladder reads as one ordered policy" + )] fn resolve_placement( &self, fid: FrontendId, @@ -3594,7 +3615,12 @@ impl EditorCore { }); } } - } else if request.height.is_some() { + } else if request.side.is_none() && request.height.is_some() { + // A freestanding `height` with no side request is a mistake. + // A `height` that arrived WITH a side request and fell + // through (not panel-capable, or the one slot is dedicated + // elsewhere) is discarded, not rejected — capability + // fallback must not turn into an error (Q#BP2c). return Err("display: `height` requires a side window".into()); } @@ -3624,11 +3650,17 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); for candidate in candidates { - let eligible = self.windows.get(&candidate).is_some_and(|w| { - !w.params.dedicated || w.buffer_id == request.buffer_id - }); + let eligible = self + .windows + .get(&candidate) + .is_some_and(|w| !w.params.dedicated || w.buffer_id == request.buffer_id); if eligible { return Ok(Placement { target: candidate, @@ -3681,7 +3713,8 @@ impl EditorCore { let requested_side = request.side.unwrap_or(Side::Bottom); if created { - let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; + let rows = + Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; let origin = self.non_side_target(fid).ok(); let text_view = { let reg = self.registry.borrow(); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index a1ea3fc..3d8a4e9 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8501,8 +8501,8 @@ fn install_terminal( let supervisor = supervisor.clone(); terminal.set( "_open", - lua.create_function(move |lua, spec: Table| -> mlua::Result { - let spec = parse_terminal_spec(&spec)?; + lua.create_function(move |lua, spec_table: Table| -> mlua::Result { + let spec = parse_terminal_spec(&spec_table)?; let core = lua .app_data_ref::() .map(|core| core.clone()) @@ -8515,37 +8515,54 @@ fn install_terminal( "pmacs.terminal.open: target frontend has no active window", )); } + // Bottom-panel arc (Q#BP11b): parse placement BEFORE the + // session, process, buffer, or wrapper exists, so an + // unknown `display` value creates nothing to roll back. + let placement = window_panel::parse_adopter_placement( + &core, + frontend_id, + "pmacs.terminal.open", + spec_table.get::>("display")?.as_deref(), + spec_table.get::>("window")?, + )?; let buffer_id = { let mut manager = manager.borrow_mut(); manager .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) .map_err(mlua::Error::external)? }; - let key = { - let mut core = core.borrow_mut(); - if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { + let outcome = match window_panel::place_adopter_buffer( + lua, + &core, + frontend_id, + buffer_id, + &placement, + true, + ) { + Ok(outcome) => outcome, + Err(error) => { + let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager .borrow_mut() .prune(&mut core, &mut supervisor.borrow_mut()); - return Err(mlua::Error::external(format!( - "pmacs.terminal.open: active-window switch failed: {error}" - ))); + return Err(error); } - crate::terminal::TerminalViewKey::new( - frontend_id, - core.views - .get(&frontend_id) - .expect("checked frontend has active view") - .active, - buffer_id, - ) }; + let key = + crate::terminal::TerminalViewKey::new(frontend_id, outcome.target, buffer_id); let claimed = { let mut manager = manager.borrow_mut(); manager.register_view(key) && manager.claim_controller(key) }; if !claimed { + // Placement failure removes any side wrapper this + // transaction created, BEFORE the existing + // session/buffer rollback completes (Q#BP11b). + if outcome.created_side { + core.borrow_mut() + .remove_side_window(frontend_id, outcome.target); + } let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager @@ -8555,7 +8572,7 @@ fn install_terminal( "pmacs.terminal.open: failed to claim the new terminal view", )); } - run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + window_panel::finish_adopter_placement(lua, &core, frontend_id, outcome)?; Ok(BufferIdLua(buffer_id)) })?, )?; @@ -8717,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result mlua::Result { win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut() - .close_others() - .map_err(mlua::Error::runtime) + cc.borrow_mut().close_others().map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index a28add0..d0ee6c0 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -120,10 +120,11 @@ fn complete_display( let target_ok = visible(core, fid, outcome.target); let saved_ok = visible(core, fid, outcome.saved_active); let final_focus = match (outcome.select, target_ok, saved_ok) { - (true, true, _) => Some(outcome.target), - (true, false, true) => Some(outcome.saved_active), - (false, _, true) => Some(outcome.saved_active), - (false, true, false) => Some(outcome.target), + // `select = true` KEEPS the target selected. + (true, true, _) | (false, true, false) => Some(outcome.target), + // `select = false` restores the saved window even when it is the + // panel — a passive display from a focused panel must not blur it. + (true, false, true) | (false, _, true) => Some(outcome.saved_active), // Both ids died with the hook: fall back to the non-side target // rule rather than leaving focus on a dead window. _ => None, @@ -202,8 +203,135 @@ fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result, + window: Option, +) -> mlua::Result { + let display = match display { + None | Some("current") => AdopterPlacement::Current, + Some("panel") => AdopterPlacement::Panel, + Some(other) => { + return Err(mlua::Error::runtime(format!( + "{operation}: unknown display {other:?} (expected \"current\" or \"panel\")" + ))); + } + }; + match (window, &display) { + (Some(_), AdopterPlacement::Panel) => Err(mlua::Error::runtime(format!( + "{operation}: `window` and `display = \"panel\"` are mutually exclusive" + ))), + (Some(raw), _) => Ok(AdopterPlacement::Window(lookup_window(core, fid, raw)?)), + (None, _) => Ok(display), + } +} + +/// Install `buffer_id` per `placement`, returning Phase 1's outcome +/// (Q#BP11b). +/// +/// `Current` keeps the pre-arc raw switch: it is the deliberate escape +/// hatch every existing adopter caller already relies on, and it does not +/// consult display-policy dedication. +/// +/// # Errors +/// Any placement failure. The caller owns its own session/buffer +/// rollback, and inspects `created_side` to remove a wrapper this +/// transaction created. +pub(crate) fn place_adopter_buffer( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, + placement: &AdopterPlacement, + select: bool, +) -> mlua::Result { + if matches!(placement, AdopterPlacement::Current) { + let mut borrowed = core.borrow_mut(); + borrowed + .switch_active_buffer_for(fid, buffer_id) + .map_err(mlua::Error::runtime)?; + let target = borrowed + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| { + mlua::Error::runtime("adopter placement: acting frontend has no active window") + })?; + return Ok(DisplayOutcome { + target, + saved_active: target, + select: true, + created_side: false, + }); + } + let mut request = DisplayRequest::new(buffer_id); + match placement { + AdopterPlacement::Panel => request.side = Some(Side::Bottom), + AdopterPlacement::Window(window) => request.window = Some(*window), + AdopterPlacement::Current => unreachable!("handled above"), + } + request.select = Some(select); + request.default_panel_rows = config_u32( + lua, + "window.panel-height", + Some(buffer_id), + DEFAULT_PANEL_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS); + core.borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime) +} + +/// Phase 2 for an adopter that had to interleave its own work (claiming a +/// terminal controller, seating a cursor) between placement and the hook. +/// +/// # Errors +/// Propagates the final-focus resolution error when both window ids died +/// inside the hook. +pub(crate) fn finish_adopter_placement( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + outcome: DisplayOutcome, +) -> mlua::Result<()> { + complete_display(lua, core, fid, outcome, HookKind::AfterSwitch) +} + /// Install the bottom-panel surface onto the existing `pmacs.window` /// table. +#[allow( + clippy::too_many_lines, + reason = "one flat list of bindings, each following the same \ + acting-frontend / Rc-borrow shape; splitting them fragments \ + a coherent surface" +)] pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { { let cc = core.clone(); @@ -386,10 +514,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .quit_action() .map_or(0, crate::window::QuitAction::depth), )?; - table.set( - "hidden", - window.is_side() && core.panel_hidden_for(fid), - )?; + table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?; Ok(table) })?, )?; @@ -400,41 +525,43 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let cc = core.clone(); win.set( "set_params", - lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { - let fid = acting_frontend(lua, &cc); - let id = lookup_window(&cc, fid, target)?; - for key in ["side", "origin_document", "quit_action"] { - if opts.get::(key)? != Value::Nil { - return Err(mlua::Error::runtime(format!( - "pmacs.window.set_params: `{key}` is not settable" - ))); + lua.create_function( + move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = lookup_window(&cc, fid, target)?; + for key in ["side", "origin_document", "quit_action"] { + if opts.get::(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); + 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; + } } - if let Some(dedicated) = dedicated { - window.params.dedicated = dedicated; - } - } - reconcile_panel_layout(lua, &cc, fid); - Ok(()) - })?, + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + }, + )?, )?; } @@ -466,8 +593,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .iter_ids() .into_iter() .map(|id| { - let buffer_id = - core.windows.get(&id).map(|w| w.buffer_id); + let buffer_id = core.windows.get(&id).map(|w| w.buffer_id); ( id, config_u32( diff --git a/src/window.rs b/src/window.rs index 2d8afff..f38cf53 100644 --- a/src/window.rs +++ b/src/window.rs @@ -975,20 +975,21 @@ fn compute_node( let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { - let extent = match extents[i] { - Some(rows) => rows, - None => { - let w = weights.get(i).copied().unwrap_or(1).max(1); - let e = if Some(i) == last_flexible { - remainder - flexible_used - } else if total == 0 { - 0 - } else { - remainder * w / total - }; - flexible_used += e; - e - } + let extent = if let Some(rows) = extents[i] { + rows + } else { + let w = weights.get(i).copied().unwrap_or(1).max(1); + let e = if Some(i) == last_flexible { + remainder - flexible_used + } else { + remainder + .checked_mul(w) + .unwrap_or(remainder) + .checked_div(total) + .unwrap_or(0) + }; + flexible_used += e; + e }; let child_area = match orientation { Orientation::Horizontal => Rect { diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs new file mode 100644 index 0000000..b89ca15 --- /dev/null +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -0,0 +1,2224 @@ +// bottom_panel_stage1_acceptance.rs --- bottom-panel Stage 1 acceptance +// (docs/bottom-panel-framing.md, acceptance items 1-35). + +//! Window placement + TUI side windows. No wire change. +//! +//! Every claim about geometry is asserted through a **production** +//! caller: `window_placements` (via the real `paint_frame`) or the +//! peer-presence overlay pass, never against `Layout::compute` in +//! isolation — the whole point of R5-B1 is that a second caller derives +//! its own rect and would otherwise keep computing unfixed geometry. +//! Placement, quit, and visit claims run through the real Lua surface +//! and the real adopter entry points. + +use std::collections::HashMap; +use std::time::Duration; + +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use pmacs::buffer::BufferId; +use pmacs::cell::{CellCoord, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::{DisplayRequest, EditorCore}; +use pmacs::protocol::FrontendId; +use pmacs::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Rect, Side, Window, WindowId, subtree_min_rows, +}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Terminal geometry. `paint_frame` reserves the last row for the status +/// line, so the window area is `ROWS - 1`. +const ROWS: u32 = 24; +const COLS: u32 = 60; +const AREA_ROWS: u32 = ROWS - 1; + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + // Geometry is authoritative state, and a grid frontend's real frame + // size IS its declaration. Every test that does not render declares + // it here, before any input. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn try_exec(s: &EditorState, src: &str) -> Result<(), String> { + s.lua_host + .lua() + .load(src.to_string()) + .exec() + .map_err(|e| e.to_string()) +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Render one real frame and return the per-window outer rects keyed by +/// window id, as `window_placements` computed them. +fn render(s: &EditorState) -> HashMap { + render_at(s, CellSize::new(ROWS, COLS)) +} + +fn render_at(s: &EditorState, size: CellSize) -> HashMap { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + placements(s, size) +} + +/// The production placement pass, at `size`. +fn placements(s: &EditorState, size: CellSize) -> HashMap { + let core = s.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view"); + let area = Rect::new(0, 0, size.rows - 1, size.cols); + let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows); + view.layout.compute(area, &fixed) +} + +/// Paint one frame and hand back the grid text, row by row. +fn painted_rows(s: &EditorState, size: CellSize) -> Vec { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + (0..size.rows) + .map(|row| { + (0..size.cols) + .map(|col| match &cells[(row * size.cols + col) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(_) => '?', + Glyph::Continuation => ' ', + }) + .collect() + }) + .collect() +} + +fn side_window(s: &EditorState) -> Option { + s.core.borrow().side_window_for(FrontendId::LOCAL) +} + +fn active_window(s: &EditorState) -> WindowId { + s.core.borrow().active_window_id() +} + +fn fixed_rows_of(s: &EditorState, win: WindowId) -> Option { + s.core.borrow().windows.get(&win)?.params.fixed_rows +} + +fn layout_root(s: &EditorState) -> LayoutNode { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .layout + .root + .clone() +} + +/// Structural fingerprint: node shape, weights, order, and ids — what +/// Bet B6 promises stays byte-identical when a panel opens. +fn structure(node: &LayoutNode) -> String { + match node { + LayoutNode::Leaf(id) => format!("L{}", id.raw()), + LayoutNode::Split { + orientation, + weights, + children, + } => format!( + "S{}{weights:?}({})", + match orientation { + Orientation::Horizontal => "H", + Orientation::Vertical => "V", + }, + children.iter().map(structure).collect::>().join(",") + ), + } +} + +/// Create a panel showing a fresh generated buffer, through the real Lua +/// display surface. +fn open_panel(s: &EditorState, name: &str, height: u32) -> WindowId { + exec( + s, + &format!( + "PANEL_BUF = pmacs.buffer.create({name:?}) + PANEL_WIN = pmacs.window.display(PANEL_BUF, \ + {{ side = \"bottom\", height = {height} }})" + ), + ); + side_window(s).expect("panel exists") +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn mouse(kind: MouseEventKind, row: u16, column: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +/// Register a second frontend with its own single-window layout. +fn attach_frontend(s: &EditorState, fid: FrontendId, panel_capable: bool) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win = WindowId::next(); + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable, + frame_geometry: None, + panel_hidden: false, + }, + ); + drop(core); + if panel_capable { + s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS)); + } + win +} + +// --------------------------------------------------------------------------- +// 1 — fixed extents reach BOTH production callers +// --------------------------------------------------------------------------- + +#[test] +fn acc1_fixed_extent_reaches_both_production_callers() { + let s = editor(); + let document = active_window(&s); + let before = render(&s); + assert_eq!( + before[&document].size.rows, AREA_ROWS, + "one window takes the whole area" + ); + + let panel = open_panel(&s, "*panel*", 6); + let after = render(&s); + assert_eq!( + after[&panel].size.rows, 6, + "the side child gets exactly N rows" + ); + assert_eq!( + after[&document].size.rows, + AREA_ROWS - 6, + "the sibling divides the remainder" + ); + + // The second production caller (`overlay_paint`) derives its OWN + // text-area rect and never routes through `window_placements`. Paint + // a peer cursor into the document window and assert it lands on the + // row the fixed geometry says — the assertion that fails if that + // caller keeps computing unfixed geometry. + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let row_with_panel = peer_cursor_row(&s, document_buffer, 0); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + let row_without_panel = peer_cursor_row(&s, document_buffer, 0); + assert_eq!( + row_with_panel, row_without_panel, + "a peer cursor in the document window paints at the same row \ + whether or not a panel is open" + ); +} + +/// Paint the peer-presence overlay pass and report the grid row the peer +/// cursor landed on. +fn peer_cursor_row(s: &EditorState, buffer_id: BufferId, position: u64) -> u32 { + let size = CellSize::new(ROWS, COLS); + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + let presence = pmacs::overlay_paint::OtherPresence { + frontend_id: FrontendId(7), + color_slot: 0, + snapshot: pmacs::presence::PresenceSnapshot { + buffer_id, + cursor: position, + selection: None, + }, + }; + pmacs::overlay_paint::paint_other_frontend_overlays(s, &mut grid, size, &[presence]); + for row in 0..size.rows { + for col in 0..size.cols { + if cells[(row * size.cols + col) as usize].style.reverse { + return row; + } + } + } + panic!("peer cursor was not painted anywhere"); +} + +// --------------------------------------------------------------------------- +// 2 — opening a panel preserves the document subtree's STRUCTURE (B6) +// --------------------------------------------------------------------------- + +#[test] +fn acc2_opening_a_panel_preserves_document_structure() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = layout_root(&s); + let before_rects = render(&s); + + open_panel(&s, "*panel*", 5); + let after = layout_root(&s); + let LayoutNode::Split { children, .. } = &after else { + panic!("the panel wrapper is a split"); + }; + assert_eq!( + structure(&before), + structure(&children[0]), + "nodes, weights, order and ids of the document subtree are identical" + ); + let after_rects = render(&s); + assert!( + before_rects + .keys() + .any(|id| before_rects[id] != after_rects[id]), + "…while the rectangles necessarily change, being recomputed \ + inside the smaller flexible remainder" + ); +} + +// --------------------------------------------------------------------------- +// 3 — the minimum is RECURSIVE +// --------------------------------------------------------------------------- + +#[test] +fn acc3_subtree_minimum_is_recursive_and_clamps_the_panel() { + // Horizontal inside vertical inside horizontal: four leaves, of + // which three stack rows. + let leaf_a = WindowId::next(); + let leaf_b = WindowId::next(); + let leaf_c = WindowId::next(); + let leaf_d = WindowId::next(); + let nested = LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_a), + LayoutNode::Split { + orientation: Orientation::Vertical, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_b), + LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![LayoutNode::Leaf(leaf_c), LayoutNode::Leaf(leaf_d)], + }, + ], + }, + ], + }; + // Rows add across a horizontal split and the tallest child governs a + // vertical one: 2 + max(2, 2 + 2) = 6. A flat "two rows at the root" + // reading would answer 2. + assert_eq!(subtree_min_rows(&nested), 6); + + // In a live layout the PANEL is clamped, never the document. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical(); \ + pmacs.window.split_horizontal()", + ); + let document_min = { + let core = s.core.borrow(); + subtree_min_rows(&core.views[&FrontendId::LOCAL].layout.root) + }; + let panel = open_panel(&s, "*panel*", AREA_ROWS); + let rects = render(&s); + assert_eq!( + rects[&panel].size.rows, + AREA_ROWS - document_min, + "the panel takes min(requested, area - subtree_min_rows(document))" + ); +} + +// --------------------------------------------------------------------------- +// 4 — clamping, rejection, and saturating arithmetic +// --------------------------------------------------------------------------- + +#[test] +fn acc4_height_requests_clamp_to_the_floor_and_reject_zero() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 1); + assert_eq!( + fixed_rows_of(&s, panel), + Some(MIN_WINDOW_OUTER_ROWS), + "a one-row request clamps up to the structural floor" + ); + assert_eq!(render(&s)[&panel].size.rows, MIN_WINDOW_OUTER_ROWS); + + let zero = try_exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*z*\"), \ + { side = \"bottom\", height = 0 })", + ); + assert!( + zero.is_err(), + "a request of zero is rejected, not an invisible open" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 0 }})", + panel.raw() + ) + ) + .is_err(), + "set_params rejects zero too" + ); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 1 }})", + panel.raw() + ), + ); + assert_eq!(fixed_rows_of(&s, panel), Some(MIN_WINDOW_OUTER_ROWS)); + + // `window.panel-height` is the creation default, clamped the same way. + exec(&s, "pmacs.config.set(\"window.panel-height\", 2)"); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*p2*\"), { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + assert_eq!(fixed_rows_of(&s, panel), Some(2)); + + // An intrinsically tiny frame saturates and hides rather than + // underflowing; a zero-column frame is never presentable. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(3, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, 0)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); +} + +// --------------------------------------------------------------------------- +// 5 — absolute height vs proportional ratio, in ONE layout +// --------------------------------------------------------------------------- + +#[test] +fn acc5_resize_preserves_absolute_panel_height_and_flexible_ratio() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 6); + let ids: Vec = { + let core = s.core.borrow(); + core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .filter(|id| *id != panel) + .collect() + }; + let wide = render_at(&s, CellSize::new(ROWS, COLS)); + assert_eq!(wide[&panel].size.rows, 6); + let ratio_before = f64::from(wide[&ids[0]].size.rows) / f64::from(wide[&ids[1]].size.rows); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 10, COLS)); + let tall = render_at(&s, CellSize::new(ROWS + 10, COLS)); + assert_eq!( + tall[&panel].size.rows, 6, + "the side window keeps its ABSOLUTE height" + ); + let ratio_after = f64::from(tall[&ids[0]].size.rows) / f64::from(tall[&ids[1]].size.rows); + assert!( + (ratio_before - ratio_after).abs() < 0.35, + "the flexible pair keeps its RATIO ({ratio_before} vs {ratio_after})" + ); +} + +// --------------------------------------------------------------------------- +// 6 / 7 / 8 — hiding is a durable transition +// --------------------------------------------------------------------------- + +#[test] +fn acc6_reconciliation_hides_moves_focus_and_releases_before_the_next_key() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "the panel is focused"); + + // Shrink the frame to something that cannot satisfy the panel, then + // dispatch a key in the same burst. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + active_window(&s), + document, + "focus moved out of the invisible panel" + ); + let rects = placements(&s, CellSize::new(4, COLS)); + assert_eq!( + rects[&panel].size.rows, 0, + "a hidden panel has an empty rect" + ); + assert_eq!( + rects[&document].size.rows, 3, + "the document subtree receives every reclaimed row" + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(8), + "the stored request survives hiding" + ); +} + +#[test] +fn acc7_reappearing_restores_the_request_but_not_focus() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + + let before = layout_root(&s); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert_eq!( + structure(&before), + structure(&layout_root(&s)), + "wrapper, ids, weights and order survive hiding" + ); + // While hidden the panel is not a focus destination. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!( + active_window(&s), + document, + "focus_next skips a hidden panel" + ); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!(!s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + render(&s)[&panel].size.rows, + 8, + "restored at the exact request" + ); + assert_eq!( + active_window(&s), + document, + "focus is NOT auto-restored — the user moved on" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "…but C-x o reaches it again"); +} + +#[test] +fn acc8_keys_while_hidden_reach_the_document_window() { + let mut s = editor(); + let document = active_window(&s); + open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let text: String = { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + let buf = reg.get(document_buffer).unwrap(); + let mut bytes = vec![0u8; buf.len() as usize]; + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() + }; + assert!( + text.contains('x'), + "the keystroke landed in the document buffer, not the invisible panel" + ); +} + +// --------------------------------------------------------------------------- +// 9 — window.min-height is an INTERACTIVE preference only +// --------------------------------------------------------------------------- + +#[test] +fn acc9_min_height_constrains_interactive_resize_only() { + let s = editor(); + exec(&s, "pmacs.config.set(\"window.min-height\", 1)"); + let panel = open_panel(&s, "*panel*", 6); + // Below the structural floor: the resolver clamps it back up. + assert_eq!(s.window_min_height(None), MIN_WINDOW_OUTER_ROWS); + + // A value materially above the floor constrains resize recursively + // across a nested document tree. + exec( + &s, + "pmacs.config.set(\"window.min-height\", 5) + pmacs.window.split_horizontal()", + ); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document target"); + // Two document leaves at 5 rows each = 10; the frame area is 23, so + // the panel can never grow past 13. + let _ = s.resize_window_boundary(FrontendId::LOCAL, panel, 100, AREA_ROWS); + assert!( + fixed_rows_of(&s, panel).expect("panel rows") <= AREA_ROWS - 10, + "the recursive interactive minimum bounds the panel" + ); + // Frame-resize layout ignores the preference entirely: an area that + // only satisfies the STRUCTURAL floor still lays out. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(8, COLS)); + let rects = placements(&s, CellSize::new(8, COLS)); + assert!( + rects[&document].size.rows > 0, + "changing a preference never invalidates an existing layout" + ); +} + +// --------------------------------------------------------------------------- +// 10 — closing collapses the wrapper +// --------------------------------------------------------------------------- + +#[test] +fn acc10_closing_the_panel_restores_the_prior_root_exactly() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = structure(&layout_root(&s)); + let panel = open_panel(&s, "*panel*", 5); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + assert_eq!( + before, + structure(&layout_root(&s)), + "the wrapper collapses and the prior root returns unchanged" + ); +} + +// --------------------------------------------------------------------------- +// 11 — parameter write discipline +// --------------------------------------------------------------------------- + +#[test] +fn acc11_parameter_writes_are_restricted_and_ids_are_frontend_scoped() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + for forbidden in [ + "side = \"bottom\"", + "origin_document = 1", + "quit_action = \"delete\"", + ] { + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ {forbidden} }})", + panel.raw() + ) + ) + .is_err(), + "set_params must reject `{forbidden}`" + ); + } + // `params` may REPORT the implementation-owned bookkeeping. + exec(&s, "pmacs.window.focus_next()"); + let origin: Option = eval( + &s, + &format!( + "return pmacs.window.params({}).origin_document", + panel.raw() + ), + ); + assert_eq!(origin, Some(document.raw())); + + // A stray `fixed_rows` on a non-side window is inert. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 4 }})", + document.raw() + ), + ); + let rects = render(&s); + assert_eq!( + rects[&document].size.rows, + AREA_ROWS - 5, + "the fixed map is built from side windows only" + ); + + // Every WindowId-taking operation rejects a live id owned by another + // frontend. + let foreign = attach_frontend(&s, FrontendId(9), true); + for call in [ + format!("pmacs.window.params({})", foreign.raw()), + format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + foreign.raw() + ), + format!("pmacs.window.resize({}, 1)", foreign.raw()), + format!("pmacs.window.quit({})", foreign.raw()), + format!( + "pmacs.window.display(pmacs.buffer.create(\"*f*\"), {{ window = {} }})", + foreign.raw() + ), + ] { + assert!( + try_exec(&s, &call).is_err(), + "a cross-frontend id must be a pointed error: {call}" + ); + } +} + +// --------------------------------------------------------------------------- +// 12 — dedication binds the POLICY layer only +// --------------------------------------------------------------------------- + +#[test] +fn acc12_dedication_binds_display_policy_not_the_raw_switch() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + &format!( + "OTHER = pmacs.buffer.create(\"*other*\") + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + let pinned_buffer = s.core.borrow().windows[&document].buffer_id; + + // The raw escape hatch ignores dedication. + exec(&s, "pmacs.window.switch_buffer(OTHER)"); + assert_ne!( + s.core.borrow().windows[&document].buffer_id, + pinned_buffer, + "raw switch_buffer ignores `dedicated`" + ); + + // The policy layer honors it on every candidate. + exec( + &s, + &format!( + "pmacs.window.switch_buffer(pmacs.buffer.list()[1]) + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec(&s, "pmacs.window.display(OTHER)").is_err(), + "display_buffer refuses to overwrite a dedicated window with no alternative" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ window = {} }})", + document.raw() + ) + ) + .is_err(), + "…and refuses a dedicated EXACT target too" + ); + + // An ordinary display never reuses a matching side window. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + document.raw() + ), + ); + let panel = open_panel(&s, "*shared*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + let target: u64 = eval(&s, "return pmacs.window.display(PANEL_BUF)"); + assert_ne!( + target, + panel.raw(), + "an ordinary display never selects the panel by coincidence" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and leaves the panel's own presentation alone" + ); +} + +// --------------------------------------------------------------------------- +// 13 — side placement affinity + option-valued height/dedication +// --------------------------------------------------------------------------- + +#[test] +fn acc13_side_placement_is_affinity_aware_and_option_valued() { + let s = editor(); + let document = active_window(&s); + // A buffer already visible in a DOCUMENT window must not preempt a + // requested usable side slot. + exec( + &s, + "SHARED = pmacs.buffer.create(\"*shared*\"); pmacs.window.switch_buffer(SHARED)", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(SHARED, { side = \"bottom\", height = 7 })", + ); + let panel = side_window(&s).expect("panel created"); + assert_eq!(target, panel.raw(), "the requested side placement wins"); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + s.core.borrow().windows[&panel].buffer_id + ); + + // Same-buffer redisplay preserves an omitted height, dedication, and + // quit action. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(SHARED, { side = \"bottom\" })"); + assert_eq!(fixed_rows_of(&s, panel), Some(7)); + assert!(s.core.borrow().windows[&panel].params.dedicated); + + // A dedicated side slot never spawns a second one: the request falls + // back after discarding height/dedication/quit state. + exec(&s, "OTHER = pmacs.buffer.create(\"*other*\")"); + let fallback: u64 = eval( + &s, + "return pmacs.window.display(OTHER, { side = \"bottom\", height = 9, dedicated = true })", + ); + assert_ne!(fallback, panel.raw()); + assert_eq!(side_window(&s), Some(panel), "still exactly one side slot"); + { + let core = s.core.borrow(); + let fell_back = core + .windows + .values() + .find(|w| w.id.raw() == fallback) + .expect("fallback window"); + assert!( + !fell_back.params.dedicated, + "a failed request may not dedicate" + ); + assert!(fell_back.params.fixed_rows.is_none(), "…nor pin"); + assert!( + fell_back.params.quit_action().is_none(), + "…nor leave quit state" + ); + } + + // Replacement preserves an omitted (user-resized) height but starts + // undedicated. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(OTHER, { side = \"bottom\" })"); + assert_eq!( + fixed_rows_of(&s, panel), + Some(7), + "the resized height survives" + ); + assert!(!s.core.borrow().windows[&panel].params.dedicated); + + // Mutual exclusion and a freestanding height are pointed errors. + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ side = \"bottom\", window = {} }})", + document.raw() + ) + ) + .is_err() + ); + assert!(try_exec(&s, "pmacs.window.display(OTHER, { height = 4 })").is_err()); + assert!( + try_exec(&s, "pmacs.window.display(OTHER, { side = \"left\" })").is_err(), + "Stage 1 ships only the bottom side" + ); + + // An explicit `dedicated = false` cannot clear-and-bypass an existing + // dedication in the same call. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "THIRD = pmacs.buffer.create(\"*third*\")"); + let bypass: u64 = eval( + &s, + "return pmacs.window.display(THIRD, { side = \"bottom\", dedicated = false })", + ); + assert_ne!( + bypass, + panel.raw(), + "eligibility is checked before the new dedication" + ); +} + +// --------------------------------------------------------------------------- +// 14 — capability fallback +// --------------------------------------------------------------------------- + +#[test] +fn acc14_capability_fallback_discards_every_side_parameter() { + let s = editor(); + let fid = FrontendId(11); + let document = attach_frontend(&s, fid, false); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*panel*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + request.height = Some(9); + request.dedicated = Some(true); + let outcome = s + .core + .borrow_mut() + .display_buffer(fid, &request) + .expect("fallback succeeds"); + assert_eq!(outcome.target, document, "fell back to the document target"); + assert!( + s.core.borrow().side_window_for(fid).is_none(), + "no side window was created" + ); + let core = s.core.borrow(); + let window = &core.windows[&document]; + assert!( + !window.params.dedicated, + "the document target is left undedicated" + ); + assert!(window.params.fixed_rows.is_none(), "…and unpinned"); + assert!(window.params.side.is_none()); + assert!(window.params.quit_action().is_none()); +} + +// --------------------------------------------------------------------------- +// 15 / 16 — the final-focus matrix and the hook-failure arms +// --------------------------------------------------------------------------- + +#[test] +fn acc15_final_focus_matrix_all_six_rows() { + // Row 1 — select = true, target live: the target stays selected. + let s = editor(); + let document = active_window(&s); + exec( + &s, + "P = pmacs.buffer.create(\"*p*\") + pmacs.window.display(P, { side = \"bottom\", height = 5, select = true })", + ); + assert_eq!(active_window(&s), side_window(&s).unwrap()); + + // Row 4 — select = false with a live saved window that IS the panel: + // a passive display invoked from a focused panel must not blur it. + let panel = side_window(&s).unwrap(); + exec( + &s, + "Q = pmacs.buffer.create(\"*q*\") + pmacs.window.display(Q, { select = false })", + ); + assert_eq!( + active_window(&s), + panel, + "select = false restores a SIDE saved_active" + ); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + eval::(&s, "return Q").0, + "…while the buffer really did land in the document window" + ); + + // Row 5 — select = false, saved window died in the hook, target live. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + SAVED = pmacs.window.list()[1] + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_SAVED then KILL_SAVED = nil; pmacs.window.focus_next(); pmacs.window.close() end + end)", + ); + exec(&s, "R = pmacs.buffer.create(\"*r*\")"); + let saved = active_window(&s); + exec(&s, "KILL_SAVED = true"); + let target: u64 = eval(&s, "return pmacs.window.display(R, { select = false })"); + assert!( + !s.core.borrow().windows.contains_key(&saved) || active_window(&s).raw() == target, + "focus falls to the live target when the saved window dies" + ); + + // Rows 2/3/6 — the target dies in the hook. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_TARGET then KILL_TARGET = nil; pmacs.window.close() end + end) + T = pmacs.buffer.create(\"*t*\") + KILL_TARGET = true", + ); + let before = active_window(&s); + exec(&s, "pmacs.window.display(T, { select = true })"); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "focus always lands on a live window" + ); + let _ = before; +} + +#[test] +fn acc16_hook_failure_arms_are_covered_in_both_select_modes() { + for select in ["true", "false"] { + // The hook switches the target's buffer out from under us. + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + if SWAP then SWAP = nil; pmacs.window.switch_buffer(pmacs.buffer.create(\"*swap*\")) end + end) + X = pmacs.buffer.create(\"*x*\") + SWAP = true", + ); + exec( + &s, + &format!("pmacs.window.display(X, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays on a live window after a buffer-switching hook" + ); + + // The hook closes the target. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if CLOSE then CLOSE = nil; pmacs.window.close() end + end) + Y = pmacs.buffer.create(\"*y*\") + CLOSE = true", + ); + exec( + &s, + &format!("pmacs.window.display(Y, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays live after a target-closing hook" + ); + } +} + +// --------------------------------------------------------------------------- +// 17 — a passive display re-attaches overlays +// --------------------------------------------------------------------------- + +#[test] +fn acc17_passive_display_reattaches_overlays() { + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + SEEN_ACTIVE = pmacs.window.list_active and 1 or 1 + HOOK_WINDOW = pmacs.window.current() + end) + Z = pmacs.buffer.create(\"*z*\")", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(Z, { side = \"bottom\", height = 5 })", + ); + let hook_window: u64 = eval(&s, "return HOOK_WINDOW"); + assert_eq!( + hook_window, target, + "the switch hook observes the TARGET window as active, which is \ + what re-attaches store-backed overlays on a passive display" + ); + assert_ne!( + active_window(&s).raw(), + target, + "…while the passive display leaves focus where it was" + ); +} + +// --------------------------------------------------------------------------- +// 18 — display_file +// --------------------------------------------------------------------------- + +#[test] +fn acc18_display_file_targets_the_document_from_a_focused_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("visit.txt"); + std::fs::write(&file, b"hello\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + exec( + &s, + "pmacs.hook.add(\"buffer.after-load\", function() + LOAD_WINDOW = pmacs.window.current() + end)", + ); + let target: u64 = eval( + &s, + &format!("return pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + target, + document.raw(), + "the visit lands in the document target" + ); + assert_eq!( + eval::(&s, "return LOAD_WINDOW"), + document.raw(), + "buffer.after-load fires with the DOCUMENT TARGET active" + ); + assert_eq!(side_window(&s), Some(panel), "the panel is intact"); + + // A dedicated exact target fails WITHOUT loading. + let unopened = dir.path().join("unopened.txt"); + std::fs::write(&unopened, b"nope\n").unwrap(); + let unopened_path = unopened.display().to_string(); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display_file({unopened_path:?}, {{ window = {} }})", + document.raw() + ) + ) + .is_err() + ); + let opened_names: Vec = eval( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out", + ); + assert!( + !opened_names.iter().any(|n| n.contains("unopened")), + "the file must not be loaded when the destination is ineligible" + ); + + // An omitted target skips a dedicated remembered origin and chooses + // the next eligible non-side window — before I/O. + exec(&s, "pmacs.window.split_horizontal()"); + exec(&s, &format!("pmacs.window.display_file({unopened_path:?})")); + assert!( + eval::>( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out" + ) + .iter() + .any(|n| n.contains("unopened")), + "…and succeeds once another eligible window exists" + ); + + // A NotFound path creates a path-backed buffer and fires NO hook. + let s = editor(); + exec( + &s, + "LOADS = 0 + pmacs.hook.add(\"buffer.after-load\", function() LOADS = LOADS + 1 end) + SWITCHES = 0 + pmacs.hook.add(\"buffer.after-switch\", function() SWITCHES = SWITCHES + 1 end)", + ); + let missing = dir.path().join("brand-new.txt").display().to_string(); + exec(&s, &format!("pmacs.window.display_file({missing:?})")); + assert_eq!(eval::(&s, "return LOADS"), 0); + assert_eq!(eval::(&s, "return SWITCHES"), 0); + assert_eq!( + eval::(&s, "return pmacs.window.buffer():path()"), + missing, + "the new buffer is path-backed" + ); +} + +// --------------------------------------------------------------------------- +// 19 — adopters place through their REAL entry points +// --------------------------------------------------------------------------- + +#[test] +fn acc19_adopters_place_side_affinely_through_real_entry_points() { + // listview: pre-seed the persistent panel buffer in a DOCUMENT window + // first, so side-affine placement cannot be vacuous. + let s = editor(); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } } }", + ); + let seeded = active_window(&s); + assert!( + side_window(&s).is_none(), + "the default placement is unchanged" + ); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("listview opened a panel"); + assert_eq!( + active_window(&s), + panel, + "an interactive listview takes select = true" + ); + assert_ne!(panel, seeded); + assert!( + try_exec( + &s, + "pmacs.listview.open { name = \"*bogus*\", rows = {}, display = \"sideways\" }" + ) + .is_err(), + "an unknown display value is a pointed error" + ); + + // compile: same shape, but passive (`select = false`). + let s = editor(); + exec(&s, "pmacs.compile.run(\"true\")"); + assert!(side_window(&s).is_none()); + let document = active_window(&s); + exec(&s, "pmacs.compile.run(\"true\", { display = \"panel\" })"); + let panel = side_window(&s).expect("compile opened a panel"); + assert_eq!( + active_window(&s), + document, + "compile output is passive: select = false" + ); + assert_ne!(panel, document); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec(&s, "pmacs.compile.run(\"true\", { display = \"nope\" })").is_err(), + "an unknown display value fails BEFORE the run starts" + ); + assert_eq!( + s.core.borrow().registry.borrow().ids().len(), + before, + "…and creates no buffer" + ); + + // terminal: the panel opt-in uses select = true. + let s = editor(); + let document = active_window(&s); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec( + &s, + "pmacs.terminal.open { command = \"/bin/sh\", display = \"elsewhere\" }" + ) + .is_err(), + "unknown display fails before session/process/buffer creation" + ); + assert_eq!(s.core.borrow().registry.borrow().ids().len(), before); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal opened a panel"); + assert_eq!(active_window(&s), panel); + assert_ne!(panel, document); +} + +// --------------------------------------------------------------------------- +// 20 / 23 — quit: delete, restore chains, revalidation, and the cap +// --------------------------------------------------------------------------- + +#[test] +fn acc20_quit_deletes_then_restores_each_saved_presentation() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + C = pmacs.buffer.create(\"*C*\") + pmacs.window.display(A, { side = \"bottom\", height = 6, select = true })", + ); + let panel = side_window(&s).expect("panel"); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 9 }})", + panel.raw() + ), + ); + exec( + &s, + "pmacs.window.display(B, { side = \"bottom\", select = true })", + ); + exec( + &s, + "pmacs.window.display(C, { side = \"bottom\", select = true })", + ); + + // C -> B -> A -> delete. + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return B").0 + ); + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return A").0 + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(9), + "the saved (user-resized) height is restored with its presentation" + ); + exec(&s, "pmacs.window.quit()"); + assert!(side_window(&s).is_none(), "the last quit deletes the slot"); + assert_eq!(active_window(&s), document); + + // A window with no quit action is a pointed error that changes nothing. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, "pmacs.window.quit()").is_err()); + assert_eq!(before, structure(&layout_root(&s))); +} + +#[test] +fn acc20b_quit_history_is_bounded_at_max_panel_quit_depth() { + let s = editor(); + exec(&s, "P0 = pmacs.buffer.create(\"*p0*\")"); + exec( + &s, + "pmacs.window.display(P0, { side = \"bottom\", height = 4 })", + ); + let panel = side_window(&s).expect("panel"); + for i in 1..=(MAX_PANEL_QUIT_DEPTH + 20) { + exec( + &s, + &format!( + "pmacs.window.display(pmacs.buffer.create(\"*p{i}*\"), {{ side = \"bottom\" }})" + ), + ); + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert!( + depth <= MAX_PANEL_QUIT_DEPTH, + "depth never grows beyond the cap (saw {depth} at replacement {i})" + ); + } + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert_eq!( + depth, MAX_PANEL_QUIT_DEPTH, + "exactly the newest 64 are retained" + ); + for _ in 0..MAX_PANEL_QUIT_DEPTH { + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + } + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!(side_window(&s).is_none(), "the chain terminates in Delete"); +} + +#[test] +fn acc23_quit_revalidates_a_killed_restore_target() { + let s = editor(); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + pmacs.window.display(A, { side = \"bottom\", height = 5 }) + pmacs.window.display(B, { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + exec(&s, "pmacs.buffer.kill(A)"); + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!( + side_window(&s).is_none(), + "a killed restore target degrades the whole chain to Delete" + ); +} + +// --------------------------------------------------------------------------- +// 21 / 22 — the jump ring +// --------------------------------------------------------------------------- + +#[test] +fn acc21_panel_visit_and_jump_back_returns_to_the_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("src.txt"); + std::fs::write(&file, b"one\ntwo\nthree\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*outline*", 6); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + // Move the panel cursor so the restored row is observable. + s.core.borrow_mut().windows.get_mut(&panel).unwrap().cursor = 0; + + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + &format!("pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + active_window(&s), + document, + "RET visited the document window" + ); + + let jumped: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(jumped); + assert_eq!( + active_window(&s), + panel, + "M-, returns focus to the EXISTING panel, not a duplicate" + ); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 2, + "no duplicate presentation was created" + ); +} + +#[test] +fn acc22_jump_histories_are_per_frontend_and_skip_stale_side_origins() { + let s = editor(); + let fid = FrontendId(21); + let foreign = attach_frontend(&s, fid, true); + + // LOCAL pushes; the foreign frontend must not be able to pop it. + exec(&s, "pmacs.editor.push_jump()"); + s.core.borrow_mut().active_frontend = fid; + assert!( + !s.core.borrow_mut().jump_back(), + "one frontend cannot consume another's navigation trail" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + s.core.borrow_mut().jump_back(), + "LOCAL's own entry survives" + ); + let _ = foreign; + + // A SIDE origin whose buffer was replaced is skipped, not resurrected. + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*new*\"), { side = \"bottom\" })", + ); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + !s.core.borrow_mut().jump_back(), + "a replaced side origin is skipped rather than duplicated into the document" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and the panel keeps its current presentation" + ); +} + +// --------------------------------------------------------------------------- +// 24 / 25 / 26 / 27 — the window guards +// --------------------------------------------------------------------------- + +#[test] +fn acc24_killing_a_panel_buffer_closes_the_side_window() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + exec(&s, "pmacs.buffer.kill(PANEL_BUF)"); + assert!(side_window(&s).is_none(), "the side window closed"); + assert!( + !s.core.borrow().windows.contains_key(&panel), + "…rather than being redirected to *scratch*" + ); + assert!(!s.core.borrow().registry.borrow().contains(panel_buffer)); +} + +#[test] +fn acc25_close_active_refuses_only_the_last_document_window() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + // A document window with only the panel beside it still cannot close. + assert!( + !s.core.borrow_mut().close_active(), + "the last document window is protected" + ); + // The panel itself always may — even as the only other window. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + s.core.borrow_mut().close_active(), + "closing the side window is always legal" + ); + assert!(side_window(&s).is_none()); + assert_eq!(active_window(&s), document); +} + +#[test] +fn acc26_close_others_and_split_respect_the_side_window() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 5); + // From a side window both are pointed errors. + exec(&s, "pmacs.window.focus_next()"); + while active_window(&s) != panel { + exec(&s, "pmacs.window.focus_next()"); + } + assert!(s.core.borrow_mut().close_others().is_err()); + assert!( + s.core + .borrow_mut() + .try_split_active(Orientation::Horizontal, true) + .is_err() + ); + assert!(side_window(&s).is_some(), "nothing was mutated"); + + // From a document window, close_others deletes the panel too. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.core + .borrow_mut() + .close_others() + .expect("document may close others"); + assert!(side_window(&s).is_none()); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 1 + ); +} + +#[test] +fn acc27_traversal_refreshes_the_remembered_document_origin() { + let s = editor(); + let a = active_window(&s); + exec(&s, "pmacs.window.split_horizontal()"); + let b = s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .find(|id| *id != a) + .expect("second document window"); + // Create the panel from A. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + let panel = open_panel(&s, "*panel*", 5); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(a) + ); + // Enter the panel from B: the memory retargets. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, b); + s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(b), + "entering the panel from B retargets the remembered origin" + ); + assert_eq!( + eval::(&s, "return pmacs.window.display_target()"), + b.raw(), + "display_target follows it" + ); + // A Delete-form quit focuses B, not the creation-time window. + exec(&s, "pmacs.window.quit()"); + assert_eq!(active_window(&s), b); +} + +// --------------------------------------------------------------------------- +// 29 — optimistic input is gated per WINDOW, not per buffer +// --------------------------------------------------------------------------- + +#[test] +fn acc29_focused_side_window_gates_dispatch_idle_without_marking_the_buffer() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + s.dispatch_idle_for(FrontendId::LOCAL), + "a document window is idle" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a focused side window turns optimistic apply off" + ); + assert!( + !s.core.borrow().buffer_round_trips(panel_buffer), + "…WITHOUT marking the buffer round-trip" + ); + + // Another frontend showing that same buffer as its DOCUMENT keeps + // optimistic apply. + let other = FrontendId(29); + let other_window = attach_frontend(&s, other, true); + s.core + .borrow_mut() + .install_buffer_in_window(other_window, panel_buffer) + .expect("install"); + assert!( + s.dispatch_idle_for(other), + "the buffer-global set is untouched, so the peer stays optimistic" + ); +} + +// --------------------------------------------------------------------------- +// 30 / 31 — the divider +// --------------------------------------------------------------------------- + +#[test] +fn acc30_divider_drag_writes_fixed_rows_and_weights_and_creates_no_selection() { + let s0 = editor(); + let mut s = s0; + let panel = open_panel(&s, "*panel*", 6); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + let rects = render(&s); + let divider_row = u16::try_from(rects[&document].origin.row + rects[&document].size.rows - 1) + .expect("row fits"); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + assert!( + s.core.borrow().active_window().selection.is_none(), + "a press on the reserved row creates no selection" + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(4), + "dragging the divider DOWN shrinks the side window's fixed rows" + ); + + // A flexible pair writes weights instead. + let mut s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + let rects = render(&s); + let divider_row = + u16::try_from(rects[&top].origin.row + rects[&top].size.rows - 1).expect("row fits"); + let before = rects[&top].size.rows; + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 3, 3), + CellSize::new(ROWS, COLS), + ); + let after = render(&s)[&top].size.rows; + assert_eq!( + after, + before + 3, + "the flexible boundary moved by the drag delta" + ); + // …and the ratio survives a frame resize, which is the whole point of + // writing weights rather than a fixed extent. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS * 2, COLS)); + let doubled = render_at(&s, CellSize::new(ROWS * 2, COLS))[&top].size.rows; + assert!(doubled > after, "the ratio scales with the frame"); +} + +#[test] +fn acc30b_ui_divider_face_resolves_and_paints_every_exposed_segment() { + let s = editor(); + // A boundary whose upper child is a VERTICAL split exposes several + // leaf mode-line segments along the same edge. + exec(&s, "pmacs.window.split_vertical()"); + open_panel(&s, "*panel*", 5); + exec( + &s, + "pmacs.theme.set { [\"ui.divider\"] = { fg = { 255, 0, 255 } } }", + ); + let rows = painted_rows(&s, CellSize::new(ROWS, COLS)); + let boundary_rows: Vec = rows + .iter() + .enumerate() + .filter(|(_, line)| line.contains('⇕')) + .map(|(i, _)| i) + .collect(); + assert_eq!( + boundary_rows.len(), + 1, + "both exposed segments sit on the SAME boundary row" + ); + + // Dragging either segment resolves the same boundary. + let core = s.core.borrow(); + let ids = core.views[&FrontendId::LOCAL].layout.iter_ids(); + let leaves: Vec = ids + .into_iter() + .filter(|id| !core.windows[id].is_side()) + .collect(); + let layout = core.views[&FrontendId::LOCAL].layout.clone(); + drop(core); + assert_eq!(leaves.len(), 2); + assert_eq!( + layout.boundary_below(leaves[0]), + layout.boundary_below(leaves[1]), + "every leaf segment touching the same bottom edge resolves to one boundary" + ); +} + +#[test] +fn acc31_keyboard_resize_matches_the_equivalent_drag_in_a_nested_layout() { + // Build H[ H[A, C], B ] — A's nearest horizontal ancestor is the + // inner split; C's is that same split, but C is its FINAL child, so + // C's boundary is the outer one. The naive "nearest horizontal + // ancestor" reading picks the wrong split for C. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let a = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + exec(&s, "pmacs.window.split_horizontal()"); + let ids = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids(); + assert_eq!(ids.len(), 3); + let (a, c, b) = (ids[0], ids[1], ids[2]); + + let layout = s.core.borrow().views[&FrontendId::LOCAL].layout.clone(); + assert_ne!( + layout.boundary_below(a), + layout.boundary_below(c), + "A owns the INNER boundary; C, as that split's final child, \ + resolves upward to the outer one — the naive \"nearest \ + horizontal ancestor\" reading picks the wrong split for C" + ); + assert_eq!( + layout.boundary_below(c).expect("C has a boundary").path, + Vec::::new(), + "C's boundary is the ROOT split, not its own parent" + ); + assert!( + layout.boundary_below(b).is_none(), + "the last child owns no boundary" + ); + + // The keyboard resize and the equivalent DRAG move the same boundary + // to the same place. `resize(win, delta)` resolves from the SUPPLIED + // window (the Lua entry point is explicit). + let before = render(&s); + exec(&s, &format!("pmacs.window.resize({}, 2)", c.raw())); + let by_command: HashMap = render(&s) + .iter() + .map(|(id, rect)| (*id, rect.size.rows)) + .collect(); + assert!( + by_command[&c] > before[&c].size.rows, + "C grew: {} -> {}", + before[&c].size.rows, + by_command[&c] + ); + + let mut dragged = editor(); + exec(&dragged, "pmacs.window.split_horizontal()"); + let da = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids()[0]; + dragged + .core + .borrow_mut() + .focus_window(FrontendId::LOCAL, da); + exec(&dragged, "pmacs.window.split_horizontal()"); + let dids = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids(); + let dc = dids[1]; + let rects = render(&dragged); + let divider_row = + u16::try_from(rects[&dc].origin.row + rects[&dc].size.rows - 1).expect("row fits"); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + let by_drag = render(&dragged); + assert_eq!( + by_command[&c], by_drag[&dc].size.rows, + "keyboard resize equals the equivalent drag on that window's \ + bottom mode-line row" + ); + + // The no-adjustable-boundary case reports and no-ops. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, &format!("pmacs.window.resize({}, 1)", b.raw())).is_err()); + assert_eq!(before, structure(&layout_root(&s))); + + // The commands act on the ACTIVE window and equal the same move. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, top); + let before = render(&s)[&top].size.rows; + exec(&s, "pmacs.command.invoke(\"window.enlarge\")"); + assert_eq!(render(&s)[&top].size.rows, before + 1); + exec(&s, "pmacs.command.invoke(\"window.shrink\")"); + assert_eq!(render(&s)[&top].size.rows, before); +} + +// --------------------------------------------------------------------------- +// 32 / 33 / 34 — a terminal panel's height changes +// --------------------------------------------------------------------------- + +#[test] +fn acc32_terminal_panel_height_change_is_a_viewport_change() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"for i in $(seq 1 200); do echo line$i; done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + + // Wait for output. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let has_output = s + .terminal_manager + .borrow() + .snapshot(buffer.0) + .is_some_and(|snap| !snap.cells.is_empty()); + if has_output || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back, then change the panel height. `top` is preserved + // verbatim: a height change is a viewport change, never a scroll one. + exec(&s, "pmacs.window.focus_next()"); + let key_before = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + s.terminal_manager + .borrow_mut() + .scroll_view(key_before, CellSize::new(6, COLS), 5); + let offset_before = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 10 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let offset_after = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + assert_eq!( + offset_before, offset_after, + "a scrolled-back terminal panel keeps its top across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// **Bet B1 pin.** Panel-as-window means the terminal controller, the +/// fixed `C-c` escape, and release-on-blur need zero new code: the +/// controller is keyed `(frontend_id, window_id)` and `view.active` +/// already answers "which window", whether or not that window is a side +/// window. +#[test] +fn acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut s = editor(); + exec( + &s, + &format!( + "TERM_BUF = pmacs.terminal.open {{ + command = \"/usr/bin/python3\", + args = {{ \"-c\", {} }}, + rows = 4, cols = 20, + display = \"panel\", + }}", + format_args!("{probe:?}") + ), + ); + let panel = side_window(&s).expect("terminal panel"); + assert_eq!( + active_window(&s), + panel, + "the panel opt-in selects the panel" + ); + assert_eq!( + wait_for_file(&ready_path, Duration::from_secs(5)), + b"1", + "the child in the PANEL reached raw mode" + ); + + // Exactly the Stage 2 vterm contract, unchanged: unescaped bound keys + // reach the child, `C-c` escapes for one key, `C-c C-c` sends one + // literal interrupt. + for ev in [ + key(KeyCode::Char('v'), KeyModifiers::ALT), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('w'), KeyModifiers::ALT), + ] { + s.dispatch_key(FrontendId::LOCAL, ev); + } + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "child input routing through a SIDE window is byte-identical" + ); + + // Release-on-blur still works: leaving the panel drops the controller. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!( + s.terminal_manager + .borrow() + .controller_view_for_frontend(FrontendId::LOCAL) + .is_none(), + "the controller is released when focus leaves the panel" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +fn wait_for_file(path: &std::path::Path, timeout: Duration) -> Vec { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Ok(bytes) = std::fs::read(path) + && !bytes.is_empty() + { + return bytes; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + let view_size = CellSize::new(5, COLS); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let seen = s + .terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, view_size) + .is_some(); + if seen || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back into history and start a selection there. + { + let mut manager = s.terminal_manager.borrow_mut(); + assert!(manager.scroll_view(key_id, view_size, 10)); + assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); + } + let before = s.terminal_manager.borrow_mut().view_status(key_id); + + // Grow the panel enough that it would otherwise reach the live tail. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 20 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let after = s.terminal_manager.borrow_mut().view_status(key_id); + assert_eq!( + before.map(|status| (status.scroll_offset, status.selection)), + after.map(|status| (status.scroll_offset, status.selection)), + "a historical selection freezes the anchor across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +#[test] +fn acc34_only_the_controller_resizes_the_pty() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let controlled = s.terminal_manager.borrow().screen_size(buffer.0); + + // A second frontend that does NOT control the session may hold its + // own panel height without resizing the child. + let other = FrontendId(34); + attach_frontend(&s, other, true); + s.sync_terminal_layout(other, CellSize::new(ROWS, COLS)); + assert_eq!( + s.terminal_manager.borrow().screen_size(buffer.0), + controlled, + "only the controller's height change resizes the PTY" + ); + let _ = panel; + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +// --------------------------------------------------------------------------- +// 35 — the desktop never persists a side window +// --------------------------------------------------------------------------- + +#[test] +fn acc35_desktop_round_trip_omits_the_side_leaf_and_its_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("saved.txt"); + std::fs::write(&file, b"content\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + exec(&s, &format!("pmacs.buffer.find_or_open({path:?})")); + let document_structure = structure(&layout_root(&s)); + exec( + &s, + &format!( + "PANEL_BUF = pmacs.buffer.find_or_open({path:?}) + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = 6 }})" + ), + ); + assert!(side_window(&s).is_some()); + + let snapshot = + pmacs::desktop::snapshot(&s.core.borrow(), "test".into()).expect("a file window survives"); + assert_eq!( + snapshot.version, + pmacs::desktop::DESKTOP_VERSION, + "the desktop format version does not change" + ); + assert!( + matches!(snapshot.root, pmacs::desktop::SavedNode::Leaf(_)), + "neither the side leaf nor its root wrapper is persisted \ + (saw {:?})", + snapshot.root + ); + let _ = document_structure; +} + +// --------------------------------------------------------------------------- +// Core-level invariants that back the above +// --------------------------------------------------------------------------- + +#[test] +fn panel_hidden_never_describes_a_panel_that_no_longer_exists() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 8); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + s.reconcile_panel_layout(FrontendId::LOCAL); + assert!( + !s.core.borrow().views[&FrontendId::LOCAL].panel_hidden, + "reconciliation clears the flag once the window is gone" + ); +} + +#[test] +fn unknown_geometry_is_not_twenty_four_by_eighty() { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fid = FrontendId(77); + attach_frontend(&s, fid, false); + assert!( + s.core.borrow().frontend_area_rows(fid).is_none(), + "a semantic view's geometry is UNKNOWN, never the attach placeholder" + ); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*p*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + let _ = s.core.borrow_mut().display_buffer(fid, &request); + // Not panel-capable in Stage 1, so it fell back; and even a capable + // view with unknown geometry would follow the hidden arm. + assert!(s.core.borrow().side_window_for(fid).is_none()); +} + +#[test] +fn quit_action_truncation_is_iterative_and_bounded() { + let mut action = QuitAction::Delete; + for _ in 0..(MAX_PANEL_QUIT_DEPTH * 3) { + action = QuitAction::Restore { + buffer_id: BufferId::from_raw(1), + fixed_rows: 4, + dedicated: false, + cursor: 0, + view_top: 0, + goal_col: None, + selection: None, + then: Box::new(action), + }; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + assert!(action.depth() <= MAX_PANEL_QUIT_DEPTH); + } +} + +#[test] +fn clamp_panel_rows_rejects_zero_and_lifts_to_the_floor() { + assert!(EditorCore::clamp_panel_rows(0).is_err()); + assert_eq!(EditorCore::clamp_panel_rows(1), Ok(MIN_WINDOW_OUTER_ROWS)); + assert_eq!(EditorCore::clamp_panel_rows(30), Ok(30)); +} + +#[test] +fn cell_coord_helper_is_used() { + // Keeps the CellCoord import honest for grid assertions above. + assert_eq!(CellCoord::new(1, 2).row, 1); +}