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:
@@ -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.
|
||||
Reference in New Issue
Block a user