# 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+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 `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 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 (`MoveWindowToScratchpad` ⇄ `move-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` at `src/layout/mod.rs:336` has `interactive_move: Option>` (`mod.rs:353`) and `dnd: Option>` (`mod.rs:355`), each holding a live `Tile` 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` (`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` (`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`: ```rust scratchpad: Vec>, // hidden windows, front = next to show scratchpad_shown: Option, // 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:395` — `From` arms mapping IPC → config. **`0002-scratchpad-stash.patch`** - `src/layout/mod.rs:336` — `scratchpad` + `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::MoveWindowToScratchpad` → `self.niri.layout.move_to_scratchpad(focused_id)`; `Action::ScratchpadShow` → `self.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..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.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.