07791f0c23
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>
731 lines
32 KiB
Markdown
731 lines
32 KiB
Markdown
# 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 336–369; inits at ~695–708 and ~721–732; 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 2–3); `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 ~50–54) 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 2–4 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 2–4) + 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.
|