niri: native scratchpad via patched niri-scratchpad package

Replace the workspace-based minimize (declared 'minimized' workspace +
+1 offset + window-restore.py) with a native Sway-style scratchpad built
into niri. Ships as niri-patches/0001-scratchpad.patch + a niri-scratchpad
PKGBUILD. Mod+M stashes; Mod+Shift+M shows/toggles/cycles. Mod+1..9 no
longer offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-17 12:09:02 -07:00
parent 07791f0c23
commit d05b9f8a0e
7 changed files with 471 additions and 308 deletions
+7 -1
View File
@@ -55,7 +55,13 @@ ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
echo "==> Building/installing patched niri (native scratchpad)"
if ! pacman -Q niri-scratchpad &>/dev/null; then
(cd "$(pwd)/pkg/niri-scratchpad" \
&& cp ../../niri-patches/0001-scratchpad.patch . \
&& makepkg -si --noconfirm)
fi
echo "==> Enabling systemd user services" echo "==> Enabling systemd user services"
mkdir -p ~/.config/systemd/user mkdir -p ~/.config/systemd/user
+390
View File
@@ -0,0 +1,390 @@
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/input/mod.rs b/src/input/mod.rs
index f1ad4932..91bc8942 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -1376,6 +1376,13 @@ impl State {
}
}
}
+ Action::MoveWindowToScratchpad => {
+ let focus = self.niri.layout.focus().map(|m| m.window.clone());
+ if let Some(window) = focus {
+ self.niri.layout.move_to_scratchpad(&window);
+ 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 {
Action::StopCast(session_id) => {
self.niri.stop_cast(CastSessionId::from(session_id));
}
+ Action::ScratchpadShow => {
+ self.niri.layout.scratchpad_show();
+ 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
--- 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.
@@ -699,6 +706,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 +733,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 +1125,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 +1222,93 @@ 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.
+ }
+
+ /// 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 -> 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.
+ 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;
+ }
+ };
+ self.add_removed_tile_floating(ws_id, removed);
+ self.scratchpad_shown = Some(id);
+ }
+
+ /// 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>) {
+ let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else {
+ return;
+ };
+ let Some(mon) = monitors
+ .iter_mut()
+ .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id))
+ else {
+ return;
+ };
+
+ mon.add_tile(
+ removed.tile,
+ MonitorAddWindowTarget::Workspace {
+ id: ws_id,
+ column_idx: None,
+ },
+ ActivateWindow::Yes,
+ true,
+ removed.width,
+ removed.is_full_width,
+ true,
+ );
+ }
+
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> {
}
}
+ // 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..65e02f2b 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,93 @@ 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);
+}
+20 -31
View File
@@ -47,13 +47,6 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
spawn-at-startup "xwayland-satellite" spawn-at-startup "xwayland-satellite"
spawn-at-startup "gamemode-watch" spawn-at-startup "gamemode-watch"
// Stash for minimized windows (Mod+M). niri pins declared named workspaces to
// index 1 and refuses to move them, so real workspaces start at index 2 — hence
// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration:
// without it the "minimized" binds silently do nothing (niri validate still
// passes, because the name is just a string to the parser).
workspace "minimized"
binds { binds {
Mod+Q repeat=false { close-window; } Mod+Q repeat=false { close-window; }
Mod+F { fullscreen-window; } Mod+F { fullscreen-window; }
@@ -86,34 +79,30 @@ binds {
Mod+Shift+Down { move-window-down; } Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; } Mod+Shift+Up { move-window-up; }
// +1 offset: the "minimized" workspace is always index 1 (see the Mod+1 { focus-workspace 1; }
// declaration above), so the first real workspace is index 2. Mod+2 { focus-workspace 2; }
Mod+1 { focus-workspace 2; } Mod+3 { focus-workspace 3; }
Mod+2 { focus-workspace 3; } Mod+4 { focus-workspace 4; }
Mod+3 { focus-workspace 4; } Mod+5 { focus-workspace 5; }
Mod+4 { focus-workspace 5; } Mod+6 { focus-workspace 6; }
Mod+5 { focus-workspace 6; } Mod+7 { focus-workspace 7; }
Mod+6 { focus-workspace 7; } Mod+8 { focus-workspace 8; }
Mod+7 { focus-workspace 8; } Mod+9 { focus-workspace 9; }
Mod+8 { focus-workspace 9; }
Mod+9 { focus-workspace 10; }
Mod+Shift+1 { move-window-to-workspace 2; } Mod+Shift+1 { move-window-to-workspace 1; }
Mod+Shift+2 { move-window-to-workspace 3; } Mod+Shift+2 { move-window-to-workspace 2; }
Mod+Shift+3 { move-window-to-workspace 4; } Mod+Shift+3 { move-window-to-workspace 3; }
Mod+Shift+4 { move-window-to-workspace 5; } Mod+Shift+4 { move-window-to-workspace 4; }
Mod+Shift+5 { move-window-to-workspace 6; } Mod+Shift+5 { move-window-to-workspace 5; }
Mod+Shift+6 { move-window-to-workspace 7; } Mod+Shift+6 { move-window-to-workspace 6; }
Mod+Shift+7 { move-window-to-workspace 8; } Mod+Shift+7 { move-window-to-workspace 7; }
Mod+Shift+8 { move-window-to-workspace 9; } Mod+Shift+8 { move-window-to-workspace 8; }
Mod+Shift+9 { move-window-to-workspace 10; } Mod+Shift+9 { move-window-to-workspace 9; }
Mod+Print { spawn "screenshot"; } Mod+Print { spawn "screenshot"; }
// focus=false is required here — otherwise focus follows the window into Mod+M { move-window-to-scratchpad; }
// the stash, the opposite of minimizing. Mod+Shift+M { scratchpad-show; }
Mod+M { move-window-to-workspace "minimized" focus=false; }
Mod+Shift+M { spawn "window-restore"; }
Mod+Shift+E { spawn "hyprlock"; } Mod+Shift+E { spawn "hyprlock"; }
Mod+Shift+P { spawn "powermenu"; } Mod+Shift+P { spawn "powermenu"; }
+6
View File
@@ -0,0 +1,6 @@
src/
pkg/
niri/
*.pkg.tar.*
*.log
0001-scratchpad.patch
+48
View File
@@ -0,0 +1,48 @@
# Maintainer: alex <funman300@gmail.com>
pkgname=niri-scratchpad
pkgver=26.04
pkgrel=1
_commit=8ed0da4
pkgdesc="niri with a native Sway-style scratchpad (local patch set)"
arch=('x86_64')
url="https://github.com/YaLTeR/niri"
license=('GPL-3.0-or-later')
depends=('cairo' 'glib2' 'glibc' 'libdisplay-info' 'libgcc' 'libinput'
'libpipewire' 'libxkbcommon' 'mesa' 'pango' 'pixman' 'seatd'
'systemd-libs' 'xdg-desktop-portal-impl')
makedepends=('git' 'cargo' 'clang' 'mesa')
provides=('niri')
conflicts=('niri')
options=('!lto')
source=("niri::git+https://github.com/YaLTeR/niri.git#commit=${_commit}"
"0001-scratchpad.patch")
sha256sums=('SKIP'
'SKIP')
prepare() {
cd niri
git apply "${srcdir}/0001-scratchpad.patch"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd niri
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --release --frozen
}
package() {
cd niri
install -Dm755 target/release/niri "${pkgdir}/usr/bin/niri"
install -Dm755 resources/niri-session "${pkgdir}/usr/bin/niri-session"
install -Dm644 resources/niri.desktop \
"${pkgdir}/usr/share/wayland-sessions/niri.desktop"
install -Dm644 resources/niri-portals.conf \
"${pkgdir}/usr/share/xdg-desktop-portal/niri-portals.conf"
install -Dm644 resources/niri.service \
"${pkgdir}/usr/lib/systemd/user/niri.service"
install -Dm644 resources/niri-shutdown.target \
"${pkgdir}/usr/lib/systemd/user/niri-shutdown.target"
}
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess).
Loads the hyphenated script by path and drives the pure functions with the JSON
shapes `niri msg -j workspaces` / `windows` actually return."""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_PATH = os.path.join(HERE, "..", "window-restore.py")
spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH)
wr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wr)
fails = 0
def check(desc, got, want):
global fails
if got == want:
print(f"ok - {desc}")
else:
print(f"FAIL - {desc} (got {got!r} want {want!r})")
fails = 1
# Shapes mirror real `niri msg -j` output: the stash is always idx 1.
WS_WITH_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": False},
{"id": 2, "idx": 2, "name": None, "is_focused": True},
{"id": 3, "idx": 3, "name": None, "is_focused": False},
]
WS_NO_STASH = [
{"id": 2, "idx": 1, "name": None, "is_focused": True},
{"id": 3, "idx": 2, "name": None, "is_focused": False},
]
WS_ON_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": True},
{"id": 2, "idx": 2, "name": None, "is_focused": False},
]
WINDOWS = [
{"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1},
{"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1},
{"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2},
]
# --- stash_workspace / current_workspace -----------------------------------
check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1)
check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None)
check("stash is None for empty list", wr.stash_workspace([]), None)
check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2)
check("current is None when none focused", wr.current_workspace([]), None)
check("focused-on-stash is detectable",
wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True)
# --- stashed_windows -------------------------------------------------------
check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11])
check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), [])
# --- label -----------------------------------------------------------------
check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole")
check("label falls back to title when app_id missing",
wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title")
check("label falls back to app_id when title empty",
wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox")
check("label never renders empty",
wr.label({"id": 5, "app_id": None, "title": None}), "window 5")
# --- resolve ---------------------------------------------------------------
check("resolve maps a label back to its window",
wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11)
check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None)
DUPES = [
{"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
{"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
]
check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20)
# --- window_exists -----------------------------------------------------------
check("window_exists is True for a present window", wr.window_exists(WINDOWS, 11), True)
check("window_exists is False once the window has closed", wr.window_exists(WINDOWS, 999), False)
check("window_exists is False for an empty list", wr.window_exists([], 11), False)
# --- pick ------------------------------------------------------------------
check("pick returns the chooser's selection",
wr.pick(["a", "b"], lambda text: "b"), "b")
check("pick passes newline-joined labels to the chooser",
wr.pick(["a", "b"], lambda text: text), "a\nb")
check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "")
sys.exit(fails)
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env python3
"""window-restore — bring a minimized window back from the stash workspace.
Mod+M moves the focused window to the declared "minimized" workspace; this is
Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the
workspace you are currently on and focus it.
Restore targets the *current* workspace by design — the stash workspace is the
entire state, so nothing tracks where a window came from and nothing goes stale.
The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri
silently ignores moves to an undeclared named workspace, so if the declaration
goes missing this script says so rather than failing quietly.
"""
import json
import subprocess
import sys
STASH = "minimized"
# --- pure logic -------------------------------------------------------------
def stash_workspace(workspaces):
"""The declared stash workspace, or None if it is not declared."""
return next((w for w in workspaces if w.get("name") == STASH), None)
def current_workspace(workspaces):
return next((w for w in workspaces if w.get("is_focused")), None)
def stashed_windows(windows, stash_id):
return [w for w in windows if w.get("workspace_id") == stash_id]
def label(window):
"""Display string for the picker. XWayland windows can lack app_id, so fall
back rather than rendering a bare dash or an empty row."""
app = (window.get("app_id") or "").strip()
title = (window.get("title") or "").strip()
if app and title:
return f"{app}{title}"
return app or title or f"window {window['id']}"
def resolve(windows, chosen):
"""First window whose label matches. Windows with identical labels are
indistinguishable in the menu anyway, and restoring the first still drains
the stash on repeat."""
return next((w for w in windows if label(w) == chosen), None)
def window_exists(windows, window_id):
"""Whether window_id is still present in a fresh `windows` list. Used after
the picker returns, since wofi can block long enough for the chosen window
to have closed out from under us."""
return any(w.get("id") == window_id for w in windows)
def pick(labels, chooser):
"""chooser(text) -> selected line. Injected so tests need no wofi."""
return chooser("\n".join(labels))
# --- io ---------------------------------------------------------------------
def notify(message):
subprocess.run(
["notify-send", "-a", "window-restore", message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def niri_json(what):
"""`niri msg -j <what>` parsed, or None if niri/IPC is unavailable."""
try:
out = subprocess.run(
["niri", "msg", "-j", what], capture_output=True, text=True
)
except FileNotFoundError:
return None
if out.returncode != 0:
return None
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
return None
def niri_action(*args):
subprocess.run(
["niri", "msg", "action", *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wofi_chooser(text):
try:
out = subprocess.run(
["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600",
"--height", "400", "--insensitive"],
input=text, capture_output=True, text=True,
)
except FileNotFoundError:
return ""
return out.stdout.strip()
def main():
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
stash = stash_workspace(workspaces)
if stash is None:
notify(f'no "{STASH}" workspace — declare it in niri/config.kdl')
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
# Restoring here would move a window to the workspace it is already on.
# Reachable by clicking the waybar pill; no keybind goes here.
if current["id"] == stash["id"]:
notify("Already on the minimized workspace")
return 0
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
stashed = stashed_windows(windows, stash["id"])
if not stashed:
notify("No minimized windows")
return 0
chosen = pick([label(w) for w in stashed], wofi_chooser)
if not chosen:
return 0 # cancelled
window = resolve(stashed, chosen)
if window is None:
return 0
# wofi may have sat open for however long the user took. In that time niri
# can destroy a dynamic workspace (its last window closed) and renumber
# every later one down, so the pre-pick `current["idx"]` may now name the
# wrong workspace; the stashed window may also have closed. Re-read state
# rather than trust anything captured before pick() blocked.
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
if not window_exists(windows, window["id"]):
notify("that window closed before it could be restored")
return 0
# The window is not focused (it is in the stash), so --focus does not apply;
# focus it explicitly afterwards.
niri_action("move-window-to-workspace", "--window-id", str(window["id"]),
str(current["idx"]))
niri_action("focus-window", "--id", str(window["id"]))
return 0
if __name__ == "__main__":
sys.exit(main())