diff --git a/niri-patches/0001-scratchpad.patch b/niri-patches/0001-scratchpad.patch index 596c6e5..ebf4d9a 100644 --- a/niri-patches/0001-scratchpad.patch +++ b/niri-patches/0001-scratchpad.patch @@ -168,29 +168,81 @@ index 38440c90..fe16901f 100644 // 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..91bc8942 100644 +index f1ad4932..87b5ea7a 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs -@@ -1376,6 +1376,13 @@ impl State { +@@ -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 +2252,10 @@ impl State { +@@ -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 => { diff --git a/pkg/niri-scratchpad/tests/acceptance.py b/pkg/niri-scratchpad/tests/acceptance.py index c186aa1..066475e 100644 --- a/pkg/niri-scratchpad/tests/acceptance.py +++ b/pkg/niri-scratchpad/tests/acceptance.py @@ -97,17 +97,26 @@ class Niri: time.sleep(0.15) return self.windows() - def spawn_window(self): - """Spawn an alacritty window and return its id once it appears.""" + def spawn_window(self, timeout=30): + """Spawn an alacritty window and return its id once it appears. + + The wait is deliberately generous: every test launches its own nested + niri, so a cold alacritty start can occasionally be slow under that + load. Do NOT convert this into a retry — re-issuing the spawn risks a + second window arriving late and corrupting the window counts the tests + assert on. A slow test beats a flaky one. + """ before = {w["id"] for w in self.windows()} self.action("spawn", "--", "alacritty") t0 = time.time() - while time.time() - t0 < 10: + while time.time() - t0 < timeout: new = [w for w in self.windows() if w["id"] not in before] if new: return new[0]["id"] time.sleep(0.15) - raise RuntimeError("spawned window never appeared") + raise RuntimeError( + f"spawned window never appeared within {timeout}s " + f"(compositor alive={self.alive()})") def by_id(self, i): return next((w for w in self.windows() if w["id"] == i), None)