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>
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+Mmoves the focused window to a hidden scratchpad.Mod+Shift+Mshows the next scratchpad window as a floating, centered overlay on the current output; pressing it again while that window is focused hides it; repeated presses cycle through the stash (round-robin).
The scratchpad is a global stash on niri's Layout, not a workspace. This is
the whole point: the previous implementation used a declared named workspace,
which niri pins to index 1, forcing a +1 offset on every Mod+1..9 bind and
breaking on multi-monitor. A stash that is not a workspace has no index, never
appears in waybar, and is output-independent — so all of that goes away.
The old Python implementation (scripts/window-restore.py), the
workspace "minimized" declaration, and the +1 offset are all removed.
Motivation
The current minimize (docs/superpowers/specs/2026-07-16-niri-minimize-design.md)
works, but pays a structural tax:
- A declared named workspace is always index 1 and cannot be moved, so every
index workspace bind is offset by one (
Mod+1→focus-workspace 2). - The stash is per-output, so a second monitor's workspaces start at index 1 with no stash, throwing the offset off — a latent multi-monitor bug.
- The
minimizedworkspace 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
.patchfiles 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.
-
Two
Actionenums, kept in sync by hand.- IPC/CLI
Action:niri-ipc/src/lib.rs:194(derives clap + serde + JsonSchema). Parsed byniri msg action .... - Config
Action:niri-config/src/binds.rs:102(derivesknuffel::Decode). Parsed fromconfig.kdl; this is what the runtime dispatch matches on. - Bridged by hand-written
impl From<niri_ipc::Action> for Actionatniri-config/src/binds.rs:395. TheFromimpl is exhaustive over the IPC enum, so every IPC variant needs aFromarm; config-only variants need none. - knuffel maps PascalCase variants to kebab-case KDL nodes automatically
(
MoveWindowToScratchpad⇄move-window-to-scratchpad). No-arg variant = bare variant (cf.Suspend,atbinds.rs:106). Afocus=false-style property =#[knuffel(property(name = "focus"), default = true)] bool(cf.binds.rs:228).
- IPC/CLI
-
Runtime dispatch is
State::do_action,src/input/mod.rs:650, the bigmatch action {at line 659. Precedent handlerMoveWindowToWorkspaceatsrc/input/mod.rs:1283: resolves target, callsself.niri.layout.move_to_workspace(...), thenqueue_redraw_all(). -
Layoutowns cross-workspace state and already holds workspaceless live tiles.Layout<W>atsrc/layout/mod.rs:336hasinteractive_move: Option<InteractiveMoveState<W>>(mod.rs:353) anddnd: Option<DndData<W>>(mod.rs:355), each holding a liveTile<W>that belongs to no workspace. This is the exact precedent for a stash. -
Remove/re-add primitives keep geometry.
Layout::remove_window(src/layout/mod.rs:1112) returnsOption<RemovedTile<W>>;RemovedTile(mod.rs:492) bundles theTilepluswidth,is_full_width,is_floating— everything to reinsert identically. It auto-cleans emptied workspaces.Layout::add_window(mod.rs:928) takesAddWindowTarget(Auto/Output/Workspace(id)/NextTo), abool is_floating, andActivateWindow.Monitor::add_tile/Workspace::add_tilere-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.
-
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 atsrc/input/mod.rs:1289. -
Mapped window & id:
Mappedatsrc/window/mapped.rs:51,is_floatingat line 98,id: MappedIdat 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)
- If nothing is focused → no-op.
remove_windowthe focused window →RemovedTile.- Push it onto the back of
scratchpad. - If its id equals
scratchpad_shown, clearscratchpad_shown. 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)
- 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.
- 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_showncleared on the staleness check). Re-stash withMod+M. This avoids threading a persistent per-windowis_scratchpadflag 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— IPCActionvariantsMoveWindowToScratchpad,ScratchpadShow.niri-config/src/binds.rs:102— configActionvariants (kebab-case nodes derived by knuffel).niri-config/src/binds.rs:395—Fromarms mapping IPC → config.
0002-scratchpad-stash.patch
src/layout/mod.rs:336—scratchpad+scratchpad_shownfields (init in theLayoutconstructor).src/layout/mod.rs— methodsmove_to_scratchpad(&mut self, id)andscratchpad_show(&mut self), modeled onmove_to_output(mod.rs:3351), reusingremove_window/add_window/add_tile. First-show centering.- Window-destroy hook: on the unmap/destroy path, also drop the window from
scratchpadif 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 intolayout.remove_window; add ascratchpad.retain(...)alongside.)
0003-scratchpad-dispatch.patch
src/input/mod.rs:659— twodo_actionarms:Action::MoveWindowToScratchpad→self.niri.layout.move_to_scratchpad(focused_id);Action::ScratchpadShow→self.niri.layout.scratchpad_show(); each ending inqueue_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 tagv26.04(commit8ed0da4) plus the three local.patchfiles.prepare()applies the patches in order.build()=cargo build --release --locked.package()installs theniribinary and the session/portal files exactly as the upstreamniripackage does (mirror the official PKGBUILD'spackage()).
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-restoresymlink line. - Add a step that builds and installs
niri-scratchpadviamakepkg -si(idempotent: skip if the installednirialreadyprovidesthe patched version / matches the pinned pkgver).
Config changes (niri/config.kdl)
- Delete the
workspace "minimized"declaration and its comment block. Mod+1..9→focus-workspace 1..9(drop the+1).Mod+Shift+1..9→move-window-to-workspace 1..9(drop the+1).Mod+M→move-window-to-scratchpad.Mod+Shift+M→scratchpad-show.
Deletions
scripts/window-restore.pyscripts/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_showon empty stash = no-op.- Staleness:
scratchpad_shownpointing at an absent window resolves to "nothing shown". - Destroy-while-stashed removes the entry from
scratchpad.
Manual acceptance (the parts no unit test reaches):
niri validate -c niri/config.kdlpasses.Mod+Mon a focused window: it vanishes, focus moves to another window (regression guard for the focus-release risk).Mod+Shift+M: the window reappears centered on the current output, focused.Mod+Shift+Magain (window focused): it hides.- Stash two windows;
Mod+Shift+Mrepeatedly cycles A→(hide)→B→(hide)→A. - Show a window, move/resize it, hide, show again: same geometry.
- Close an app while it is stashed, then
Mod+Shift+M: no crash, no stale entry, cycle skips it. - Two monitors:
Mod+Shift+Mshows on the focused output; repeat on the other output. Mod+1..9land on workspaces1..9with no offset; the scratchpad has no waybar pill.Mod+Shift+Mwith an empty stash: nothing happens, no crash.
Out of scope
- Multiple scratchpad windows shown simultaneously (single-shown by design).
- A persistent per-window
is_scratchpadflag / 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.