Compare commits

...

9 Commits

Author SHA1 Message Date
funman300 d0fef1478d niri-scratchpad: pull shown window to focused output; don't drop tiles
Two fixes found reviewing the paths the acceptance suite couldn't cover.

Show-while-shown-elsewhere used activate_window, which sets
active_monitor_idx and so dragged *focus* to whichever output the window
was parked on. A scratchpad should come to you: if the window is on
another monitor, remove and re-add it floating on the active workspace;
if it is already on the active monitor, focus in place as before.

add_removed_tile_floating dropped the RemovedTile on both of its early
returns (NoOutputs, monitor-not-found), destroying the window while its
client was still alive and leaving it wedged with no surface anywhere.
Both were unreachable via scratchpad_show's guards, but it is a
window-loss footgun for the next caller. It now returns
Result<(), RemovedTile<W>> so callers can stash the tile instead.

Adds two layout tests (they simulate outputs, so multi-monitor is
covered without a second physical display) and corrects the acceptance
suite's test-8 skip message, which claimed the code used active_output
— it never did; it uses active_workspace.

cargo test --lib scratchpad: 10/10. acceptance.py: 8 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:47:06 -07:00
funman300 640deb397e niri-scratchpad: stop screencasts on stash; harden acceptance spawn wait
Stashing a window now stops an active screencast of it, rather than leaving the
consumer on a frozen last frame (a stashed window is never rendered). Covers
both stash paths - Mod+M and Mod+Shift+M's hide branch - by diffing which
windows left the layout across the action, so scratchpad_show's branch logic
isn't duplicated in the caller. Dynamic-target casts are retargeted to Nothing
rather than ended; that carve-out is documented inline.

Also widen the acceptance harness spawn wait to 30s: each test launches its own
nested niri, and a cold alacritty start occasionally exceeded the old 10s.
Deliberately not a retry, which could double-spawn and corrupt window counts.

117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:04:14 -07:00
funman300 f557ffdfc7 niri-scratchpad: refresh patch with cast-leak and unmap-path fixes
Review of the destroy-path fix found two Important defects, both now closed:
a stashed window whose client died never stopped its screencast (leaking a
PipeWire stream/portal session nothing could reclaim), and the unmap path had
the identical hole (a stashed window that unmapped was never reinstated into
unmapped_windows, wedging it permanently on re-map). Also corrects a factually
wrong hook-teardown comment. 117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:46:02 -07:00
funman300 a9ce3a13cf niri-scratchpad: refresh patch with multi-monitor tests
Adds two synthetic multi-output tests (niri's layout harness supports several
outputs) proving scratchpad_show targets the FOCUSED output, plus a control
that it returns to the origin output when focus is unchanged. This is the
substitute for manual multi-monitor verification on a single-display machine.
117 layout tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:12:55 -07:00
funman300 0e0b5fcbe3 niri-scratchpad: fix close-while-stashed eviction + add live acceptance test
Refresh the patch with the destroy-path fix (a client dying while stashed is
now evicted instead of leaked/zombied), and add pkg/niri-scratchpad/tests/
acceptance.py: a headless IPC-driven regression test that drives a nested
patched niri and asserts on `niri msg -j windows`. It found the above bug
(unit tests miss it — the harness calls remove_window directly). Run it after
rebasing the patch onto a new niri release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:02:35 -07:00
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
funman300 b8a858d7a7 niri: working minimize via declared stash workspace (#1)
Mod+M/Mod+Shift+M referenced an undeclared 'minimized' workspace and were silent no-ops. Declare the stash, add focus=false, and add the window-restore wofi picker.
2026-07-16 17:19:57 +00:00
10 changed files with 2180 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
+769
View File
@@ -0,0 +1,769 @@
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/handlers/compositor.rs b/src/handlers/compositor.rs
index f38b2934..a085ff70 100644
--- a/src/handlers/compositor.rs
+++ b/src/handlers/compositor.rs
@@ -24,6 +24,7 @@ use crate::layout::{ActivateWindow, AddWindowTarget, LayoutElement as _};
use crate::niri::{CastTarget, ClientState, LockState, State};
use crate::utils::transaction::Transaction;
use crate::utils::{is_mapped, send_scale_transform};
+use crate::window::mapped::MappedId;
use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped};
impl CompositorHandler for State {
@@ -368,6 +369,52 @@ impl CompositorHandler for State {
return;
}
+ // A window stashed in the scratchpad is in no workspace, so the lookup
+ // above can't see it — but its client still owns the surface and may
+ // unmap it at any time. Handle that here, otherwise the tile would sit
+ // in the stash with a bufferless surface and never be reinstated into
+ // unmapped_windows, so a later re-map could not fire and the window
+ // would be permanently wedged.
+ if !is_mapped(surface) {
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface) {
+ trace!("stashed toplevel got unmapped");
+
+ let window = removed.window().window.clone();
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = removed.window().id();
+ // Drop the Mapped here so its mapped-toplevel pre-commit hook is
+ // torn down before we install the default one. The two are
+ // distinct HookIds (Mapped::drop removes only its own), so this
+ // ordering is tidiness, not correctness.
+ drop(removed);
+
+ window.on_commit();
+
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
+ self.add_default_dmabuf_pre_commit_hook(surface);
+
+ // Newly-unmapped toplevels must perform the initial
+ // commit-configure sequence afresh.
+ let unmapped = Unmapped::new(window);
+ self.niri.unmapped_windows.insert(surface.clone(), unmapped);
+
+ // Nothing else to do: a stashed window is in no workspace, so it
+ // can't be the focus and isn't rendered on any output. It is also
+ // absent from window_mru_ui, unless the overlay happened to be
+ // open when it was stashed — that leaves a stale thumbnail, which
+ // the MRU lookups bail on harmlessly.
+ //
+ // A stashed window that commits while still mapped falls through
+ // to the unrecognized-surface trace below. Buffer state is still
+ // handled (on_commit_buffer_handler runs unconditionally), so only
+ // Window-level geometry/popup bookkeeping goes stale, and that
+ // self-heals on the first commit after the window is shown.
+ return;
+ }
+ }
+
// This is a commit of a non-toplevel root.
}
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
index 38440c90..fe16901f 100644
--- a/src/handlers/xdg_shell.rs
+++ b/src/handlers/xdg_shell.rs
@@ -47,6 +47,7 @@ use crate::utils::transaction::Transaction;
use crate::utils::{
get_monotonic_time, output_matches_name, send_scale_transform, update_tiled_state, ResizeEdge,
};
+use crate::window::mapped::MappedId;
use crate::window::{InitialConfigureState, ResolvedWindowRules, Unmapped, WindowRef};
impl XdgShellHandler for State {
@@ -834,6 +835,33 @@ impl XdgShellHandler for State {
.find_window_and_output(surface.wl_surface());
let Some((mapped, output)) = win_out else {
+ // A window whose client died while stashed in the scratchpad is in no
+ // workspace, so find_window_and_output can't see it; evict it here.
+ //
+ // Casts must still be stopped: a Cast lives in niri.casting, independent
+ // of the layout, and the render loop just skips ids it can't find — so a
+ // cast of a stashed window would otherwise stay live and frozen forever.
+ //
+ // The rest of the teardown below is genuinely not needed here:
+ // window_mru_ui and layout.focus() are both derived from
+ // layout.workspaces(), so a stashed window is in neither; and there is
+ // nothing on screen to snapshot, animate closed, or redraw.
+ //
+ // We also deliberately skip re-adding the default dmabuf pre-commit hook
+ // that the normal path installs. Mapped::drop removes only its own
+ // mapped-toplevel hook (a distinct HookId), and the surface cannot gain a
+ // new role after toplevel destruction, so nothing will sample it again;
+ // destroyed() removes hooks with `if let Some(..)`, making the absent
+ // entry a no-op rather than an error.
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface.wl_surface()) {
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = removed.window().id();
+ drop(removed);
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
+ return;
+ }
// I have no idea how this can happen, but I saw it happen once, in a weird interaction
// involving laptop going to sleep and resuming.
error!("toplevel missing from both unmapped_windows and layout");
diff --git a/src/input/mod.rs b/src/input/mod.rs
index f1ad4932..87b5ea7a 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -54,6 +54,7 @@ use crate::ui::mru::{WindowMru, WindowMruUi};
use crate::ui::screenshot_ui::ScreenshotUi;
use crate::utils::spawning::{spawn, spawn_sh};
use crate::utils::{center, get_monotonic_time, CastSessionId, ResizeEdge};
+use crate::window::mapped::MappedId;
pub mod backend_ext;
pub mod move_grab;
@@ -647,6 +648,38 @@ impl State {
}
}
+ /// Ids of all windows currently in the layout. Excludes the scratchpad stash,
+ /// which is not part of any workspace.
+ fn layout_window_ids(&self) -> HashSet<u64> {
+ self.niri
+ .layout
+ .windows()
+ .map(|(_, mapped)| {
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = mapped.id();
+ id.get()
+ })
+ .collect()
+ }
+
+ /// Stop screencasts of windows that were in the layout before an action and are
+ /// gone after it. A stashed window is never rendered, so its cast would otherwise
+ /// sit frozen on its last frame indefinitely; stopping is the chosen policy over
+ /// rendering stashed tiles.
+ ///
+ /// Note the carve-out inherited from `stop_casts_for_target`: a cast with a
+ /// *dynamic* target is not ended, it is retargeted to `CastTarget::Nothing`. So a
+ /// follow-focus cast survives a stash and goes blank rather than stopping. That is
+ /// shared behaviour with every other stop site, not specific to the scratchpad.
+ fn stop_casts_for_windows_gone(&mut self, before: HashSet<u64>) {
+ let after = self.layout_window_ids();
+ for id in before.difference(&after) {
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: *id });
+ }
+ }
+
pub fn do_action(&mut self, action: Action, allow_when_locked: bool) {
if self.niri.is_locked() && !(allow_when_locked || allowed_when_locked(&action)) {
return;
@@ -1376,6 +1409,15 @@ impl State {
}
}
}
+ Action::MoveWindowToScratchpad => {
+ let focus = self.niri.layout.focus().map(|m| m.window.clone());
+ if let Some(window) = focus {
+ let before = self.layout_window_ids();
+ self.niri.layout.move_to_scratchpad(&window);
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all();
+ }
+ }
Action::MoveColumnToWorkspaceDown(focus) => {
self.niri.layout.move_column_to_workspace_down(focus);
self.maybe_warp_cursor_to_focus();
@@ -2245,6 +2287,13 @@ impl State {
Action::StopCast(session_id) => {
self.niri.stop_cast(CastSessionId::from(session_id));
}
+ Action::ScratchpadShow => {
+ // The hide branch of scratchpad_show also stashes a window.
+ let before = self.layout_window_ids();
+ self.niri.layout.scratchpad_show();
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all();
+ }
Action::ToggleOverview => {
self.niri.layout.toggle_overview();
self.niri.queue_redraw_all();
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 5c4dd639..8696fcd9 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.
@@ -499,6 +506,12 @@ pub struct RemovedTile<W: LayoutElement> {
is_floating: bool,
}
+impl<W: LayoutElement> RemovedTile<W> {
+ pub fn window(&self) -> &W {
+ self.tile.window()
+ }
+}
+
/// Whether to activate a newly added window.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ActivateWindow {
@@ -699,6 +712,8 @@ impl<W: LayoutElement> Layout<W> {
last_active_workspace_id: HashMap::new(),
interactive_move: None,
dnd: None,
+ scratchpad: VecDeque::new(),
+ scratchpad_shown: None,
clock,
update_render_elements_time: Duration::ZERO,
overview_open: false,
@@ -724,6 +739,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 +1131,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 +1228,164 @@ 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.
+ }
+
+ /// Evict a stashed window from the scratchpad by its surface, returning the
+ /// tile so the caller can run the rest of the teardown (casts, unmapped
+ /// bookkeeping). Needed on both the destroy and unmap paths: a window that
+ /// dies or unmaps *while stashed* is in no workspace, so
+ /// `find_window_and_output` (and therefore `remove_window`) never sees it —
+ /// this is the only thing that takes it out. A shown scratchpad window lives
+ /// in a workspace and is handled by `remove_window` instead.
+ pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> Option<RemovedTile<W>> {
+ let idx = self
+ .scratchpad
+ .iter()
+ .position(|rt| rt.tile.window().is_wl_surface(surface))?;
+ self.scratchpad.remove(idx)
+ }
+
+ /// Show / toggle / cycle the scratchpad. See the state table in the plan.
+ pub fn scratchpad_show(&mut self) {
+ // Drop a stale "shown" id (window was closed or moved away).
+ 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 -> bring it to the monitor you are on, then
+ // focus it. `activate_window` alone would move *focus* to whichever
+ // monitor the window is parked on (it sets active_monitor_idx),
+ // dragging you across outputs; a scratchpad should come to you.
+ // Same monitor: nothing to move, just focus.
+ if self.monitor_idx_of_window(&id) != self.active_monitor_idx() {
+ if let Some(mut removed) = self.remove_window(&id, Transaction::new()) {
+ // remove_window cleared scratchpad_shown; re-set it below
+ // once the tile is placed.
+ removed.tile.stop_move_animations();
+
+ let Some(ws_id) = self.active_workspace().map(|ws| ws.id()) else {
+ self.scratchpad.push_front(removed); // no active ws
+ return;
+ };
+
+ match self.add_removed_tile_floating(ws_id, removed) {
+ Ok(()) => self.scratchpad_shown = Some(id),
+ // Could not place it; stash rather than drop it.
+ Err(tile) => self.scratchpad.push_front(tile),
+ }
+ return;
+ }
+ }
+ self.activate_window(&id);
+ }
+ return;
+ }
+
+ // 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;
+ }
+ };
+ match self.add_removed_tile_floating(ws_id, removed) {
+ Ok(()) => self.scratchpad_shown = Some(id),
+ // Could not place it; stash rather than drop it.
+ Err(tile) => self.scratchpad.push_front(tile),
+ }
+ }
+
+ /// Index of the monitor holding `window`, or None if it is not in the layout
+ /// (which includes every window stashed in the scratchpad).
+ fn monitor_idx_of_window(&self, window: &W::Id) -> Option<usize> {
+ let MonitorSet::Normal { monitors, .. } = &self.monitor_set else {
+ return None;
+ };
+ monitors.iter().position(|mon| {
+ mon.workspaces
+ .iter()
+ .any(|ws| ws.windows().any(|win| win.id() == window))
+ })
+ }
+
+ /// Index of the currently active monitor, if there are any outputs.
+ fn active_monitor_idx(&self) -> Option<usize> {
+ match &self.monitor_set {
+ MonitorSet::Normal {
+ active_monitor_idx, ..
+ } => Some(*active_monitor_idx),
+ _ => None,
+ }
+ }
+
+ /// Re-add a previously-removed tile to the workspace `ws_id`, forcing it onto
+ /// the floating layer. Mirrors the re-add pattern in `move_to_workspace`.
+ ///
+ /// Returns the tile back in `Err` when it could not be placed, so the caller
+ /// can stash it. Dropping it here would destroy the window while its client
+ /// is still alive, leaving it permanently wedged with no surface anywhere.
+ fn add_removed_tile_floating(
+ &mut self,
+ ws_id: WorkspaceId,
+ removed: RemovedTile<W>,
+ ) -> Result<(), RemovedTile<W>> {
+ let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else {
+ return Err(removed);
+ };
+ let Some(mon) = monitors
+ .iter_mut()
+ .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id))
+ else {
+ return Err(removed);
+ };
+
+ mon.add_tile(
+ removed.tile,
+ MonitorAddWindowTarget::Workspace {
+ id: ws_id,
+ column_idx: None,
+ },
+ ActivateWindow::Yes,
+ true,
+ removed.width,
+ removed.is_full_width,
+ true,
+ );
+ Ok(())
+ }
+
pub fn descendants_added(&mut self, id: &W::Id) -> bool {
for ws in self.workspaces_mut() {
if ws.descendants_added(id) {
@@ -2444,6 +2626,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..4c22a115 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,225 @@ 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);
+}
+
+#[test]
+fn scratchpad_shows_on_the_focused_output() {
+ // First, confirm that while stashed the window lives on no monitor at all (checked against
+ // a standalone run so `check_ops`'s post-op invariant checks still see a fully-formed op
+ // sequence).
+ let stash_only_ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ ];
+ let stashed_layout = check_ops(stash_only_ops);
+ {
+ let MonitorSet::Normal { monitors, .. } = &stashed_layout.monitor_set else {
+ unreachable!()
+ };
+ assert!(
+ monitors.iter().all(|m| !m.windows().any(|w| *w.id() == 0)),
+ "stashed window must not be present on any monitor"
+ );
+ }
+
+ // Stash on output 1, then switch focus to output 2 before showing.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ Op::FocusOutput(2),
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ // The window must appear on whichever output is currently focused (output 2), not on the
+ // output it was originally stashed from (output 1).
+ for (idx, monitor) in monitors.iter().enumerate() {
+ let has_window = monitor.windows().any(|w| *w.id() == 0);
+ if idx == *active_monitor_idx {
+ assert!(has_window, "shown window should be on the focused output");
+ } else {
+ assert!(!has_window, "shown window should not be on a non-focused output");
+ }
+ }
+}
+
+#[test]
+fn scratchpad_shows_on_origin_output_when_focus_unchanged() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ // no focus change here
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ // Focus never left output 1, so the window should reappear there.
+ assert_eq!(*active_monitor_idx, 0, "output 1 should still be active");
+ assert!(
+ monitors[*active_monitor_idx].windows().any(|w| *w.id() == 0),
+ "shown window should be on the still-focused origin output"
+ );
+}
+
+#[test]
+fn scratchpad_show_pulls_already_shown_window_to_focused_output() {
+ // Show on output 1, walk to output 2, then press show again. The window must
+ // come to output 2 — not drag focus back to output 1, which is what plain
+ // `activate_window` would do.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow, // shown on output 1
+ Op::FocusOutput(2), // shown, but no longer focused
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ assert_eq!(*active_monitor_idx, 1, "focus must stay on output 2");
+ assert!(
+ monitors[1].windows().any(|w| *w.id() == 0),
+ "window should have been pulled to output 2"
+ );
+ assert!(
+ !monitors[0].windows().any(|w| *w.id() == 0),
+ "window must not be left behind on output 1"
+ );
+ assert_eq!(layout.scratchpad_shown, Some(0), "still tracked as shown");
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
+}
+
+#[test]
+fn scratchpad_show_focuses_in_place_on_same_output() {
+ // Same output, shown but not focused: focus it without moving it.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow, // shown and focused
+ Op::AddWindow { params: TestWindowParams::new(1) }, // steals focus
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ assert_eq!(layout.scratchpad_shown, Some(0));
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
+ assert_eq!(
+ layout.focus().map(|w| *w.id()),
+ Some(0),
+ "shown-but-unfocused window should get focus, not be hidden"
+ );
+}
+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"
}
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Live acceptance / regression test for the niri native scratchpad patch.
Drives a real (patched) niri through its own IPC — the exact `do_action`
path a keybind hits — and asserts on `niri msg -j windows`. No keyboard
injection, so it can't type into the host session's windows. Each test gets
its own fresh nested niri, so cases are hermetic and order-independent.
Run this after rebasing the patch onto a new niri release to confirm the
scratchpad still behaves. It needs:
- a running Wayland (or X) session to host the nested winit window,
- the patched niri binary (built from niri-patches/ via the PKGBUILD, or a
dev checkout),
- `alacritty` as the throwaway test client.
Usage:
python3 acceptance.py [PATH_TO_NIRI]
Binary resolution order: argv[1], $NIRI_BIN, `niri` on PATH,
~/build/niri-scratchpad/target/release/niri. The binary MUST be patched
(its `niri msg action --help` must list `scratchpad-show`).
Exit code 0 iff every non-skipped test passes.
"""
import glob, json, os, shutil, signal, subprocess, sys, tempfile, time
MINIMAL_CONFIG = """\
// Hermetic config for scratchpad acceptance: no startup spawns.
input { keyboard { xkb { } } }
binds {
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
Mod+Q { close-window; }
}
"""
def resolve_niri():
for cand in (sys.argv[1] if len(sys.argv) > 1 else None,
os.environ.get("NIRI_BIN"),
shutil.which("niri"),
os.path.expanduser("~/build/niri-scratchpad/target/release/niri")):
if cand and os.path.exists(cand):
return cand
sys.exit("ERROR: no niri binary found (pass a path, set $NIRI_BIN, or "
"install niri-scratchpad).")
NIRI = resolve_niri()
RT = os.environ["XDG_RUNTIME_DIR"]
class Niri:
"""A fresh nested niri instance driven over IPC."""
def __init__(self, config_path):
before = set(glob.glob(f"{RT}/niri.*.sock"))
self.proc = subprocess.Popen(
[NIRI, "-c", config_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sock = None
t0 = time.time()
while time.time() - t0 < 15:
new = set(glob.glob(f"{RT}/niri.*.sock")) - before
if new:
self.sock = new.pop()
break
time.sleep(0.2)
if not self.sock:
self.close()
raise RuntimeError("nested niri socket never appeared")
self.env = dict(os.environ, NIRI_SOCKET=self.sock)
for _ in range(40): # wait for IPC to answer
if self.alive():
break
time.sleep(0.2)
def msg(self, *args):
r = subprocess.run([NIRI, "msg", "-j", *args],
capture_output=True, text=True, env=self.env)
return json.loads(r.stdout) if r.stdout.strip() else None
def action(self, *args):
return subprocess.run([NIRI, "msg", "action", *args],
capture_output=True, text=True, env=self.env)
def alive(self):
return self.msg("version") is not None
def windows(self):
return self.msg("windows") or []
def wait_count(self, n, timeout=8):
t0 = time.time()
while time.time() - t0 < timeout:
if len(self.windows()) == n:
return self.windows()
time.sleep(0.15)
return self.windows()
def spawn_window(self, timeout=30):
"""Spawn an alacritty window and return its id once it appears.
The wait is deliberately generous: every test launches its own nested
niri, so a cold alacritty start can occasionally be slow under that
load. Do NOT convert this into a retry — re-issuing the spawn risks a
second window arriving late and corrupting the window counts the tests
assert on. A slow test beats a flaky one.
"""
before = {w["id"] for w in self.windows()}
self.action("spawn", "--", "alacritty")
t0 = time.time()
while time.time() - t0 < timeout:
new = [w for w in self.windows() if w["id"] not in before]
if new:
return new[0]["id"]
time.sleep(0.15)
raise RuntimeError(
f"spawned window never appeared within {timeout}s "
f"(compositor alive={self.alive()})")
def by_id(self, i):
return next((w for w in self.windows() if w["id"] == i), None)
def focused(self):
return next((w for w in self.windows() if w["is_focused"]), None)
def floating_pos(self, i, tries=12):
# tile_pos_in_workspace_view is transiently null right after a show.
for _ in range(tries):
w = self.by_id(i)
p = w["layout"].get("tile_pos_in_workspace_view") if w else None
if p is not None:
return p
time.sleep(0.08)
return None
def output_size(self):
outs = self.msg("outputs") or {}
for o in outs.values():
lg = o.get("logical") or {}
if lg.get("width") and lg.get("height"):
return lg["width"], lg["height"]
return None
def close(self):
try:
self.proc.send_signal(signal.SIGTERM)
except Exception:
pass
try:
self.proc.wait(timeout=3)
except Exception:
try:
self.proc.kill()
except Exception:
pass
# ---- tests: each takes a fresh Niri and returns (ok, detail) ----
def t_stash_hides_and_moves_focus(n):
a = n.spawn_window()
b = n.spawn_window()
n.action("focus-window", "--id", str(b))
time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(1)
f = n.focused()
ok = (len(n.windows()) == 1 and n.by_id(b) is None
and f is not None and f["id"] == a)
return ok, f"remaining={len(n.windows())}, b_gone={n.by_id(b) is None}, focus={f['id'] if f else None}/want {a}"
def t_show_floating_focused_centered(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
w = n.by_id(b)
pos = n.floating_pos(b)
centered = "n/a"
ok = bool(w and w["is_floating"] and w["is_focused"] and pos and pos[0] > 5)
osz = n.output_size()
if ok and osz and w:
ww, wh = w["layout"]["window_size"]
cx, cy = (osz[0] - ww) / 2, (osz[1] - wh) / 2
centered = abs(pos[0] - cx) < osz[0] * 0.15 and abs(pos[1] - cy) < osz[1] * 0.15
ok = ok and bool(centered)
return ok, f"floating={w['is_floating'] if w else None}, focused={w['is_focused'] if w else None}, pos={pos}, centered={centered}"
def t_reshow_hides(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1) # show (focused)
n.action("scratchpad-show"); n.wait_count(0) # hide
ok = len(n.windows()) == 0
return ok, f"count after re-show={len(n.windows())} (want 0)"
def t_cycle_round_robin(n):
a = n.spawn_window()
b = n.spawn_window()
for wid in (b, a):
n.action("focus-window", "--id", str(wid)); time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(0)
n.action("scratchpad-show"); w1 = n.wait_count(1); first = w1[0]["id"] if w1 else None
n.action("scratchpad-show"); n.wait_count(0) # hide (focused)
n.action("scratchpad-show"); w2 = n.wait_count(1); second = w2[0]["id"] if w2 else None
ok = first is not None and second is not None and first != second
return ok, f"first={first}, second={second} (want different)"
def t_geometry_remembered(n):
w = n.spawn_window()
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-floating-window", "-x", "+130", "-y", "+90"); time.sleep(0.4)
pos1 = n.floating_pos(w)
n.action("scratchpad-show"); n.wait_count(0) # hide
n.action("scratchpad-show"); n.wait_count(1) # show again (single window)
pos2 = n.floating_pos(w)
back = n.by_id(w)
ok = (back is not None and back["is_floating"] and pos1 and pos2
and abs(pos1[0] - pos2[0]) < 2 and abs(pos1[1] - pos2[1]) < 2)
return ok, f"pos before hide={pos1}, after show={pos2}, floating={back['is_floating'] if back else None}"
def t_shown_not_focused_gets_focus(n):
p = n.spawn_window() # persistent tiled window
s = n.spawn_window() # to be stashed + shown
n.action("focus-window", "--id", str(s)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(1)
n.action("scratchpad-show"); n.wait_count(2) # s shown floating, focused
n.action("focus-window", "--id", str(p)); time.sleep(0.3)
npre = len(n.windows())
n.action("scratchpad-show"); time.sleep(0.4) # should just focus s back
f = n.focused()
ok = len(n.windows()) == npre and f is not None and f["id"] == s
return ok, f"count {npre}->{len(n.windows())} (want same), focus={f['id'] if f else None}/want {s}"
def t_close_while_stashed(n):
w = n.spawn_window()
pid = n.by_id(w)["pid"]
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
os.kill(pid, signal.SIGKILL) # client dies WHILE stashed
time.sleep(0.6)
r = n.action("scratchpad-show"); time.sleep(0.5)
ok = n.alive() and n.by_id(w) is None and r.returncode == 0
return ok, f"alive={n.alive()}, dead_shown={n.by_id(w) is not None}, rc={r.returncode}"
def t_empty_stash_noop(n):
n.spawn_window()
r = n.action("scratchpad-show"); time.sleep(0.3)
ok = len(n.windows()) == 1 and n.alive() and r.returncode == 0
return ok, f"count stayed {len(n.windows())}, alive={n.alive()}, rc={r.returncode}"
TESTS = [
("1 stash hides + focus moves", t_stash_hides_and_moves_focus),
("2 show floating+focused+centered", t_show_floating_focused_centered),
("3 re-show hides", t_reshow_hides),
("4 cycle round-robin", t_cycle_round_robin),
("5 geometry remembered", t_geometry_remembered),
("6 shown-not-focused -> focus it", t_shown_not_focused_gets_focus),
("7 close-while-stashed no crash", t_close_while_stashed),
("10 empty-stash no-op", t_empty_stash_noop),
]
def main():
if not shutil.which("alacritty"):
sys.exit("ERROR: alacritty is required as the test client.")
# confirm the binary is patched
h = subprocess.run([NIRI, "msg", "action", "--help"],
capture_output=True, text=True)
if "scratchpad-show" not in h.stdout:
sys.exit(f"ERROR: {NIRI} is not patched (no scratchpad-show action).")
print(f"niri: {NIRI}\n")
cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
cfg.write(MINIMAL_CONFIG); cfg.close()
results = []
try:
for name, fn in TESTS:
n = None
try:
n = Niri(cfg.name)
ok, detail = fn(n)
except Exception as e:
ok, detail = False, f"exception: {e}"
finally:
if n:
n.close()
time.sleep(0.3)
results.append((name, ok, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
finally:
os.unlink(cfg.name)
# #8 multi-monitor cannot be exercised nested (single winit output).
print(" [SKIP] 8 multi-monitor: needs a physical 2nd output. Covered by the "
"layout unit tests instead (scratchpad_shows_on_the_focused_output, "
"scratchpad_show_pulls_already_shown_window_to_focused_output), which "
"simulate outputs: `cargo test --lib scratchpad`.")
passed = sum(1 for _, ok, _ in results if ok)
failed = sum(1 for _, ok, _ in results if not ok)
print(f"\n{passed} passed, {failed} failed, 1 skipped")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
-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())