Compare commits
5 Commits
d05b9f8a0e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fef1478d | |||
| 640deb397e | |||
| f557ffdfc7 | |||
| a9ce3a13cf | |||
| 0e0b5fcbe3 |
@@ -56,37 +56,200 @@ index 0aa3cb4f..a5b712e2 100644
|
||||
/// 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..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<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 +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 => {
|
||||
self.niri.layout.toggle_overview();
|
||||
self.niri.queue_redraw_all();
|
||||
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
|
||||
index 5c4dd639..077a0570 100644
|
||||
index 5c4dd639..8696fcd9 100644
|
||||
--- a/src/layout/mod.rs
|
||||
+++ b/src/layout/mod.rs
|
||||
@@ -32,6 +32,7 @@
|
||||
@@ -117,7 +280,20 @@ index 5c4dd639..077a0570 100644
|
||||
pub struct RemovedTile<W: LayoutElement> {
|
||||
tile: Tile<W>,
|
||||
/// Width of the column the tile was in.
|
||||
@@ -699,6 +706,8 @@ impl<W: LayoutElement> Layout<W> {
|
||||
@@ -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,
|
||||
@@ -126,7 +302,7 @@ index 5c4dd639..077a0570 100644
|
||||
clock,
|
||||
update_render_elements_time: Duration::ZERO,
|
||||
overview_open: false,
|
||||
@@ -724,6 +733,8 @@ impl<W: LayoutElement> Layout<W> {
|
||||
@@ -724,6 +739,8 @@ impl<W: LayoutElement> Layout<W> {
|
||||
last_active_workspace_id: HashMap::new(),
|
||||
interactive_move: None,
|
||||
dnd: None,
|
||||
@@ -135,7 +311,7 @@ index 5c4dd639..077a0570 100644
|
||||
clock,
|
||||
update_render_elements_time: Duration::ZERO,
|
||||
overview_open: false,
|
||||
@@ -1114,6 +1125,13 @@ impl<W: LayoutElement> Layout<W> {
|
||||
@@ -1114,6 +1131,13 @@ impl<W: LayoutElement> Layout<W> {
|
||||
window: &W::Id,
|
||||
transaction: Transaction,
|
||||
) -> Option<RemovedTile<W>> {
|
||||
@@ -149,7 +325,7 @@ index 5c4dd639..077a0570 100644
|
||||
if let Some(state) = &self.interactive_move {
|
||||
match state {
|
||||
InteractiveMoveState::Starting { window_id, .. } => {
|
||||
@@ -1204,6 +1222,93 @@ impl<W: LayoutElement> Layout<W> {
|
||||
@@ -1204,6 +1228,164 @@ impl<W: LayoutElement> Layout<W> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -164,6 +340,21 @@ index 5c4dd639..077a0570 100644
|
||||
+ // (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).
|
||||
@@ -182,9 +373,30 @@ index 5c4dd639..077a0570 100644
|
||||
+ }
|
||||
+ self.scratchpad_shown = None;
|
||||
+ } else {
|
||||
+ // shown + not focused -> focus it in place. `activate_window` walks
|
||||
+ // every monitor/workspace looking for the window and focuses it
|
||||
+ // without moving it, which is exactly what we want here.
|
||||
+ // 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;
|
||||
@@ -209,21 +421,55 @@ index 5c4dd639..077a0570 100644
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+ self.add_removed_tile_floating(ws_id, removed);
|
||||
+ self.scratchpad_shown = Some(id);
|
||||
+ 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`.
|
||||
+ fn add_removed_tile_floating(&mut self, ws_id: WorkspaceId, removed: RemovedTile<W>) {
|
||||
+ ///
|
||||
+ /// 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;
|
||||
+ return Err(removed);
|
||||
+ };
|
||||
+ let Some(mon) = monitors
|
||||
+ .iter_mut()
|
||||
+ .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id))
|
||||
+ else {
|
||||
+ return;
|
||||
+ return Err(removed);
|
||||
+ };
|
||||
+
|
||||
+ mon.add_tile(
|
||||
@@ -238,12 +484,13 @@ index 5c4dd639..077a0570 100644
|
||||
+ 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 +2549,24 @@ impl<W: LayoutElement> Layout<W> {
|
||||
@@ -2444,6 +2626,24 @@ impl<W: LayoutElement> Layout<W> {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +516,7 @@ index 5c4dd639..077a0570 100644
|
||||
let mut seen_workspace_name = Vec::<String>::new();
|
||||
|
||||
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
|
||||
index 31265dd9..65e02f2b 100644
|
||||
index 31265dd9..4c22a115 100644
|
||||
--- a/src/layout/tests.rs
|
||||
+++ b/src/layout/tests.rs
|
||||
@@ -753,6 +753,8 @@ enum Op {
|
||||
@@ -294,7 +541,7 @@ index 31265dd9..65e02f2b 100644
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3929,3 +3937,93 @@ proptest! {
|
||||
@@ -3929,3 +3937,225 @@ proptest! {
|
||||
check_ops_with_options(options, ops);
|
||||
}
|
||||
}
|
||||
@@ -388,3 +635,135 @@ index 31265dd9..65e02f2b 100644
|
||||
+ 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"
|
||||
+ );
|
||||
+}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live acceptance / regression test for the niri native scratchpad patch.
|
||||
|
||||
Drives a real (patched) niri through its own IPC — the exact `do_action`
|
||||
path a keybind hits — and asserts on `niri msg -j windows`. No keyboard
|
||||
injection, so it can't type into the host session's windows. Each test gets
|
||||
its own fresh nested niri, so cases are hermetic and order-independent.
|
||||
|
||||
Run this after rebasing the patch onto a new niri release to confirm the
|
||||
scratchpad still behaves. It needs:
|
||||
- a running Wayland (or X) session to host the nested winit window,
|
||||
- the patched niri binary (built from niri-patches/ via the PKGBUILD, or a
|
||||
dev checkout),
|
||||
- `alacritty` as the throwaway test client.
|
||||
|
||||
Usage:
|
||||
python3 acceptance.py [PATH_TO_NIRI]
|
||||
Binary resolution order: argv[1], $NIRI_BIN, `niri` on PATH,
|
||||
~/build/niri-scratchpad/target/release/niri. The binary MUST be patched
|
||||
(its `niri msg action --help` must list `scratchpad-show`).
|
||||
|
||||
Exit code 0 iff every non-skipped test passes.
|
||||
"""
|
||||
import glob, json, os, shutil, signal, subprocess, sys, tempfile, time
|
||||
|
||||
MINIMAL_CONFIG = """\
|
||||
// Hermetic config for scratchpad acceptance: no startup spawns.
|
||||
input { keyboard { xkb { } } }
|
||||
binds {
|
||||
Mod+M { move-window-to-scratchpad; }
|
||||
Mod+Shift+M { scratchpad-show; }
|
||||
Mod+Q { close-window; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def resolve_niri():
|
||||
for cand in (sys.argv[1] if len(sys.argv) > 1 else None,
|
||||
os.environ.get("NIRI_BIN"),
|
||||
shutil.which("niri"),
|
||||
os.path.expanduser("~/build/niri-scratchpad/target/release/niri")):
|
||||
if cand and os.path.exists(cand):
|
||||
return cand
|
||||
sys.exit("ERROR: no niri binary found (pass a path, set $NIRI_BIN, or "
|
||||
"install niri-scratchpad).")
|
||||
|
||||
|
||||
NIRI = resolve_niri()
|
||||
RT = os.environ["XDG_RUNTIME_DIR"]
|
||||
|
||||
|
||||
class Niri:
|
||||
"""A fresh nested niri instance driven over IPC."""
|
||||
|
||||
def __init__(self, config_path):
|
||||
before = set(glob.glob(f"{RT}/niri.*.sock"))
|
||||
self.proc = subprocess.Popen(
|
||||
[NIRI, "-c", config_path],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sock = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 15:
|
||||
new = set(glob.glob(f"{RT}/niri.*.sock")) - before
|
||||
if new:
|
||||
self.sock = new.pop()
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if not self.sock:
|
||||
self.close()
|
||||
raise RuntimeError("nested niri socket never appeared")
|
||||
self.env = dict(os.environ, NIRI_SOCKET=self.sock)
|
||||
for _ in range(40): # wait for IPC to answer
|
||||
if self.alive():
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
def msg(self, *args):
|
||||
r = subprocess.run([NIRI, "msg", "-j", *args],
|
||||
capture_output=True, text=True, env=self.env)
|
||||
return json.loads(r.stdout) if r.stdout.strip() else None
|
||||
|
||||
def action(self, *args):
|
||||
return subprocess.run([NIRI, "msg", "action", *args],
|
||||
capture_output=True, text=True, env=self.env)
|
||||
|
||||
def alive(self):
|
||||
return self.msg("version") is not None
|
||||
|
||||
def windows(self):
|
||||
return self.msg("windows") or []
|
||||
|
||||
def wait_count(self, n, timeout=8):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if len(self.windows()) == n:
|
||||
return self.windows()
|
||||
time.sleep(0.15)
|
||||
return self.windows()
|
||||
|
||||
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 < 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(
|
||||
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)
|
||||
|
||||
def focused(self):
|
||||
return next((w for w in self.windows() if w["is_focused"]), None)
|
||||
|
||||
def floating_pos(self, i, tries=12):
|
||||
# tile_pos_in_workspace_view is transiently null right after a show.
|
||||
for _ in range(tries):
|
||||
w = self.by_id(i)
|
||||
p = w["layout"].get("tile_pos_in_workspace_view") if w else None
|
||||
if p is not None:
|
||||
return p
|
||||
time.sleep(0.08)
|
||||
return None
|
||||
|
||||
def output_size(self):
|
||||
outs = self.msg("outputs") or {}
|
||||
for o in outs.values():
|
||||
lg = o.get("logical") or {}
|
||||
if lg.get("width") and lg.get("height"):
|
||||
return lg["width"], lg["height"]
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- tests: each takes a fresh Niri and returns (ok, detail) ----
|
||||
|
||||
def t_stash_hides_and_moves_focus(n):
|
||||
a = n.spawn_window()
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b))
|
||||
time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad")
|
||||
n.wait_count(1)
|
||||
f = n.focused()
|
||||
ok = (len(n.windows()) == 1 and n.by_id(b) is None
|
||||
and f is not None and f["id"] == a)
|
||||
return ok, f"remaining={len(n.windows())}, b_gone={n.by_id(b) is None}, focus={f['id'] if f else None}/want {a}"
|
||||
|
||||
|
||||
def t_show_floating_focused_centered(n):
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1)
|
||||
w = n.by_id(b)
|
||||
pos = n.floating_pos(b)
|
||||
centered = "n/a"
|
||||
ok = bool(w and w["is_floating"] and w["is_focused"] and pos and pos[0] > 5)
|
||||
osz = n.output_size()
|
||||
if ok and osz and w:
|
||||
ww, wh = w["layout"]["window_size"]
|
||||
cx, cy = (osz[0] - ww) / 2, (osz[1] - wh) / 2
|
||||
centered = abs(pos[0] - cx) < osz[0] * 0.15 and abs(pos[1] - cy) < osz[1] * 0.15
|
||||
ok = ok and bool(centered)
|
||||
return ok, f"floating={w['is_floating'] if w else None}, focused={w['is_focused'] if w else None}, pos={pos}, centered={centered}"
|
||||
|
||||
|
||||
def t_reshow_hides(n):
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1) # show (focused)
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide
|
||||
ok = len(n.windows()) == 0
|
||||
return ok, f"count after re-show={len(n.windows())} (want 0)"
|
||||
|
||||
|
||||
def t_cycle_round_robin(n):
|
||||
a = n.spawn_window()
|
||||
b = n.spawn_window()
|
||||
for wid in (b, a):
|
||||
n.action("focus-window", "--id", str(wid)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad")
|
||||
n.wait_count(0)
|
||||
n.action("scratchpad-show"); w1 = n.wait_count(1); first = w1[0]["id"] if w1 else None
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide (focused)
|
||||
n.action("scratchpad-show"); w2 = n.wait_count(1); second = w2[0]["id"] if w2 else None
|
||||
ok = first is not None and second is not None and first != second
|
||||
return ok, f"first={first}, second={second} (want different)"
|
||||
|
||||
|
||||
def t_geometry_remembered(n):
|
||||
w = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1)
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-floating-window", "-x", "+130", "-y", "+90"); time.sleep(0.4)
|
||||
pos1 = n.floating_pos(w)
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide
|
||||
n.action("scratchpad-show"); n.wait_count(1) # show again (single window)
|
||||
pos2 = n.floating_pos(w)
|
||||
back = n.by_id(w)
|
||||
ok = (back is not None and back["is_floating"] and pos1 and pos2
|
||||
and abs(pos1[0] - pos2[0]) < 2 and abs(pos1[1] - pos2[1]) < 2)
|
||||
return ok, f"pos before hide={pos1}, after show={pos2}, floating={back['is_floating'] if back else None}"
|
||||
|
||||
|
||||
def t_shown_not_focused_gets_focus(n):
|
||||
p = n.spawn_window() # persistent tiled window
|
||||
s = n.spawn_window() # to be stashed + shown
|
||||
n.action("focus-window", "--id", str(s)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(1)
|
||||
n.action("scratchpad-show"); n.wait_count(2) # s shown floating, focused
|
||||
n.action("focus-window", "--id", str(p)); time.sleep(0.3)
|
||||
npre = len(n.windows())
|
||||
n.action("scratchpad-show"); time.sleep(0.4) # should just focus s back
|
||||
f = n.focused()
|
||||
ok = len(n.windows()) == npre and f is not None and f["id"] == s
|
||||
return ok, f"count {npre}->{len(n.windows())} (want same), focus={f['id'] if f else None}/want {s}"
|
||||
|
||||
|
||||
def t_close_while_stashed(n):
|
||||
w = n.spawn_window()
|
||||
pid = n.by_id(w)["pid"]
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
os.kill(pid, signal.SIGKILL) # client dies WHILE stashed
|
||||
time.sleep(0.6)
|
||||
r = n.action("scratchpad-show"); time.sleep(0.5)
|
||||
ok = n.alive() and n.by_id(w) is None and r.returncode == 0
|
||||
return ok, f"alive={n.alive()}, dead_shown={n.by_id(w) is not None}, rc={r.returncode}"
|
||||
|
||||
|
||||
def t_empty_stash_noop(n):
|
||||
n.spawn_window()
|
||||
r = n.action("scratchpad-show"); time.sleep(0.3)
|
||||
ok = len(n.windows()) == 1 and n.alive() and r.returncode == 0
|
||||
return ok, f"count stayed {len(n.windows())}, alive={n.alive()}, rc={r.returncode}"
|
||||
|
||||
|
||||
TESTS = [
|
||||
("1 stash hides + focus moves", t_stash_hides_and_moves_focus),
|
||||
("2 show floating+focused+centered", t_show_floating_focused_centered),
|
||||
("3 re-show hides", t_reshow_hides),
|
||||
("4 cycle round-robin", t_cycle_round_robin),
|
||||
("5 geometry remembered", t_geometry_remembered),
|
||||
("6 shown-not-focused -> focus it", t_shown_not_focused_gets_focus),
|
||||
("7 close-while-stashed no crash", t_close_while_stashed),
|
||||
("10 empty-stash no-op", t_empty_stash_noop),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil.which("alacritty"):
|
||||
sys.exit("ERROR: alacritty is required as the test client.")
|
||||
# confirm the binary is patched
|
||||
h = subprocess.run([NIRI, "msg", "action", "--help"],
|
||||
capture_output=True, text=True)
|
||||
if "scratchpad-show" not in h.stdout:
|
||||
sys.exit(f"ERROR: {NIRI} is not patched (no scratchpad-show action).")
|
||||
|
||||
print(f"niri: {NIRI}\n")
|
||||
cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
|
||||
cfg.write(MINIMAL_CONFIG); cfg.close()
|
||||
results = []
|
||||
try:
|
||||
for name, fn in TESTS:
|
||||
n = None
|
||||
try:
|
||||
n = Niri(cfg.name)
|
||||
ok, detail = fn(n)
|
||||
except Exception as e:
|
||||
ok, detail = False, f"exception: {e}"
|
||||
finally:
|
||||
if n:
|
||||
n.close()
|
||||
time.sleep(0.3)
|
||||
results.append((name, ok, detail))
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||
finally:
|
||||
os.unlink(cfg.name)
|
||||
|
||||
# #8 multi-monitor cannot be exercised nested (single winit output).
|
||||
print(" [SKIP] 8 multi-monitor: needs a physical 2nd output. Covered by the "
|
||||
"layout unit tests instead (scratchpad_shows_on_the_focused_output, "
|
||||
"scratchpad_show_pulls_already_shown_window_to_focused_output), which "
|
||||
"simulate outputs: `cargo test --lib scratchpad`.")
|
||||
|
||||
passed = sum(1 for _, ok, _ in results if ok)
|
||||
failed = sum(1 for _, ok, _ in results if not ok)
|
||||
print(f"\n{passed} passed, {failed} failed, 1 skipped")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user