diff --git a/install.sh b/install.sh index 48d5615..51410c6 100755 --- a/install.sh +++ b/install.sh @@ -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/gamemode-session.sh" ~/.local/bin/gamemode-session 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" mkdir -p ~/.config/systemd/user diff --git a/niri-patches/0001-scratchpad.patch b/niri-patches/0001-scratchpad.patch new file mode 100644 index 0000000..6c45b96 --- /dev/null +++ b/niri-patches/0001-scratchpad.patch @@ -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 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 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 { + interactive_move: Option>, + /// Ongoing drag-and-drop operation. + dnd: Option>, ++ /// 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>, ++ /// Id of the scratchpad window currently shown as a floating overlay, if any. ++ scratchpad_shown: Option, + /// 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 { + tile: Tile, + /// Width of the column the tile was in. +@@ -699,6 +706,8 @@ impl Layout { + 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 Layout { + 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 Layout { + window: &W::Id, + transaction: Transaction, + ) -> Option> { ++ // 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 Layout { + 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) { ++ 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 Layout { + } + } + ++ // 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::::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, + }, ++ 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); ++} diff --git a/niri/config.kdl b/niri/config.kdl index 3922703..7f3202d 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -47,13 +47,6 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1" spawn-at-startup "xwayland-satellite" 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 { Mod+Q repeat=false { close-window; } Mod+F { fullscreen-window; } @@ -86,34 +79,30 @@ binds { Mod+Shift+Down { move-window-down; } Mod+Shift+Up { move-window-up; } - // +1 offset: the "minimized" workspace is always index 1 (see the - // declaration above), so the first real workspace is index 2. - Mod+1 { focus-workspace 2; } - Mod+2 { focus-workspace 3; } - Mod+3 { focus-workspace 4; } - Mod+4 { focus-workspace 5; } - Mod+5 { focus-workspace 6; } - Mod+6 { focus-workspace 7; } - Mod+7 { focus-workspace 8; } - Mod+8 { focus-workspace 9; } - Mod+9 { focus-workspace 10; } + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+3 { focus-workspace 3; } + Mod+4 { focus-workspace 4; } + Mod+5 { focus-workspace 5; } + Mod+6 { focus-workspace 6; } + Mod+7 { focus-workspace 7; } + Mod+8 { focus-workspace 8; } + Mod+9 { focus-workspace 9; } - Mod+Shift+1 { move-window-to-workspace 2; } - Mod+Shift+2 { move-window-to-workspace 3; } - Mod+Shift+3 { move-window-to-workspace 4; } - Mod+Shift+4 { move-window-to-workspace 5; } - Mod+Shift+5 { move-window-to-workspace 6; } - Mod+Shift+6 { move-window-to-workspace 7; } - Mod+Shift+7 { move-window-to-workspace 8; } - Mod+Shift+8 { move-window-to-workspace 9; } - Mod+Shift+9 { move-window-to-workspace 10; } + Mod+Shift+1 { move-window-to-workspace 1; } + Mod+Shift+2 { move-window-to-workspace 2; } + Mod+Shift+3 { move-window-to-workspace 3; } + Mod+Shift+4 { move-window-to-workspace 4; } + Mod+Shift+5 { move-window-to-workspace 5; } + Mod+Shift+6 { move-window-to-workspace 6; } + Mod+Shift+7 { move-window-to-workspace 7; } + Mod+Shift+8 { move-window-to-workspace 8; } + Mod+Shift+9 { move-window-to-workspace 9; } Mod+Print { spawn "screenshot"; } - // focus=false is required here — otherwise focus follows the window into - // the stash, the opposite of minimizing. - Mod+M { move-window-to-workspace "minimized" focus=false; } - Mod+Shift+M { spawn "window-restore"; } + Mod+M { move-window-to-scratchpad; } + Mod+Shift+M { scratchpad-show; } Mod+Shift+E { spawn "hyprlock"; } Mod+Shift+P { spawn "powermenu"; } diff --git a/pkg/niri-scratchpad/.gitignore b/pkg/niri-scratchpad/.gitignore new file mode 100644 index 0000000..514a12d --- /dev/null +++ b/pkg/niri-scratchpad/.gitignore @@ -0,0 +1,6 @@ +src/ +pkg/ +niri/ +*.pkg.tar.* +*.log +0001-scratchpad.patch diff --git a/pkg/niri-scratchpad/PKGBUILD b/pkg/niri-scratchpad/PKGBUILD new file mode 100644 index 0000000..680b131 --- /dev/null +++ b/pkg/niri-scratchpad/PKGBUILD @@ -0,0 +1,48 @@ +# Maintainer: alex +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" +} diff --git a/scripts/tests/window-restore.test.py b/scripts/tests/window-restore.test.py deleted file mode 100644 index 1accc11..0000000 --- a/scripts/tests/window-restore.test.py +++ /dev/null @@ -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) diff --git a/scripts/window-restore.py b/scripts/window-restore.py deleted file mode 100755 index 9efa915..0000000 --- a/scripts/window-restore.py +++ /dev/null @@ -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 ` 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())