18 KiB
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(notcargo test),nix flake check(not nix2 commands).- Code, comments and docs in plain English; match the surrounding comment density.
- niri requires an absolute
pathforScreenshotWindow. - 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
- 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). - 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). - 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). - Esc during the pick:
Cancelled(exit 3), thumbnails come back (theHideGuarddrops as before). Real check in the checklist. - 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) -> boolpub 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:
// 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:
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:
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:
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:
/// 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
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)>inmain.rs, used again by Task 3. -
Step 1: Write the failing tests
Create src/window.rs with only the tests:
#[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:
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:
//! 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:
/// Click a window to capture it (Cmd+Shift+4, then Space).
Window {
#[command(flatten)]
common: Common,
},
Add Window to enum Mode:
enum Mode {
Screen { all: bool },
Region,
Window,
}
In run, after the Region arm:
Command::Window { common } => capture(Mode::Window, common),
In capture's match mode, after the Mode::Region arm:
Mode::Window => window_shot(&outputs, cursor)?,
Add below capture:
/// 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
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:
#[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:
/// 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:
/// 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:
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:
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:
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
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"):
## 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
jj commit -m "docs: window mode checklist"