Files
dotfiles/niri-patches/0001-scratchpad.patch
T
funman300 d0fef1478d niri-scratchpad: pull shown window to focused output; don't drop tiles
Two fixes found reviewing the paths the acceptance suite couldn't cover.

Show-while-shown-elsewhere used activate_window, which sets
active_monitor_idx and so dragged *focus* to whichever output the window
was parked on. A scratchpad should come to you: if the window is on
another monitor, remove and re-add it floating on the active workspace;
if it is already on the active monitor, focus in place as before.

add_removed_tile_floating dropped the RemovedTile on both of its early
returns (NoOutputs, monitor-not-found), destroying the window while its
client was still alive and leaving it wedged with no surface anywhere.
Both were unreachable via scratchpad_show's guards, but it is a
window-loss footgun for the next caller. It now returns
Result<(), RemovedTile<W>> so callers can stash the tile instead.

Adds two layout tests (they simulate outputs, so multi-monitor is
covered without a second physical display) and corrects the acceptance
suite's test-8 skip message, which claimed the code used active_output
— it never did; it uses active_workspace.

cargo test --lib scratchpad: 10/10. acceptance.py: 8 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:47:06 -07:00

770 lines
31 KiB
Diff

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<niri_ipc::Action> 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<niri_ipc::Action> 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<u64> {
+ 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<u64>) {
+ 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<W: LayoutElement> {
interactive_move: Option<InteractiveMoveState<W>>,
/// Ongoing drag-and-drop operation.
dnd: Option<DndData<W>>,
+ /// 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<RemovedTile<W>>,
+ /// Id of the scratchpad window currently shown as a floating overlay, if any.
+ scratchpad_shown: Option<W::Id>,
/// 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<W: LayoutElement> {
tile: Tile<W>,
/// Width of the column the tile was in.
@@ -499,6 +506,12 @@ pub struct RemovedTile<W: LayoutElement> {
is_floating: bool,
}
+impl<W: LayoutElement> RemovedTile<W> {
+ 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<W: LayoutElement> Layout<W> {
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<W: LayoutElement> Layout<W> {
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<W: LayoutElement> Layout<W> {
window: &W::Id,
transaction: Transaction,
) -> Option<RemovedTile<W>> {
+ // 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<W: LayoutElement> Layout<W> {
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<RemovedTile<W>> {
+ 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<usize> {
+ 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<usize> {
+ 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<W>,
+ ) -> Result<(), RemovedTile<W>> {
+ 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<W: LayoutElement> Layout<W> {
}
}
+ // 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::<String>::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<niri_config::LayoutPart>,
},
+ 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"
+ );
+}