Files
dotfiles/docs/superpowers/plans/2026-07-17-niri-scratchpad.md
T
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

731 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.