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>
This commit is contained in:
funman300
2026-07-17 11:09:52 -07:00
parent b8a858d7a7
commit 286bb8a3b3
@@ -0,0 +1,279 @@
# 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<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
(`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<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>`:
```rust
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: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.