diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 0be7596f..e6a56939 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -233,6 +233,7 @@ pub enum Action { reference: WorkspaceReference, focus: bool, }, + MoveWindowToScratchpad, MoveColumnToWorkspaceDown(#[knuffel(property(name = "focus"), default = true)] bool), MoveColumnToWorkspaceUp(#[knuffel(property(name = "focus"), default = true)] bool), MoveColumnToWorkspace( @@ -359,6 +360,7 @@ pub enum Action { ClearDynamicCastTarget, #[knuffel(skip)] StopCast(u64), + ScratchpadShow, ToggleOverview, OpenOverview, CloseOverview, @@ -530,6 +532,7 @@ impl From for Action { reference: WorkspaceReference::from(reference), focus, }, + niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad, niri_ipc::Action::MoveColumnToWorkspaceDown { focus } => { Self::MoveColumnToWorkspaceDown(focus) } @@ -694,6 +697,7 @@ impl From for Action { } niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget, niri_ipc::Action::StopCast { session_id } => Self::StopCast(session_id), + niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow, niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview, niri_ipc::Action::OpenOverview {} => Self::OpenOverview, niri_ipc::Action::CloseOverview {} => Self::CloseOverview, diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 0aa3cb4f..a5b712e2 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -524,6 +524,8 @@ pub enum Action { #[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))] focus: bool, }, + /// Move the focused window to the scratchpad (hide it). + MoveWindowToScratchpad {}, /// Move the focused column to the workspace below. MoveColumnToWorkspaceDown { /// Whether the focus should follow the target workspace. @@ -908,6 +910,8 @@ pub enum Action { #[cfg_attr(feature = "clap", arg(long))] session_id: u64, }, + /// Show, toggle, or cycle the scratchpad. + ScratchpadShow {}, /// Toggle (open/close) the Overview. ToggleOverview {}, /// Open the Overview. diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs index f38b2934..a085ff70 100644 --- a/src/handlers/compositor.rs +++ b/src/handlers/compositor.rs @@ -24,6 +24,7 @@ use crate::layout::{ActivateWindow, AddWindowTarget, LayoutElement as _}; use crate::niri::{CastTarget, ClientState, LockState, State}; use crate::utils::transaction::Transaction; use crate::utils::{is_mapped, send_scale_transform}; +use crate::window::mapped::MappedId; use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped}; impl CompositorHandler for State { @@ -368,6 +369,52 @@ impl CompositorHandler for State { return; } + // A window stashed in the scratchpad is in no workspace, so the lookup + // above can't see it — but its client still owns the surface and may + // unmap it at any time. Handle that here, otherwise the tile would sit + // in the stash with a bufferless surface and never be reinstated into + // unmapped_windows, so a later re-map could not fire and the window + // would be permanently wedged. + if !is_mapped(surface) { + if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface) { + trace!("stashed toplevel got unmapped"); + + let window = removed.window().window.clone(); + // Annotated to pin the inherent Mapped::id() -> MappedId; the + // LayoutElement trait also has an id(), returning &Window. + let id: MappedId = removed.window().id(); + // Drop the Mapped here so its mapped-toplevel pre-commit hook is + // torn down before we install the default one. The two are + // distinct HookIds (Mapped::drop removes only its own), so this + // ordering is tidiness, not correctness. + drop(removed); + + window.on_commit(); + + self.niri + .stop_casts_for_target(CastTarget::Window { id: id.get() }); + self.add_default_dmabuf_pre_commit_hook(surface); + + // Newly-unmapped toplevels must perform the initial + // commit-configure sequence afresh. + let unmapped = Unmapped::new(window); + self.niri.unmapped_windows.insert(surface.clone(), unmapped); + + // Nothing else to do: a stashed window is in no workspace, so it + // can't be the focus and isn't rendered on any output. It is also + // absent from window_mru_ui, unless the overlay happened to be + // open when it was stashed — that leaves a stale thumbnail, which + // the MRU lookups bail on harmlessly. + // + // A stashed window that commits while still mapped falls through + // to the unrecognized-surface trace below. Buffer state is still + // handled (on_commit_buffer_handler runs unconditionally), so only + // Window-level geometry/popup bookkeeping goes stale, and that + // self-heals on the first commit after the window is shown. + return; + } + } + // This is a commit of a non-toplevel root. } diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index 38440c90..fe16901f 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -47,6 +47,7 @@ use crate::utils::transaction::Transaction; use crate::utils::{ get_monotonic_time, output_matches_name, send_scale_transform, update_tiled_state, ResizeEdge, }; +use crate::window::mapped::MappedId; use crate::window::{InitialConfigureState, ResolvedWindowRules, Unmapped, WindowRef}; impl XdgShellHandler for State { @@ -834,6 +835,33 @@ impl XdgShellHandler for State { .find_window_and_output(surface.wl_surface()); let Some((mapped, output)) = win_out else { + // A window whose client died while stashed in the scratchpad is in no + // workspace, so find_window_and_output can't see it; evict it here. + // + // Casts must still be stopped: a Cast lives in niri.casting, independent + // of the layout, and the render loop just skips ids it can't find — so a + // cast of a stashed window would otherwise stay live and frozen forever. + // + // The rest of the teardown below is genuinely not needed here: + // window_mru_ui and layout.focus() are both derived from + // layout.workspaces(), so a stashed window is in neither; and there is + // nothing on screen to snapshot, animate closed, or redraw. + // + // We also deliberately skip re-adding the default dmabuf pre-commit hook + // that the normal path installs. Mapped::drop removes only its own + // mapped-toplevel hook (a distinct HookId), and the surface cannot gain a + // new role after toplevel destruction, so nothing will sample it again; + // destroyed() removes hooks with `if let Some(..)`, making the absent + // entry a no-op rather than an error. + if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface.wl_surface()) { + // Annotated to pin the inherent Mapped::id() -> MappedId; the + // LayoutElement trait also has an id(), returning &Window. + let id: MappedId = removed.window().id(); + drop(removed); + self.niri + .stop_casts_for_target(CastTarget::Window { id: id.get() }); + return; + } // I have no idea how this can happen, but I saw it happen once, in a weird interaction // involving laptop going to sleep and resuming. error!("toplevel missing from both unmapped_windows and layout"); diff --git a/src/input/mod.rs b/src/input/mod.rs index f1ad4932..87b5ea7a 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -54,6 +54,7 @@ use crate::ui::mru::{WindowMru, WindowMruUi}; use crate::ui::screenshot_ui::ScreenshotUi; use crate::utils::spawning::{spawn, spawn_sh}; use crate::utils::{center, get_monotonic_time, CastSessionId, ResizeEdge}; +use crate::window::mapped::MappedId; pub mod backend_ext; pub mod move_grab; @@ -647,6 +648,38 @@ impl State { } } + /// Ids of all windows currently in the layout. Excludes the scratchpad stash, + /// which is not part of any workspace. + fn layout_window_ids(&self) -> HashSet { + self.niri + .layout + .windows() + .map(|(_, mapped)| { + // Annotated to pin the inherent Mapped::id() -> MappedId; the + // LayoutElement trait also has an id(), returning &Window. + let id: MappedId = mapped.id(); + id.get() + }) + .collect() + } + + /// Stop screencasts of windows that were in the layout before an action and are + /// gone after it. A stashed window is never rendered, so its cast would otherwise + /// sit frozen on its last frame indefinitely; stopping is the chosen policy over + /// rendering stashed tiles. + /// + /// Note the carve-out inherited from `stop_casts_for_target`: a cast with a + /// *dynamic* target is not ended, it is retargeted to `CastTarget::Nothing`. So a + /// follow-focus cast survives a stash and goes blank rather than stopping. That is + /// shared behaviour with every other stop site, not specific to the scratchpad. + fn stop_casts_for_windows_gone(&mut self, before: HashSet) { + let after = self.layout_window_ids(); + for id in before.difference(&after) { + self.niri + .stop_casts_for_target(CastTarget::Window { id: *id }); + } + } + pub fn do_action(&mut self, action: Action, allow_when_locked: bool) { if self.niri.is_locked() && !(allow_when_locked || allowed_when_locked(&action)) { return; @@ -1376,6 +1409,15 @@ impl State { } } } + Action::MoveWindowToScratchpad => { + let focus = self.niri.layout.focus().map(|m| m.window.clone()); + if let Some(window) = focus { + let before = self.layout_window_ids(); + self.niri.layout.move_to_scratchpad(&window); + self.stop_casts_for_windows_gone(before); + self.niri.queue_redraw_all(); + } + } Action::MoveColumnToWorkspaceDown(focus) => { self.niri.layout.move_column_to_workspace_down(focus); self.maybe_warp_cursor_to_focus(); @@ -2245,6 +2287,13 @@ impl State { Action::StopCast(session_id) => { self.niri.stop_cast(CastSessionId::from(session_id)); } + Action::ScratchpadShow => { + // The hide branch of scratchpad_show also stashes a window. + let before = self.layout_window_ids(); + self.niri.layout.scratchpad_show(); + self.stop_casts_for_windows_gone(before); + self.niri.queue_redraw_all(); + } Action::ToggleOverview => { self.niri.layout.toggle_overview(); self.niri.queue_redraw_all(); diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 5c4dd639..8696fcd9 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -32,6 +32,7 @@ //! don't want an unassuming workspace to end up on it. use std::collections::HashMap; +use std::collections::VecDeque; use std::mem; use std::rc::Rc; use std::time::Duration; @@ -353,6 +354,11 @@ pub struct Layout { interactive_move: Option>, /// Ongoing drag-and-drop operation. dnd: Option>, + /// Windows hidden in the scratchpad, front = next to show. Not part of any + /// workspace, so they have no index and never appear in the workspace list. + scratchpad: VecDeque>, + /// Id of the scratchpad window currently shown as a floating overlay, if any. + scratchpad_shown: Option, /// Clock for driving animations. clock: Clock, /// Time that we last updated render elements for. @@ -489,6 +495,7 @@ pub enum ConfigureIntent { } /// Tile that was just removed from the layout. +#[derive(Debug)] pub struct RemovedTile { tile: Tile, /// Width of the column the tile was in. @@ -499,6 +506,12 @@ pub struct RemovedTile { is_floating: bool, } +impl RemovedTile { + pub fn window(&self) -> &W { + self.tile.window() + } +} + /// Whether to activate a newly added window. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum ActivateWindow { @@ -699,6 +712,8 @@ impl Layout { last_active_workspace_id: HashMap::new(), interactive_move: None, dnd: None, + scratchpad: VecDeque::new(), + scratchpad_shown: None, clock, update_render_elements_time: Duration::ZERO, overview_open: false, @@ -724,6 +739,8 @@ impl Layout { last_active_workspace_id: HashMap::new(), interactive_move: None, dnd: None, + scratchpad: VecDeque::new(), + scratchpad_shown: None, clock, update_render_elements_time: Duration::ZERO, overview_open: false, @@ -1114,6 +1131,13 @@ impl Layout { window: &W::Id, transaction: Transaction, ) -> Option> { + // A window may be sitting in the scratchpad (not in any workspace). + // Drop it here so both toplevel-destroy paths and the close op cover it. + self.scratchpad.retain(|rt| rt.tile.window().id() != window); + if self.scratchpad_shown.as_ref() == Some(window) { + self.scratchpad_shown = None; + } + if let Some(state) = &self.interactive_move { match state { InteractiveMoveState::Starting { window_id, .. } => { @@ -1204,6 +1228,164 @@ impl Layout { None } + /// Hide `window` into the scratchpad. Focus falls to the next window via the + /// normal remove path. No-op if the window is not in the layout. + pub fn move_to_scratchpad(&mut self, window: &W::Id) { + let Some(removed) = self.remove_window(window, Transaction::new()) else { + return; + }; + self.scratchpad.push_back(removed); + // scratchpad_shown is cleared by the eviction hook in remove_window + // (Task 4) if this window was the one shown. + } + + /// Evict a stashed window from the scratchpad by its surface, returning the + /// tile so the caller can run the rest of the teardown (casts, unmapped + /// bookkeeping). Needed on both the destroy and unmap paths: a window that + /// dies or unmaps *while stashed* is in no workspace, so + /// `find_window_and_output` (and therefore `remove_window`) never sees it — + /// this is the only thing that takes it out. A shown scratchpad window lives + /// in a workspace and is handled by `remove_window` instead. + pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> Option> { + let idx = self + .scratchpad + .iter() + .position(|rt| rt.tile.window().is_wl_surface(surface))?; + self.scratchpad.remove(idx) + } + + /// Show / toggle / cycle the scratchpad. See the state table in the plan. + pub fn scratchpad_show(&mut self) { + // Drop a stale "shown" id (window was closed or moved away). + if let Some(id) = self.scratchpad_shown.clone() { + if !self.has_window(&id) { + self.scratchpad_shown = None; + } + } + + if let Some(id) = self.scratchpad_shown.clone() { + let focused = self.focus().map(|w| w.id().clone()); + if focused.as_ref() == Some(&id) { + // shown + focused -> hide it + if let Some(removed) = self.remove_window(&id, Transaction::new()) { + self.scratchpad.push_back(removed); + } + self.scratchpad_shown = None; + } else { + // shown + not focused -> bring it to the monitor you are on, then + // focus it. `activate_window` alone would move *focus* to whichever + // monitor the window is parked on (it sets active_monitor_idx), + // dragging you across outputs; a scratchpad should come to you. + // Same monitor: nothing to move, just focus. + if self.monitor_idx_of_window(&id) != self.active_monitor_idx() { + if let Some(mut removed) = self.remove_window(&id, Transaction::new()) { + // remove_window cleared scratchpad_shown; re-set it below + // once the tile is placed. + removed.tile.stop_move_animations(); + + let Some(ws_id) = self.active_workspace().map(|ws| ws.id()) else { + self.scratchpad.push_front(removed); // no active ws + return; + }; + + match self.add_removed_tile_floating(ws_id, removed) { + Ok(()) => self.scratchpad_shown = Some(id), + // Could not place it; stash rather than drop it. + Err(tile) => self.scratchpad.push_front(tile), + } + return; + } + } + self.activate_window(&id); + } + return; + } + + // nothing shown -> show the front of the stash + let Some(mut removed) = self.scratchpad.pop_front() else { + return; + }; + removed.tile.stop_move_animations(); + + // First-show centering: niri's floating layer places a position-less tile + // using its own default placement, which is acceptable for now. Deferring + // precise centering geometry to manual verification (Task 7); no unit test + // exercises this here. + + let id = removed.tile.window().id().clone(); + let ws_id = match self.active_workspace() { + Some(ws) => ws.id(), + None => { + self.scratchpad.push_front(removed); // no active ws; put it back + return; + } + }; + match self.add_removed_tile_floating(ws_id, removed) { + Ok(()) => self.scratchpad_shown = Some(id), + // Could not place it; stash rather than drop it. + Err(tile) => self.scratchpad.push_front(tile), + } + } + + /// Index of the monitor holding `window`, or None if it is not in the layout + /// (which includes every window stashed in the scratchpad). + fn monitor_idx_of_window(&self, window: &W::Id) -> Option { + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { + return None; + }; + monitors.iter().position(|mon| { + mon.workspaces + .iter() + .any(|ws| ws.windows().any(|win| win.id() == window)) + }) + } + + /// Index of the currently active monitor, if there are any outputs. + fn active_monitor_idx(&self) -> Option { + match &self.monitor_set { + MonitorSet::Normal { + active_monitor_idx, .. + } => Some(*active_monitor_idx), + _ => None, + } + } + + /// Re-add a previously-removed tile to the workspace `ws_id`, forcing it onto + /// the floating layer. Mirrors the re-add pattern in `move_to_workspace`. + /// + /// Returns the tile back in `Err` when it could not be placed, so the caller + /// can stash it. Dropping it here would destroy the window while its client + /// is still alive, leaving it permanently wedged with no surface anywhere. + fn add_removed_tile_floating( + &mut self, + ws_id: WorkspaceId, + removed: RemovedTile, + ) -> Result<(), RemovedTile> { + let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else { + return Err(removed); + }; + let Some(mon) = monitors + .iter_mut() + .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id)) + else { + return Err(removed); + }; + + mon.add_tile( + removed.tile, + MonitorAddWindowTarget::Workspace { + id: ws_id, + column_idx: None, + }, + ActivateWindow::Yes, + true, + removed.width, + removed.is_full_width, + true, + ); + Ok(()) + } + pub fn descendants_added(&mut self, id: &W::Id) -> bool { for ws in self.workspaces_mut() { if ws.descendants_added(id) { @@ -2444,6 +2626,24 @@ impl Layout { } } + // Scratchpad invariants: stashed windows belong to no workspace, and the + // shown window (if any) belongs to a workspace. This must run for both + // MonitorSet variants, so it lives before the match below (the + // MonitorSet::NoOutputs branch returns early). + for tile in &self.scratchpad { + let id = tile.tile.window().id(); + assert!( + !self.has_window(id), + "scratchpad window {id:?} must not also be in a workspace", + ); + } + if let Some(id) = &self.scratchpad_shown { + assert!( + self.has_window(id), + "scratchpad_shown window {id:?} must be present in a workspace", + ); + } + let mut seen_workspace_id = HashSet::new(); let mut seen_workspace_name = Vec::::new(); diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 31265dd9..4c22a115 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -753,6 +753,8 @@ enum Op { #[proptest(strategy = "arbitrary_layout_part().prop_map(Box::new)")] layout_config: Box, }, + ScratchpadStash(#[proptest(strategy = "1..=5usize")] usize), + ScratchpadShow, } impl Op { @@ -1625,6 +1627,12 @@ impl Op { layout.update_options(options); } + Op::ScratchpadStash(id) => { + layout.move_to_scratchpad(&id); + } + Op::ScratchpadShow => { + layout.scratchpad_show(); + } } } } @@ -3929,3 +3937,225 @@ proptest! { check_ops_with_options(options, ops); } } + +#[test] +fn scratchpad_stash_removes_window_from_workspace() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + ]; + let layout = check_ops(ops); + + // The window left every workspace... + assert!(!layout.has_window(&0), "window should not be in any workspace"); + // ...and now lives in the stash. + assert_eq!(layout.scratchpad.len(), 1); + assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0); +} + +#[test] +fn scratchpad_show_brings_window_back_focused() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + assert!(layout.scratchpad.is_empty(), "stash drained on show"); + assert!(layout.has_window(&0), "window returned to a workspace"); + assert_eq!(layout.scratchpad_shown, Some(0)); + assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused"); +} + +#[test] +fn scratchpad_show_twice_hides_again() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + Op::ScratchpadShow, // show + Op::ScratchpadShow, // focused -> hide + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad.len(), 1, "window back in stash"); + assert!(!layout.has_window(&0)); + assert_eq!(layout.scratchpad_shown, None); +} + +#[test] +fn scratchpad_show_cycles_round_robin() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::AddWindow { params: TestWindowParams::new(1) }, + Op::ScratchpadStash(0), // stash: [0] + Op::ScratchpadStash(1), // stash: [0, 1] + Op::ScratchpadShow, // show 0 (front) + Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0] + Op::ScratchpadShow, // show 1 (new front) + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window"); + assert!(layout.has_window(&1)); +} + +#[test] +fn scratchpad_show_empty_is_noop() { + let ops = [Op::AddOutput(1), Op::ScratchpadShow]; + let layout = check_ops(ops); + assert_eq!(layout.scratchpad_shown, None); + assert!(layout.scratchpad.is_empty()); +} + +#[test] +fn scratchpad_evicts_closed_stashed_window() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::AddWindow { params: TestWindowParams::new(1) }, + Op::ScratchpadStash(0), + Op::ScratchpadStash(1), + Op::CloseWindow(0), // close a window while it is stashed + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash"); + assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1); +} + +#[test] +fn scratchpad_shows_on_the_focused_output() { + // First, confirm that while stashed the window lives on no monitor at all (checked against + // a standalone run so `check_ops`'s post-op invariant checks still see a fully-formed op + // sequence). + let stash_only_ops = [ + Op::AddOutput(1), + Op::AddOutput(2), + Op::FocusOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1 + Op::ScratchpadStash(0), + ]; + let stashed_layout = check_ops(stash_only_ops); + { + let MonitorSet::Normal { monitors, .. } = &stashed_layout.monitor_set else { + unreachable!() + }; + assert!( + monitors.iter().all(|m| !m.windows().any(|w| *w.id() == 0)), + "stashed window must not be present on any monitor" + ); + } + + // Stash on output 1, then switch focus to output 2 before showing. + let ops = [ + Op::AddOutput(1), + Op::AddOutput(2), + Op::FocusOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1 + Op::ScratchpadStash(0), + Op::FocusOutput(2), + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else { + unreachable!() + }; + + // The window must appear on whichever output is currently focused (output 2), not on the + // output it was originally stashed from (output 1). + for (idx, monitor) in monitors.iter().enumerate() { + let has_window = monitor.windows().any(|w| *w.id() == 0); + if idx == *active_monitor_idx { + assert!(has_window, "shown window should be on the focused output"); + } else { + assert!(!has_window, "shown window should not be on a non-focused output"); + } + } +} + +#[test] +fn scratchpad_shows_on_origin_output_when_focus_unchanged() { + let ops = [ + Op::AddOutput(1), + Op::AddOutput(2), + Op::FocusOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1 + Op::ScratchpadStash(0), + // no focus change here + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else { + unreachable!() + }; + + // Focus never left output 1, so the window should reappear there. + assert_eq!(*active_monitor_idx, 0, "output 1 should still be active"); + assert!( + monitors[*active_monitor_idx].windows().any(|w| *w.id() == 0), + "shown window should be on the still-focused origin output" + ); +} + +#[test] +fn scratchpad_show_pulls_already_shown_window_to_focused_output() { + // Show on output 1, walk to output 2, then press show again. The window must + // come to output 2 — not drag focus back to output 1, which is what plain + // `activate_window` would do. + let ops = [ + Op::AddOutput(1), + Op::AddOutput(2), + Op::FocusOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1 + Op::ScratchpadStash(0), + Op::ScratchpadShow, // shown on output 1 + Op::FocusOutput(2), // shown, but no longer focused + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else { + unreachable!() + }; + + assert_eq!(*active_monitor_idx, 1, "focus must stay on output 2"); + assert!( + monitors[1].windows().any(|w| *w.id() == 0), + "window should have been pulled to output 2" + ); + assert!( + !monitors[0].windows().any(|w| *w.id() == 0), + "window must not be left behind on output 1" + ); + assert_eq!(layout.scratchpad_shown, Some(0), "still tracked as shown"); + assert!(layout.scratchpad.is_empty(), "must not be re-stashed"); +} + +#[test] +fn scratchpad_show_focuses_in_place_on_same_output() { + // Same output, shown but not focused: focus it without moving it. + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + Op::ScratchpadShow, // shown and focused + Op::AddWindow { params: TestWindowParams::new(1) }, // steals focus + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad_shown, Some(0)); + assert!(layout.scratchpad.is_empty(), "must not be re-stashed"); + assert_eq!( + layout.focus().map(|w| *w.id()), + Some(0), + "shown-but-unfocused window should get focus, not be hidden" + ); +}