Compare commits

..

3 Commits

Author SHA1 Message Date
funman300 d05b9f8a0e 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>
2026-07-17 12:24:11 -07:00
funman300 07791f0c23 plan: native niri scratchpad implementation
Seven-task TDD plan: stash fields + move_to_scratchpad, show/cycle state
machine, close-eviction hook, action wiring, patch+PKGBUILD packaging, and
nested-niri manual acceptance. Grounded in niri 26.04 source anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:22:27 -07:00
funman300 286bb8a3b3 spec: native niri scratchpad (replaces workspace-based minimize)
Design for a Sway-style scratchpad built into niri as a pinned patch set +
niri-scratchpad PKGBUILD. Stores hidden windows in a global stash on Layout
rather than a named workspace, removing the +1 offset and multi-monitor bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:09:52 -07:00
9 changed files with 1480 additions and 308 deletions
@@ -0,0 +1,730 @@
# Niri Native Scratchpad Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Sway-style scratchpad natively to niri — hide the focused window into a global stash and show/toggle/cycle it back as a centered floating overlay — shipped as a pinned patch built by a custom `niri-scratchpad` Arch package, replacing the old workspace-based minimize.
**Architecture:** The scratchpad is a stash (`VecDeque<RemovedTile<W>>`) plus a "currently shown" id (`Option<W::Id>`) on niri's `Layout<W>` struct — the same place that already holds workspaceless live tiles for interactive-move and drag-and-drop. Hide = `Layout::remove_window` → push onto the stash; show = pop → re-add to the active workspace as floating. Because the stash is not a workspace, it has no index, never appears in waybar, and is output-independent — eliminating the `+1` offset and multi-monitor bug of the old approach. Two new bindable actions (`move-window-to-scratchpad`, `scratchpad-show`) are threaded through niri's two `Action` enums and its `do_action` dispatch. The core state machine is unit-tested in niri's synthetic `src/layout/tests.rs` harness (no compositor); focus/close/multi-monitor behavior is verified manually in a nested niri.
**Tech Stack:** Rust (niri 26.04, commit `8ed0da4`), knuffel (KDL config derive), clap (IPC), smithay; Arch `makepkg`/PKGBUILD; niri KDL config; fish `install.sh`.
## Global Constraints
- **Pinned upstream:** niri git tag `v26.04`, commit `8ed0da4`. Every source line/anchor below is against that commit. Do not develop against `main`.
- **Delta lives as patches:** all Rust changes ship as `niri-patches/*.patch` applied by `pkg/niri-scratchpad/PKGBUILD`. Upstream is never vendored into the repo.
- **Stash key type is `W::Id`** (production `Window`, tests `usize`) — never `MappedId`. This keeps the state machine unit-testable and generic.
- **Single-shown invariant:** at most one scratchpad window is shown at a time (accepted simplification — no multi-shown, no persistent per-window `is_scratchpad` flag).
- **Repo principles (CLAUDE.md):** idempotent `install.sh`, no sleep-based hacks, complete files, Tomorrow Night theme untouched.
- **Two repos:** Rust work happens in a disposable niri dev clone (`~/build/niri-scratchpad`); its commits are throwaway — only the exported patch is a deliverable. Real deliverables (patch, PKGBUILD, install.sh, config, deletions) are committed in the dotfiles repo.
**Path shorthand in this plan:** `$DEV` = `~/build/niri-scratchpad` (niri dev clone). `$DOT` = `/home/alex/Documents/dotfiles`.
---
## File Structure
**niri dev clone (`$DEV`) — source of the patch, throwaway commits:**
- Modify `src/layout/mod.rs` — stash fields on `Layout<W>`; `move_to_scratchpad` / `scratchpad_show` methods; close-eviction hook inside `remove_window`.
- Modify `src/layout/tests.rs` — new `Op` variants + `apply` arms + `#[test]`s.
- Modify `niri-ipc/src/lib.rs` — IPC `Action` variants.
- Modify `niri-config/src/binds.rs` — config `Action` variants + `From` arms.
- Modify `src/input/mod.rs``do_action` arms.
**dotfiles repo (`$DOT`) — real deliverables:**
- Create `niri-patches/0001-scratchpad.patch` — the whole Rust delta (single patch; see Task 6 rationale).
- Create `pkg/niri-scratchpad/PKGBUILD` — builds pinned niri + patch.
- Modify `install.sh` — build/install `niri-scratchpad`; drop `window-restore` symlink.
- Modify `niri/config.kdl` — remove `workspace "minimized"`, un-offset `Mod+1..9`, new `Mod+M` / `Mod+Shift+M` binds.
- Delete `scripts/window-restore.py`, `scripts/tests/window-restore.test.py`.
---
## Task 1: Dev clone + baseline build
Establishes a known-good, buildable, runnable baseline before any change. A patch is only meaningful as a diff against this exact tree.
**Files:** none in repo yet (environment setup).
- [ ] **Step 1: Clone niri at the pinned commit onto a work branch**
```bash
mkdir -p ~/build && cd ~/build
git clone https://github.com/YaLTeR/niri.git niri-scratchpad
cd ~/build/niri-scratchpad
git checkout 8ed0da4
git switch -c scratchpad # all dev commits land here
git tag baseline 8ed0da4 # patch is diffed against this
```
- [ ] **Step 2: Confirm the baseline builds**
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -5`
Expected: `Finished \`release\` profile ... target(s)` (first build is slow; that is fine).
- [ ] **Step 3: Confirm the baseline runs nested**
Run (inside an existing graphical session, from a terminal): `cd ~/build/niri-scratchpad && ./target/release/niri --version`
Expected: `niri 26.04 (8ed0da4...)`. (A full nested `niri` window is exercised in Task 7; here we only confirm the binary links and reports the pinned version.)
- [ ] **Step 4: No commit** — this task produces no repo artifact. Proceed to Task 2.
---
## Task 2: Stash state + `move-window-to-scratchpad`
Adds the two `Layout` fields and the hide method, unit-tested in the synthetic harness. No compositor.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (struct `Layout<W>` ~line 336369; inits at ~695708 and ~721732; new methods in the `impl<W: LayoutElement> Layout<W>` block starting ~690)
- Test: `$DEV/src/layout/tests.rs` (`Op` enum + `apply` at ~759; new `#[test]`s)
**Interfaces:**
- Produces: `Layout::move_to_scratchpad(&mut self, window: &W::Id)`; fields `scratchpad: VecDeque<RemovedTile<W>>`, `scratchpad_shown: Option<W::Id>`.
- Consumes (existing, verbatim signatures): `Layout::remove_window(&mut self, window: &W::Id, transaction: Transaction) -> Option<RemovedTile<W>>`; `RemovedTile<W>` has module-private field `tile: Tile<W>`; `Tile::window(&self) -> &W`; `W::id(&self) -> &W::Id`.
- [ ] **Step 1: Add the fields to the `Layout<W>` struct**
In `$DEV/src/layout/mod.rs`, in the `pub struct Layout<W: LayoutElement>` definition (ends ~line 369), add after the `dnd` field:
```rust
/// 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>,
```
Ensure `use std::collections::VecDeque;` exists at the top of the file (add it if not).
- [ ] **Step 2: Initialize the fields in BOTH constructors**
There are two field-init blocks; both must set the new fields or the crate will not compile. In `with_options` (~line 695) and in `with_options_and_workspaces` (~line 721), add alongside `dnd: None,`:
```rust
scratchpad: VecDeque::new(),
scratchpad_shown: None,
```
- [ ] **Step 3: Add test-harness `Op` variants**
In `$DEV/src/layout/tests.rs`, add to the `Op` enum:
```rust
ScratchpadStash(usize),
ScratchpadShow,
```
In `Op::apply` (match at ~line 759), add arms:
```rust
Op::ScratchpadStash(id) => {
layout.move_to_scratchpad(&id);
}
Op::ScratchpadShow => {
layout.scratchpad_show();
}
```
(`scratchpad_show` is implemented in Task 3; to keep this task compiling on its own, temporarily add a stub `pub fn scratchpad_show(&mut self) {}` in mod.rs now and flesh it out in Task 3.)
- [ ] **Step 4: Write the failing test for stashing**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[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);
}
```
- [ ] **Step 5: Run it, verify it fails**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
Expected: compile error `no method named \`move_to_scratchpad\`` (method not yet written).
- [ ] **Step 6: Implement `move_to_scratchpad`**
In the `impl<W: LayoutElement> Layout<W>` block in `$DEV/src/layout/mod.rs`, add:
```rust
/// 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.
}
```
- [ ] **Step 7: Run it, verify it passes**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
Expected: `test result: ok. 1 passed`.
- [ ] **Step 8: Commit (throwaway dev commit)**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: stash fields + move_to_scratchpad"
```
---
## Task 3: `scratchpad_show` state machine + centering
Implements show/hide/cycle/focus and first-show centering, unit-tested. Replaces the Task 2 stub.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (replace the `scratchpad_show` stub; methods block ~690)
- Test: `$DEV/src/layout/tests.rs`
**Interfaces:**
- Produces: `Layout::scratchpad_show(&mut self)`.
- Consumes (existing, verbatim): `Layout::has_window(&self, window: &W::Id) -> bool`; `Layout::focus(&self) -> Option<&W>`; `Layout::active_output(&self) -> Option<&Output>`; `Layout::active_workspace(&self) -> Option<&Workspace<W>>`; `Monitor::add_tile(&mut self, tile, MonitorAddWindowTarget::Workspace { id, column_idx: None }, ActivateWindow::Yes, true, width, is_full_width, is_floating)` — re-add pattern verbatim at `mod.rs:3359-3373`; `RemovedTile` fields `tile`, `width`, `is_full_width`, `is_floating`.
**State table (the contract this task implements):**
| State (after staleness check) | Behavior |
|---|---|
| shown = Some(id) AND id is focused | Hide: `remove_window(id)` → `push_back` to stash; `scratchpad_shown = None` |
| shown = Some(id) AND id not focused | Focus that window; stash unchanged |
| shown = None, stash non-empty | Pop front → add to active workspace, floating, focused, centered on first show; set `scratchpad_shown` |
| shown = None, stash empty | No-op |
- [ ] **Step 1: Write the failing tests**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[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());
}
```
- [ ] **Step 2: Run them, verify they fail**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_show 2>&1 | tail -30`
Expected: assertion failures (the stub does nothing) — e.g. `scratchpad_show_brings_window_back_focused` fails on `has_window`.
- [ ] **Step 3: Implement `scratchpad_show`**
Replace the stub in `$DEV/src/layout/mod.rs` with:
```rust
/// 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.
// CONFIRM at implementation: the exact focus-by-id call. Candidates
// in this file are the activation path used by move actions; use
// whichever method focuses an existing window by &W::Id without
// moving it. Verify against `scratchpad_show_*` tests staying green.
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: if this tile has never floated, place it centered
// on the active output. If it has a remembered floating position, leave it.
// CONFIRM at implementation: centering uses the active output geometry; if
// niri's floating layer already centers a position-less tile acceptably,
// this block may reduce to leaving floating_pos as-is. Verified visually in
// Task 7 (manual acceptance #3), not by unit test.
// center_floating_tile(&mut removed.tile, self.active_output());
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); // helper below
self.scratchpad_shown = Some(id);
}
```
Add a small private helper next to it that mirrors the verbatim re-add pattern at `mod.rs:3359-3373`, forcing `is_floating = true` and targeting the given workspace's monitor (find the monitor owning `ws_id` in `self.monitor_set`, then `mon.add_tile(removed.tile, MonitorAddWindowTarget::Workspace { id: ws_id, column_idx: None }, ActivateWindow::Yes, true, removed.width, removed.is_full_width, true)`).
- [ ] **Step 4: Run the tests, verify they pass**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
Expected: all `scratchpad_*` tests pass. If the "focus it" branch cannot be reached by these tests (it isn't — it needs manual focus change), that branch is covered by Task 7 manual test; the four tests above must be green.
- [ ] **Step 5: Run the full layout suite to catch invariant regressions**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri --lib layout 2>&1 | tail -20`
Expected: no new failures (each op runs `verify_invariants`).
- [ ] **Step 6: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: show/hide/cycle state machine + centering"
```
---
## Task 4: Evict on close (single hook in `remove_window`)
A window closed *while stashed* is in no workspace, so nothing removes it from the stash — a leak / stale-id bug. Fix at the single choke point every removal passes through.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (`Layout::remove_window`, ~line 1112)
- Test: `$DEV/src/layout/tests.rs`
**Interfaces:**
- Consumes: `Op::CloseWindow(usize)` in the harness already calls `remove_window` (`tests.rs:1061`).
- [ ] **Step 1: Write the failing test**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[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);
}
```
- [ ] **Step 2: Run it, verify it fails**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_evicts_closed_stashed_window 2>&1 | tail -20`
Expected: `assertion \`left == right\` failed: closed window evicted from stash` — stash still has 2 (the close didn't touch it).
- [ ] **Step 3: Add the eviction hook at the top of `remove_window`**
At the very start of `Layout::remove_window` (before the existing monitor_set lookup), add:
```rust
// 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;
}
```
This is the single point of truth: it covers `handlers/compositor.rs` (unmap) and `handlers/xdg_shell.rs` (destroy), which both call `remove_window`, and the harness `Op::CloseWindow`. It is a no-op for the common case (window not in the stash).
- [ ] **Step 4: Run it, verify it passes; re-run the scratchpad suite**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
Expected: all `scratchpad_*` tests pass (the new one plus the four from Task 3 — confirm none regressed, since `remove_window` now also runs during hide).
- [ ] **Step 5: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: evict stashed window on close via remove_window"
```
---
## Task 5: Wire the bindable actions (IPC + config + dispatch)
Threads `move-window-to-scratchpad` and `scratchpad-show` through niri's two `Action` enums and `do_action`. After this, the whole binary compiles and the binds parse.
**Files:**
- Modify: `$DEV/niri-ipc/src/lib.rs` (IPC `Action` enum, ~line 194)
- Modify: `$DEV/niri-config/src/binds.rs` (config `Action` enum ~line 102; `From` impl ~line 395)
- Modify: `$DEV/src/input/mod.rs` (`do_action` match, ~line 659)
**Interfaces:**
- Consumes: `Layout::move_to_scratchpad`, `Layout::scratchpad_show` (Tasks 23); `self.niri.layout.focus().map(|m| m.window.clone())` yields the focused `Window` (= `W::Id`); `self.niri.queue_redraw_all()`.
- [ ] **Step 1: Add IPC `Action` variants**
In `$DEV/niri-ipc/src/lib.rs`, in the `pub enum Action`, add two unit-like struct variants (mirror the style of neighboring variants):
```rust
/// Move the focused window to the scratchpad (hide it).
MoveWindowToScratchpad {},
/// Show, toggle, or cycle the scratchpad.
ScratchpadShow {},
```
- [ ] **Step 2: Add config `Action` variants**
In `$DEV/niri-config/src/binds.rs`, in the `pub enum Action` (`#[derive(knuffel::Decode, ...)]`), add (knuffel derives kebab-case node names `move-window-to-scratchpad` / `scratchpad-show`):
```rust
MoveWindowToScratchpad,
ScratchpadShow,
```
- [ ] **Step 3: Add `From<niri_ipc::Action>` arms**
In the `impl From<niri_ipc::Action> for Action` (~line 395) — exhaustive over the IPC enum, so both new IPC variants need an arm:
```rust
niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad,
niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow,
```
- [ ] **Step 4: Add `do_action` dispatch arms**
In `$DEV/src/input/mod.rs`, in the `match action {` (~line 659), add (patterned on `Action::FullscreenWindow` at ~line 812):
```rust
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::ScratchpadShow => {
self.niri.layout.scratchpad_show();
self.niri.queue_redraw_all();
}
```
- [ ] **Step 5: Build the whole binary**
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -15`
Expected: `Finished` with no errors. (If a match is non-exhaustive, an arm is missing — add it where the compiler points.)
- [ ] **Step 6: Verify the binds parse via `niri validate`**
Create a throwaway config and validate it:
```bash
cat > /tmp/sp-test.kdl <<'EOF'
binds {
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
}
EOF
cd ~/build/niri-scratchpad && ./target/release/niri validate -c /tmp/sp-test.kdl && echo VALID
```
Expected: `VALID` (no unknown-node error — proves knuffel accepts the new nodes).
- [ ] **Step 7: Verify the IPC action parses**
Run: `cd ~/build/niri-scratchpad && ./target/release/niri msg action scratchpad-show 2>&1 | head -3`
Expected: it fails to *connect* (no running niri socket) rather than failing to *parse* the action — i.e. an error about the socket, not `unrecognized subcommand`. (Full IPC behavior is exercised in Task 7.)
- [ ] **Step 8: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: bindable + IPC actions wired through do_action"
```
---
## Task 6: Package the patch + dotfiles changes
Export the Rust delta as one patch, build it via a PKGBUILD, and make all the dotfiles-side changes (config, install.sh, deletions) in a single reviewable commit.
**Rationale for a single patch file:** the changes interleave within `src/layout/mod.rs` (fields, methods, and the `remove_window` hook are all in that one file). Splitting into three `git apply`-able patches would force a fixed apply order and make rebasing fragile. One patch = one `git diff baseline..scratchpad`, trivially re-generated after a rebase.
**Files:**
- Create: `$DOT/niri-patches/0001-scratchpad.patch`
- Create: `$DOT/pkg/niri-scratchpad/PKGBUILD`
- Modify: `$DOT/install.sh`
- Modify: `$DOT/niri/config.kdl`
- Delete: `$DOT/scripts/window-restore.py`, `$DOT/scripts/tests/window-restore.test.py`
- [ ] **Step 1: Export the patch**
```bash
mkdir -p /home/alex/Documents/dotfiles/niri-patches
cd ~/build/niri-scratchpad
git diff baseline..scratchpad > /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
head -5 /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
```
Expected: a unified diff beginning `diff --git a/niri-config/src/binds.rs ...` (touching binds.rs, lib.rs, mod.rs, tests.rs, input/mod.rs).
- [ ] **Step 2: Verify the patch applies cleanly to a fresh checkout**
```bash
cd /tmp && rm -rf niri-apply-check && git clone -q https://github.com/YaLTeR/niri.git niri-apply-check
cd /tmp/niri-apply-check && git checkout -q 8ed0da4
git apply --check /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch && echo "APPLIES CLEANLY"
```
Expected: `APPLIES CLEANLY`.
- [ ] **Step 3: Write the PKGBUILD**
Create `$DOT/pkg/niri-scratchpad/PKGBUILD`. Mirrors the official niri package install layout (from `resources/`), adds the patch in `prepare()`, and `provides`/`conflicts` stock niri:
```bash
# 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=('libgbm' 'libglvnd' 'libinput' 'libxkbcommon' 'mesa' 'pango'
'pipewire' 'seatd' 'systemd-libs' 'wayland')
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"
}
```
The `0001-scratchpad.patch` source is a repo-relative sibling: symlink or copy it next to the PKGBUILD at build time (Step 5 handles this). CONFIRM at implementation: cross-check `depends`/install lines against `pacman -Qi niri` on this machine so the packaged binary keeps identical runtime deps and session integration.
- [ ] **Step 4: Build and install the package**
```bash
cd /home/alex/Documents/dotfiles/pkg/niri-scratchpad
cp ../../niri-patches/0001-scratchpad.patch . # makepkg needs sources local
makepkg -si --noconfirm 2>&1 | tail -20
```
Expected: package builds and installs; `pacman -Q niri-scratchpad` shows `26.04-1`.
- [ ] **Step 5: Verify the installed binary is the patched one**
```bash
niri --version && niri msg action scratchpad-show 2>&1 | head -2
```
Expected: version `26.04`; the action is recognized (socket error if run outside niri, not `unrecognized subcommand`).
- [ ] **Step 6: Update `niri/config.kdl`** — remove the stash workspace, un-offset, rebind
In `$DOT/niri/config.kdl`:
- Delete the comment block (lines ~5054) and the `workspace "minimized"` declaration (line ~55).
- `Mod+1..9`: change `focus-workspace 2..10` back to `focus-workspace 1..9` (drop the comment about the +1 offset too).
- `Mod+Shift+1..9`: change `move-window-to-workspace 2..10` back to `move-window-to-workspace 1..9`.
- Replace the two minimize binds:
```kdl
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
```
- [ ] **Step 7: Validate the real config with the patched binary**
Run: `niri validate -c /home/alex/Documents/dotfiles/niri/config.kdl && echo VALID`
Expected: `VALID`.
- [ ] **Step 8: Delete the old Python implementation**
```bash
cd /home/alex/Documents/dotfiles
git rm scripts/window-restore.py scripts/tests/window-restore.test.py
```
- [ ] **Step 9: Update `install.sh`**
In `$DOT/install.sh`:
- Remove the line that symlinks `window-restore` into `~/.local/bin`.
- Add a step (idempotent) that builds/installs the package, e.g. skip when already current:
```bash
# Build & install patched niri (native scratchpad) if not already present.
if ! pacman -Q niri-scratchpad &>/dev/null; then
(cd "$(pwd)/pkg/niri-scratchpad" \
&& cp ../../niri-patches/0001-scratchpad.patch . \
&& makepkg -si --noconfirm)
fi
```
CONFIRM placement matches the existing symlink/step block style in `install.sh` (read the file first; match its status-message convention).
- [ ] **Step 10: Commit the dotfiles changes**
```bash
cd /home/alex/Documents/dotfiles
git add niri-patches pkg install.sh niri/config.kdl
git add -u scripts # stage the deletions
git commit -m "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>"
```
---
## Task 7: End-to-end manual acceptance (nested niri)
The behaviors no unit test reaches: real focus routing, the "focus it" branch, close-while-stashed under a live client, centering, and multi-monitor. Run the patched niri nested inside the current session.
**Files:** none (verification only).
- [ ] **Step 1: Launch patched niri nested with the repo config**
Run (from a terminal in the current graphical session):
`niri -c /home/alex/Documents/dotfiles/niri/config.kdl`
Expected: a nested niri window opens without error. Open two terminals inside it.
- [ ] **Step 2: Run the acceptance checklist** — observe each, all must hold:
1. On a window, `Mod+M`: it vanishes and **focus moves to another window** (regression guard for the focus-release risk).
2. `Mod+Shift+M`: the window reappears **centered on the current output**, focused.
3. `Mod+Shift+M` again (window still focused): it hides.
4. Stash two windows; `Mod+Shift+M` repeatedly cycles A → (hide) → B → (hide) → A.
5. Show a window, move/resize it, hide, show again: **same geometry** (remembered).
6. Show a window, click/focus the *other* window, `Mod+Shift+M`: focus returns to the shown window (the "shown-but-not-focused → focus it" branch).
7. Stash a terminal, then `exit` its shell so the client dies while stashed; `Mod+Shift+M`: no crash, cycle skips it, stash count correct.
8. If a second output is available (or a nested second output), show on the **focused** output; repeat focused on the other output — each shows there.
9. `Mod+1..9` land on workspaces `1..9` with **no offset**; the scratchpad has **no** waybar pill.
10. `Mod+Shift+M` with an empty stash: nothing happens, no crash.
- [ ] **Step 3: Record results**
Note any failing item with the observed behavior. A failure in #1, #6, #7, or #8 points back at the focus/cast/close bookkeeping risk — fix in the relevant Task 24 method, re-export the patch (Task 6 Step 1), rebuild, and re-run this checklist. Do not claim completion until all ten hold.
- [ ] **Step 4: Final verification statement**
Only after all ten pass and `cargo test -p niri scratchpad` is green: the feature is complete. Log-out / log-in to the real session (now running `niri-scratchpad`) as the last real-world confirmation.
---
## Self-Review (author checklist — completed)
**Spec coverage:** stash-not-workspace (Task 2 fields) ✓; `Mod+M` hide (Task 2) ✓; show/hide/cycle/focus table (Task 3) ✓; centering + remember (Task 3) ✓; single-shown invariant (design, enforced by `Option` field) ✓; close-while-stashed (Task 4) ✓; two-Action wiring + From + do_action (Task 5) ✓; patch + PKGBUILD (Task 6) ✓; config un-offset + rebind + workspace-decl removal (Task 6) ✓; Python deletion (Task 6) ✓; install.sh (Task 6) ✓; unit tests (Tasks 24) + manual acceptance incl. multi-monitor (Task 7) ✓.
**Placeholder scan:** two `CONFIRM at implementation` notes remain by design — the exact focus-by-id method name and the centering call are the two spots the spec flagged as compiler/geometry-dependent; each names the location, the pattern to mirror, and the test that must stay green, rather than deferring undefined work.
**Type consistency:** stash keyed on `W::Id` throughout; `scratchpad: VecDeque<RemovedTile<W>>` / `scratchpad_shown: Option<W::Id>` named identically in fields, methods, tests, and the `remove_window` hook; method names `move_to_scratchpad` / `scratchpad_show` consistent across Layout, `do_action`, and the harness `Op` arms.
@@ -0,0 +1,279 @@
# Niri Native Scratchpad Design
**Date:** 2026-07-17
**Status:** Approved
## Summary
Replace the workspace-based minimize/stash with a **native Sway-style scratchpad
built into niri itself**, shipped as a small, rebase-friendly patch set on top of
a pinned upstream niri (`v26.04`, commit `8ed0da4`) and built via a custom Arch
package (`niri-scratchpad`).
- `Mod+M` moves the focused window to a hidden scratchpad.
- `Mod+Shift+M` shows the next scratchpad window as a floating, centered overlay
on the current output; pressing it again while that window is focused hides it;
repeated presses cycle through the stash (round-robin).
The scratchpad is a **global stash on niri's `Layout`**, not a workspace. This is
the whole point: the previous implementation used a declared named workspace,
which niri pins to index 1, forcing a `+1` offset on every `Mod+1..9` bind and
breaking on multi-monitor. A stash that is not a workspace has no index, never
appears in waybar, and is output-independent — so all of that goes away.
The old Python implementation (`scripts/window-restore.py`), the
`workspace "minimized"` declaration, and the `+1` offset are all removed.
## Motivation
The current minimize (`docs/superpowers/specs/2026-07-16-niri-minimize-design.md`)
works, but pays a structural tax:
- A declared named workspace is **always index 1** and cannot be moved, so every
index workspace bind is offset by one (`Mod+1``focus-workspace 2`).
- The stash is **per-output**, so a second monitor's workspaces start at index 1
with no stash, throwing the offset off — a latent multi-monitor bug.
- The `minimized` workspace is **permanently visible** as a waybar pill.
- Restore is an **external wofi picker**, not a real toggle — there is no
"show/hide/cycle" the way a scratchpad has.
A native scratchpad removes the workspace entirely, which removes every one of
these problems at the source.
## Architecture decision
Chosen: **patch niri's Rust source** (fork route), shipped as `.patch` files +
PKGBUILD. Rejected alternatives:
- *External Rust IPC daemon.* Would keep stock niri but cannot make a window
live "outside all workspaces" — niri's IPC has no primitive for a hidden
holding area, so a daemon would be forced back onto the named-workspace hack it
is meant to replace.
- *Full fork as submodule / separate repo.* More vendoring and rebase surface
than needed. A handful of `.patch` files against a pinned tag is the smallest
reviewable delta and is Arch-native to build.
## Verified niri internals (niri 26.04, commit 8ed0da4)
Read from source, not inferred. These are the load-bearing facts.
1. **Two `Action` enums, kept in sync by hand.**
- IPC/CLI `Action`: `niri-ipc/src/lib.rs:194` (derives clap + serde +
JsonSchema). Parsed by `niri msg action ...`.
- Config `Action`: `niri-config/src/binds.rs:102` (derives `knuffel::Decode`).
Parsed from `config.kdl`; this is what the runtime dispatch matches on.
- Bridged by hand-written `impl From<niri_ipc::Action> for Action` at
`niri-config/src/binds.rs:395`. The `From` impl is exhaustive over the IPC
enum, so every IPC variant needs a `From` arm; config-only variants need
none.
- knuffel maps PascalCase variants to kebab-case KDL nodes automatically
(`MoveWindowToScratchpad``move-window-to-scratchpad`). No-arg variant =
bare variant (cf. `Suspend,` at `binds.rs:106`). A `focus=false`-style
property = `#[knuffel(property(name = "focus"), default = true)] bool`
(cf. `binds.rs:228`).
2. **Runtime dispatch** is `State::do_action`, `src/input/mod.rs:650`, the big
`match action {` at line 659. Precedent handler `MoveWindowToWorkspace` at
`src/input/mod.rs:1283`: resolves target, calls
`self.niri.layout.move_to_workspace(...)`, then `queue_redraw_all()`.
3. **`Layout` owns cross-workspace state and already holds workspaceless live
tiles.** `Layout<W>` at `src/layout/mod.rs:336` has
`interactive_move: Option<InteractiveMoveState<W>>` (`mod.rs:353`) and
`dnd: Option<DndData<W>>` (`mod.rs:355`), each holding a live `Tile<W>` that
belongs to no workspace. This is the exact precedent for a stash.
4. **Remove/re-add primitives keep geometry.**
- `Layout::remove_window` (`src/layout/mod.rs:1112`) returns
`Option<RemovedTile<W>>`; `RemovedTile` (`mod.rs:492`) bundles the `Tile`
plus `width`, `is_full_width`, `is_floating` — everything to reinsert
identically. It auto-cleans emptied workspaces.
- `Layout::add_window` (`mod.rs:928`) takes `AddWindowTarget`
(`Auto`/`Output`/`Workspace(id)`/`NextTo`), a `bool is_floating`, and
`ActivateWindow`. `Monitor::add_tile` / `Workspace::add_tile` re-add a
prebuilt tile.
- The show/hide pattern is literally `move_to_output` (`mod.rs:3351`):
`remove_tile → stop_move_animations → add_tile(..., is_floating)`.
- `Tile<W>` (`src/layout/tile.rs:40`) persists floating geometry across
transitions: `floating_window_size` (`tile.rs:69`), `floating_pos`
(`tile.rs:76`), and preset width/height indices. So geometry survives
hide→show with no extra bookkeeping.
5. **Active output/workspace accessors** usable inside `do_action`:
`Layout::active_output` (`mod.rs:1586`), `Layout::active_workspace` /
`active_workspace_mut` (`mod.rs:1599` / `1613`). Used in handlers already at
`src/input/mod.rs:1289`.
6. **Mapped window & id:** `Mapped` at `src/window/mapped.rs:51`, `is_floating`
at line 98, `id: MappedId` at line 55.
## Target behavior
### Keys
| Key | Action | KDL |
|---|---|---|
| `Mod+M` | Move focused window to scratchpad (hidden). Focus falls to next window. | `move-window-to-scratchpad` |
| `Mod+Shift+M` | Show/toggle/cycle the scratchpad. | `scratchpad-show` |
### State
Two new fields on `Layout<W>`:
```rust
scratchpad: Vec<RemovedTile<W>>, // hidden windows, front = next to show
scratchpad_shown: Option<MappedId>, // the one currently visible, if any
```
`scratchpad_shown` is a *remembered id only*. A shown scratchpad window is an
otherwise-ordinary floating window living in a real workspace; the stash proper
is only the hidden `Vec`.
### `move-window-to-scratchpad` (`Mod+M`)
1. If nothing is focused → no-op.
2. `remove_window` the focused window → `RemovedTile`.
3. Push it onto the **back** of `scratchpad`.
4. If its id equals `scratchpad_shown`, clear `scratchpad_shown`.
5. `queue_redraw_all()`. Focus falls to the next window via niri's normal remove
path.
### `scratchpad-show` (`Mod+Shift+M`)
First, validate `scratchpad_shown`: if it names a window that no longer exists in
any workspace (closed or moved away), treat it as `None`.
| State | Behavior |
|---|---|
| `scratchpad_shown = Some(id)` **and** `id` is the focused window | **Hide:** `remove_window(id)`, push to back of `scratchpad`, clear `scratchpad_shown`. |
| `scratchpad_shown = Some(id)` **and** `id` not focused | **Focus** that window. Nothing added or removed. |
| `scratchpad_shown = None`, stash non-empty | **Show:** pop front → `add_window` onto the active workspace with `is_floating = true`, focused; set `scratchpad_shown`. Center on first show (see below). |
| `scratchpad_shown = None`, stash empty | No-op. |
Cycling is emergent: show→A(focused), press→hide A (to back), press→show B,
press→hide B, press→C … round-robin. Then `queue_redraw_all()`.
### Placement
On show, if the tile has **no remembered `floating_pos`** (never floated before),
set it so the window is **centered on the active output** at its natural size.
If it *does* have a remembered pos/size (shown, moved, hidden, shown again), reuse
it unchanged — `Tile` already persists this, so "remember where I left it" needs
no new storage, only the first-show centering tweak.
## Deliberate simplifications (accepted)
1. **At most one scratchpad window shown at a time.** Full Sway allows several
shown simultaneously; that needs a *set* of shown ids and multi-floating
management for negligible benefit. Single-shown is deterministic.
2. **A shown window is a normal floating window, not specially owned.** The stash
is only the hidden set. Moving a shown window with `Mod+Shift+N`, or closing
it, simply removes it from scratchpad tracking (`scratchpad_shown` cleared on
the staleness check). Re-stash with `Mod+M`. This avoids threading a
persistent per-window `is_scratchpad` flag through the codebase — which is
what Sway does and what we explicitly do not need.
## The patch set
Three logical patches on the pinned tag, ordered so each compiles:
**`0001-scratchpad-actions.patch`**
- `niri-ipc/src/lib.rs:194` — IPC `Action` variants `MoveWindowToScratchpad`,
`ScratchpadShow`.
- `niri-config/src/binds.rs:102` — config `Action` variants (kebab-case nodes
derived by knuffel).
- `niri-config/src/binds.rs:395``From` arms mapping IPC → config.
**`0002-scratchpad-stash.patch`**
- `src/layout/mod.rs:336``scratchpad` + `scratchpad_shown` fields (init in the
`Layout` constructor).
- `src/layout/mod.rs` — methods `move_to_scratchpad(&mut self, id)` and
`scratchpad_show(&mut self)`, modeled on `move_to_output` (`mod.rs:3351`),
reusing `remove_window` / `add_window` / `add_tile`. First-show centering.
- **Window-destroy hook:** on the unmap/destroy path, also drop the window from
`scratchpad` if present — a window closed *while stashed* is in no workspace,
so the normal handler will not find it. (Locate the unmap handler that today
calls into `layout.remove_window`; add a `scratchpad.retain(...)` alongside.)
**`0003-scratchpad-dispatch.patch`**
- `src/input/mod.rs:659` — two `do_action` arms:
`Action::MoveWindowToScratchpad``self.niri.layout.move_to_scratchpad(focused_id)`;
`Action::ScratchpadShow``self.niri.layout.scratchpad_show()`; each ending in
`queue_redraw_all()`.
### Known implementation risk
On hide, a stashed window must be released from keyboard focus, window-cast /
screencast targets, and frame-callback bookkeeping; on show, reattached. The
`interactive_move` / `dnd` live-tile handling is the model for a window that is
temporarily outside all workspaces. This is the single delicate area and is
covered by explicit manual tests below.
## Packaging
**`pkg/niri-scratchpad/PKGBUILD`**
- `pkgname=niri-scratchpad`, `provides=('niri')`, `conflicts=('niri')`.
- `source` = niri git at the pinned tag `v26.04` (commit `8ed0da4`) plus the
three local `.patch` files.
- `prepare()` applies the patches in order.
- `build()` = `cargo build --release --locked`.
- `package()` installs the `niri` binary and the session/portal files exactly as
the upstream `niri` package does (mirror the official PKGBUILD's `package()`).
**`niri-patches/`** — holds the three `.patch` files, the single source of truth
for the delta. Rebasing niri = bump the pin, re-fix these three files.
**`install.sh`**
- Remove the `window-restore` symlink line.
- Add a step that builds and installs `niri-scratchpad` via `makepkg -si`
(idempotent: skip if the installed `niri` already `provides` the patched
version / matches the pinned pkgver).
## Config changes (`niri/config.kdl`)
- Delete the `workspace "minimized"` declaration and its comment block.
- `Mod+1..9``focus-workspace 1..9` (drop the `+1`).
- `Mod+Shift+1..9``move-window-to-workspace 1..9` (drop the `+1`).
- `Mod+M``move-window-to-scratchpad`.
- `Mod+Shift+M``scratchpad-show`.
## Deletions
- `scripts/window-restore.py`
- `scripts/tests/window-restore.test.py`
## Testing
**Unit (in-tree, mirrors niri's existing `src/layout` tests, no compositor):**
- Stash push on move; front-pop / back-push ordering (round-robin).
- Show/hide toggle transitions of the state table.
- `scratchpad_show` on empty stash = no-op.
- Staleness: `scratchpad_shown` pointing at an absent window resolves to "nothing
shown".
- Destroy-while-stashed removes the entry from `scratchpad`.
**Manual acceptance (the parts no unit test reaches):**
1. `niri validate -c niri/config.kdl` passes.
2. `Mod+M` on a focused window: it vanishes, **focus moves to another window**
(regression guard for the focus-release risk).
3. `Mod+Shift+M`: the window reappears **centered on the current output**,
focused.
4. `Mod+Shift+M` again (window focused): it hides.
5. Stash two windows; `Mod+Shift+M` repeatedly cycles A→(hide)→B→(hide)→A.
6. Show a window, move/resize it, hide, show again: **same geometry**.
7. Close an app while it is stashed, then `Mod+Shift+M`: no crash, no stale
entry, cycle skips it.
8. Two monitors: `Mod+Shift+M` shows on the **focused** output; repeat on the
other output.
9. `Mod+1..9` land on workspaces `1..9` with **no offset**; the scratchpad has no
waybar pill.
10. `Mod+Shift+M` with an empty stash: nothing happens, no crash.
## Out of scope
- Multiple scratchpad windows shown simultaneously (single-shown by design).
- A persistent per-window `is_scratchpad` flag / Sway's exact ownership model.
- Upstreaming to niri (possible later; the patch is kept clean enough to try).
- A named/dropdown "Quake terminal" binding on top of the scratchpad — separate
feature, deferred.
- Animations for show/hide beyond niri's existing floating add/remove behavior.
+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/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
+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 "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"; }
+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())