niri-scratchpad: stop screencasts on stash; harden acceptance spawn wait

Stashing a window now stops an active screencast of it, rather than leaving the
consumer on a frozen last frame (a stashed window is never rendered). Covers
both stash paths - Mod+M and Mod+Shift+M's hide branch - by diffing which
windows left the layout across the action, so scratchpad_show's branch logic
isn't duplicated in the caller. Dynamic-target casts are retargeted to Nothing
rather than ended; that carve-out is documented inline.

Also widen the acceptance harness spawn wait to 30s: each test launches its own
nested niri, and a cold alacritty start occasionally exceeded the old 10s.
Deliberately not a retry, which could double-spawn and corrupt window counts.

117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-18 11:04:14 -07:00
parent f557ffdfc7
commit 640deb397e
2 changed files with 68 additions and 7 deletions
+55 -3
View File
@@ -168,29 +168,81 @@ index 38440c90..fe16901f 100644
// involving laptop going to sleep and resuming. // involving laptop going to sleep and resuming.
error!("toplevel missing from both unmapped_windows and layout"); error!("toplevel missing from both unmapped_windows and layout");
diff --git a/src/input/mod.rs b/src/input/mod.rs 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 --- a/src/input/mod.rs
+++ b/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<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 => { + Action::MoveWindowToScratchpad => {
+ let focus = self.niri.layout.focus().map(|m| m.window.clone()); + let focus = self.niri.layout.focus().map(|m| m.window.clone());
+ if let Some(window) = focus { + if let Some(window) = focus {
+ let before = self.layout_window_ids();
+ self.niri.layout.move_to_scratchpad(&window); + self.niri.layout.move_to_scratchpad(&window);
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all(); + self.niri.queue_redraw_all();
+ } + }
+ } + }
Action::MoveColumnToWorkspaceDown(focus) => { Action::MoveColumnToWorkspaceDown(focus) => {
self.niri.layout.move_column_to_workspace_down(focus); self.niri.layout.move_column_to_workspace_down(focus);
self.maybe_warp_cursor_to_focus(); self.maybe_warp_cursor_to_focus();
@@ -2245,6 +2252,10 @@ impl State { @@ -2245,6 +2287,13 @@ impl State {
Action::StopCast(session_id) => { Action::StopCast(session_id) => {
self.niri.stop_cast(CastSessionId::from(session_id)); self.niri.stop_cast(CastSessionId::from(session_id));
} }
+ Action::ScratchpadShow => { + Action::ScratchpadShow => {
+ // The hide branch of scratchpad_show also stashes a window.
+ let before = self.layout_window_ids();
+ self.niri.layout.scratchpad_show(); + self.niri.layout.scratchpad_show();
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all(); + self.niri.queue_redraw_all();
+ } + }
Action::ToggleOverview => { Action::ToggleOverview => {
+13 -4
View File
@@ -97,17 +97,26 @@ class Niri:
time.sleep(0.15) time.sleep(0.15)
return self.windows() return self.windows()
def spawn_window(self): def spawn_window(self, timeout=30):
"""Spawn an alacritty window and return its id once it appears.""" """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()} before = {w["id"] for w in self.windows()}
self.action("spawn", "--", "alacritty") self.action("spawn", "--", "alacritty")
t0 = time.time() 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] new = [w for w in self.windows() if w["id"] not in before]
if new: if new:
return new[0]["id"] return new[0]["id"]
time.sleep(0.15) 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): def by_id(self, i):
return next((w for w in self.windows() if w["id"] == i), None) return next((w for w in self.windows() if w["id"] == i), None)