docs: phase 3 window mode implementation plan
This commit is contained in:
1 parent
2dfb7cb7a4
commit
cf1958b86e
1 file changed
+557
@@ -0,0 +1,557 @@
|
||||
# valw Phase 3: window mode 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:** `valw window` captures the window the user clicks; Space in region mode (before dragging) switches to it.
|
||||
|
||||
**Architecture:** niri picks the window (`Request::PickWindow`) and renders it into a temporary PNG (`Action::ScreenshotWindow` with `path`); valw waits for niri's `ScreenshotCaptured` event on a second socket, decodes the file, deletes it, and feeds the image into the existing save → preview → clipboard flow. The region overlay gains a `Choice::Window` outcome for Space.
|
||||
|
||||
**Tech Stack:** Rust 2024, `niri-ipc =26.4.0`, SCTK 0.21 keyboard (`Keysym`), `image` 0.25.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-02-valw-phase-3-window-mode-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Version control is jj only: commit with `jj commit -m "..."`, never git.
|
||||
- `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` (not `cargo test`), `nix flake check` (not nix2 commands).
|
||||
- Code, comments and docs in plain English; match the surrounding comment density.
|
||||
- niri requires an absolute `path` for `ScreenshotWindow`.
|
||||
- Wait for the capture event at most 5 s.
|
||||
- Don't run valw captures against the user's live session without telling them.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **niri's own screenshot binding fires while valw waits:** the stream reports a capture with another path; valw must keep waiting for its own. Test: `niri::tests::only_our_capture_counts` (Task 1).
|
||||
2. **The window closes between the pick and the shot, or niri fails to write:** a clear error within 5 s, never a hang. niri's error reply is surfaced by `request`; a silent failure hits the timeout (Task 1 code, real check in the checklist).
|
||||
3. **The temporary file is left behind** after an error between "written" and "decoded": the guard removes it on every path. Test: `window::tests::temp_file_is_removed_on_drop` (Task 2).
|
||||
4. **Esc during the pick:** `Cancelled` (exit 3), thumbnails come back (the `HideGuard` drops as before). Real check in the checklist.
|
||||
5. **Space while dragging a region:** nothing happens; the drag continues. Test: `selection::tests::window_switch_only_before_a_drag` (Task 3).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Task |
|
||||
|---|---|---|
|
||||
| `src/niri.rs` | `pick_window`, `workspaces`, `screenshot_window`, `is_our_capture`, `output_of` | 1 |
|
||||
| `src/window.rs` (new) | `capture(cursor)`, `TempFile` guard, `temp_path()` | 2 |
|
||||
| `src/main.rs` | `mod window;`, `Command::Window`, `Mode::Window`, `window_shot` | 2, 3 |
|
||||
| `src/selection.rs` | `Selection::allows_window_switch` | 3 |
|
||||
| `src/region.rs` | `Choice` replaces `Picked`; `Overlay::switch_to_window` | 3 |
|
||||
| `src/wayland.rs` | Space in `press_key` | 3 |
|
||||
| `docs/test-checklist.md` | Phase 3 section | 4 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: niri window IPC
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/niri.rs`, `src/main.rs` (allowance)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (in `niri`):
|
||||
- `pub fn pick_window() -> Result<Option<niri_ipc::Window>>`
|
||||
- `pub fn workspaces() -> Result<Vec<niri_ipc::Workspace>>`
|
||||
- `pub fn screenshot_window(id: u64, path: &Path, cursor: bool, timeout: Duration) -> Result<()>`
|
||||
- `pub fn is_our_capture(event: &Event, path: &Path) -> bool`
|
||||
- `pub fn output_of(workspace_id: Option<u64>, workspaces: &[Workspace]) -> Option<String>`
|
||||
|
||||
- [ ] **Step 1: Dead-code allowance**
|
||||
|
||||
At the very top of `src/main.rs`, followed by an empty line:
|
||||
|
||||
```rust
|
||||
// Window-mode helpers land before main uses them; Task 2 removes this.
|
||||
#![allow(dead_code)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Add to the `tests` module of `src/niri.rs`:
|
||||
|
||||
```rust
|
||||
use niri_ipc::{Event, Workspace};
|
||||
use std::path::Path;
|
||||
|
||||
fn ws(id: u64, output: Option<&str>) -> Workspace {
|
||||
Workspace {
|
||||
id,
|
||||
idx: 1,
|
||||
name: None,
|
||||
output: output.map(str::to_owned),
|
||||
is_urgent: false,
|
||||
is_active: true,
|
||||
is_focused: false,
|
||||
active_window_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_capture_counts() {
|
||||
let ours = Path::new("/run/user/1000/valw-window-7.png");
|
||||
let captured = |p: Option<&str>| Event::ScreenshotCaptured {
|
||||
path: p.map(str::to_owned),
|
||||
};
|
||||
assert!(is_our_capture(&captured(Some("/run/user/1000/valw-window-7.png")), ours));
|
||||
assert!(!is_our_capture(&captured(None), ours), "clipboard-only shot");
|
||||
assert!(
|
||||
!is_our_capture(&captured(Some("/home/u/Pictures/Screenshot.png")), ours),
|
||||
"niri's own binding"
|
||||
);
|
||||
assert!(!is_our_capture(&Event::WindowClosed { id: 3 }, ours));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_of_follows_the_workspace() {
|
||||
let all = [ws(1, Some("DP-1")), ws(2, Some("HDMI-A-1")), ws(3, None)];
|
||||
assert_eq!(output_of(Some(2), &all), Some("HDMI-A-1".to_owned()));
|
||||
assert_eq!(output_of(None, &all), None, "no workspace");
|
||||
assert_eq!(output_of(Some(9), &all), None, "unknown workspace");
|
||||
assert_eq!(output_of(Some(3), &all), None, "workspace without output");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run them and watch them fail**
|
||||
|
||||
Run: `cargo nextest run niri::`
|
||||
Expected: compilation fails, `` cannot find function `is_our_capture` `` / `` `output_of` ``.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Replace the imports at the top of `src/niri.rs`:
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use niri_ipc::{Action, Event, Request, Response, Window, Workspace, socket::Socket};
|
||||
```
|
||||
|
||||
Split the connection out of `request` so the event stream can reuse it:
|
||||
|
||||
```rust
|
||||
fn connect() -> Result<Socket> {
|
||||
Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)")
|
||||
}
|
||||
|
||||
fn request(request: Request) -> Result<Response> {
|
||||
connect()?
|
||||
.send(request)
|
||||
.context("niri IPC request failed")?
|
||||
.map_err(|e| anyhow!("niri replied with an error: {e}"))
|
||||
}
|
||||
```
|
||||
|
||||
Add after `focused_output`:
|
||||
|
||||
```rust
|
||||
/// Lets the user click a window; niri shows its own pick cursor.
|
||||
/// `None` if they pressed Esc.
|
||||
pub fn pick_window() -> Result<Option<Window>> {
|
||||
match request(Request::PickWindow)? {
|
||||
Response::PickedWindow(window) => Ok(window),
|
||||
other => Err(anyhow!("unexpected niri reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspaces() -> Result<Vec<Workspace>> {
|
||||
match request(Request::Workspaces)? {
|
||||
Response::Workspaces(workspaces) => Ok(workspaces),
|
||||
other => Err(anyhow!("unexpected niri reply: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Has niri render window `id` into `path` (absolute) and waits until the
|
||||
/// file is written.
|
||||
pub fn screenshot_window(id: u64, path: &Path, cursor: bool, timeout: Duration) -> Result<()> {
|
||||
// Listen before asking, so the event can't come and go unseen.
|
||||
let mut events = connect()?;
|
||||
events
|
||||
.send(Request::EventStream)
|
||||
.context("niri IPC request failed")?
|
||||
.map_err(|e| anyhow!("niri replied with an error: {e}"))?;
|
||||
let mut read = events.read_events();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let wanted = path.to_path_buf();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(event) = read() {
|
||||
if is_our_capture(&event, &wanted) {
|
||||
let _ = tx.send(());
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let path = path
|
||||
.to_str()
|
||||
.context("the temporary screenshot path is not valid UTF-8")?;
|
||||
match request(Request::Action(Action::ScreenshotWindow {
|
||||
id: Some(id),
|
||||
write_to_disk: true,
|
||||
show_pointer: cursor,
|
||||
path: Some(path.to_owned()),
|
||||
}))? {
|
||||
Response::Handled => {}
|
||||
other => bail!("unexpected niri reply: {other:?}"),
|
||||
}
|
||||
rx.recv_timeout(timeout).map_err(|_| {
|
||||
anyhow!(
|
||||
"niri did not deliver the window screenshot within {}s",
|
||||
timeout.as_secs()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `event` reports the screenshot written to `path`. niri reports
|
||||
/// every screenshot on the stream, including its own bindings'.
|
||||
pub fn is_our_capture(event: &Event, path: &Path) -> bool {
|
||||
matches!(event, Event::ScreenshotCaptured { path: Some(p) } if Path::new(p) == path)
|
||||
}
|
||||
|
||||
/// The output showing the workspace a window is on, if niri knows it.
|
||||
pub fn output_of(workspace_id: Option<u64>, workspaces: &[Workspace]) -> Option<String> {
|
||||
let id = workspace_id?;
|
||||
workspaces.iter().find(|w| w.id == id)?.output.clone()
|
||||
}
|
||||
```
|
||||
|
||||
Delete the old `request` body (it is replaced above). If `use std::path::Path;` in the test module now duplicates the outer import, drop it from the test module.
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `cargo nextest run niri::`
|
||||
Expected: 4 passed (`parses_versions`, `ipc_version_matches_cargo_toml`, `only_our_capture_counts`, `output_of_follows_the_workspace`).
|
||||
|
||||
Run: `cargo clippy -- -D warnings`
|
||||
Expected: no warnings.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```sh
|
||||
jj commit -m "feat: niri window pick and screenshot IPC"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `valw window`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/window.rs`
|
||||
- Modify: `src/main.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `niri::{pick_window, workspaces, screenshot_window, output_of}`; `thumbnail::load(&Path) -> Result<RgbaImage>`; `lock::default_path()`; `error::Cancelled`.
|
||||
- Produces: `pub fn window::capture(cursor: bool) -> Result<(RgbaImage, Option<String>)>` (image, output name if known); `fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<(Vec<(RgbaImage, Option<String>)>, usize, String)>` in `main.rs`, used again by Task 3.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `src/window.rs` with only the tests:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn temp_file_is_removed_on_drop() {
|
||||
let dir = std::env::temp_dir().join(format!("valw-window-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
std::fs::write(&path, b"png").unwrap();
|
||||
drop(TempFile(path.clone()));
|
||||
assert!(!path.exists());
|
||||
// A file niri never wrote is fine too.
|
||||
drop(TempFile(dir.join("missing.png")));
|
||||
std::fs::remove_dir(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_path_is_absolute_and_per_process() {
|
||||
let p = temp_path();
|
||||
assert!(p.is_absolute(), "niri rejects relative paths: {p:?}");
|
||||
assert!(p.to_string_lossy().contains(&std::process::id().to_string()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `mod window;` to the module list in `src/main.rs` (after `mod wayland;`). Add to the `flag_conflicts` test in `src/main.rs`:
|
||||
|
||||
```rust
|
||||
assert!(parses(&["window", "--cursor", "--delay", "2"]));
|
||||
assert!(!parses(&["window", "--clipboard-only", "-o", "a.png"]));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run them and watch them fail**
|
||||
|
||||
Run: `cargo nextest run window:: flag_conflicts`
|
||||
Expected: compilation fails (`TempFile`, `temp_path` not found).
|
||||
|
||||
- [ ] **Step 3: Implement `window.rs`**
|
||||
|
||||
Above the test module:
|
||||
|
||||
```rust
|
||||
//! Window mode: niri picks the window and renders it; valw reads the result.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use image::RgbaImage;
|
||||
|
||||
use crate::error::Cancelled;
|
||||
use crate::niri;
|
||||
|
||||
/// How long niri gets to render and write the window.
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Lets the user click a window. Returns its pixels and the output it is
|
||||
/// on, if niri knows. Esc during the pick is `Cancelled`.
|
||||
pub fn capture(cursor: bool) -> Result<(RgbaImage, Option<String>)> {
|
||||
let window = niri::pick_window()?.ok_or(Cancelled)?;
|
||||
tracing::info!("picked window {} ({:?})", window.id, window.app_id);
|
||||
let tmp = TempFile(temp_path());
|
||||
niri::screenshot_window(window.id, &tmp.0, cursor, TIMEOUT)?;
|
||||
let image = crate::thumbnail::load(&tmp.0)?;
|
||||
let workspaces = niri::workspaces().unwrap_or_else(|e| {
|
||||
tracing::warn!("no workspace list: {e:#}");
|
||||
Vec::new()
|
||||
});
|
||||
Ok((image, niri::output_of(window.workspace_id, &workspaces)))
|
||||
}
|
||||
|
||||
/// Where niri writes the window: next to the capture lock, one per process.
|
||||
fn temp_path() -> PathBuf {
|
||||
crate::lock::default_path().with_file_name(format!("valw-window-{}.png", std::process::id()))
|
||||
}
|
||||
|
||||
/// Removes the temporary screenshot however `capture` ends.
|
||||
struct TempFile(PathBuf);
|
||||
|
||||
impl Drop for TempFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire `main.rs`**
|
||||
|
||||
Remove the Task 1 allowance (the two lines at the top of `src/main.rs`).
|
||||
|
||||
Add the subcommand to `enum Command`, after `Region`:
|
||||
|
||||
```rust
|
||||
/// Click a window to capture it (Cmd+Shift+4, then Space).
|
||||
Window {
|
||||
#[command(flatten)]
|
||||
common: Common,
|
||||
},
|
||||
```
|
||||
|
||||
Add `Window` to `enum Mode`:
|
||||
|
||||
```rust
|
||||
enum Mode {
|
||||
Screen { all: bool },
|
||||
Region,
|
||||
Window,
|
||||
}
|
||||
```
|
||||
|
||||
In `run`, after the `Region` arm:
|
||||
|
||||
```rust
|
||||
Command::Window { common } => capture(Mode::Window, common),
|
||||
```
|
||||
|
||||
In `capture`'s `match mode`, after the `Mode::Region` arm:
|
||||
|
||||
```rust
|
||||
Mode::Window => window_shot(&outputs, cursor)?,
|
||||
```
|
||||
|
||||
Add below `capture`:
|
||||
|
||||
```rust
|
||||
/// A window shot, shaped like the other modes' results. The preview goes to
|
||||
/// the window's output, or niri's focused one if that is unknown.
|
||||
fn window_shot(
|
||||
outputs: &[wayland::Output],
|
||||
cursor: bool,
|
||||
) -> Result<(Vec<(RgbaImage, Option<String>)>, usize, String)> {
|
||||
let (image, output) = window::capture(cursor)?;
|
||||
let source = output.unwrap_or_else(|| outputs[focused_output(outputs)].geom.name.clone());
|
||||
Ok((vec![(image, None)], 0, source))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests and checks**
|
||||
|
||||
Run: `cargo nextest run`
|
||||
Expected: all pass (previous count + 4: two `window::` tests, the two new `flag_conflicts` asserts live in an existing test).
|
||||
|
||||
Run: `cargo clippy -- -D warnings && cargo fmt --check`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```sh
|
||||
jj commit -m "feat: valw window"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Space switches region mode to window mode
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/selection.rs`, `src/region.rs`, `src/wayland.rs`, `src/main.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2's `window_shot`.
|
||||
- Produces: `Selection::allows_window_switch(&self) -> bool`; `pub enum region::Choice { Region(usize, PixelRect), Window }`; `region::select(...) -> Result<Choice>`; `Overlay::switch_to_window(&mut self)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to the tests of `src/selection.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn window_switch_only_before_a_drag() {
|
||||
let mut s = Selection::default();
|
||||
assert!(s.allows_window_switch());
|
||||
s.press(Point { x: 1.0, y: 1.0 });
|
||||
assert!(!s.allows_window_switch(), "Space during a drag is reserved");
|
||||
s.release(Point { x: 5.0, y: 5.0 });
|
||||
assert!(s.allows_window_switch());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it and watch it fail**
|
||||
|
||||
Run: `cargo nextest run selection::`
|
||||
Expected: compilation fails, no method `allows_window_switch`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `impl Selection` (`src/selection.rs`), after `release`:
|
||||
|
||||
```rust
|
||||
/// Space switches to window mode only before a drag starts.
|
||||
pub fn allows_window_switch(&self) -> bool {
|
||||
*self == Selection::Idle
|
||||
}
|
||||
```
|
||||
|
||||
In `src/region.rs`, replace the `Picked` alias:
|
||||
|
||||
```rust
|
||||
/// What the user chose in the overlay.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Choice {
|
||||
/// A rectangle in the frame of output `.0`.
|
||||
Region(usize, PixelRect),
|
||||
/// Space before dragging: capture a window instead.
|
||||
Window,
|
||||
}
|
||||
```
|
||||
|
||||
In `struct Overlay`: `outcome: Option<Option<Choice>>,`.
|
||||
|
||||
`select`'s signature becomes `-> Result<Choice>` (body unchanged) and its doc comment gains: "Space before dragging returns `Choice::Window`."
|
||||
|
||||
In `pointer`'s release arm: `self.outcome = Some(Some(Choice::Region(a, rect)));`.
|
||||
|
||||
In `impl Overlay`, after `cancel`:
|
||||
|
||||
```rust
|
||||
pub fn switch_to_window(&mut self) {
|
||||
if self.selection.allows_window_switch() {
|
||||
tracing::info!("switching to window mode");
|
||||
self.outcome.get_or_insert(Some(Choice::Window));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `src/wayland.rs`, `press_key` body becomes:
|
||||
|
||||
```rust
|
||||
let Some(overlay) = &mut self.overlay else {
|
||||
return;
|
||||
};
|
||||
match event.keysym {
|
||||
Keysym::Escape => overlay.cancel(),
|
||||
Keysym::space => overlay.switch_to_window(),
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
In `src/main.rs`, the `Mode::Region` arm becomes:
|
||||
|
||||
```rust
|
||||
Mode::Region => {
|
||||
let frames = wl.capture(&outputs, cursor)?;
|
||||
match region::select(&mut wl, &outputs, &frames)? {
|
||||
region::Choice::Region(i, r) => {
|
||||
let source = frames[i].output.name.clone();
|
||||
(vec![(frames[i].to_rgba(r), None)], 0, source)
|
||||
}
|
||||
region::Choice::Window => {
|
||||
drop(frames);
|
||||
window_shot(&outputs, cursor)?
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests and checks**
|
||||
|
||||
Run: `cargo nextest run`
|
||||
Expected: all pass (+1).
|
||||
|
||||
Run: `cargo clippy -- -D warnings && cargo fmt --check`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```sh
|
||||
jj commit -m "feat: space switches region mode to window mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Checks and checklist
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/test-checklist.md`
|
||||
|
||||
- [ ] **Step 1: Full checks**
|
||||
|
||||
Run: `nix flake check -L --keep-going`
|
||||
Expected: every check passes (the headless sway check is unchanged; window mode needs niri and is not covered there).
|
||||
|
||||
- [ ] **Step 2: Append the checklist section**
|
||||
|
||||
Append at the end of `docs/test-checklist.md` (after the drag-out "Known minors"):
|
||||
|
||||
```markdown
|
||||
|
||||
## Window mode (Phase 3)
|
||||
|
||||
- [ ] `valw window`, click a tiled window → that window alone is saved, preview on its monitor.
|
||||
- [ ] Same for a floating window, and for a window partly covered by another (the covered part is still there).
|
||||
- [ ] A window on the other monitor (different scale, if any) → right size and sharpness; preview on that monitor.
|
||||
- [ ] Esc during the pick → nothing saved, exit code 3, thumbnails come back.
|
||||
- [ ] `--cursor` with the pointer over the window → the pointer is in the shot.
|
||||
- [ ] `valw region`, press Space before dragging → the overlay closes and the window pick starts; Space while dragging does nothing.
|
||||
- [ ] `--clipboard-only` and `-o -` → clipboard / stdout get the window; no stray `valw-window-*.png` left in `$XDG_RUNTIME_DIR`.
|
||||
- [ ] niri's own "Screenshot captured" notification appears (expected, see the spec); the clipboard ends up with the window.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```sh
|
||||
jj commit -m "docs: window mode checklist"
|
||||
```
|
||||
Reference in new issue
Block a user