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:
@@ -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 => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user