Compare commits
8 Commits
b8a858d7a7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fef1478d | |||
| 640deb397e | |||
| f557ffdfc7 | |||
| a9ce3a13cf | |||
| 0e0b5fcbe3 | |||
| d05b9f8a0e | |||
| 07791f0c23 | |||
| 286bb8a3b3 |
@@ -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/volume.sh" ~/.local/bin/volume
|
||||||
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
|
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/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"
|
echo "==> Enabling systemd user services"
|
||||||
mkdir -p ~/.config/systemd/user
|
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 "xwayland-satellite"
|
||||||
spawn-at-startup "gamemode-watch"
|
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 {
|
binds {
|
||||||
Mod+Q repeat=false { close-window; }
|
Mod+Q repeat=false { close-window; }
|
||||||
Mod+F { fullscreen-window; }
|
Mod+F { fullscreen-window; }
|
||||||
@@ -86,34 +79,30 @@ binds {
|
|||||||
Mod+Shift+Down { move-window-down; }
|
Mod+Shift+Down { move-window-down; }
|
||||||
Mod+Shift+Up { move-window-up; }
|
Mod+Shift+Up { move-window-up; }
|
||||||
|
|
||||||
// +1 offset: the "minimized" workspace is always index 1 (see the
|
Mod+1 { focus-workspace 1; }
|
||||||
// declaration above), so the first real workspace is index 2.
|
Mod+2 { focus-workspace 2; }
|
||||||
Mod+1 { focus-workspace 2; }
|
Mod+3 { focus-workspace 3; }
|
||||||
Mod+2 { focus-workspace 3; }
|
Mod+4 { focus-workspace 4; }
|
||||||
Mod+3 { focus-workspace 4; }
|
Mod+5 { focus-workspace 5; }
|
||||||
Mod+4 { focus-workspace 5; }
|
Mod+6 { focus-workspace 6; }
|
||||||
Mod+5 { focus-workspace 6; }
|
Mod+7 { focus-workspace 7; }
|
||||||
Mod+6 { focus-workspace 7; }
|
Mod+8 { focus-workspace 8; }
|
||||||
Mod+7 { focus-workspace 8; }
|
Mod+9 { focus-workspace 9; }
|
||||||
Mod+8 { focus-workspace 9; }
|
|
||||||
Mod+9 { focus-workspace 10; }
|
|
||||||
|
|
||||||
Mod+Shift+1 { move-window-to-workspace 2; }
|
Mod+Shift+1 { move-window-to-workspace 1; }
|
||||||
Mod+Shift+2 { move-window-to-workspace 3; }
|
Mod+Shift+2 { move-window-to-workspace 2; }
|
||||||
Mod+Shift+3 { move-window-to-workspace 4; }
|
Mod+Shift+3 { move-window-to-workspace 3; }
|
||||||
Mod+Shift+4 { move-window-to-workspace 5; }
|
Mod+Shift+4 { move-window-to-workspace 4; }
|
||||||
Mod+Shift+5 { move-window-to-workspace 6; }
|
Mod+Shift+5 { move-window-to-workspace 5; }
|
||||||
Mod+Shift+6 { move-window-to-workspace 7; }
|
Mod+Shift+6 { move-window-to-workspace 6; }
|
||||||
Mod+Shift+7 { move-window-to-workspace 8; }
|
Mod+Shift+7 { move-window-to-workspace 7; }
|
||||||
Mod+Shift+8 { move-window-to-workspace 9; }
|
Mod+Shift+8 { move-window-to-workspace 8; }
|
||||||
Mod+Shift+9 { move-window-to-workspace 10; }
|
Mod+Shift+9 { move-window-to-workspace 9; }
|
||||||
|
|
||||||
Mod+Print { spawn "screenshot"; }
|
Mod+Print { spawn "screenshot"; }
|
||||||
|
|
||||||
// focus=false is required here — otherwise focus follows the window into
|
Mod+M { move-window-to-scratchpad; }
|
||||||
// the stash, the opposite of minimizing.
|
Mod+Shift+M { scratchpad-show; }
|
||||||
Mod+M { move-window-to-workspace "minimized" focus=false; }
|
|
||||||
Mod+Shift+M { spawn "window-restore"; }
|
|
||||||
|
|
||||||
Mod+Shift+E { spawn "hyprlock"; }
|
Mod+Shift+E { spawn "hyprlock"; }
|
||||||
Mod+Shift+P { spawn "powermenu"; }
|
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