Files
dotfiles/docs/superpowers/specs/2026-07-17-niri-scratchpad-design.md
T
funman300 286bb8a3b3 spec: native niri scratchpad (replaces workspace-based minimize)
Design for a Sway-style scratchpad built into niri as a pinned patch set +
niri-scratchpad PKGBUILD. Stores hidden windows in a global stash on Layout
rather than a named workspace, removing the +1 offset and multi-monitor bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:09:52 -07:00

13 KiB

Niri Native Scratchpad Design

Date: 2026-07-17 Status: Approved

Summary

Replace the workspace-based minimize/stash with a native Sway-style scratchpad built into niri itself, shipped as a small, rebase-friendly patch set on top of a pinned upstream niri (v26.04, commit 8ed0da4) and built via a custom Arch package (niri-scratchpad).

  • Mod+M moves the focused window to a hidden scratchpad.
  • Mod+Shift+M shows the next scratchpad window as a floating, centered overlay on the current output; pressing it again while that window is focused hides it; repeated presses cycle through the stash (round-robin).

The scratchpad is a global stash on niri's Layout, not a workspace. This is the whole point: the previous implementation used a declared named workspace, which niri pins to index 1, forcing a +1 offset on every Mod+1..9 bind and breaking on multi-monitor. A stash that is not a workspace has no index, never appears in waybar, and is output-independent — so all of that goes away.

The old Python implementation (scripts/window-restore.py), the workspace "minimized" declaration, and the +1 offset are all removed.

Motivation

The current minimize (docs/superpowers/specs/2026-07-16-niri-minimize-design.md) works, but pays a structural tax:

  • A declared named workspace is always index 1 and cannot be moved, so every index workspace bind is offset by one (Mod+1focus-workspace 2).
  • The stash is per-output, so a second monitor's workspaces start at index 1 with no stash, throwing the offset off — a latent multi-monitor bug.
  • The minimized workspace is permanently visible as a waybar pill.
  • Restore is an external wofi picker, not a real toggle — there is no "show/hide/cycle" the way a scratchpad has.

A native scratchpad removes the workspace entirely, which removes every one of these problems at the source.

Architecture decision

Chosen: patch niri's Rust source (fork route), shipped as .patch files + PKGBUILD. Rejected alternatives:

  • External Rust IPC daemon. Would keep stock niri but cannot make a window live "outside all workspaces" — niri's IPC has no primitive for a hidden holding area, so a daemon would be forced back onto the named-workspace hack it is meant to replace.
  • Full fork as submodule / separate repo. More vendoring and rebase surface than needed. A handful of .patch files against a pinned tag is the smallest reviewable delta and is Arch-native to build.

Verified niri internals (niri 26.04, commit 8ed0da4)

Read from source, not inferred. These are the load-bearing facts.

  1. Two Action enums, kept in sync by hand.

    • IPC/CLI Action: niri-ipc/src/lib.rs:194 (derives clap + serde + JsonSchema). Parsed by niri msg action ....
    • Config Action: niri-config/src/binds.rs:102 (derives knuffel::Decode). Parsed from config.kdl; this is what the runtime dispatch matches on.
    • Bridged by hand-written impl From<niri_ipc::Action> for Action at niri-config/src/binds.rs:395. The From impl is exhaustive over the IPC enum, so every IPC variant needs a From arm; config-only variants need none.
    • knuffel maps PascalCase variants to kebab-case KDL nodes automatically (MoveWindowToScratchpadmove-window-to-scratchpad). No-arg variant = bare variant (cf. Suspend, at binds.rs:106). A focus=false-style property = #[knuffel(property(name = "focus"), default = true)] bool (cf. binds.rs:228).
  2. Runtime dispatch is State::do_action, src/input/mod.rs:650, the big match action { at line 659. Precedent handler MoveWindowToWorkspace at src/input/mod.rs:1283: resolves target, calls self.niri.layout.move_to_workspace(...), then queue_redraw_all().

  3. Layout owns cross-workspace state and already holds workspaceless live tiles. Layout<W> at src/layout/mod.rs:336 has interactive_move: Option<InteractiveMoveState<W>> (mod.rs:353) and dnd: Option<DndData<W>> (mod.rs:355), each holding a live Tile<W> that belongs to no workspace. This is the exact precedent for a stash.

  4. Remove/re-add primitives keep geometry.

    • Layout::remove_window (src/layout/mod.rs:1112) returns Option<RemovedTile<W>>; RemovedTile (mod.rs:492) bundles the Tile plus width, is_full_width, is_floating — everything to reinsert identically. It auto-cleans emptied workspaces.
    • Layout::add_window (mod.rs:928) takes AddWindowTarget (Auto/Output/Workspace(id)/NextTo), a bool is_floating, and ActivateWindow. Monitor::add_tile / Workspace::add_tile re-add a prebuilt tile.
    • The show/hide pattern is literally move_to_output (mod.rs:3351): remove_tile → stop_move_animations → add_tile(..., is_floating).
    • Tile<W> (src/layout/tile.rs:40) persists floating geometry across transitions: floating_window_size (tile.rs:69), floating_pos (tile.rs:76), and preset width/height indices. So geometry survives hide→show with no extra bookkeeping.
  5. Active output/workspace accessors usable inside do_action: Layout::active_output (mod.rs:1586), Layout::active_workspace / active_workspace_mut (mod.rs:1599 / 1613). Used in handlers already at src/input/mod.rs:1289.

  6. Mapped window & id: Mapped at src/window/mapped.rs:51, is_floating at line 98, id: MappedId at line 55.

Target behavior

Keys

Key Action KDL
Mod+M Move focused window to scratchpad (hidden). Focus falls to next window. move-window-to-scratchpad
Mod+Shift+M Show/toggle/cycle the scratchpad. scratchpad-show

State

Two new fields on Layout<W>:

scratchpad: Vec<RemovedTile<W>>,   // hidden windows, front = next to show
scratchpad_shown: Option<MappedId>, // the one currently visible, if any

scratchpad_shown is a remembered id only. A shown scratchpad window is an otherwise-ordinary floating window living in a real workspace; the stash proper is only the hidden Vec.

move-window-to-scratchpad (Mod+M)

  1. If nothing is focused → no-op.
  2. remove_window the focused window → RemovedTile.
  3. Push it onto the back of scratchpad.
  4. If its id equals scratchpad_shown, clear scratchpad_shown.
  5. queue_redraw_all(). Focus falls to the next window via niri's normal remove path.

scratchpad-show (Mod+Shift+M)

First, validate scratchpad_shown: if it names a window that no longer exists in any workspace (closed or moved away), treat it as None.

State Behavior
scratchpad_shown = Some(id) and id is the focused window Hide: remove_window(id), push to back of scratchpad, clear scratchpad_shown.
scratchpad_shown = Some(id) and id not focused Focus that window. Nothing added or removed.
scratchpad_shown = None, stash non-empty Show: pop front → add_window onto the active workspace with is_floating = true, focused; set scratchpad_shown. Center on first show (see below).
scratchpad_shown = None, stash empty No-op.

Cycling is emergent: show→A(focused), press→hide A (to back), press→show B, press→hide B, press→C … round-robin. Then queue_redraw_all().

Placement

On show, if the tile has no remembered floating_pos (never floated before), set it so the window is centered on the active output at its natural size. If it does have a remembered pos/size (shown, moved, hidden, shown again), reuse it unchanged — Tile already persists this, so "remember where I left it" needs no new storage, only the first-show centering tweak.

Deliberate simplifications (accepted)

  1. At most one scratchpad window shown at a time. Full Sway allows several shown simultaneously; that needs a set of shown ids and multi-floating management for negligible benefit. Single-shown is deterministic.
  2. A shown window is a normal floating window, not specially owned. The stash is only the hidden set. Moving a shown window with Mod+Shift+N, or closing it, simply removes it from scratchpad tracking (scratchpad_shown cleared on the staleness check). Re-stash with Mod+M. This avoids threading a persistent per-window is_scratchpad flag through the codebase — which is what Sway does and what we explicitly do not need.

The patch set

Three logical patches on the pinned tag, ordered so each compiles:

0001-scratchpad-actions.patch

  • niri-ipc/src/lib.rs:194 — IPC Action variants MoveWindowToScratchpad, ScratchpadShow.
  • niri-config/src/binds.rs:102 — config Action variants (kebab-case nodes derived by knuffel).
  • niri-config/src/binds.rs:395From arms mapping IPC → config.

0002-scratchpad-stash.patch

  • src/layout/mod.rs:336scratchpad + scratchpad_shown fields (init in the Layout constructor).
  • src/layout/mod.rs — methods move_to_scratchpad(&mut self, id) and scratchpad_show(&mut self), modeled on move_to_output (mod.rs:3351), reusing remove_window / add_window / add_tile. First-show centering.
  • Window-destroy hook: on the unmap/destroy path, also drop the window from scratchpad if present — a window closed while stashed is in no workspace, so the normal handler will not find it. (Locate the unmap handler that today calls into layout.remove_window; add a scratchpad.retain(...) alongside.)

0003-scratchpad-dispatch.patch

  • src/input/mod.rs:659 — two do_action arms: Action::MoveWindowToScratchpadself.niri.layout.move_to_scratchpad(focused_id); Action::ScratchpadShowself.niri.layout.scratchpad_show(); each ending in queue_redraw_all().

Known implementation risk

On hide, a stashed window must be released from keyboard focus, window-cast / screencast targets, and frame-callback bookkeeping; on show, reattached. The interactive_move / dnd live-tile handling is the model for a window that is temporarily outside all workspaces. This is the single delicate area and is covered by explicit manual tests below.

Packaging

pkg/niri-scratchpad/PKGBUILD

  • pkgname=niri-scratchpad, provides=('niri'), conflicts=('niri').
  • source = niri git at the pinned tag v26.04 (commit 8ed0da4) plus the three local .patch files.
  • prepare() applies the patches in order.
  • build() = cargo build --release --locked.
  • package() installs the niri binary and the session/portal files exactly as the upstream niri package does (mirror the official PKGBUILD's package()).

niri-patches/ — holds the three .patch files, the single source of truth for the delta. Rebasing niri = bump the pin, re-fix these three files.

install.sh

  • Remove the window-restore symlink line.
  • Add a step that builds and installs niri-scratchpad via makepkg -si (idempotent: skip if the installed niri already provides the patched version / matches the pinned pkgver).

Config changes (niri/config.kdl)

  • Delete the workspace "minimized" declaration and its comment block.
  • Mod+1..9focus-workspace 1..9 (drop the +1).
  • Mod+Shift+1..9move-window-to-workspace 1..9 (drop the +1).
  • Mod+Mmove-window-to-scratchpad.
  • Mod+Shift+Mscratchpad-show.

Deletions

  • scripts/window-restore.py
  • scripts/tests/window-restore.test.py

Testing

Unit (in-tree, mirrors niri's existing src/layout tests, no compositor):

  • Stash push on move; front-pop / back-push ordering (round-robin).
  • Show/hide toggle transitions of the state table.
  • scratchpad_show on empty stash = no-op.
  • Staleness: scratchpad_shown pointing at an absent window resolves to "nothing shown".
  • Destroy-while-stashed removes the entry from scratchpad.

Manual acceptance (the parts no unit test reaches):

  1. niri validate -c niri/config.kdl passes.
  2. Mod+M on a focused window: it vanishes, focus moves to another window (regression guard for the focus-release risk).
  3. Mod+Shift+M: the window reappears centered on the current output, focused.
  4. Mod+Shift+M again (window focused): it hides.
  5. Stash two windows; Mod+Shift+M repeatedly cycles A→(hide)→B→(hide)→A.
  6. Show a window, move/resize it, hide, show again: same geometry.
  7. Close an app while it is stashed, then Mod+Shift+M: no crash, no stale entry, cycle skips it.
  8. Two monitors: Mod+Shift+M shows on the focused output; repeat on the other output.
  9. Mod+1..9 land on workspaces 1..9 with no offset; the scratchpad has no waybar pill.
  10. Mod+Shift+M with an empty stash: nothing happens, no crash.

Out of scope

  • Multiple scratchpad windows shown simultaneously (single-shown by design).
  • A persistent per-window is_scratchpad flag / Sway's exact ownership model.
  • Upstreaming to niri (possible later; the patch is kept clean enough to try).
  • A named/dropdown "Quake terminal" binding on top of the scratchpad — separate feature, deferred.
  • Animations for show/hide beyond niri's existing floating add/remove behavior.