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:
+7
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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"; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
src/
|
||||
pkg/
|
||||
niri/
|
||||
*.pkg.tar.*
|
||||
*.log
|
||||
0001-scratchpad.patch
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user