From 286bb8a3b3f5c8c8c6c40b5114faf39b3f992142 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 17 Jul 2026 11:09:52 -0700 Subject: [PATCH] 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) --- .../2026-07-17-niri-scratchpad-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-niri-scratchpad-design.md diff --git a/docs/superpowers/specs/2026-07-17-niri-scratchpad-design.md b/docs/superpowers/specs/2026-07-17-niri-scratchpad-design.md new file mode 100644 index 0000000..7d2fb74 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-niri-scratchpad-design.md @@ -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 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.