Compare commits
9 Commits
05cc026d75
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fef1478d | |||
| 640deb397e | |||
| f557ffdfc7 | |||
| a9ce3a13cf | |||
| 0e0b5fcbe3 | |||
| d05b9f8a0e | |||
| 07791f0c23 | |||
| 286bb8a3b3 | |||
| b8a858d7a7 |
@@ -0,0 +1,730 @@
|
||||
# Niri Native Scratchpad Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a Sway-style scratchpad natively to niri — hide the focused window into a global stash and show/toggle/cycle it back as a centered floating overlay — shipped as a pinned patch built by a custom `niri-scratchpad` Arch package, replacing the old workspace-based minimize.
|
||||
|
||||
**Architecture:** The scratchpad is a stash (`VecDeque<RemovedTile<W>>`) plus a "currently shown" id (`Option<W::Id>`) on niri's `Layout<W>` struct — the same place that already holds workspaceless live tiles for interactive-move and drag-and-drop. Hide = `Layout::remove_window` → push onto the stash; show = pop → re-add to the active workspace as floating. Because the stash is not a workspace, it has no index, never appears in waybar, and is output-independent — eliminating the `+1` offset and multi-monitor bug of the old approach. Two new bindable actions (`move-window-to-scratchpad`, `scratchpad-show`) are threaded through niri's two `Action` enums and its `do_action` dispatch. The core state machine is unit-tested in niri's synthetic `src/layout/tests.rs` harness (no compositor); focus/close/multi-monitor behavior is verified manually in a nested niri.
|
||||
|
||||
**Tech Stack:** Rust (niri 26.04, commit `8ed0da4`), knuffel (KDL config derive), clap (IPC), smithay; Arch `makepkg`/PKGBUILD; niri KDL config; fish `install.sh`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Pinned upstream:** niri git tag `v26.04`, commit `8ed0da4`. Every source line/anchor below is against that commit. Do not develop against `main`.
|
||||
- **Delta lives as patches:** all Rust changes ship as `niri-patches/*.patch` applied by `pkg/niri-scratchpad/PKGBUILD`. Upstream is never vendored into the repo.
|
||||
- **Stash key type is `W::Id`** (production `Window`, tests `usize`) — never `MappedId`. This keeps the state machine unit-testable and generic.
|
||||
- **Single-shown invariant:** at most one scratchpad window is shown at a time (accepted simplification — no multi-shown, no persistent per-window `is_scratchpad` flag).
|
||||
- **Repo principles (CLAUDE.md):** idempotent `install.sh`, no sleep-based hacks, complete files, Tomorrow Night theme untouched.
|
||||
- **Two repos:** Rust work happens in a disposable niri dev clone (`~/build/niri-scratchpad`); its commits are throwaway — only the exported patch is a deliverable. Real deliverables (patch, PKGBUILD, install.sh, config, deletions) are committed in the dotfiles repo.
|
||||
|
||||
**Path shorthand in this plan:** `$DEV` = `~/build/niri-scratchpad` (niri dev clone). `$DOT` = `/home/alex/Documents/dotfiles`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**niri dev clone (`$DEV`) — source of the patch, throwaway commits:**
|
||||
- Modify `src/layout/mod.rs` — stash fields on `Layout<W>`; `move_to_scratchpad` / `scratchpad_show` methods; close-eviction hook inside `remove_window`.
|
||||
- Modify `src/layout/tests.rs` — new `Op` variants + `apply` arms + `#[test]`s.
|
||||
- Modify `niri-ipc/src/lib.rs` — IPC `Action` variants.
|
||||
- Modify `niri-config/src/binds.rs` — config `Action` variants + `From` arms.
|
||||
- Modify `src/input/mod.rs` — `do_action` arms.
|
||||
|
||||
**dotfiles repo (`$DOT`) — real deliverables:**
|
||||
- Create `niri-patches/0001-scratchpad.patch` — the whole Rust delta (single patch; see Task 6 rationale).
|
||||
- Create `pkg/niri-scratchpad/PKGBUILD` — builds pinned niri + patch.
|
||||
- Modify `install.sh` — build/install `niri-scratchpad`; drop `window-restore` symlink.
|
||||
- Modify `niri/config.kdl` — remove `workspace "minimized"`, un-offset `Mod+1..9`, new `Mod+M` / `Mod+Shift+M` binds.
|
||||
- Delete `scripts/window-restore.py`, `scripts/tests/window-restore.test.py`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Dev clone + baseline build
|
||||
|
||||
Establishes a known-good, buildable, runnable baseline before any change. A patch is only meaningful as a diff against this exact tree.
|
||||
|
||||
**Files:** none in repo yet (environment setup).
|
||||
|
||||
- [ ] **Step 1: Clone niri at the pinned commit onto a work branch**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/build && cd ~/build
|
||||
git clone https://github.com/YaLTeR/niri.git niri-scratchpad
|
||||
cd ~/build/niri-scratchpad
|
||||
git checkout 8ed0da4
|
||||
git switch -c scratchpad # all dev commits land here
|
||||
git tag baseline 8ed0da4 # patch is diffed against this
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the baseline builds**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -5`
|
||||
Expected: `Finished \`release\` profile ... target(s)` (first build is slow; that is fine).
|
||||
|
||||
- [ ] **Step 3: Confirm the baseline runs nested**
|
||||
|
||||
Run (inside an existing graphical session, from a terminal): `cd ~/build/niri-scratchpad && ./target/release/niri --version`
|
||||
Expected: `niri 26.04 (8ed0da4...)`. (A full nested `niri` window is exercised in Task 7; here we only confirm the binary links and reports the pinned version.)
|
||||
|
||||
- [ ] **Step 4: No commit** — this task produces no repo artifact. Proceed to Task 2.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Stash state + `move-window-to-scratchpad`
|
||||
|
||||
Adds the two `Layout` fields and the hide method, unit-tested in the synthetic harness. No compositor.
|
||||
|
||||
**Files:**
|
||||
- Modify: `$DEV/src/layout/mod.rs` (struct `Layout<W>` ~line 336–369; inits at ~695–708 and ~721–732; new methods in the `impl<W: LayoutElement> Layout<W>` block starting ~690)
|
||||
- Test: `$DEV/src/layout/tests.rs` (`Op` enum + `apply` at ~759; new `#[test]`s)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Layout::move_to_scratchpad(&mut self, window: &W::Id)`; fields `scratchpad: VecDeque<RemovedTile<W>>`, `scratchpad_shown: Option<W::Id>`.
|
||||
- Consumes (existing, verbatim signatures): `Layout::remove_window(&mut self, window: &W::Id, transaction: Transaction) -> Option<RemovedTile<W>>`; `RemovedTile<W>` has module-private field `tile: Tile<W>`; `Tile::window(&self) -> &W`; `W::id(&self) -> &W::Id`.
|
||||
|
||||
- [ ] **Step 1: Add the fields to the `Layout<W>` struct**
|
||||
|
||||
In `$DEV/src/layout/mod.rs`, in the `pub struct Layout<W: LayoutElement>` definition (ends ~line 369), add after the `dnd` field:
|
||||
|
||||
```rust
|
||||
/// Windows hidden in the scratchpad, front = next to show. Not part of any
|
||||
/// workspace, so they have no index and never appear in the workspace list.
|
||||
scratchpad: VecDeque<RemovedTile<W>>,
|
||||
/// Id of the scratchpad window currently shown as a floating overlay, if any.
|
||||
scratchpad_shown: Option<W::Id>,
|
||||
```
|
||||
|
||||
Ensure `use std::collections::VecDeque;` exists at the top of the file (add it if not).
|
||||
|
||||
- [ ] **Step 2: Initialize the fields in BOTH constructors**
|
||||
|
||||
There are two field-init blocks; both must set the new fields or the crate will not compile. In `with_options` (~line 695) and in `with_options_and_workspaces` (~line 721), add alongside `dnd: None,`:
|
||||
|
||||
```rust
|
||||
scratchpad: VecDeque::new(),
|
||||
scratchpad_shown: None,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test-harness `Op` variants**
|
||||
|
||||
In `$DEV/src/layout/tests.rs`, add to the `Op` enum:
|
||||
|
||||
```rust
|
||||
ScratchpadStash(usize),
|
||||
ScratchpadShow,
|
||||
```
|
||||
|
||||
In `Op::apply` (match at ~line 759), add arms:
|
||||
|
||||
```rust
|
||||
Op::ScratchpadStash(id) => {
|
||||
layout.move_to_scratchpad(&id);
|
||||
}
|
||||
Op::ScratchpadShow => {
|
||||
layout.scratchpad_show();
|
||||
}
|
||||
```
|
||||
|
||||
(`scratchpad_show` is implemented in Task 3; to keep this task compiling on its own, temporarily add a stub `pub fn scratchpad_show(&mut self) {}` in mod.rs now and flesh it out in Task 3.)
|
||||
|
||||
- [ ] **Step 4: Write the failing test for stashing**
|
||||
|
||||
Add to `$DEV/src/layout/tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn scratchpad_stash_removes_window_from_workspace() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
Op::ScratchpadStash(0),
|
||||
];
|
||||
let layout = check_ops(ops);
|
||||
|
||||
// The window left every workspace...
|
||||
assert!(!layout.has_window(&0), "window should not be in any workspace");
|
||||
// ...and now lives in the stash.
|
||||
assert_eq!(layout.scratchpad.len(), 1);
|
||||
assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run it, verify it fails**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
|
||||
Expected: compile error `no method named \`move_to_scratchpad\`` (method not yet written).
|
||||
|
||||
- [ ] **Step 6: Implement `move_to_scratchpad`**
|
||||
|
||||
In the `impl<W: LayoutElement> Layout<W>` block in `$DEV/src/layout/mod.rs`, add:
|
||||
|
||||
```rust
|
||||
/// Hide `window` into the scratchpad. Focus falls to the next window via the
|
||||
/// normal remove path. No-op if the window is not in the layout.
|
||||
pub fn move_to_scratchpad(&mut self, window: &W::Id) {
|
||||
let Some(removed) = self.remove_window(window, Transaction::new()) else {
|
||||
return;
|
||||
};
|
||||
self.scratchpad.push_back(removed);
|
||||
// scratchpad_shown is cleared by the eviction hook in remove_window
|
||||
// (Task 4) if this window was the one shown.
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run it, verify it passes**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
|
||||
Expected: `test result: ok. 1 passed`.
|
||||
|
||||
- [ ] **Step 8: Commit (throwaway dev commit)**
|
||||
|
||||
```bash
|
||||
cd ~/build/niri-scratchpad
|
||||
git add -A && git commit -m "scratchpad: stash fields + move_to_scratchpad"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `scratchpad_show` state machine + centering
|
||||
|
||||
Implements show/hide/cycle/focus and first-show centering, unit-tested. Replaces the Task 2 stub.
|
||||
|
||||
**Files:**
|
||||
- Modify: `$DEV/src/layout/mod.rs` (replace the `scratchpad_show` stub; methods block ~690)
|
||||
- Test: `$DEV/src/layout/tests.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Layout::scratchpad_show(&mut self)`.
|
||||
- Consumes (existing, verbatim): `Layout::has_window(&self, window: &W::Id) -> bool`; `Layout::focus(&self) -> Option<&W>`; `Layout::active_output(&self) -> Option<&Output>`; `Layout::active_workspace(&self) -> Option<&Workspace<W>>`; `Monitor::add_tile(&mut self, tile, MonitorAddWindowTarget::Workspace { id, column_idx: None }, ActivateWindow::Yes, true, width, is_full_width, is_floating)` — re-add pattern verbatim at `mod.rs:3359-3373`; `RemovedTile` fields `tile`, `width`, `is_full_width`, `is_floating`.
|
||||
|
||||
**State table (the contract this task implements):**
|
||||
|
||||
| State (after staleness check) | Behavior |
|
||||
|---|---|
|
||||
| shown = Some(id) AND id is focused | Hide: `remove_window(id)` → `push_back` to stash; `scratchpad_shown = None` |
|
||||
| shown = Some(id) AND id not focused | Focus that window; stash unchanged |
|
||||
| shown = None, stash non-empty | Pop front → add to active workspace, floating, focused, centered on first show; set `scratchpad_shown` |
|
||||
| shown = None, stash empty | No-op |
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `$DEV/src/layout/tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn scratchpad_show_brings_window_back_focused() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
Op::ScratchpadStash(0),
|
||||
Op::ScratchpadShow,
|
||||
];
|
||||
let layout = check_ops(ops);
|
||||
|
||||
assert!(layout.scratchpad.is_empty(), "stash drained on show");
|
||||
assert!(layout.has_window(&0), "window returned to a workspace");
|
||||
assert_eq!(layout.scratchpad_shown, Some(0));
|
||||
assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratchpad_show_twice_hides_again() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
Op::ScratchpadStash(0),
|
||||
Op::ScratchpadShow, // show
|
||||
Op::ScratchpadShow, // focused -> hide
|
||||
];
|
||||
let layout = check_ops(ops);
|
||||
|
||||
assert_eq!(layout.scratchpad.len(), 1, "window back in stash");
|
||||
assert!(!layout.has_window(&0));
|
||||
assert_eq!(layout.scratchpad_shown, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratchpad_show_cycles_round_robin() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
Op::AddWindow { params: TestWindowParams::new(1) },
|
||||
Op::ScratchpadStash(0), // stash: [0]
|
||||
Op::ScratchpadStash(1), // stash: [0, 1]
|
||||
Op::ScratchpadShow, // show 0 (front)
|
||||
Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0]
|
||||
Op::ScratchpadShow, // show 1 (new front)
|
||||
];
|
||||
let layout = check_ops(ops);
|
||||
|
||||
assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window");
|
||||
assert!(layout.has_window(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratchpad_show_empty_is_noop() {
|
||||
let ops = [Op::AddOutput(1), Op::ScratchpadShow];
|
||||
let layout = check_ops(ops);
|
||||
assert_eq!(layout.scratchpad_shown, None);
|
||||
assert!(layout.scratchpad.is_empty());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run them, verify they fail**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_show 2>&1 | tail -30`
|
||||
Expected: assertion failures (the stub does nothing) — e.g. `scratchpad_show_brings_window_back_focused` fails on `has_window`.
|
||||
|
||||
- [ ] **Step 3: Implement `scratchpad_show`**
|
||||
|
||||
Replace the stub in `$DEV/src/layout/mod.rs` with:
|
||||
|
||||
```rust
|
||||
/// Show / toggle / cycle the scratchpad. See the state table in the plan.
|
||||
pub fn scratchpad_show(&mut self) {
|
||||
// Drop a stale "shown" id (window was closed or moved away).
|
||||
if let Some(id) = self.scratchpad_shown.clone() {
|
||||
if !self.has_window(&id) {
|
||||
self.scratchpad_shown = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = self.scratchpad_shown.clone() {
|
||||
let focused = self.focus().map(|w| w.id().clone());
|
||||
if focused.as_ref() == Some(&id) {
|
||||
// shown + focused -> hide it
|
||||
if let Some(removed) = self.remove_window(&id, Transaction::new()) {
|
||||
self.scratchpad.push_back(removed);
|
||||
}
|
||||
self.scratchpad_shown = None;
|
||||
} else {
|
||||
// shown + not focused -> focus it.
|
||||
// CONFIRM at implementation: the exact focus-by-id call. Candidates
|
||||
// in this file are the activation path used by move actions; use
|
||||
// whichever method focuses an existing window by &W::Id without
|
||||
// moving it. Verify against `scratchpad_show_*` tests staying green.
|
||||
self.activate_window(&id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing shown -> show the front of the stash
|
||||
let Some(mut removed) = self.scratchpad.pop_front() else {
|
||||
return;
|
||||
};
|
||||
removed.tile.stop_move_animations();
|
||||
|
||||
// First-show centering: if this tile has never floated, place it centered
|
||||
// on the active output. If it has a remembered floating position, leave it.
|
||||
// CONFIRM at implementation: centering uses the active output geometry; if
|
||||
// niri's floating layer already centers a position-less tile acceptably,
|
||||
// this block may reduce to leaving floating_pos as-is. Verified visually in
|
||||
// Task 7 (manual acceptance #3), not by unit test.
|
||||
// center_floating_tile(&mut removed.tile, self.active_output());
|
||||
|
||||
let id = removed.tile.window().id().clone();
|
||||
let ws_id = match self.active_workspace() {
|
||||
Some(ws) => ws.id(),
|
||||
None => {
|
||||
self.scratchpad.push_front(removed); // no active ws; put it back
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.add_removed_tile_floating(ws_id, removed); // helper below
|
||||
self.scratchpad_shown = Some(id);
|
||||
}
|
||||
```
|
||||
|
||||
Add a small private helper next to it that mirrors the verbatim re-add pattern at `mod.rs:3359-3373`, forcing `is_floating = true` and targeting the given workspace's monitor (find the monitor owning `ws_id` in `self.monitor_set`, then `mon.add_tile(removed.tile, MonitorAddWindowTarget::Workspace { id: ws_id, column_idx: None }, ActivateWindow::Yes, true, removed.width, removed.is_full_width, true)`).
|
||||
|
||||
- [ ] **Step 4: Run the tests, verify they pass**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
|
||||
Expected: all `scratchpad_*` tests pass. If the "focus it" branch cannot be reached by these tests (it isn't — it needs manual focus change), that branch is covered by Task 7 manual test; the four tests above must be green.
|
||||
|
||||
- [ ] **Step 5: Run the full layout suite to catch invariant regressions**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri --lib layout 2>&1 | tail -20`
|
||||
Expected: no new failures (each op runs `verify_invariants`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd ~/build/niri-scratchpad
|
||||
git add -A && git commit -m "scratchpad: show/hide/cycle state machine + centering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Evict on close (single hook in `remove_window`)
|
||||
|
||||
A window closed *while stashed* is in no workspace, so nothing removes it from the stash — a leak / stale-id bug. Fix at the single choke point every removal passes through.
|
||||
|
||||
**Files:**
|
||||
- Modify: `$DEV/src/layout/mod.rs` (`Layout::remove_window`, ~line 1112)
|
||||
- Test: `$DEV/src/layout/tests.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Op::CloseWindow(usize)` in the harness already calls `remove_window` (`tests.rs:1061`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `$DEV/src/layout/tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn scratchpad_evicts_closed_stashed_window() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
Op::AddWindow { params: TestWindowParams::new(1) },
|
||||
Op::ScratchpadStash(0),
|
||||
Op::ScratchpadStash(1),
|
||||
Op::CloseWindow(0), // close a window while it is stashed
|
||||
];
|
||||
let layout = check_ops(ops);
|
||||
|
||||
assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash");
|
||||
assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_evicts_closed_stashed_window 2>&1 | tail -20`
|
||||
Expected: `assertion \`left == right\` failed: closed window evicted from stash` — stash still has 2 (the close didn't touch it).
|
||||
|
||||
- [ ] **Step 3: Add the eviction hook at the top of `remove_window`**
|
||||
|
||||
At the very start of `Layout::remove_window` (before the existing monitor_set lookup), add:
|
||||
|
||||
```rust
|
||||
// A window may be sitting in the scratchpad (not in any workspace).
|
||||
// Drop it here so both toplevel-destroy paths and the close op cover it.
|
||||
self.scratchpad.retain(|rt| rt.tile.window().id() != window);
|
||||
if self.scratchpad_shown.as_ref() == Some(window) {
|
||||
self.scratchpad_shown = None;
|
||||
}
|
||||
```
|
||||
|
||||
This is the single point of truth: it covers `handlers/compositor.rs` (unmap) and `handlers/xdg_shell.rs` (destroy), which both call `remove_window`, and the harness `Op::CloseWindow`. It is a no-op for the common case (window not in the stash).
|
||||
|
||||
- [ ] **Step 4: Run it, verify it passes; re-run the scratchpad suite**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
|
||||
Expected: all `scratchpad_*` tests pass (the new one plus the four from Task 3 — confirm none regressed, since `remove_window` now also runs during hide).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd ~/build/niri-scratchpad
|
||||
git add -A && git commit -m "scratchpad: evict stashed window on close via remove_window"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire the bindable actions (IPC + config + dispatch)
|
||||
|
||||
Threads `move-window-to-scratchpad` and `scratchpad-show` through niri's two `Action` enums and `do_action`. After this, the whole binary compiles and the binds parse.
|
||||
|
||||
**Files:**
|
||||
- Modify: `$DEV/niri-ipc/src/lib.rs` (IPC `Action` enum, ~line 194)
|
||||
- Modify: `$DEV/niri-config/src/binds.rs` (config `Action` enum ~line 102; `From` impl ~line 395)
|
||||
- Modify: `$DEV/src/input/mod.rs` (`do_action` match, ~line 659)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Layout::move_to_scratchpad`, `Layout::scratchpad_show` (Tasks 2–3); `self.niri.layout.focus().map(|m| m.window.clone())` yields the focused `Window` (= `W::Id`); `self.niri.queue_redraw_all()`.
|
||||
|
||||
- [ ] **Step 1: Add IPC `Action` variants**
|
||||
|
||||
In `$DEV/niri-ipc/src/lib.rs`, in the `pub enum Action`, add two unit-like struct variants (mirror the style of neighboring variants):
|
||||
|
||||
```rust
|
||||
/// Move the focused window to the scratchpad (hide it).
|
||||
MoveWindowToScratchpad {},
|
||||
/// Show, toggle, or cycle the scratchpad.
|
||||
ScratchpadShow {},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add config `Action` variants**
|
||||
|
||||
In `$DEV/niri-config/src/binds.rs`, in the `pub enum Action` (`#[derive(knuffel::Decode, ...)]`), add (knuffel derives kebab-case node names `move-window-to-scratchpad` / `scratchpad-show`):
|
||||
|
||||
```rust
|
||||
MoveWindowToScratchpad,
|
||||
ScratchpadShow,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `From<niri_ipc::Action>` arms**
|
||||
|
||||
In the `impl From<niri_ipc::Action> for Action` (~line 395) — exhaustive over the IPC enum, so both new IPC variants need an arm:
|
||||
|
||||
```rust
|
||||
niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad,
|
||||
niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `do_action` dispatch arms**
|
||||
|
||||
In `$DEV/src/input/mod.rs`, in the `match action {` (~line 659), add (patterned on `Action::FullscreenWindow` at ~line 812):
|
||||
|
||||
```rust
|
||||
Action::MoveWindowToScratchpad => {
|
||||
let focus = self.niri.layout.focus().map(|m| m.window.clone());
|
||||
if let Some(window) = focus {
|
||||
self.niri.layout.move_to_scratchpad(&window);
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
}
|
||||
Action::ScratchpadShow => {
|
||||
self.niri.layout.scratchpad_show();
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build the whole binary**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -15`
|
||||
Expected: `Finished` with no errors. (If a match is non-exhaustive, an arm is missing — add it where the compiler points.)
|
||||
|
||||
- [ ] **Step 6: Verify the binds parse via `niri validate`**
|
||||
|
||||
Create a throwaway config and validate it:
|
||||
|
||||
```bash
|
||||
cat > /tmp/sp-test.kdl <<'EOF'
|
||||
binds {
|
||||
Mod+M { move-window-to-scratchpad; }
|
||||
Mod+Shift+M { scratchpad-show; }
|
||||
}
|
||||
EOF
|
||||
cd ~/build/niri-scratchpad && ./target/release/niri validate -c /tmp/sp-test.kdl && echo VALID
|
||||
```
|
||||
|
||||
Expected: `VALID` (no unknown-node error — proves knuffel accepts the new nodes).
|
||||
|
||||
- [ ] **Step 7: Verify the IPC action parses**
|
||||
|
||||
Run: `cd ~/build/niri-scratchpad && ./target/release/niri msg action scratchpad-show 2>&1 | head -3`
|
||||
Expected: it fails to *connect* (no running niri socket) rather than failing to *parse* the action — i.e. an error about the socket, not `unrecognized subcommand`. (Full IPC behavior is exercised in Task 7.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd ~/build/niri-scratchpad
|
||||
git add -A && git commit -m "scratchpad: bindable + IPC actions wired through do_action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Package the patch + dotfiles changes
|
||||
|
||||
Export the Rust delta as one patch, build it via a PKGBUILD, and make all the dotfiles-side changes (config, install.sh, deletions) in a single reviewable commit.
|
||||
|
||||
**Rationale for a single patch file:** the changes interleave within `src/layout/mod.rs` (fields, methods, and the `remove_window` hook are all in that one file). Splitting into three `git apply`-able patches would force a fixed apply order and make rebasing fragile. One patch = one `git diff baseline..scratchpad`, trivially re-generated after a rebase.
|
||||
|
||||
**Files:**
|
||||
- Create: `$DOT/niri-patches/0001-scratchpad.patch`
|
||||
- Create: `$DOT/pkg/niri-scratchpad/PKGBUILD`
|
||||
- Modify: `$DOT/install.sh`
|
||||
- Modify: `$DOT/niri/config.kdl`
|
||||
- Delete: `$DOT/scripts/window-restore.py`, `$DOT/scripts/tests/window-restore.test.py`
|
||||
|
||||
- [ ] **Step 1: Export the patch**
|
||||
|
||||
```bash
|
||||
mkdir -p /home/alex/Documents/dotfiles/niri-patches
|
||||
cd ~/build/niri-scratchpad
|
||||
git diff baseline..scratchpad > /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
|
||||
head -5 /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
|
||||
```
|
||||
|
||||
Expected: a unified diff beginning `diff --git a/niri-config/src/binds.rs ...` (touching binds.rs, lib.rs, mod.rs, tests.rs, input/mod.rs).
|
||||
|
||||
- [ ] **Step 2: Verify the patch applies cleanly to a fresh checkout**
|
||||
|
||||
```bash
|
||||
cd /tmp && rm -rf niri-apply-check && git clone -q https://github.com/YaLTeR/niri.git niri-apply-check
|
||||
cd /tmp/niri-apply-check && git checkout -q 8ed0da4
|
||||
git apply --check /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch && echo "APPLIES CLEANLY"
|
||||
```
|
||||
|
||||
Expected: `APPLIES CLEANLY`.
|
||||
|
||||
- [ ] **Step 3: Write the PKGBUILD**
|
||||
|
||||
Create `$DOT/pkg/niri-scratchpad/PKGBUILD`. Mirrors the official niri package install layout (from `resources/`), adds the patch in `prepare()`, and `provides`/`conflicts` stock niri:
|
||||
|
||||
```bash
|
||||
# Maintainer: alex <funman300@gmail.com>
|
||||
pkgname=niri-scratchpad
|
||||
pkgver=26.04
|
||||
pkgrel=1
|
||||
_commit=8ed0da4
|
||||
pkgdesc="niri with a native Sway-style scratchpad (local patch set)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/YaLTeR/niri"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('libgbm' 'libglvnd' 'libinput' 'libxkbcommon' 'mesa' 'pango'
|
||||
'pipewire' 'seatd' 'systemd-libs' 'wayland')
|
||||
makedepends=('git' 'cargo' 'clang' 'mesa')
|
||||
provides=('niri')
|
||||
conflicts=('niri')
|
||||
options=('!lto')
|
||||
source=("niri::git+https://github.com/YaLTeR/niri.git#commit=${_commit}"
|
||||
"0001-scratchpad.patch")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
prepare() {
|
||||
cd niri
|
||||
git apply "${srcdir}/0001-scratchpad.patch"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd niri
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --release --frozen
|
||||
}
|
||||
|
||||
package() {
|
||||
cd niri
|
||||
install -Dm755 target/release/niri "${pkgdir}/usr/bin/niri"
|
||||
install -Dm755 resources/niri-session "${pkgdir}/usr/bin/niri-session"
|
||||
install -Dm644 resources/niri.desktop \
|
||||
"${pkgdir}/usr/share/wayland-sessions/niri.desktop"
|
||||
install -Dm644 resources/niri-portals.conf \
|
||||
"${pkgdir}/usr/share/xdg-desktop-portal/niri-portals.conf"
|
||||
install -Dm644 resources/niri.service \
|
||||
"${pkgdir}/usr/lib/systemd/user/niri.service"
|
||||
install -Dm644 resources/niri-shutdown.target \
|
||||
"${pkgdir}/usr/lib/systemd/user/niri-shutdown.target"
|
||||
}
|
||||
```
|
||||
|
||||
The `0001-scratchpad.patch` source is a repo-relative sibling: symlink or copy it next to the PKGBUILD at build time (Step 5 handles this). CONFIRM at implementation: cross-check `depends`/install lines against `pacman -Qi niri` on this machine so the packaged binary keeps identical runtime deps and session integration.
|
||||
|
||||
- [ ] **Step 4: Build and install the package**
|
||||
|
||||
```bash
|
||||
cd /home/alex/Documents/dotfiles/pkg/niri-scratchpad
|
||||
cp ../../niri-patches/0001-scratchpad.patch . # makepkg needs sources local
|
||||
makepkg -si --noconfirm 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: package builds and installs; `pacman -Q niri-scratchpad` shows `26.04-1`.
|
||||
|
||||
- [ ] **Step 5: Verify the installed binary is the patched one**
|
||||
|
||||
```bash
|
||||
niri --version && niri msg action scratchpad-show 2>&1 | head -2
|
||||
```
|
||||
|
||||
Expected: version `26.04`; the action is recognized (socket error if run outside niri, not `unrecognized subcommand`).
|
||||
|
||||
- [ ] **Step 6: Update `niri/config.kdl`** — remove the stash workspace, un-offset, rebind
|
||||
|
||||
In `$DOT/niri/config.kdl`:
|
||||
- Delete the comment block (lines ~50–54) and the `workspace "minimized"` declaration (line ~55).
|
||||
- `Mod+1..9`: change `focus-workspace 2..10` back to `focus-workspace 1..9` (drop the comment about the +1 offset too).
|
||||
- `Mod+Shift+1..9`: change `move-window-to-workspace 2..10` back to `move-window-to-workspace 1..9`.
|
||||
- Replace the two minimize binds:
|
||||
|
||||
```kdl
|
||||
Mod+M { move-window-to-scratchpad; }
|
||||
Mod+Shift+M { scratchpad-show; }
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Validate the real config with the patched binary**
|
||||
|
||||
Run: `niri validate -c /home/alex/Documents/dotfiles/niri/config.kdl && echo VALID`
|
||||
Expected: `VALID`.
|
||||
|
||||
- [ ] **Step 8: Delete the old Python implementation**
|
||||
|
||||
```bash
|
||||
cd /home/alex/Documents/dotfiles
|
||||
git rm scripts/window-restore.py scripts/tests/window-restore.test.py
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Update `install.sh`**
|
||||
|
||||
In `$DOT/install.sh`:
|
||||
- Remove the line that symlinks `window-restore` into `~/.local/bin`.
|
||||
- Add a step (idempotent) that builds/installs the package, e.g. skip when already current:
|
||||
|
||||
```bash
|
||||
# Build & install patched niri (native scratchpad) if not already present.
|
||||
if ! pacman -Q niri-scratchpad &>/dev/null; then
|
||||
(cd "$(pwd)/pkg/niri-scratchpad" \
|
||||
&& cp ../../niri-patches/0001-scratchpad.patch . \
|
||||
&& makepkg -si --noconfirm)
|
||||
fi
|
||||
```
|
||||
|
||||
CONFIRM placement matches the existing symlink/step block style in `install.sh` (read the file first; match its status-message convention).
|
||||
|
||||
- [ ] **Step 10: Commit the dotfiles changes**
|
||||
|
||||
```bash
|
||||
cd /home/alex/Documents/dotfiles
|
||||
git add niri-patches pkg install.sh niri/config.kdl
|
||||
git add -u scripts # stage the deletions
|
||||
git commit -m "niri: native scratchpad via patched niri-scratchpad package
|
||||
|
||||
Replace the workspace-based minimize (declared 'minimized' workspace +
|
||||
+1 offset + window-restore.py) with a native Sway-style scratchpad built
|
||||
into niri. Ships as niri-patches/0001-scratchpad.patch + a niri-scratchpad
|
||||
PKGBUILD. Mod+M stashes; Mod+Shift+M shows/toggles/cycles. Mod+1..9 no
|
||||
longer offset.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: End-to-end manual acceptance (nested niri)
|
||||
|
||||
The behaviors no unit test reaches: real focus routing, the "focus it" branch, close-while-stashed under a live client, centering, and multi-monitor. Run the patched niri nested inside the current session.
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Launch patched niri nested with the repo config**
|
||||
|
||||
Run (from a terminal in the current graphical session):
|
||||
`niri -c /home/alex/Documents/dotfiles/niri/config.kdl`
|
||||
Expected: a nested niri window opens without error. Open two terminals inside it.
|
||||
|
||||
- [ ] **Step 2: Run the acceptance checklist** — observe each, all must hold:
|
||||
|
||||
1. On a window, `Mod+M`: it vanishes and **focus moves to another window** (regression guard for the focus-release risk).
|
||||
2. `Mod+Shift+M`: the window reappears **centered on the current output**, focused.
|
||||
3. `Mod+Shift+M` again (window still focused): it hides.
|
||||
4. Stash two windows; `Mod+Shift+M` repeatedly cycles A → (hide) → B → (hide) → A.
|
||||
5. Show a window, move/resize it, hide, show again: **same geometry** (remembered).
|
||||
6. Show a window, click/focus the *other* window, `Mod+Shift+M`: focus returns to the shown window (the "shown-but-not-focused → focus it" branch).
|
||||
7. Stash a terminal, then `exit` its shell so the client dies while stashed; `Mod+Shift+M`: no crash, cycle skips it, stash count correct.
|
||||
8. If a second output is available (or a nested second output), show on the **focused** output; repeat focused on the other output — each shows there.
|
||||
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.
|
||||
|
||||
- [ ] **Step 3: Record results**
|
||||
|
||||
Note any failing item with the observed behavior. A failure in #1, #6, #7, or #8 points back at the focus/cast/close bookkeeping risk — fix in the relevant Task 2–4 method, re-export the patch (Task 6 Step 1), rebuild, and re-run this checklist. Do not claim completion until all ten hold.
|
||||
|
||||
- [ ] **Step 4: Final verification statement**
|
||||
|
||||
Only after all ten pass and `cargo test -p niri scratchpad` is green: the feature is complete. Log-out / log-in to the real session (now running `niri-scratchpad`) as the last real-world confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (author checklist — completed)
|
||||
|
||||
**Spec coverage:** stash-not-workspace (Task 2 fields) ✓; `Mod+M` hide (Task 2) ✓; show/hide/cycle/focus table (Task 3) ✓; centering + remember (Task 3) ✓; single-shown invariant (design, enforced by `Option` field) ✓; close-while-stashed (Task 4) ✓; two-Action wiring + From + do_action (Task 5) ✓; patch + PKGBUILD (Task 6) ✓; config un-offset + rebind + workspace-decl removal (Task 6) ✓; Python deletion (Task 6) ✓; install.sh (Task 6) ✓; unit tests (Tasks 2–4) + manual acceptance incl. multi-monitor (Task 7) ✓.
|
||||
|
||||
**Placeholder scan:** two `CONFIRM at implementation` notes remain by design — the exact focus-by-id method name and the centering call are the two spots the spec flagged as compiler/geometry-dependent; each names the location, the pattern to mirror, and the test that must stay green, rather than deferring undefined work.
|
||||
|
||||
**Type consistency:** stash keyed on `W::Id` throughout; `scratchpad: VecDeque<RemovedTile<W>>` / `scratchpad_shown: Option<W::Id>` named identically in fields, methods, tests, and the `remove_window` hook; method names `move_to_scratchpad` / `scratchpad_show` consistent across Layout, `do_action`, and the harness `Op` arms.
|
||||
@@ -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.
|
||||
+7
-1
@@ -55,7 +55,13 @@ ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
|
||||
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
|
||||
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
|
||||
ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch
|
||||
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
|
||||
|
||||
echo "==> Building/installing patched niri (native scratchpad)"
|
||||
if ! pacman -Q niri-scratchpad &>/dev/null; then
|
||||
(cd "$(pwd)/pkg/niri-scratchpad" \
|
||||
&& cp ../../niri-patches/0001-scratchpad.patch . \
|
||||
&& makepkg -si --noconfirm)
|
||||
fi
|
||||
|
||||
echo "==> Enabling systemd user services"
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs
|
||||
index 0be7596f..e6a56939 100644
|
||||
--- a/niri-config/src/binds.rs
|
||||
+++ b/niri-config/src/binds.rs
|
||||
@@ -233,6 +233,7 @@ pub enum Action {
|
||||
reference: WorkspaceReference,
|
||||
focus: bool,
|
||||
},
|
||||
+ MoveWindowToScratchpad,
|
||||
MoveColumnToWorkspaceDown(#[knuffel(property(name = "focus"), default = true)] bool),
|
||||
MoveColumnToWorkspaceUp(#[knuffel(property(name = "focus"), default = true)] bool),
|
||||
MoveColumnToWorkspace(
|
||||
@@ -359,6 +360,7 @@ pub enum Action {
|
||||
ClearDynamicCastTarget,
|
||||
#[knuffel(skip)]
|
||||
StopCast(u64),
|
||||
+ ScratchpadShow,
|
||||
ToggleOverview,
|
||||
OpenOverview,
|
||||
CloseOverview,
|
||||
@@ -530,6 +532,7 @@ impl From<niri_ipc::Action> for Action {
|
||||
reference: WorkspaceReference::from(reference),
|
||||
focus,
|
||||
},
|
||||
+ niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad,
|
||||
niri_ipc::Action::MoveColumnToWorkspaceDown { focus } => {
|
||||
Self::MoveColumnToWorkspaceDown(focus)
|
||||
}
|
||||
@@ -694,6 +697,7 @@ impl From<niri_ipc::Action> for Action {
|
||||
}
|
||||
niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget,
|
||||
niri_ipc::Action::StopCast { session_id } => Self::StopCast(session_id),
|
||||
+ niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow,
|
||||
niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview,
|
||||
niri_ipc::Action::OpenOverview {} => Self::OpenOverview,
|
||||
niri_ipc::Action::CloseOverview {} => Self::CloseOverview,
|
||||
diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs
|
||||
index 0aa3cb4f..a5b712e2 100644
|
||||
--- a/niri-ipc/src/lib.rs
|
||||
+++ b/niri-ipc/src/lib.rs
|
||||
@@ -524,6 +524,8 @@ pub enum Action {
|
||||
#[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))]
|
||||
focus: bool,
|
||||
},
|
||||
+ /// Move the focused window to the scratchpad (hide it).
|
||||
+ MoveWindowToScratchpad {},
|
||||
/// Move the focused column to the workspace below.
|
||||
MoveColumnToWorkspaceDown {
|
||||
/// Whether the focus should follow the target workspace.
|
||||
@@ -908,6 +910,8 @@ pub enum Action {
|
||||
#[cfg_attr(feature = "clap", arg(long))]
|
||||
session_id: u64,
|
||||
},
|
||||
+ /// Show, toggle, or cycle the scratchpad.
|
||||
+ ScratchpadShow {},
|
||||
/// Toggle (open/close) the Overview.
|
||||
ToggleOverview {},
|
||||
/// Open the Overview.
|
||||
diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs
|
||||
index f38b2934..a085ff70 100644
|
||||
--- a/src/handlers/compositor.rs
|
||||
+++ b/src/handlers/compositor.rs
|
||||
@@ -24,6 +24,7 @@ use crate::layout::{ActivateWindow, AddWindowTarget, LayoutElement as _};
|
||||
use crate::niri::{CastTarget, ClientState, LockState, State};
|
||||
use crate::utils::transaction::Transaction;
|
||||
use crate::utils::{is_mapped, send_scale_transform};
|
||||
+use crate::window::mapped::MappedId;
|
||||
use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped};
|
||||
|
||||
impl CompositorHandler for State {
|
||||
@@ -368,6 +369,52 @@ impl CompositorHandler for State {
|
||||
return;
|
||||
}
|
||||
|
||||
+ // A window stashed in the scratchpad is in no workspace, so the lookup
|
||||
+ // above can't see it — but its client still owns the surface and may
|
||||
+ // unmap it at any time. Handle that here, otherwise the tile would sit
|
||||
+ // in the stash with a bufferless surface and never be reinstated into
|
||||
+ // unmapped_windows, so a later re-map could not fire and the window
|
||||
+ // would be permanently wedged.
|
||||
+ if !is_mapped(surface) {
|
||||
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface) {
|
||||
+ trace!("stashed toplevel got unmapped");
|
||||
+
|
||||
+ let window = removed.window().window.clone();
|
||||
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
|
||||
+ // LayoutElement trait also has an id(), returning &Window.
|
||||
+ let id: MappedId = removed.window().id();
|
||||
+ // Drop the Mapped here so its mapped-toplevel pre-commit hook is
|
||||
+ // torn down before we install the default one. The two are
|
||||
+ // distinct HookIds (Mapped::drop removes only its own), so this
|
||||
+ // ordering is tidiness, not correctness.
|
||||
+ drop(removed);
|
||||
+
|
||||
+ window.on_commit();
|
||||
+
|
||||
+ self.niri
|
||||
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
|
||||
+ self.add_default_dmabuf_pre_commit_hook(surface);
|
||||
+
|
||||
+ // Newly-unmapped toplevels must perform the initial
|
||||
+ // commit-configure sequence afresh.
|
||||
+ let unmapped = Unmapped::new(window);
|
||||
+ self.niri.unmapped_windows.insert(surface.clone(), unmapped);
|
||||
+
|
||||
+ // Nothing else to do: a stashed window is in no workspace, so it
|
||||
+ // can't be the focus and isn't rendered on any output. It is also
|
||||
+ // absent from window_mru_ui, unless the overlay happened to be
|
||||
+ // open when it was stashed — that leaves a stale thumbnail, which
|
||||
+ // the MRU lookups bail on harmlessly.
|
||||
+ //
|
||||
+ // A stashed window that commits while still mapped falls through
|
||||
+ // to the unrecognized-surface trace below. Buffer state is still
|
||||
+ // handled (on_commit_buffer_handler runs unconditionally), so only
|
||||
+ // Window-level geometry/popup bookkeeping goes stale, and that
|
||||
+ // self-heals on the first commit after the window is shown.
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// This is a commit of a non-toplevel root.
|
||||
}
|
||||
|
||||
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
|
||||
index 38440c90..fe16901f 100644
|
||||
--- a/src/handlers/xdg_shell.rs
|
||||
+++ b/src/handlers/xdg_shell.rs
|
||||
@@ -47,6 +47,7 @@ use crate::utils::transaction::Transaction;
|
||||
use crate::utils::{
|
||||
get_monotonic_time, output_matches_name, send_scale_transform, update_tiled_state, ResizeEdge,
|
||||
};
|
||||
+use crate::window::mapped::MappedId;
|
||||
use crate::window::{InitialConfigureState, ResolvedWindowRules, Unmapped, WindowRef};
|
||||
|
||||
impl XdgShellHandler for State {
|
||||
@@ -834,6 +835,33 @@ impl XdgShellHandler for State {
|
||||
.find_window_and_output(surface.wl_surface());
|
||||
|
||||
let Some((mapped, output)) = win_out else {
|
||||
+ // A window whose client died while stashed in the scratchpad is in no
|
||||
+ // workspace, so find_window_and_output can't see it; evict it here.
|
||||
+ //
|
||||
+ // Casts must still be stopped: a Cast lives in niri.casting, independent
|
||||
+ // of the layout, and the render loop just skips ids it can't find — so a
|
||||
+ // cast of a stashed window would otherwise stay live and frozen forever.
|
||||
+ //
|
||||
+ // The rest of the teardown below is genuinely not needed here:
|
||||
+ // window_mru_ui and layout.focus() are both derived from
|
||||
+ // layout.workspaces(), so a stashed window is in neither; and there is
|
||||
+ // nothing on screen to snapshot, animate closed, or redraw.
|
||||
+ //
|
||||
+ // We also deliberately skip re-adding the default dmabuf pre-commit hook
|
||||
+ // that the normal path installs. Mapped::drop removes only its own
|
||||
+ // mapped-toplevel hook (a distinct HookId), and the surface cannot gain a
|
||||
+ // new role after toplevel destruction, so nothing will sample it again;
|
||||
+ // destroyed() removes hooks with `if let Some(..)`, making the absent
|
||||
+ // entry a no-op rather than an error.
|
||||
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface.wl_surface()) {
|
||||
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
|
||||
+ // LayoutElement trait also has an id(), returning &Window.
|
||||
+ let id: MappedId = removed.window().id();
|
||||
+ drop(removed);
|
||||
+ self.niri
|
||||
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
|
||||
+ return;
|
||||
+ }
|
||||
// I have no idea how this can happen, but I saw it happen once, in a weird interaction
|
||||
// involving laptop going to sleep and resuming.
|
||||
error!("toplevel missing from both unmapped_windows and layout");
|
||||
diff --git a/src/input/mod.rs b/src/input/mod.rs
|
||||
index f1ad4932..87b5ea7a 100644
|
||||
--- a/src/input/mod.rs
|
||||
+++ b/src/input/mod.rs
|
||||
@@ -54,6 +54,7 @@ use crate::ui::mru::{WindowMru, WindowMruUi};
|
||||
use crate::ui::screenshot_ui::ScreenshotUi;
|
||||
use crate::utils::spawning::{spawn, spawn_sh};
|
||||
use crate::utils::{center, get_monotonic_time, CastSessionId, ResizeEdge};
|
||||
+use crate::window::mapped::MappedId;
|
||||
|
||||
pub mod backend_ext;
|
||||
pub mod move_grab;
|
||||
@@ -647,6 +648,38 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Ids of all windows currently in the layout. Excludes the scratchpad stash,
|
||||
+ /// which is not part of any workspace.
|
||||
+ fn layout_window_ids(&self) -> HashSet<u64> {
|
||||
+ self.niri
|
||||
+ .layout
|
||||
+ .windows()
|
||||
+ .map(|(_, mapped)| {
|
||||
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
|
||||
+ // LayoutElement trait also has an id(), returning &Window.
|
||||
+ let id: MappedId = mapped.id();
|
||||
+ id.get()
|
||||
+ })
|
||||
+ .collect()
|
||||
+ }
|
||||
+
|
||||
+ /// Stop screencasts of windows that were in the layout before an action and are
|
||||
+ /// gone after it. A stashed window is never rendered, so its cast would otherwise
|
||||
+ /// sit frozen on its last frame indefinitely; stopping is the chosen policy over
|
||||
+ /// rendering stashed tiles.
|
||||
+ ///
|
||||
+ /// Note the carve-out inherited from `stop_casts_for_target`: a cast with a
|
||||
+ /// *dynamic* target is not ended, it is retargeted to `CastTarget::Nothing`. So a
|
||||
+ /// follow-focus cast survives a stash and goes blank rather than stopping. That is
|
||||
+ /// shared behaviour with every other stop site, not specific to the scratchpad.
|
||||
+ fn stop_casts_for_windows_gone(&mut self, before: HashSet<u64>) {
|
||||
+ let after = self.layout_window_ids();
|
||||
+ for id in before.difference(&after) {
|
||||
+ self.niri
|
||||
+ .stop_casts_for_target(CastTarget::Window { id: *id });
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
pub fn do_action(&mut self, action: Action, allow_when_locked: bool) {
|
||||
if self.niri.is_locked() && !(allow_when_locked || allowed_when_locked(&action)) {
|
||||
return;
|
||||
@@ -1376,6 +1409,15 @@ impl State {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ Action::MoveWindowToScratchpad => {
|
||||
+ let focus = self.niri.layout.focus().map(|m| m.window.clone());
|
||||
+ if let Some(window) = focus {
|
||||
+ let before = self.layout_window_ids();
|
||||
+ self.niri.layout.move_to_scratchpad(&window);
|
||||
+ self.stop_casts_for_windows_gone(before);
|
||||
+ self.niri.queue_redraw_all();
|
||||
+ }
|
||||
+ }
|
||||
Action::MoveColumnToWorkspaceDown(focus) => {
|
||||
self.niri.layout.move_column_to_workspace_down(focus);
|
||||
self.maybe_warp_cursor_to_focus();
|
||||
@@ -2245,6 +2287,13 @@ impl State {
|
||||
Action::StopCast(session_id) => {
|
||||
self.niri.stop_cast(CastSessionId::from(session_id));
|
||||
}
|
||||
+ Action::ScratchpadShow => {
|
||||
+ // The hide branch of scratchpad_show also stashes a window.
|
||||
+ let before = self.layout_window_ids();
|
||||
+ self.niri.layout.scratchpad_show();
|
||||
+ self.stop_casts_for_windows_gone(before);
|
||||
+ self.niri.queue_redraw_all();
|
||||
+ }
|
||||
Action::ToggleOverview => {
|
||||
self.niri.layout.toggle_overview();
|
||||
self.niri.queue_redraw_all();
|
||||
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
|
||||
index 5c4dd639..8696fcd9 100644
|
||||
--- a/src/layout/mod.rs
|
||||
+++ b/src/layout/mod.rs
|
||||
@@ -32,6 +32,7 @@
|
||||
//! don't want an unassuming workspace to end up on it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
+use std::collections::VecDeque;
|
||||
use std::mem;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
@@ -353,6 +354,11 @@ pub struct Layout<W: LayoutElement> {
|
||||
interactive_move: Option<InteractiveMoveState<W>>,
|
||||
/// Ongoing drag-and-drop operation.
|
||||
dnd: Option<DndData<W>>,
|
||||
+ /// Windows hidden in the scratchpad, front = next to show. Not part of any
|
||||
+ /// workspace, so they have no index and never appear in the workspace list.
|
||||
+ scratchpad: VecDeque<RemovedTile<W>>,
|
||||
+ /// Id of the scratchpad window currently shown as a floating overlay, if any.
|
||||
+ scratchpad_shown: Option<W::Id>,
|
||||
/// Clock for driving animations.
|
||||
clock: Clock,
|
||||
/// Time that we last updated render elements for.
|
||||
@@ -489,6 +495,7 @@ pub enum ConfigureIntent {
|
||||
}
|
||||
|
||||
/// Tile that was just removed from the layout.
|
||||
+#[derive(Debug)]
|
||||
pub struct RemovedTile<W: LayoutElement> {
|
||||
tile: Tile<W>,
|
||||
/// Width of the column the tile was in.
|
||||
@@ -499,6 +506,12 @@ pub struct RemovedTile<W: LayoutElement> {
|
||||
is_floating: bool,
|
||||
}
|
||||
|
||||
+impl<W: LayoutElement> RemovedTile<W> {
|
||||
+ pub fn window(&self) -> &W {
|
||||
+ self.tile.window()
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/// Whether to activate a newly added window.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ActivateWindow {
|
||||
@@ -699,6 +712,8 @@ impl<W: LayoutElement> Layout<W> {
|
||||
last_active_workspace_id: HashMap::new(),
|
||||
interactive_move: None,
|
||||
dnd: None,
|
||||
+ scratchpad: VecDeque::new(),
|
||||
+ scratchpad_shown: None,
|
||||
clock,
|
||||
update_render_elements_time: Duration::ZERO,
|
||||
overview_open: false,
|
||||
@@ -724,6 +739,8 @@ impl<W: LayoutElement> Layout<W> {
|
||||
last_active_workspace_id: HashMap::new(),
|
||||
interactive_move: None,
|
||||
dnd: None,
|
||||
+ scratchpad: VecDeque::new(),
|
||||
+ scratchpad_shown: None,
|
||||
clock,
|
||||
update_render_elements_time: Duration::ZERO,
|
||||
overview_open: false,
|
||||
@@ -1114,6 +1131,13 @@ impl<W: LayoutElement> Layout<W> {
|
||||
window: &W::Id,
|
||||
transaction: Transaction,
|
||||
) -> Option<RemovedTile<W>> {
|
||||
+ // A window may be sitting in the scratchpad (not in any workspace).
|
||||
+ // Drop it here so both toplevel-destroy paths and the close op cover it.
|
||||
+ self.scratchpad.retain(|rt| rt.tile.window().id() != window);
|
||||
+ if self.scratchpad_shown.as_ref() == Some(window) {
|
||||
+ self.scratchpad_shown = None;
|
||||
+ }
|
||||
+
|
||||
if let Some(state) = &self.interactive_move {
|
||||
match state {
|
||||
InteractiveMoveState::Starting { window_id, .. } => {
|
||||
@@ -1204,6 +1228,164 @@ impl<W: LayoutElement> Layout<W> {
|
||||
None
|
||||
}
|
||||
|
||||
+ /// Hide `window` into the scratchpad. Focus falls to the next window via the
|
||||
+ /// normal remove path. No-op if the window is not in the layout.
|
||||
+ pub fn move_to_scratchpad(&mut self, window: &W::Id) {
|
||||
+ let Some(removed) = self.remove_window(window, Transaction::new()) else {
|
||||
+ return;
|
||||
+ };
|
||||
+ self.scratchpad.push_back(removed);
|
||||
+ // scratchpad_shown is cleared by the eviction hook in remove_window
|
||||
+ // (Task 4) if this window was the one shown.
|
||||
+ }
|
||||
+
|
||||
+ /// Evict a stashed window from the scratchpad by its surface, returning the
|
||||
+ /// tile so the caller can run the rest of the teardown (casts, unmapped
|
||||
+ /// bookkeeping). Needed on both the destroy and unmap paths: a window that
|
||||
+ /// dies or unmaps *while stashed* is in no workspace, so
|
||||
+ /// `find_window_and_output` (and therefore `remove_window`) never sees it —
|
||||
+ /// this is the only thing that takes it out. A shown scratchpad window lives
|
||||
+ /// in a workspace and is handled by `remove_window` instead.
|
||||
+ pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> Option<RemovedTile<W>> {
|
||||
+ let idx = self
|
||||
+ .scratchpad
|
||||
+ .iter()
|
||||
+ .position(|rt| rt.tile.window().is_wl_surface(surface))?;
|
||||
+ self.scratchpad.remove(idx)
|
||||
+ }
|
||||
+
|
||||
+ /// Show / toggle / cycle the scratchpad. See the state table in the plan.
|
||||
+ pub fn scratchpad_show(&mut self) {
|
||||
+ // Drop a stale "shown" id (window was closed or moved away).
|
||||
+ if let Some(id) = self.scratchpad_shown.clone() {
|
||||
+ if !self.has_window(&id) {
|
||||
+ self.scratchpad_shown = None;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if let Some(id) = self.scratchpad_shown.clone() {
|
||||
+ let focused = self.focus().map(|w| w.id().clone());
|
||||
+ if focused.as_ref() == Some(&id) {
|
||||
+ // shown + focused -> hide it
|
||||
+ if let Some(removed) = self.remove_window(&id, Transaction::new()) {
|
||||
+ self.scratchpad.push_back(removed);
|
||||
+ }
|
||||
+ self.scratchpad_shown = None;
|
||||
+ } else {
|
||||
+ // shown + not focused -> bring it to the monitor you are on, then
|
||||
+ // focus it. `activate_window` alone would move *focus* to whichever
|
||||
+ // monitor the window is parked on (it sets active_monitor_idx),
|
||||
+ // dragging you across outputs; a scratchpad should come to you.
|
||||
+ // Same monitor: nothing to move, just focus.
|
||||
+ if self.monitor_idx_of_window(&id) != self.active_monitor_idx() {
|
||||
+ if let Some(mut removed) = self.remove_window(&id, Transaction::new()) {
|
||||
+ // remove_window cleared scratchpad_shown; re-set it below
|
||||
+ // once the tile is placed.
|
||||
+ removed.tile.stop_move_animations();
|
||||
+
|
||||
+ let Some(ws_id) = self.active_workspace().map(|ws| ws.id()) else {
|
||||
+ self.scratchpad.push_front(removed); // no active ws
|
||||
+ return;
|
||||
+ };
|
||||
+
|
||||
+ match self.add_removed_tile_floating(ws_id, removed) {
|
||||
+ Ok(()) => self.scratchpad_shown = Some(id),
|
||||
+ // Could not place it; stash rather than drop it.
|
||||
+ Err(tile) => self.scratchpad.push_front(tile),
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ self.activate_window(&id);
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // nothing shown -> show the front of the stash
|
||||
+ let Some(mut removed) = self.scratchpad.pop_front() else {
|
||||
+ return;
|
||||
+ };
|
||||
+ removed.tile.stop_move_animations();
|
||||
+
|
||||
+ // First-show centering: niri's floating layer places a position-less tile
|
||||
+ // using its own default placement, which is acceptable for now. Deferring
|
||||
+ // precise centering geometry to manual verification (Task 7); no unit test
|
||||
+ // exercises this here.
|
||||
+
|
||||
+ let id = removed.tile.window().id().clone();
|
||||
+ let ws_id = match self.active_workspace() {
|
||||
+ Some(ws) => ws.id(),
|
||||
+ None => {
|
||||
+ self.scratchpad.push_front(removed); // no active ws; put it back
|
||||
+ return;
|
||||
+ }
|
||||
+ };
|
||||
+ match self.add_removed_tile_floating(ws_id, removed) {
|
||||
+ Ok(()) => self.scratchpad_shown = Some(id),
|
||||
+ // Could not place it; stash rather than drop it.
|
||||
+ Err(tile) => self.scratchpad.push_front(tile),
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Index of the monitor holding `window`, or None if it is not in the layout
|
||||
+ /// (which includes every window stashed in the scratchpad).
|
||||
+ fn monitor_idx_of_window(&self, window: &W::Id) -> Option<usize> {
|
||||
+ let MonitorSet::Normal { monitors, .. } = &self.monitor_set else {
|
||||
+ return None;
|
||||
+ };
|
||||
+ monitors.iter().position(|mon| {
|
||||
+ mon.workspaces
|
||||
+ .iter()
|
||||
+ .any(|ws| ws.windows().any(|win| win.id() == window))
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ /// Index of the currently active monitor, if there are any outputs.
|
||||
+ fn active_monitor_idx(&self) -> Option<usize> {
|
||||
+ match &self.monitor_set {
|
||||
+ MonitorSet::Normal {
|
||||
+ active_monitor_idx, ..
|
||||
+ } => Some(*active_monitor_idx),
|
||||
+ _ => None,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Re-add a previously-removed tile to the workspace `ws_id`, forcing it onto
|
||||
+ /// the floating layer. Mirrors the re-add pattern in `move_to_workspace`.
|
||||
+ ///
|
||||
+ /// Returns the tile back in `Err` when it could not be placed, so the caller
|
||||
+ /// can stash it. Dropping it here would destroy the window while its client
|
||||
+ /// is still alive, leaving it permanently wedged with no surface anywhere.
|
||||
+ fn add_removed_tile_floating(
|
||||
+ &mut self,
|
||||
+ ws_id: WorkspaceId,
|
||||
+ removed: RemovedTile<W>,
|
||||
+ ) -> Result<(), RemovedTile<W>> {
|
||||
+ let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else {
|
||||
+ return Err(removed);
|
||||
+ };
|
||||
+ let Some(mon) = monitors
|
||||
+ .iter_mut()
|
||||
+ .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id))
|
||||
+ else {
|
||||
+ return Err(removed);
|
||||
+ };
|
||||
+
|
||||
+ mon.add_tile(
|
||||
+ removed.tile,
|
||||
+ MonitorAddWindowTarget::Workspace {
|
||||
+ id: ws_id,
|
||||
+ column_idx: None,
|
||||
+ },
|
||||
+ ActivateWindow::Yes,
|
||||
+ true,
|
||||
+ removed.width,
|
||||
+ removed.is_full_width,
|
||||
+ true,
|
||||
+ );
|
||||
+ Ok(())
|
||||
+ }
|
||||
+
|
||||
pub fn descendants_added(&mut self, id: &W::Id) -> bool {
|
||||
for ws in self.workspaces_mut() {
|
||||
if ws.descendants_added(id) {
|
||||
@@ -2444,6 +2626,24 @@ impl<W: LayoutElement> Layout<W> {
|
||||
}
|
||||
}
|
||||
|
||||
+ // Scratchpad invariants: stashed windows belong to no workspace, and the
|
||||
+ // shown window (if any) belongs to a workspace. This must run for both
|
||||
+ // MonitorSet variants, so it lives before the match below (the
|
||||
+ // MonitorSet::NoOutputs branch returns early).
|
||||
+ for tile in &self.scratchpad {
|
||||
+ let id = tile.tile.window().id();
|
||||
+ assert!(
|
||||
+ !self.has_window(id),
|
||||
+ "scratchpad window {id:?} must not also be in a workspace",
|
||||
+ );
|
||||
+ }
|
||||
+ if let Some(id) = &self.scratchpad_shown {
|
||||
+ assert!(
|
||||
+ self.has_window(id),
|
||||
+ "scratchpad_shown window {id:?} must be present in a workspace",
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
let mut seen_workspace_id = HashSet::new();
|
||||
let mut seen_workspace_name = Vec::<String>::new();
|
||||
|
||||
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
|
||||
index 31265dd9..4c22a115 100644
|
||||
--- a/src/layout/tests.rs
|
||||
+++ b/src/layout/tests.rs
|
||||
@@ -753,6 +753,8 @@ enum Op {
|
||||
#[proptest(strategy = "arbitrary_layout_part().prop_map(Box::new)")]
|
||||
layout_config: Box<niri_config::LayoutPart>,
|
||||
},
|
||||
+ ScratchpadStash(#[proptest(strategy = "1..=5usize")] usize),
|
||||
+ ScratchpadShow,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
@@ -1625,6 +1627,12 @@ impl Op {
|
||||
|
||||
layout.update_options(options);
|
||||
}
|
||||
+ Op::ScratchpadStash(id) => {
|
||||
+ layout.move_to_scratchpad(&id);
|
||||
+ }
|
||||
+ Op::ScratchpadShow => {
|
||||
+ layout.scratchpad_show();
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3929,3 +3937,225 @@ proptest! {
|
||||
check_ops_with_options(options, ops);
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_stash_removes_window_from_workspace() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ // The window left every workspace...
|
||||
+ assert!(!layout.has_window(&0), "window should not be in any workspace");
|
||||
+ // ...and now lives in the stash.
|
||||
+ assert_eq!(layout.scratchpad.len(), 1);
|
||||
+ assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_brings_window_back_focused() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::ScratchpadShow,
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ assert!(layout.scratchpad.is_empty(), "stash drained on show");
|
||||
+ assert!(layout.has_window(&0), "window returned to a workspace");
|
||||
+ assert_eq!(layout.scratchpad_shown, Some(0));
|
||||
+ assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_twice_hides_again() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::ScratchpadShow, // show
|
||||
+ Op::ScratchpadShow, // focused -> hide
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ assert_eq!(layout.scratchpad.len(), 1, "window back in stash");
|
||||
+ assert!(!layout.has_window(&0));
|
||||
+ assert_eq!(layout.scratchpad_shown, None);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_cycles_round_robin() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::AddWindow { params: TestWindowParams::new(1) },
|
||||
+ Op::ScratchpadStash(0), // stash: [0]
|
||||
+ Op::ScratchpadStash(1), // stash: [0, 1]
|
||||
+ Op::ScratchpadShow, // show 0 (front)
|
||||
+ Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0]
|
||||
+ Op::ScratchpadShow, // show 1 (new front)
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window");
|
||||
+ assert!(layout.has_window(&1));
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_empty_is_noop() {
|
||||
+ let ops = [Op::AddOutput(1), Op::ScratchpadShow];
|
||||
+ let layout = check_ops(ops);
|
||||
+ assert_eq!(layout.scratchpad_shown, None);
|
||||
+ assert!(layout.scratchpad.is_empty());
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_evicts_closed_stashed_window() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::AddWindow { params: TestWindowParams::new(1) },
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::ScratchpadStash(1),
|
||||
+ Op::CloseWindow(0), // close a window while it is stashed
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash");
|
||||
+ assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_shows_on_the_focused_output() {
|
||||
+ // First, confirm that while stashed the window lives on no monitor at all (checked against
|
||||
+ // a standalone run so `check_ops`'s post-op invariant checks still see a fully-formed op
|
||||
+ // sequence).
|
||||
+ let stash_only_ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddOutput(2),
|
||||
+ Op::FocusOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ ];
|
||||
+ let stashed_layout = check_ops(stash_only_ops);
|
||||
+ {
|
||||
+ let MonitorSet::Normal { monitors, .. } = &stashed_layout.monitor_set else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+ assert!(
|
||||
+ monitors.iter().all(|m| !m.windows().any(|w| *w.id() == 0)),
|
||||
+ "stashed window must not be present on any monitor"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ // Stash on output 1, then switch focus to output 2 before showing.
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddOutput(2),
|
||||
+ Op::FocusOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::FocusOutput(2),
|
||||
+ Op::ScratchpadShow,
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+
|
||||
+ // The window must appear on whichever output is currently focused (output 2), not on the
|
||||
+ // output it was originally stashed from (output 1).
|
||||
+ for (idx, monitor) in monitors.iter().enumerate() {
|
||||
+ let has_window = monitor.windows().any(|w| *w.id() == 0);
|
||||
+ if idx == *active_monitor_idx {
|
||||
+ assert!(has_window, "shown window should be on the focused output");
|
||||
+ } else {
|
||||
+ assert!(!has_window, "shown window should not be on a non-focused output");
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_shows_on_origin_output_when_focus_unchanged() {
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddOutput(2),
|
||||
+ Op::FocusOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ // no focus change here
|
||||
+ Op::ScratchpadShow,
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+
|
||||
+ // Focus never left output 1, so the window should reappear there.
|
||||
+ assert_eq!(*active_monitor_idx, 0, "output 1 should still be active");
|
||||
+ assert!(
|
||||
+ monitors[*active_monitor_idx].windows().any(|w| *w.id() == 0),
|
||||
+ "shown window should be on the still-focused origin output"
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_pulls_already_shown_window_to_focused_output() {
|
||||
+ // Show on output 1, walk to output 2, then press show again. The window must
|
||||
+ // come to output 2 — not drag focus back to output 1, which is what plain
|
||||
+ // `activate_window` would do.
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddOutput(2),
|
||||
+ Op::FocusOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::ScratchpadShow, // shown on output 1
|
||||
+ Op::FocusOutput(2), // shown, but no longer focused
|
||||
+ Op::ScratchpadShow,
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
|
||||
+ unreachable!()
|
||||
+ };
|
||||
+
|
||||
+ assert_eq!(*active_monitor_idx, 1, "focus must stay on output 2");
|
||||
+ assert!(
|
||||
+ monitors[1].windows().any(|w| *w.id() == 0),
|
||||
+ "window should have been pulled to output 2"
|
||||
+ );
|
||||
+ assert!(
|
||||
+ !monitors[0].windows().any(|w| *w.id() == 0),
|
||||
+ "window must not be left behind on output 1"
|
||||
+ );
|
||||
+ assert_eq!(layout.scratchpad_shown, Some(0), "still tracked as shown");
|
||||
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn scratchpad_show_focuses_in_place_on_same_output() {
|
||||
+ // Same output, shown but not focused: focus it without moving it.
|
||||
+ let ops = [
|
||||
+ Op::AddOutput(1),
|
||||
+ Op::AddWindow { params: TestWindowParams::new(0) },
|
||||
+ Op::ScratchpadStash(0),
|
||||
+ Op::ScratchpadShow, // shown and focused
|
||||
+ Op::AddWindow { params: TestWindowParams::new(1) }, // steals focus
|
||||
+ Op::ScratchpadShow,
|
||||
+ ];
|
||||
+ let layout = check_ops(ops);
|
||||
+
|
||||
+ assert_eq!(layout.scratchpad_shown, Some(0));
|
||||
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
|
||||
+ assert_eq!(
|
||||
+ layout.focus().map(|w| *w.id()),
|
||||
+ Some(0),
|
||||
+ "shown-but-unfocused window should get focus, not be hidden"
|
||||
+ );
|
||||
+}
|
||||
+20
-31
@@ -47,13 +47,6 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
|
||||
spawn-at-startup "xwayland-satellite"
|
||||
spawn-at-startup "gamemode-watch"
|
||||
|
||||
// Stash for minimized windows (Mod+M). niri pins declared named workspaces to
|
||||
// index 1 and refuses to move them, so real workspaces start at index 2 — hence
|
||||
// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration:
|
||||
// without it the "minimized" binds silently do nothing (niri validate still
|
||||
// passes, because the name is just a string to the parser).
|
||||
workspace "minimized"
|
||||
|
||||
binds {
|
||||
Mod+Q repeat=false { close-window; }
|
||||
Mod+F { fullscreen-window; }
|
||||
@@ -86,34 +79,30 @@ binds {
|
||||
Mod+Shift+Down { move-window-down; }
|
||||
Mod+Shift+Up { move-window-up; }
|
||||
|
||||
// +1 offset: the "minimized" workspace is always index 1 (see the
|
||||
// declaration above), so the first real workspace is index 2.
|
||||
Mod+1 { focus-workspace 2; }
|
||||
Mod+2 { focus-workspace 3; }
|
||||
Mod+3 { focus-workspace 4; }
|
||||
Mod+4 { focus-workspace 5; }
|
||||
Mod+5 { focus-workspace 6; }
|
||||
Mod+6 { focus-workspace 7; }
|
||||
Mod+7 { focus-workspace 8; }
|
||||
Mod+8 { focus-workspace 9; }
|
||||
Mod+9 { focus-workspace 10; }
|
||||
Mod+1 { focus-workspace 1; }
|
||||
Mod+2 { focus-workspace 2; }
|
||||
Mod+3 { focus-workspace 3; }
|
||||
Mod+4 { focus-workspace 4; }
|
||||
Mod+5 { focus-workspace 5; }
|
||||
Mod+6 { focus-workspace 6; }
|
||||
Mod+7 { focus-workspace 7; }
|
||||
Mod+8 { focus-workspace 8; }
|
||||
Mod+9 { focus-workspace 9; }
|
||||
|
||||
Mod+Shift+1 { move-window-to-workspace 2; }
|
||||
Mod+Shift+2 { move-window-to-workspace 3; }
|
||||
Mod+Shift+3 { move-window-to-workspace 4; }
|
||||
Mod+Shift+4 { move-window-to-workspace 5; }
|
||||
Mod+Shift+5 { move-window-to-workspace 6; }
|
||||
Mod+Shift+6 { move-window-to-workspace 7; }
|
||||
Mod+Shift+7 { move-window-to-workspace 8; }
|
||||
Mod+Shift+8 { move-window-to-workspace 9; }
|
||||
Mod+Shift+9 { move-window-to-workspace 10; }
|
||||
Mod+Shift+1 { move-window-to-workspace 1; }
|
||||
Mod+Shift+2 { move-window-to-workspace 2; }
|
||||
Mod+Shift+3 { move-window-to-workspace 3; }
|
||||
Mod+Shift+4 { move-window-to-workspace 4; }
|
||||
Mod+Shift+5 { move-window-to-workspace 5; }
|
||||
Mod+Shift+6 { move-window-to-workspace 6; }
|
||||
Mod+Shift+7 { move-window-to-workspace 7; }
|
||||
Mod+Shift+8 { move-window-to-workspace 8; }
|
||||
Mod+Shift+9 { move-window-to-workspace 9; }
|
||||
|
||||
Mod+Print { spawn "screenshot"; }
|
||||
|
||||
// focus=false is required here — otherwise focus follows the window into
|
||||
// the stash, the opposite of minimizing.
|
||||
Mod+M { move-window-to-workspace "minimized" focus=false; }
|
||||
Mod+Shift+M { spawn "window-restore"; }
|
||||
Mod+M { move-window-to-scratchpad; }
|
||||
Mod+Shift+M { scratchpad-show; }
|
||||
|
||||
Mod+Shift+E { spawn "hyprlock"; }
|
||||
Mod+Shift+P { spawn "powermenu"; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
src/
|
||||
pkg/
|
||||
niri/
|
||||
*.pkg.tar.*
|
||||
*.log
|
||||
0001-scratchpad.patch
|
||||
@@ -0,0 +1,48 @@
|
||||
# Maintainer: alex <funman300@gmail.com>
|
||||
pkgname=niri-scratchpad
|
||||
pkgver=26.04
|
||||
pkgrel=1
|
||||
_commit=8ed0da4
|
||||
pkgdesc="niri with a native Sway-style scratchpad (local patch set)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/YaLTeR/niri"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('cairo' 'glib2' 'glibc' 'libdisplay-info' 'libgcc' 'libinput'
|
||||
'libpipewire' 'libxkbcommon' 'mesa' 'pango' 'pixman' 'seatd'
|
||||
'systemd-libs' 'xdg-desktop-portal-impl')
|
||||
makedepends=('git' 'cargo' 'clang' 'mesa')
|
||||
provides=('niri')
|
||||
conflicts=('niri')
|
||||
options=('!lto')
|
||||
source=("niri::git+https://github.com/YaLTeR/niri.git#commit=${_commit}"
|
||||
"0001-scratchpad.patch")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
prepare() {
|
||||
cd niri
|
||||
git apply "${srcdir}/0001-scratchpad.patch"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd niri
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --release --frozen
|
||||
}
|
||||
|
||||
package() {
|
||||
cd niri
|
||||
install -Dm755 target/release/niri "${pkgdir}/usr/bin/niri"
|
||||
install -Dm755 resources/niri-session "${pkgdir}/usr/bin/niri-session"
|
||||
install -Dm644 resources/niri.desktop \
|
||||
"${pkgdir}/usr/share/wayland-sessions/niri.desktop"
|
||||
install -Dm644 resources/niri-portals.conf \
|
||||
"${pkgdir}/usr/share/xdg-desktop-portal/niri-portals.conf"
|
||||
install -Dm644 resources/niri.service \
|
||||
"${pkgdir}/usr/lib/systemd/user/niri.service"
|
||||
install -Dm644 resources/niri-shutdown.target \
|
||||
"${pkgdir}/usr/lib/systemd/user/niri-shutdown.target"
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live acceptance / regression test for the niri native scratchpad patch.
|
||||
|
||||
Drives a real (patched) niri through its own IPC — the exact `do_action`
|
||||
path a keybind hits — and asserts on `niri msg -j windows`. No keyboard
|
||||
injection, so it can't type into the host session's windows. Each test gets
|
||||
its own fresh nested niri, so cases are hermetic and order-independent.
|
||||
|
||||
Run this after rebasing the patch onto a new niri release to confirm the
|
||||
scratchpad still behaves. It needs:
|
||||
- a running Wayland (or X) session to host the nested winit window,
|
||||
- the patched niri binary (built from niri-patches/ via the PKGBUILD, or a
|
||||
dev checkout),
|
||||
- `alacritty` as the throwaway test client.
|
||||
|
||||
Usage:
|
||||
python3 acceptance.py [PATH_TO_NIRI]
|
||||
Binary resolution order: argv[1], $NIRI_BIN, `niri` on PATH,
|
||||
~/build/niri-scratchpad/target/release/niri. The binary MUST be patched
|
||||
(its `niri msg action --help` must list `scratchpad-show`).
|
||||
|
||||
Exit code 0 iff every non-skipped test passes.
|
||||
"""
|
||||
import glob, json, os, shutil, signal, subprocess, sys, tempfile, time
|
||||
|
||||
MINIMAL_CONFIG = """\
|
||||
// Hermetic config for scratchpad acceptance: no startup spawns.
|
||||
input { keyboard { xkb { } } }
|
||||
binds {
|
||||
Mod+M { move-window-to-scratchpad; }
|
||||
Mod+Shift+M { scratchpad-show; }
|
||||
Mod+Q { close-window; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def resolve_niri():
|
||||
for cand in (sys.argv[1] if len(sys.argv) > 1 else None,
|
||||
os.environ.get("NIRI_BIN"),
|
||||
shutil.which("niri"),
|
||||
os.path.expanduser("~/build/niri-scratchpad/target/release/niri")):
|
||||
if cand and os.path.exists(cand):
|
||||
return cand
|
||||
sys.exit("ERROR: no niri binary found (pass a path, set $NIRI_BIN, or "
|
||||
"install niri-scratchpad).")
|
||||
|
||||
|
||||
NIRI = resolve_niri()
|
||||
RT = os.environ["XDG_RUNTIME_DIR"]
|
||||
|
||||
|
||||
class Niri:
|
||||
"""A fresh nested niri instance driven over IPC."""
|
||||
|
||||
def __init__(self, config_path):
|
||||
before = set(glob.glob(f"{RT}/niri.*.sock"))
|
||||
self.proc = subprocess.Popen(
|
||||
[NIRI, "-c", config_path],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sock = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 15:
|
||||
new = set(glob.glob(f"{RT}/niri.*.sock")) - before
|
||||
if new:
|
||||
self.sock = new.pop()
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if not self.sock:
|
||||
self.close()
|
||||
raise RuntimeError("nested niri socket never appeared")
|
||||
self.env = dict(os.environ, NIRI_SOCKET=self.sock)
|
||||
for _ in range(40): # wait for IPC to answer
|
||||
if self.alive():
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
def msg(self, *args):
|
||||
r = subprocess.run([NIRI, "msg", "-j", *args],
|
||||
capture_output=True, text=True, env=self.env)
|
||||
return json.loads(r.stdout) if r.stdout.strip() else None
|
||||
|
||||
def action(self, *args):
|
||||
return subprocess.run([NIRI, "msg", "action", *args],
|
||||
capture_output=True, text=True, env=self.env)
|
||||
|
||||
def alive(self):
|
||||
return self.msg("version") is not None
|
||||
|
||||
def windows(self):
|
||||
return self.msg("windows") or []
|
||||
|
||||
def wait_count(self, n, timeout=8):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if len(self.windows()) == n:
|
||||
return self.windows()
|
||||
time.sleep(0.15)
|
||||
return self.windows()
|
||||
|
||||
def spawn_window(self, timeout=30):
|
||||
"""Spawn an alacritty window and return its id once it appears.
|
||||
|
||||
The wait is deliberately generous: every test launches its own nested
|
||||
niri, so a cold alacritty start can occasionally be slow under that
|
||||
load. Do NOT convert this into a retry — re-issuing the spawn risks a
|
||||
second window arriving late and corrupting the window counts the tests
|
||||
assert on. A slow test beats a flaky one.
|
||||
"""
|
||||
before = {w["id"] for w in self.windows()}
|
||||
self.action("spawn", "--", "alacritty")
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
new = [w for w in self.windows() if w["id"] not in before]
|
||||
if new:
|
||||
return new[0]["id"]
|
||||
time.sleep(0.15)
|
||||
raise RuntimeError(
|
||||
f"spawned window never appeared within {timeout}s "
|
||||
f"(compositor alive={self.alive()})")
|
||||
|
||||
def by_id(self, i):
|
||||
return next((w for w in self.windows() if w["id"] == i), None)
|
||||
|
||||
def focused(self):
|
||||
return next((w for w in self.windows() if w["is_focused"]), None)
|
||||
|
||||
def floating_pos(self, i, tries=12):
|
||||
# tile_pos_in_workspace_view is transiently null right after a show.
|
||||
for _ in range(tries):
|
||||
w = self.by_id(i)
|
||||
p = w["layout"].get("tile_pos_in_workspace_view") if w else None
|
||||
if p is not None:
|
||||
return p
|
||||
time.sleep(0.08)
|
||||
return None
|
||||
|
||||
def output_size(self):
|
||||
outs = self.msg("outputs") or {}
|
||||
for o in outs.values():
|
||||
lg = o.get("logical") or {}
|
||||
if lg.get("width") and lg.get("height"):
|
||||
return lg["width"], lg["height"]
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- tests: each takes a fresh Niri and returns (ok, detail) ----
|
||||
|
||||
def t_stash_hides_and_moves_focus(n):
|
||||
a = n.spawn_window()
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b))
|
||||
time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad")
|
||||
n.wait_count(1)
|
||||
f = n.focused()
|
||||
ok = (len(n.windows()) == 1 and n.by_id(b) is None
|
||||
and f is not None and f["id"] == a)
|
||||
return ok, f"remaining={len(n.windows())}, b_gone={n.by_id(b) is None}, focus={f['id'] if f else None}/want {a}"
|
||||
|
||||
|
||||
def t_show_floating_focused_centered(n):
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1)
|
||||
w = n.by_id(b)
|
||||
pos = n.floating_pos(b)
|
||||
centered = "n/a"
|
||||
ok = bool(w and w["is_floating"] and w["is_focused"] and pos and pos[0] > 5)
|
||||
osz = n.output_size()
|
||||
if ok and osz and w:
|
||||
ww, wh = w["layout"]["window_size"]
|
||||
cx, cy = (osz[0] - ww) / 2, (osz[1] - wh) / 2
|
||||
centered = abs(pos[0] - cx) < osz[0] * 0.15 and abs(pos[1] - cy) < osz[1] * 0.15
|
||||
ok = ok and bool(centered)
|
||||
return ok, f"floating={w['is_floating'] if w else None}, focused={w['is_focused'] if w else None}, pos={pos}, centered={centered}"
|
||||
|
||||
|
||||
def t_reshow_hides(n):
|
||||
b = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1) # show (focused)
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide
|
||||
ok = len(n.windows()) == 0
|
||||
return ok, f"count after re-show={len(n.windows())} (want 0)"
|
||||
|
||||
|
||||
def t_cycle_round_robin(n):
|
||||
a = n.spawn_window()
|
||||
b = n.spawn_window()
|
||||
for wid in (b, a):
|
||||
n.action("focus-window", "--id", str(wid)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad")
|
||||
n.wait_count(0)
|
||||
n.action("scratchpad-show"); w1 = n.wait_count(1); first = w1[0]["id"] if w1 else None
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide (focused)
|
||||
n.action("scratchpad-show"); w2 = n.wait_count(1); second = w2[0]["id"] if w2 else None
|
||||
ok = first is not None and second is not None and first != second
|
||||
return ok, f"first={first}, second={second} (want different)"
|
||||
|
||||
|
||||
def t_geometry_remembered(n):
|
||||
w = n.spawn_window()
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
n.action("scratchpad-show"); n.wait_count(1)
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-floating-window", "-x", "+130", "-y", "+90"); time.sleep(0.4)
|
||||
pos1 = n.floating_pos(w)
|
||||
n.action("scratchpad-show"); n.wait_count(0) # hide
|
||||
n.action("scratchpad-show"); n.wait_count(1) # show again (single window)
|
||||
pos2 = n.floating_pos(w)
|
||||
back = n.by_id(w)
|
||||
ok = (back is not None and back["is_floating"] and pos1 and pos2
|
||||
and abs(pos1[0] - pos2[0]) < 2 and abs(pos1[1] - pos2[1]) < 2)
|
||||
return ok, f"pos before hide={pos1}, after show={pos2}, floating={back['is_floating'] if back else None}"
|
||||
|
||||
|
||||
def t_shown_not_focused_gets_focus(n):
|
||||
p = n.spawn_window() # persistent tiled window
|
||||
s = n.spawn_window() # to be stashed + shown
|
||||
n.action("focus-window", "--id", str(s)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(1)
|
||||
n.action("scratchpad-show"); n.wait_count(2) # s shown floating, focused
|
||||
n.action("focus-window", "--id", str(p)); time.sleep(0.3)
|
||||
npre = len(n.windows())
|
||||
n.action("scratchpad-show"); time.sleep(0.4) # should just focus s back
|
||||
f = n.focused()
|
||||
ok = len(n.windows()) == npre and f is not None and f["id"] == s
|
||||
return ok, f"count {npre}->{len(n.windows())} (want same), focus={f['id'] if f else None}/want {s}"
|
||||
|
||||
|
||||
def t_close_while_stashed(n):
|
||||
w = n.spawn_window()
|
||||
pid = n.by_id(w)["pid"]
|
||||
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
|
||||
n.action("move-window-to-scratchpad"); n.wait_count(0)
|
||||
os.kill(pid, signal.SIGKILL) # client dies WHILE stashed
|
||||
time.sleep(0.6)
|
||||
r = n.action("scratchpad-show"); time.sleep(0.5)
|
||||
ok = n.alive() and n.by_id(w) is None and r.returncode == 0
|
||||
return ok, f"alive={n.alive()}, dead_shown={n.by_id(w) is not None}, rc={r.returncode}"
|
||||
|
||||
|
||||
def t_empty_stash_noop(n):
|
||||
n.spawn_window()
|
||||
r = n.action("scratchpad-show"); time.sleep(0.3)
|
||||
ok = len(n.windows()) == 1 and n.alive() and r.returncode == 0
|
||||
return ok, f"count stayed {len(n.windows())}, alive={n.alive()}, rc={r.returncode}"
|
||||
|
||||
|
||||
TESTS = [
|
||||
("1 stash hides + focus moves", t_stash_hides_and_moves_focus),
|
||||
("2 show floating+focused+centered", t_show_floating_focused_centered),
|
||||
("3 re-show hides", t_reshow_hides),
|
||||
("4 cycle round-robin", t_cycle_round_robin),
|
||||
("5 geometry remembered", t_geometry_remembered),
|
||||
("6 shown-not-focused -> focus it", t_shown_not_focused_gets_focus),
|
||||
("7 close-while-stashed no crash", t_close_while_stashed),
|
||||
("10 empty-stash no-op", t_empty_stash_noop),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil.which("alacritty"):
|
||||
sys.exit("ERROR: alacritty is required as the test client.")
|
||||
# confirm the binary is patched
|
||||
h = subprocess.run([NIRI, "msg", "action", "--help"],
|
||||
capture_output=True, text=True)
|
||||
if "scratchpad-show" not in h.stdout:
|
||||
sys.exit(f"ERROR: {NIRI} is not patched (no scratchpad-show action).")
|
||||
|
||||
print(f"niri: {NIRI}\n")
|
||||
cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
|
||||
cfg.write(MINIMAL_CONFIG); cfg.close()
|
||||
results = []
|
||||
try:
|
||||
for name, fn in TESTS:
|
||||
n = None
|
||||
try:
|
||||
n = Niri(cfg.name)
|
||||
ok, detail = fn(n)
|
||||
except Exception as e:
|
||||
ok, detail = False, f"exception: {e}"
|
||||
finally:
|
||||
if n:
|
||||
n.close()
|
||||
time.sleep(0.3)
|
||||
results.append((name, ok, detail))
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||
finally:
|
||||
os.unlink(cfg.name)
|
||||
|
||||
# #8 multi-monitor cannot be exercised nested (single winit output).
|
||||
print(" [SKIP] 8 multi-monitor: needs a physical 2nd output. Covered by the "
|
||||
"layout unit tests instead (scratchpad_shows_on_the_focused_output, "
|
||||
"scratchpad_show_pulls_already_shown_window_to_focused_output), which "
|
||||
"simulate outputs: `cargo test --lib scratchpad`.")
|
||||
|
||||
passed = sum(1 for _, ok, _ in results if ok)
|
||||
failed = sum(1 for _, ok, _ in results if not ok)
|
||||
print(f"\n{passed} passed, {failed} failed, 1 skipped")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess).
|
||||
Loads the hyphenated script by path and drives the pure functions with the JSON
|
||||
shapes `niri msg -j workspaces` / `windows` actually return."""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MOD_PATH = os.path.join(HERE, "..", "window-restore.py")
|
||||
spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH)
|
||||
wr = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(wr)
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(desc, got, want):
|
||||
global fails
|
||||
if got == want:
|
||||
print(f"ok - {desc}")
|
||||
else:
|
||||
print(f"FAIL - {desc} (got {got!r} want {want!r})")
|
||||
fails = 1
|
||||
|
||||
|
||||
# Shapes mirror real `niri msg -j` output: the stash is always idx 1.
|
||||
WS_WITH_STASH = [
|
||||
{"id": 1, "idx": 1, "name": "minimized", "is_focused": False},
|
||||
{"id": 2, "idx": 2, "name": None, "is_focused": True},
|
||||
{"id": 3, "idx": 3, "name": None, "is_focused": False},
|
||||
]
|
||||
WS_NO_STASH = [
|
||||
{"id": 2, "idx": 1, "name": None, "is_focused": True},
|
||||
{"id": 3, "idx": 2, "name": None, "is_focused": False},
|
||||
]
|
||||
WS_ON_STASH = [
|
||||
{"id": 1, "idx": 1, "name": "minimized", "is_focused": True},
|
||||
{"id": 2, "idx": 2, "name": None, "is_focused": False},
|
||||
]
|
||||
|
||||
WINDOWS = [
|
||||
{"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1},
|
||||
{"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1},
|
||||
{"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2},
|
||||
]
|
||||
|
||||
# --- stash_workspace / current_workspace -----------------------------------
|
||||
check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1)
|
||||
check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None)
|
||||
check("stash is None for empty list", wr.stash_workspace([]), None)
|
||||
check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2)
|
||||
check("current is None when none focused", wr.current_workspace([]), None)
|
||||
check("focused-on-stash is detectable",
|
||||
wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True)
|
||||
|
||||
# --- stashed_windows -------------------------------------------------------
|
||||
check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11])
|
||||
check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), [])
|
||||
|
||||
# --- label -----------------------------------------------------------------
|
||||
check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole")
|
||||
check("label falls back to title when app_id missing",
|
||||
wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title")
|
||||
check("label falls back to app_id when title empty",
|
||||
wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox")
|
||||
check("label never renders empty",
|
||||
wr.label({"id": 5, "app_id": None, "title": None}), "window 5")
|
||||
|
||||
# --- resolve ---------------------------------------------------------------
|
||||
check("resolve maps a label back to its window",
|
||||
wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11)
|
||||
check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None)
|
||||
|
||||
DUPES = [
|
||||
{"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
|
||||
{"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
|
||||
]
|
||||
check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20)
|
||||
|
||||
# --- window_exists -----------------------------------------------------------
|
||||
check("window_exists is True for a present window", wr.window_exists(WINDOWS, 11), True)
|
||||
check("window_exists is False once the window has closed", wr.window_exists(WINDOWS, 999), False)
|
||||
check("window_exists is False for an empty list", wr.window_exists([], 11), False)
|
||||
|
||||
# --- pick ------------------------------------------------------------------
|
||||
check("pick returns the chooser's selection",
|
||||
wr.pick(["a", "b"], lambda text: "b"), "b")
|
||||
check("pick passes newline-joined labels to the chooser",
|
||||
wr.pick(["a", "b"], lambda text: text), "a\nb")
|
||||
check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "")
|
||||
|
||||
sys.exit(fails)
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""window-restore — bring a minimized window back from the stash workspace.
|
||||
|
||||
Mod+M moves the focused window to the declared "minimized" workspace; this is
|
||||
Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the
|
||||
workspace you are currently on and focus it.
|
||||
|
||||
Restore targets the *current* workspace by design — the stash workspace is the
|
||||
entire state, so nothing tracks where a window came from and nothing goes stale.
|
||||
|
||||
The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri
|
||||
silently ignores moves to an undeclared named workspace, so if the declaration
|
||||
goes missing this script says so rather than failing quietly.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
STASH = "minimized"
|
||||
|
||||
|
||||
# --- pure logic -------------------------------------------------------------
|
||||
def stash_workspace(workspaces):
|
||||
"""The declared stash workspace, or None if it is not declared."""
|
||||
return next((w for w in workspaces if w.get("name") == STASH), None)
|
||||
|
||||
|
||||
def current_workspace(workspaces):
|
||||
return next((w for w in workspaces if w.get("is_focused")), None)
|
||||
|
||||
|
||||
def stashed_windows(windows, stash_id):
|
||||
return [w for w in windows if w.get("workspace_id") == stash_id]
|
||||
|
||||
|
||||
def label(window):
|
||||
"""Display string for the picker. XWayland windows can lack app_id, so fall
|
||||
back rather than rendering a bare dash or an empty row."""
|
||||
app = (window.get("app_id") or "").strip()
|
||||
title = (window.get("title") or "").strip()
|
||||
if app and title:
|
||||
return f"{app} — {title}"
|
||||
return app or title or f"window {window['id']}"
|
||||
|
||||
|
||||
def resolve(windows, chosen):
|
||||
"""First window whose label matches. Windows with identical labels are
|
||||
indistinguishable in the menu anyway, and restoring the first still drains
|
||||
the stash on repeat."""
|
||||
return next((w for w in windows if label(w) == chosen), None)
|
||||
|
||||
|
||||
def window_exists(windows, window_id):
|
||||
"""Whether window_id is still present in a fresh `windows` list. Used after
|
||||
the picker returns, since wofi can block long enough for the chosen window
|
||||
to have closed out from under us."""
|
||||
return any(w.get("id") == window_id for w in windows)
|
||||
|
||||
|
||||
def pick(labels, chooser):
|
||||
"""chooser(text) -> selected line. Injected so tests need no wofi."""
|
||||
return chooser("\n".join(labels))
|
||||
|
||||
|
||||
# --- io ---------------------------------------------------------------------
|
||||
def notify(message):
|
||||
subprocess.run(
|
||||
["notify-send", "-a", "window-restore", message],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def niri_json(what):
|
||||
"""`niri msg -j <what>` parsed, or None if niri/IPC is unavailable."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["niri", "msg", "-j", what], capture_output=True, text=True
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def niri_action(*args):
|
||||
subprocess.run(
|
||||
["niri", "msg", "action", *args],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def wofi_chooser(text):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600",
|
||||
"--height", "400", "--insensitive"],
|
||||
input=text, capture_output=True, text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
workspaces = niri_json("workspaces")
|
||||
if workspaces is None:
|
||||
notify("niri IPC unavailable")
|
||||
return 1
|
||||
|
||||
stash = stash_workspace(workspaces)
|
||||
if stash is None:
|
||||
notify(f'no "{STASH}" workspace — declare it in niri/config.kdl')
|
||||
return 1
|
||||
|
||||
current = current_workspace(workspaces)
|
||||
if current is None:
|
||||
notify("no focused workspace")
|
||||
return 1
|
||||
|
||||
# Restoring here would move a window to the workspace it is already on.
|
||||
# Reachable by clicking the waybar pill; no keybind goes here.
|
||||
if current["id"] == stash["id"]:
|
||||
notify("Already on the minimized workspace")
|
||||
return 0
|
||||
|
||||
windows = niri_json("windows")
|
||||
if windows is None:
|
||||
notify("niri IPC unavailable")
|
||||
return 1
|
||||
|
||||
stashed = stashed_windows(windows, stash["id"])
|
||||
if not stashed:
|
||||
notify("No minimized windows")
|
||||
return 0
|
||||
|
||||
chosen = pick([label(w) for w in stashed], wofi_chooser)
|
||||
if not chosen:
|
||||
return 0 # cancelled
|
||||
|
||||
window = resolve(stashed, chosen)
|
||||
if window is None:
|
||||
return 0
|
||||
|
||||
# wofi may have sat open for however long the user took. In that time niri
|
||||
# can destroy a dynamic workspace (its last window closed) and renumber
|
||||
# every later one down, so the pre-pick `current["idx"]` may now name the
|
||||
# wrong workspace; the stashed window may also have closed. Re-read state
|
||||
# rather than trust anything captured before pick() blocked.
|
||||
workspaces = niri_json("workspaces")
|
||||
if workspaces is None:
|
||||
notify("niri IPC unavailable")
|
||||
return 1
|
||||
|
||||
current = current_workspace(workspaces)
|
||||
if current is None:
|
||||
notify("no focused workspace")
|
||||
return 1
|
||||
|
||||
windows = niri_json("windows")
|
||||
if windows is None:
|
||||
notify("niri IPC unavailable")
|
||||
return 1
|
||||
|
||||
if not window_exists(windows, window["id"]):
|
||||
notify("that window closed before it could be restored")
|
||||
return 0
|
||||
|
||||
# The window is not focused (it is in the stash), so --focus does not apply;
|
||||
# focus it explicitly afterwards.
|
||||
niri_action("move-window-to-workspace", "--window-id", str(window["id"]),
|
||||
str(current["idx"]))
|
||||
niri_action("focus-window", "--id", str(window["id"]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user