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

32 KiB
Raw Permalink Blame History

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.rsdo_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
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:

    /// 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,:

            scratchpad: VecDeque::new(),
            scratchpad_shown: None,
  • Step 3: Add test-harness Op variants

In $DEV/src/layout/tests.rs, add to the Op enum:

    ScratchpadStash(usize),
    ScratchpadShow,

In Op::apply (match at ~line 759), add arms:

            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:

#[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:

    /// 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)
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:

#[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:

    /// 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
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:

#[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:

        // 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
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):

    /// 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):

    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:

            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):

            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:

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
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

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
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:

# 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
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
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:
    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
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:
# 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
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.