diff --git a/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md b/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md new file mode 100644 index 0000000..c5f2b2a --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md @@ -0,0 +1,4276 @@ +# valw Phase 0 + 1 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:** Ship `valw screen`, `valw region` and `valw doctor`: pixel-exact PNG screenshots on niri that are saved, copied to the clipboard, logged, and explained when they fail. Everything is packaged as a dendritic Nix flake. + +**Architecture:** One short-lived Rust process per command. The process captures every needed output up front through wlr-screencopy. For `region` it shows the frozen frames in layer-shell overlays and crops in memory. It then writes the PNG and forks a small child that serves the clipboard. Pure logic (pixel formats, selection math, overlay pixels, file names, config, errors) is kept apart from the SCTK/Wayland glue so it can be unit tested without a compositor. + +**Tech Stack:** Rust 2024 (stable, from nixpkgs), smithay-client-toolkit 0.21, wayland-protocols(-wlr), niri-ipc 26.4.0, wl-clipboard-rs, image (png), clap, serde/toml, tracing, rustix/libc, anyhow; Nix flake-parts with `*.ulu.nix` auto-discovery; jj. + +**Spec:** `docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md` (read it first; this plan implements it). + +## Global Constraints + +- Code, comments, error/log messages: English. Comments are plain and short and say *why*. +- Version control is **jj** (the repo is non-colocated; there is no `.git`). Commit with `jj commit -m ""`. Never use git commands. +- Use `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`). +- Run every cargo command inside the devShell. direnv loads it automatically once `.envrc` is allowed; otherwise prefix the command with `nix develop -c`. +- `CARGO_TARGET_DIR` is `${XDG_CACHE_HOME:-$HOME/.cache}/valw/target` (set by the devShell). A `target/` directory must never appear in the repo, because the `path:` flake would copy it into the store on every evaluation. +- The flake discovers `nix/**/*.ulu.nix` automatically. Never write a manual `imports = [ ... ]` list. +- Prefer nix3 commands (`nix build`, `nix develop`, `nix flake check`). Don't `find` over `/nix/store`. +- `.gitignore` is a whitelist. Any new top-level file or directory must be added to it, or jj silently leaves it out of commits. +- Dependency versions are exactly those in Task 1's `Cargo.toml`; `niri-ipc` stays pinned at `=26.4.0`. +- Layer-shell namespace: `valw-overlay`. Exit codes: 0 success, 1 error, 2 usage (clap), 3 cancelled. +- Until Task 11, `src/main.rs` starts with `#![allow(dead_code)]` because modules land before the CLI uses them. Task 11 removes it, and clippy must then be clean without it. + +## Review Focus + +These are the input classes most likely to bite a real user that no unit test fully exercises. Each one has a check in the task that owns the code. + +1. **Rotated or flipped outputs** (`transform != Normal`): `screen` and `region` must produce an upright image identical to grim's. Unit test: `frame::tests::transforms_match_grim` (Task 6). Real check: Task 13, step 4. +2. **Fractional scale (1.25)**: the overlay must look sharp (not resampled), and the crop must be pixel-exact against grim. Unit tests: `selection::tests::fractional_scale_rounds_outwards` (Task 7). Real check: Task 13, step 2. +3. **A drag that leaves the anchor output** (press on one monitor, release on another, or outside every monitor): the result must be clipped to the monitor where the drag began and never panic. Unit tests: `selection::tests::drag_across_outputs_is_clipped_to_the_output` and `output_offsets_including_negative` (Task 7). Real check: Task 11, step 7. +4. **Hotkey pressed again while an overlay is open**: the second process exits 1 with "valw is already running" and a hint, and the first keeps working. The same must hold after the first one has exited while its clipboard child is still alive. Unit tests: `lock::tests::second_acquire_fails_until_release`, `detach::tests::child_holds_neither_lock_nor_stdio` (Task 5). Real check: Task 10, step 8. +5. **Unwritable log or save locations**: an unwritable log dir must not block a screenshot (stderr-only fallback). An unwritable save dir must fail with a message that names the path. Unit tests: `log::tests::open_run_log_fails_on_unwritable_dir` (Task 4), `output::tests::write_atomic_reports_unwritable_dir` (Task 8). Real check: Task 13, step 6. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `flake.nix` | flake-parts entry; auto-discovers `nix/**/*.ulu.nix` | +| `nix/package.ulu.nix` | `packages.default` via `rustPlatform.buildRustPackage` | +| `nix/devshell.ulu.nix` | `devShells.default`; sets `CARGO_TARGET_DIR` outside the repo | +| `nix/formatter.ulu.nix` | `formatter` (nixfmt over all `.nix` files) | +| `nix/checks.ulu.nix` | `checks`: clippy, fmt, nextest, headless sway | +| `.envrc`, `.gitignore` | direnv hookup; whitelist ignore file | +| `Cargo.toml` | crate manifest, all Phase 1 dependencies | +| `src/main.rs` | clap CLI, run/exit-code plumbing, capture → deliver flow | +| `src/error.rs` | `Hint`, `HintExt`, `Cancelled`, three-part `render` | +| `src/config.rs` | config struct, defaults, TOML parse with unknown-key report | +| `src/log.rs` | per-run log file, pruning, stderr warnings, panic hook | +| `src/lock.rs` | single-instance `flock` | +| `src/detach.rs` | fork a child that outlives valw with no lock and no stdio | +| `src/frame.rs` | `OutputGeom`, `Frame`, shm format → RGBA, output transforms | +| `src/selection.rs` | drag state machine; logical → physical pixel rectangles | +| `src/render.rs` | overlay pixels: dim, paste bright selection, outline | +| `src/output.rs` | file names, unique paths, PNG encode, atomic write, stdout, clipboard | +| `src/niri.rs` | niri IPC: focused output, version, version parsing | +| `src/wayland.rs` | SCTK connection, state, handlers, dispatch helpers | +| `src/capture.rs` | `Capturer` trait; wlr-screencopy implementation | +| `src/region.rs` | layer-shell overlay surfaces and pointer/keyboard handling | +| `src/doctor.rs` | `valw doctor` checks and report | + +Unit tests live in a `#[cfg(test)] mod tests` block at the bottom of each file. + +--- + +### Task 1: Flake, devShell and crate skeleton (Phase 0) + +**Files:** +- Create: `flake.nix`, `nix/package.ulu.nix`, `nix/devshell.ulu.nix`, `nix/formatter.ulu.nix`, `.envrc`, `.gitignore`, `Cargo.toml`, `src/main.rs` +- Generated: `flake.lock`, `Cargo.lock` + +**Interfaces:** +- Consumes: nothing. +- Produces: `nix build` → `result/bin/valw`; `nix develop` shell with `cargo`, `clippy`, `rustfmt`, `cargo-nextest`, `rust-analyzer`, `grim`, `imagemagick`, `wayland-info`; `CARGO_TARGET_DIR` set outside the repo. + +- [ ] **Step 1: Write `flake.nix`** + +```nix +{ + description = "valw, macOS-style screenshots for niri"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } ( + { lib, ... }: + let + inherit (lib.filesystem) listFilesRecursive; + inherit (lib.lists) filter; + inherit (lib.strings) hasSuffix; + in + { + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix); + } + ); +} +``` + +- [ ] **Step 2: Write `nix/package.ulu.nix`** + +```nix +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + packages.default = pkgs.rustPlatform.buildRustPackage { + pname = "valw"; + version = "0.1.0"; + + src = lib.cleanSourceWith { + src = ../.; + filter = + path: _type: + !(builtins.elem (baseNameOf path) [ + ".direnv" + ".jj" + "result" + "target" + ]); + }; + + cargoLock.lockFile = ../Cargo.lock; + + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ + pkgs.libxkbcommon + pkgs.wayland + ]; + + # Tests run through `nix flake check` instead. + doCheck = false; + + meta = { + description = "macOS-style screenshots for niri"; + mainProgram = "valw"; + platforms = lib.platforms.linux; + }; + }; + }; +} +``` + +- [ ] **Step 3: Write `nix/devshell.ulu.nix`** + +```nix +{ ... }: +{ + perSystem = + { pkgs, self', ... }: + { + devShells.default = pkgs.mkShell { + inputsFrom = [ self'.packages.default ]; + + packages = with pkgs; [ + cargo-nextest + clippy + rust-analyzer + rustfmt + # Manual verification: grim is the pixel oracle, magick compares. + grim + imagemagick + wayland-utils + ]; + + # The repo is a path: flake, so Nix copies the whole tree on every + # evaluation. Keep the multi-gigabyte target dir out of it. + shellHook = '' + export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target" + ''; + }; + }; +} +``` + +- [ ] **Step 4: Write `nix/formatter.ulu.nix`** + +```nix +{ ... }: +{ + perSystem = + { pkgs, ... }: + { + formatter = pkgs.writeShellApplication { + name = "nixfmt-tree"; + runtimeInputs = [ + pkgs.nixfmt + pkgs.fd + pkgs.findutils + ]; + text = '' + if [ "$#" -eq 0 ]; then + set -- . + fi + fd --type f --extension nix --hidden --exclude .jj . "$@" \ + | xargs -r nixfmt + ''; + }; + }; +} +``` + +- [ ] **Step 5: Write `.envrc` and `.gitignore`** + +`.envrc`: + +```sh +watch_file nix/*.ulu.nix +use flake +``` + +`.gitignore`: + +```gitignore +/* +!/.gitignore +!/.envrc +!/flake.nix +!/flake.lock +!/Cargo.toml +!/Cargo.lock +!/nix/ +!/src/ +!/docs/ +!/README.md +``` + +- [ ] **Step 6: Write `Cargo.toml`** + +```toml +[package] +name = "valw" +version = "0.1.0" +edition = "2024" +description = "macOS-style screenshots for niri" +license = "MIT" + +[dependencies] +anyhow = "1.0.104" +chrono = "0.4.45" +clap = { version = "4.6.7", features = ["derive"] } +image = { version = "0.25.10", default-features = false, features = ["png"] } +libc = "0.2.189" +niri-ipc = "=26.4.0" +rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_ignored = "0.1.14" +smithay-client-toolkit = "0.21.1" +toml = "1.1.6" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +wayland-client = "0.31.15" +wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } +wl-clipboard-rs = "0.9.4" + +[dev-dependencies] +tempfile = "3.27.0" + +# Image encoding is painfully slow unoptimised; keep dev builds usable. +[profile.dev.package."*"] +opt-level = 3 +``` + +- [ ] **Step 7: Write `src/main.rs`** + +```rust +// Modules land before the CLI uses them; Task 11 removes this. +#![allow(dead_code)] + +use clap::Parser; + +#[derive(Parser)] +#[command(version, about = "macOS-style screenshots for niri")] +struct Cli {} + +fn main() { + Cli::parse(); +} +``` + +- [ ] **Step 8: Generate the lock files** + +The devShell evaluates the package, and the package reads `Cargo.lock`, so `Cargo.lock` has to exist before `nix develop` can run: + +```sh +nix shell nixpkgs#cargo -c cargo generate-lockfile +nix flake lock +``` + +Expected: `Cargo.lock` and `flake.lock` exist at the repo root. + +- [ ] **Step 9: Enter the devShell and build** + +```sh +direnv allow +cargo clippy --all-targets -- -D warnings +cargo run -- --version +``` + +Expected: clippy prints no warnings, and the last command prints `valw 0.1.0`. `ls` shows **no** `target/` directory in the repo, and `echo $CARGO_TARGET_DIR` prints a path under `~/.cache/valw/`. + +- [ ] **Step 10: Build with Nix and check the flake copy stays small** + +```sh +nix build +./result/bin/valw --version +du -sh "$(nix flake metadata --json | jq -r .path)" +``` + +Expected: `valw 0.1.0`, and the flake source is a few MB at most. If it is much larger, something like `target/` or `result` has leaked into the tree. + +- [ ] **Step 11: Commit** + +```sh +jj commit -m "build: dendritic flake, devShell and crate skeleton" +``` + +--- + +### Task 2: Error type and rendering + +**Files:** +- Create: `src/error.rs` +- Modify: `src/main.rs` (add `error` to the `mod` list) + +**Interfaces:** +- Consumes: nothing. +- Produces: `pub struct Hint(pub String)`; `pub struct Cancelled` (implements `std::error::Error`); `pub trait HintExt { fn hint(self, hint: impl Into) -> anyhow::Result; }` implemented for every `Result>`; `pub fn render(err: &anyhow::Error, log: Option<&Path>) -> String`. + +- [ ] **Step 1: Write the failing tests for `error`** + +Create `src/error.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context, anyhow}; + use std::path::PathBuf; + + #[test] + fn renders_all_parts() { + let err = Err::<(), _>(anyhow!("compositor offered only XBGR2101010")) + .context("could not convert the frame") + .hint("run `valw doctor` and include its output in a bug report") + .context("could not capture output HDMI-A-1") + .unwrap_err(); + let log = PathBuf::from("/tmp/valw.log"); + + assert_eq!( + render(&err, Some(&log)), + "error: could not capture output HDMI-A-1\n\ + \x20 cause: could not convert the frame\n\ + \x20 cause: compositor offered only XBGR2101010\n\ + \x20 hint: run `valw doctor` and include its output in a bug report\n\ + \x20 log: /tmp/valw.log\n" + ); + } + + #[test] + fn renders_without_hint_or_log() { + let err = anyhow!("disk full"); + assert_eq!(render(&err, None), "error: disk full\n"); + } + + #[test] + fn hint_as_outermost_layer_is_not_the_headline() { + let err = Err::<(), _>(anyhow!("valw is already running")) + .hint("wait for the other capture to finish") + .unwrap_err(); + assert_eq!( + render(&err, None), + "error: valw is already running\n\ + \x20 hint: wait for the other capture to finish\n" + ); + } + + #[test] + fn cancelled_is_detectable() { + let err = anyhow::Error::new(Cancelled).context("region selection"); + assert!(err.downcast_ref::().is_some()); + } +} +``` + +Add a `mod` list to `src/main.rs`, right after the `#![allow(dead_code)]` line, containing `mod error;`. + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run error::` +Expected: compilation fails, e.g. `` cannot find type `Hint` in this scope ``. + +- [ ] **Step 3: Implement `error`** + +Put this above the test module in `src/error.rs`: + +```rust +use std::fmt; +use std::path::Path; + +/// A concrete next step for the user, attached to an error with [`HintExt::hint`]. +#[derive(Debug)] +pub struct Hint(pub String); + +impl fmt::Display for Hint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// The user cancelled the capture. Not a failure; maps to exit code 3. +#[derive(Debug)] +pub struct Cancelled; + +impl fmt::Display for Cancelled { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +pub trait HintExt { + fn hint(self, hint: impl Into) -> anyhow::Result; +} + +impl HintExt for Result +where + E: Into, +{ + fn hint(self, hint: impl Into) -> anyhow::Result { + self.map_err(|e| e.into().context(Hint(hint.into()))) + } +} + +/// Renders an error as `error:` / `cause:` / `hint:` / `log:` lines. +pub fn render(err: &anyhow::Error, log: Option<&Path>) -> String { + let hint = err.downcast_ref::().map(|h| h.0.as_str()); + // Hints live in the context chain too; skip them so they print once. + let mut messages = err + .chain() + .map(|e| e.to_string()) + .filter(|m| Some(m.as_str()) != hint); + + let mut out = format!("error: {}\n", messages.next().unwrap_or_default()); + for cause in messages { + out.push_str(&format!(" cause: {cause}\n")); + } + if let Some(hint) = hint { + out.push_str(&format!(" hint: {hint}\n")); + } + if let Some(log) = log { + out.push_str(&format!(" log: {}\n", log.display())); + } + out +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run error::` +Expected: all 4 tests in `error::tests` pass. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: three-part error rendering with hints" +``` + +--- + + +### Task 3: Config file + +**Files:** +- Create: `src/config.rs` +- Modify: `src/main.rs` (add `config` to the `mod` list) + +**Interfaces:** +- Consumes: `error::HintExt` (Task 2). +- Produces: `pub struct Config { pub save: Save, pub capture: Capture }` (Default, Deserialize, PartialEq); `pub struct Save { pub directory: String, pub filename: String, pub copy_to_clipboard: bool }`; `pub struct Capture { pub show_cursor: bool }`; `impl Config { pub fn save_dir(&self) -> PathBuf }`; `pub fn home() -> PathBuf`; `pub fn default_path() -> PathBuf`; `pub fn load(path: &Path) -> Result`; `pub fn parse(text: &str) -> Result<(Config, Vec)>`; `pub fn expand_home(path: &str, home: &Path) -> PathBuf`. + +- [ ] **Step 1: Write the failing tests for `config`** + +Create `src/config.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_file_gives_defaults() { + let (config, unknown) = parse("").unwrap(); + assert_eq!(config, Config::default()); + assert!(unknown.is_empty()); + } + + #[test] + fn partial_file_keeps_other_defaults() { + let (config, _) = parse("[save]\ncopy_to_clipboard = false\n").unwrap(); + assert!(!config.save.copy_to_clipboard); + assert_eq!(config.save.directory, "~/Pictures/Screenshots"); + assert!(!config.capture.show_cursor); + } + + #[test] + fn unknown_keys_are_reported_not_rejected() { + let text = "[preview]\nenabled = true\n\n[save]\nfolder = \"x\"\n"; + let (config, unknown) = parse(text).unwrap(); + assert_eq!(config, Config::default()); + assert_eq!( + unknown, + vec!["preview".to_string(), "save.folder".to_string()] + ); + } + + #[test] + fn invalid_toml_names_the_line() { + let err = parse("[save]\ndirectory = \n").unwrap_err(); + assert!(format!("{err:#}").contains("line 2"), "{err:#}"); + } + + #[test] + fn wrong_type_is_an_error() { + assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err()); + } + + #[test] + fn missing_file_gives_defaults() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + load(&dir.path().join("nope.toml")).unwrap(), + Config::default() + ); + } + + #[test] + fn expands_home() { + let home = Path::new("/home/u"); + assert_eq!( + expand_home("~/Pictures", home), + Path::new("/home/u/Pictures") + ); + assert_eq!( + expand_home("$HOME/Pictures", home), + Path::new("/home/u/Pictures") + ); + assert_eq!(expand_home("~", home), Path::new("/home/u")); + assert_eq!(expand_home("/srv/shots", home), Path::new("/srv/shots")); + assert_eq!(expand_home("~other/x", home), Path::new("~other/x")); + } +} +``` + +Add `mod config;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run config::` +Expected: compilation fails, e.g. `` cannot find function `parse` in this scope ``. + +- [ ] **Step 3: Implement `config`** + +Put this above the test module in `src/config.rs`: + +```rust +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::error::HintExt; + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +#[serde(default)] +pub struct Config { + pub save: Save, + pub capture: Capture, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(default)] +pub struct Save { + pub directory: String, + pub filename: String, + pub copy_to_clipboard: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +#[serde(default)] +pub struct Capture { + pub show_cursor: bool, +} + +impl Default for Save { + fn default() -> Self { + Self { + directory: "~/Pictures/Screenshots".into(), + filename: "Screenshot %Y-%m-%d at %H.%M.%S.png".into(), + copy_to_clipboard: true, + } + } +} + +impl Config { + /// The save directory with `~` and `$HOME` expanded. + pub fn save_dir(&self) -> PathBuf { + expand_home(&self.save.directory, &home()) + } +} + +pub fn home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| "/".into()) +} + +/// `$XDG_CONFIG_HOME/valw/config.toml`, falling back to `~/.config`. +pub fn default_path() -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home().join(".config")) + .join("valw/config.toml") +} + +/// Loads the config. A missing file means defaults. +pub fn load(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), + Err(e) => return Err(e).with_context(|| format!("could not read {}", path.display())), + }; + let (config, unknown) = parse(&text) + .with_context(|| format!("invalid config file {}", path.display())) + .hint("fix the file or delete it to use the defaults")?; + for key in unknown { + tracing::warn!("ignoring unknown config key `{key}` in {}", path.display()); + } + Ok(config) +} + +/// Parses config text. Returns the config and the keys valw doesn't know. +pub fn parse(text: &str) -> Result<(Config, Vec)> { + let de = toml::Deserializer::parse(text)?; + let mut unknown = Vec::new(); + let config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; + Ok((config, unknown)) +} + +pub fn expand_home(path: &str, home: &Path) -> PathBuf { + if path == "~" || path == "$HOME" { + return home.to_path_buf(); + } + for prefix in ["~/", "$HOME/"] { + if let Some(rest) = path.strip_prefix(prefix) { + return home.join(rest); + } + } + PathBuf::from(path) +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run config::` +Expected: all 7 tests in `config::tests` pass. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: config file with defaults and unknown-key warnings" +``` + +--- + + +### Task 4: Per-run logs and panic hook + +**Files:** +- Create: `src/log.rs` +- Modify: `src/main.rs` (add `log` to the `mod` list) + +**Interfaces:** +- Consumes: `config::home` (Task 3); `error::{Hint, render}` (Task 2). +- Produces: `pub fn default_dir() -> PathBuf`; `pub fn init(dir: &Path, now: DateTime) -> Option` (installs the global subscriber, returns this run's log path or `None` if it fell back to stderr); `pub fn since_start() -> Duration`; `pub fn install_panic_hook(log: Option)`. + +- [ ] **Step 1: Write the failing tests for `log`** + +Create `src/log.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn log_names_sort_by_time() { + let a = Local.with_ymd_and_hms(2026, 9, 29, 9, 5, 1).unwrap(); + let b = Local.with_ymd_and_hms(2026, 9, 29, 19, 0, 0).unwrap(); + assert_eq!(log_name(a), "2026-09-29T09-05-01.000.log"); + assert!(log_name(a) < log_name(b)); + } + + #[test] + fn prune_keeps_newest() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..12 { + File::create(dir.path().join(format!("2026-09-29T10-00-{i:02}.000.log"))).unwrap(); + } + File::create(dir.path().join("notes.txt")).unwrap(); + + prune(dir.path(), 10).unwrap(); + + let mut left: Vec = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!(left.len(), 11); + assert_eq!(left[0], "2026-09-29T10-00-02.000.log"); + assert!(left.contains(&"notes.txt".to_string())); + } + + #[test] + fn open_run_log_fails_on_unwritable_dir() { + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("file"); + File::create(&blocker).unwrap(); + // A regular file where the log dir should be. + assert!(open_run_log(&blocker, Local::now()).is_err()); + } +} +``` + +Add `mod log;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run log::` +Expected: compilation fails, e.g. `` cannot find function `log_name` in this scope ``. + +- [ ] **Step 3: Implement `log`** + +Put this above the test module in `src/log.rs`: + +```rust +use std::fs::{self, File}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use tracing::Level; +use tracing_subscriber::filter::{EnvFilter, filter_fn}; +use tracing_subscriber::prelude::*; + +use crate::error::{Hint, render}; + +/// Number of run logs kept on disk, including the current one. +const KEEP: usize = 10; + +static START: OnceLock = OnceLock::new(); + +/// Time since logging was set up, i.e. roughly since the process started. +pub fn since_start() -> Duration { + START.get_or_init(Instant::now).elapsed() +} + +/// `$XDG_STATE_HOME/valw/logs`, falling back to `~/.local/state/valw/logs`. +pub fn default_dir() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| crate::config::home().join(".local/state")) + .join("valw/logs") +} + +/// Sets up tracing: this run's log file at `VALW_LOG` level (default info), +/// plus warnings and errors on stderr. Returns the log file path. +/// +/// If the log file can't be created, logging falls back to stderr only and +/// the returned path is `None`; a broken log dir must not block a screenshot. +pub fn init(dir: &Path, now: DateTime) -> Option { + START.get_or_init(Instant::now); + let file = open_run_log(dir, now); + + // Errors reach stderr through error::render, so only warnings go here. + let stderr = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .without_time() + .with_target(false) + .with_filter(filter_fn(|m| *m.level() == Level::WARN)); + + let (file_layer, path, failure) = match file { + Ok((file, path)) => { + let filter = + EnvFilter::try_from_env("VALW_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + let layer = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(file)) + .with_ansi(false) + .with_filter(filter); + (Some(layer), Some(path), None) + } + Err(e) => (None, None, Some(e)), + }; + + tracing_subscriber::registry() + .with(stderr) + .with(file_layer) + .init(); + + if let Some(e) = failure { + tracing::warn!("logging to stderr only: {e:#}"); + } + path +} + +fn open_run_log(dir: &Path, now: DateTime) -> Result<(File, PathBuf)> { + fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; + let path = dir.join(log_name(now)); + let file = + File::create(&path).with_context(|| format!("could not create {}", path.display()))?; + prune(dir, KEEP)?; + Ok((file, path)) +} + +/// Log names sort chronologically, so pruning can compare names. +fn log_name(now: DateTime) -> String { + now.format("%Y-%m-%dT%H-%M-%S%.3f.log").to_string() +} + +/// Deletes all but the `keep` newest `.log` files in `dir`. +fn prune(dir: &Path, keep: usize) -> Result<()> { + let mut logs: Vec = fs::read_dir(dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "log")) + .collect(); + logs.sort(); + let excess = logs.len().saturating_sub(keep); + for old in &logs[..excess] { + fs::remove_file(old)?; + } + Ok(()) +} + +/// Sends panics to the log and prints them in the usual error format. +pub fn install_panic_hook(log: Option) { + std::panic::set_hook(Box::new(move |info| { + let backtrace = std::backtrace::Backtrace::force_capture(); + tracing::error!("panic: {info}\n{backtrace}"); + let err = anyhow::anyhow!("{info}").context(Hint( + "this is a bug in valw; please report it with the log".into(), + )); + eprint!("{}", render(&err, log.as_deref())); + })); +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run log::` +Expected: all 3 tests in `log::tests` pass. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: per-run log files and panic hook" +``` + +--- + + +### Task 5: Single-instance lock and detached child + +**Files:** +- Create: `src/lock.rs` +- Create: `src/detach.rs` +- Modify: `src/main.rs` (add `lock`, `detach` to the `mod` list) + +**Interfaces:** +- Consumes: `error::HintExt` (Task 2). +- Produces: `pub struct Lock` with `pub fn acquire(path: &Path) -> Result` (busy → error `valw is already running` + hint) and `pub fn release(self)`; `pub fn lock::default_path() -> PathBuf`; `pub fn detach::spawn(lock: Lock, work: impl FnOnce()) -> Result<()>` (releases the lock, forks; the child gets `setsid` and /dev/null stdio, runs `work`, then `_exit(0)`). + +- [ ] **Step 1: Write the failing tests for `lock`** + +Create `src/lock.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn second_acquire_fails_until_release() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + + let first = Lock::acquire(&path).unwrap(); + let err = Lock::acquire(&path).unwrap_err(); + assert_eq!( + err.to_string(), + "wait for the other capture to finish, or press Esc in it" + ); + assert_eq!(err.root_cause().to_string(), "valw is already running"); + + first.release(); + Lock::acquire(&path).unwrap(); + } + + #[test] + fn stale_lock_file_is_fine() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + File::create(&path).unwrap(); + Lock::acquire(&path).unwrap(); + } +} +``` + +Add `mod lock;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run lock::` +Expected: compilation fails, e.g. `` failed to resolve: use of undeclared type `Lock` ``. + +- [ ] **Step 3: Implement `lock`** + +Put this above the test module in `src/lock.rs`: + +```rust +use std::fs::File; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use rustix::fs::{FlockOperation, flock}; +use rustix::io::Errno; + +use crate::error::HintExt; + +/// Held for the whole capture so two captures never overlap. The kernel +/// drops the lock when the process dies, so a crash can't leave it stuck. +#[derive(Debug)] +pub struct Lock { + _file: File, +} + +/// `$XDG_RUNTIME_DIR/valw.lock`, falling back to the temp dir. +pub fn default_path() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("valw.lock") +} + +impl Lock { + pub fn acquire(path: &Path) -> Result { + let file = File::options() + .create(true) + .truncate(false) + .write(true) + .open(path) + .with_context(|| format!("could not open lock file {}", path.display()))?; + match flock(&file, FlockOperation::NonBlockingLockExclusive) { + Ok(()) => Ok(Lock { _file: file }), + Err(Errno::WOULDBLOCK) => Err(anyhow::anyhow!("valw is already running")) + .hint("wait for the other capture to finish, or press Esc in it"), + Err(e) => { + bail!("could not lock {}: {e}", path.display()) + } + } + } + + /// Releases the lock. Must happen before forking, or the child would + /// inherit the file and keep the lock held. + pub fn release(self) {} +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run lock::` +Expected: all 2 tests in `lock::tests` pass. + +- [ ] **Step 5: Write the failing tests for `detach`** + +Create `src/detach.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn child_holds_neither_lock_nor_stdio() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw.lock"); + let lock = Lock::acquire(&path).unwrap(); + let (report_r, report_w) = rustix::pipe::pipe().unwrap(); + let (go_r, go_w) = rustix::pipe::pipe().unwrap(); + + spawn(lock, move || { + // Report whether stdout is /dev/null, then wait for the parent. + let null = rustix::fs::stat("/dev/null").unwrap(); + let out = rustix::fs::fstat(rustix::stdio::stdout()).unwrap(); + let is_null = out.st_rdev == null.st_rdev && out.st_ino == null.st_ino; + rustix::io::write(&report_w, &[is_null as u8]).unwrap(); + let _ = rustix::io::read(&go_r, &mut [0u8; 1]); + }) + .unwrap(); + + // The child is still running, yet the lock is free. + Lock::acquire(&path).unwrap(); + + let mut is_null = [0u8; 1]; + rustix::io::read(&report_r, &mut is_null).unwrap(); + assert_eq!(is_null, [1], "child stdout should be /dev/null"); + + // Closing our end lets the child exit. + drop(go_w); + } +} +``` + +Add `mod detach;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 6: Run the tests and watch them fail** + +Run: `cargo nextest run detach::` +Expected: compilation fails, e.g. `` cannot find function `spawn` in this scope ``. + +- [ ] **Step 7: Implement `detach`** + +Put this above the test module in `src/detach.rs`: + +```rust +use std::fs::File; +use std::io; + +use anyhow::{Context, Result}; + +use crate::lock::Lock; + +/// Runs `work` in a forked child that outlives valw, like `wl-copy` does. +/// The parent returns right away. +/// +/// The child drops the capture lock (so the next capture isn't blocked) and +/// points stdio at /dev/null (so `valw ... -o - | x` sees EOF without waiting +/// for the clipboard to be replaced). +pub fn spawn(lock: Lock, work: impl FnOnce()) -> Result<()> { + lock.release(); + // SAFETY: valw is single-threaded at this point, and the child only + // touches its own state before exiting with _exit. + match unsafe { libc::fork() } { + -1 => Err(io::Error::last_os_error()).context("could not fork the clipboard server"), + 0 => { + let _ = rustix::process::setsid(); + let _ = detach_stdio(); + work(); + // SAFETY: skips atexit handlers and destructors that belong to the parent. + unsafe { libc::_exit(0) } + } + _ => Ok(()), + } +} + +fn detach_stdio() -> io::Result<()> { + let null = File::options().read(true).write(true).open("/dev/null")?; + rustix::stdio::dup2_stdin(&null)?; + rustix::stdio::dup2_stdout(&null)?; + rustix::stdio::dup2_stderr(&null)?; + Ok(()) +} +``` + +- [ ] **Step 8: Run the tests and watch them pass** + +Run: `cargo nextest run detach::` +Expected: all 1 tests in `detach::tests` pass. + +- [ ] **Step 9: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 10: Commit** + +```sh +jj commit -m "feat: single-instance lock and detached background child" +``` + +--- + + +### Task 6: Frames, pixel formats and output transforms + +**Files:** +- Create: `src/frame.rs` +- Modify: `src/main.rs` (add `frame` to the `mod` list) + +**Interfaces:** +- Consumes: nothing. +- Produces: `pub struct OutputGeom { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32 }` (logical, global coordinates); `pub struct Frame { pub output: OutputGeom, pub image: RgbaImage }`; `pub const SUPPORTED: [Format; 4]` (preference order); `pub fn to_rgba(format: Format, width: u32, height: u32, stride: u32, data: &[u8], y_invert: bool) -> Result`; `pub fn apply_transform(img: RgbaImage, transform: Transform) -> RgbaImage`. `Format` is `wayland_client::protocol::wl_shm::Format`, `Transform` is `wayland_client::protocol::wl_output::Transform`. + +- [ ] **Step 1: Write the failing tests for `frame`** + +Create `src/frame.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + /// 2x1 buffer: a red pixel then a blue one, in the given byte order. + fn two_pixels(format: Format) -> Vec { + match format { + Format::Xrgb8888 | Format::Argb8888 => vec![0, 0, 255, 0, 255, 0, 0, 0], + _ => vec![255, 0, 0, 0, 0, 0, 255, 0], + } + } + + #[test] + fn converts_all_supported_formats_to_opaque_rgba() { + for format in SUPPORTED { + let img = to_rgba(format, 2, 1, 8, &two_pixels(format), false).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255], "{format:?}"); + assert_eq!(img.get_pixel(1, 0).0, [0, 0, 255, 255], "{format:?}"); + } + } + + #[test] + fn honours_stride_padding() { + // One pixel per row, 8-byte stride. + let data = [0, 0, 255, 0, 9, 9, 9, 9, 255, 0, 0, 0, 9, 9, 9, 9]; + let img = to_rgba(Format::Xrgb8888, 1, 2, 8, &data, false).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(img.get_pixel(0, 1).0, [0, 0, 255, 255]); + } + + #[test] + fn y_invert_flips_rows() { + let data = [0, 0, 255, 0, 255, 0, 0, 0]; + let img = to_rgba(Format::Xrgb8888, 1, 2, 4, &data, true).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, [0, 0, 255, 255]); + assert_eq!(img.get_pixel(0, 1).0, [255, 0, 0, 255]); + } + + #[test] + fn rejects_unknown_format_by_name() { + let err = to_rgba(Format::Xbgr2101010, 1, 1, 4, &[0; 4], false).unwrap_err(); + assert_eq!(err.to_string(), "unsupported buffer format Xbgr2101010"); + } + + #[test] + fn rejects_short_buffer() { + assert!(to_rgba(Format::Xrgb8888, 2, 2, 8, &[0; 8], false).is_err()); + } + + fn marked() -> RgbaImage { + // 3x2, top-left pixel marked. + let mut img = RgbaImage::new(3, 2); + img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); + img + } + + fn marked_at(img: &RgbaImage) -> (u32, u32, u32, u32) { + let (x, y, _) = img + .enumerate_pixels() + .find(|(_, _, p)| p.0[0] == 255) + .unwrap(); + (img.width(), img.height(), x, y) + } + + #[test] + fn transforms_match_grim() { + use Transform::*; + let cases = [ + (Normal, (3, 2, 0, 0)), + (_90, (2, 3, 1, 0)), + (_180, (3, 2, 2, 1)), + (_270, (2, 3, 0, 2)), + (Flipped, (3, 2, 2, 0)), + (Flipped90, (2, 3, 0, 0)), + (Flipped180, (3, 2, 0, 1)), + (Flipped270, (2, 3, 1, 2)), + ]; + for (t, want) in cases { + assert_eq!(marked_at(&apply_transform(marked(), t)), want, "{t:?}"); + } + } +} +``` + +Add `mod frame;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run frame::` +Expected: compilation fails, e.g. `` cannot find function `to_rgba` in this scope ``. + +- [ ] **Step 3: Implement `frame`** + +Put this above the test module in `src/frame.rs`: + +```rust +use anyhow::{Result, bail, ensure}; +use image::{RgbaImage, imageops}; +use wayland_client::protocol::{wl_output::Transform, wl_shm::Format}; + +/// An output's place in the global logical coordinate space. +#[derive(Debug, Clone, PartialEq)] +pub struct OutputGeom { + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// One captured output: upright RGBA pixels at physical resolution. +#[derive(Debug, Clone)] +pub struct Frame { + pub output: OutputGeom, + pub image: RgbaImage, +} + +/// Shm formats valw can read, in order of preference. +pub const SUPPORTED: [Format; 4] = [ + Format::Xrgb8888, + Format::Argb8888, + Format::Xbgr8888, + Format::Abgr8888, +]; + +/// Converts a screencopy buffer to opaque RGBA. +/// +/// Alpha is always set to 255: a screenshot of what's on screen is opaque, +/// even where the compositor's buffer has alpha 0. +pub fn to_rgba( + format: Format, + width: u32, + height: u32, + stride: u32, + data: &[u8], + y_invert: bool, +) -> Result { + // Little-endian byte order of each 32-bit pixel. + let (r, g, b) = match format { + Format::Xrgb8888 | Format::Argb8888 => (2, 1, 0), + Format::Xbgr8888 | Format::Abgr8888 => (0, 1, 2), + other => bail!("unsupported buffer format {other:?}"), + }; + ensure!( + stride >= width * 4, + "stride {stride} is too small for width {width}" + ); + ensure!( + data.len() >= (stride * height) as usize, + "buffer is {} bytes, expected at least {}", + data.len(), + stride * height + ); + + let mut out = RgbaImage::new(width, height); + for (y, row) in out.rows_mut().enumerate() { + let src_y = if y_invert { height as usize - 1 - y } else { y }; + let (src, _) = data[src_y * stride as usize..][..width as usize * 4].as_chunks::<4>(); + for (px, s) in row.zip(src) { + px.0 = [s[r], s[g], s[b], 255]; + } + } + Ok(out) +} + +/// Turns a buffer in the output's native orientation upright, the same way +/// grim does: rotate clockwise by the transform's angle, then mirror. +pub fn apply_transform(img: RgbaImage, transform: Transform) -> RgbaImage { + let rotated = match transform { + Transform::_90 | Transform::Flipped90 => imageops::rotate90(&img), + Transform::_180 | Transform::Flipped180 => imageops::rotate180(&img), + Transform::_270 | Transform::Flipped270 => imageops::rotate270(&img), + _ => img, + }; + match transform { + Transform::Flipped + | Transform::Flipped90 + | Transform::Flipped180 + | Transform::Flipped270 => imageops::flip_horizontal(&rotated), + _ => rotated, + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run frame::` +Expected: all 6 tests in `frame::tests` pass. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: screencopy pixel conversion and output transforms" +``` + +--- + + +### Task 7: Selection math and overlay pixels + +**Files:** +- Create: `src/selection.rs` +- Create: `src/render.rs` +- Modify: `src/main.rs` (add `selection`, `render` to the `mod` list) + +**Interfaces:** +- Consumes: `frame::OutputGeom` (Task 6). +- Produces: `selection::{MIN_PIXELS, Point { x: f64, y: f64 }, PixelRect { x, y, width, height: u32 }, Selection { Idle, Dragging { start, end } }}` with `press`, `motion`, `release(p) -> Option<(Point, Point)>`; `selection::clip(a, b, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option`; `selection::resolve(...)` (same, but `None` under `MIN_PIXELS`); `render::{DIM, to_argb8888(img: &RgbaImage, brightness: u8) -> Vec, compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option)}`. + +- [ ] **Step 1: Write the failing tests for `selection`** + +Create `src/selection.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom { + OutputGeom { + name: name.into(), + x, + y, + width, + height, + } + } + + fn p(x: f64, y: f64) -> Point { + Point { x, y } + } + + fn rect(x: u32, y: u32, width: u32, height: u32) -> PixelRect { + PixelRect { + x, + y, + width, + height, + } + } + + #[test] + fn scale_one() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!( + clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080), + Some(rect(10, 20, 100, 50)) + ); + } + + #[test] + fn drag_direction_does_not_matter() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!( + clip(p(110.0, 70.0), p(10.0, 20.0), &o, 1920, 1080), + clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080) + ); + } + + #[test] + fn fractional_scale_rounds_outwards() { + // 1920x1080 at 1.25 is 1536x864 logical. + let o = out("A", 0, 0, 1536, 864); + // 10.5 * 1.25 = 13.125 -> 13; 20.3 * 1.25 = 25.375 -> 26. + assert_eq!( + clip(p(10.5, 0.0), p(20.3, 8.0), &o, 1920, 1080), + Some(rect(13, 0, 13, 10)) + ); + } + + #[test] + fn scale_one_and_a_half() { + let o = out("A", 0, 0, 1280, 720); + assert_eq!( + clip(p(100.0, 100.0), p(200.0, 150.0), &o, 1920, 1080), + Some(rect(150, 150, 150, 75)) + ); + } + + #[test] + fn output_offsets_including_negative() { + let left = out("L", -1366, 0, 1366, 768); + let right = out("R", 0, 0, 1920, 1080); + assert_eq!( + clip(p(-1356.0, 10.0), p(-1256.0, 60.0), &left, 1366, 768), + Some(rect(10, 10, 100, 50)) + ); + assert_eq!( + clip( + p(1930.0, 10.0), + p(2030.0, 60.0), + &out("R2", 1920, 0, 800, 600), + 800, + 600 + ), + Some(rect(10, 10, 100, 50)) + ); + assert_eq!( + clip(p(-10.0, 10.0), p(10.0, 20.0), &right, 1920, 1080), + Some(rect(0, 10, 10, 10)) + ); + } + + #[test] + fn drag_across_outputs_is_clipped_to_the_output() { + let left = out("L", 0, 0, 1366, 768); + // From (1300, 100) on the left output to (1500, 900) on the right one. + assert_eq!( + clip(p(1300.0, 100.0), p(1500.0, 900.0), &left, 1366, 768), + Some(rect(1300, 100, 66, 668)) + ); + } + + #[test] + fn nothing_left_on_other_output() { + let o = out("A", 0, 0, 100, 100); + assert_eq!(clip(p(200.0, 200.0), p(300.0, 300.0), &o, 100, 100), None); + } + + #[test] + fn click_is_not_a_selection() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None); + assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); + assert_eq!( + resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), + Some(rect(10, 10, 2, 2)) + ); + } + + #[test] + fn state_machine() { + let mut s = Selection::default(); + assert_eq!(s.release(p(1.0, 1.0)), None); + + s.press(p(1.0, 2.0)); + s.motion(p(5.0, 6.0)); + assert_eq!( + s, + Selection::Dragging { + start: p(1.0, 2.0), + end: p(5.0, 6.0) + } + ); + assert_eq!(s.release(p(7.0, 8.0)), Some((p(1.0, 2.0), p(7.0, 8.0)))); + assert_eq!(s, Selection::Idle); + + s.motion(p(9.0, 9.0)); + assert_eq!(s, Selection::Idle); + } +} +``` + +Add `mod selection;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run selection::` +Expected: compilation fails, e.g. `` cannot find struct, variant or union type `Point` in this scope ``. + +- [ ] **Step 3: Implement `selection`** + +Put this above the test module in `src/selection.rs`: + +```rust +use crate::frame::OutputGeom; + +/// Selections smaller than this (in physical pixels) count as a click. +pub const MIN_PIXELS: u32 = 2; + +/// A point in global logical coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +/// A rectangle in a frame's physical pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PixelRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum Selection { + #[default] + Idle, + Dragging { + start: Point, + end: Point, + }, +} + +impl Selection { + pub fn press(&mut self, p: Point) { + *self = Selection::Dragging { start: p, end: p }; + } + + pub fn motion(&mut self, p: Point) { + if let Selection::Dragging { end, .. } = self { + *end = p; + } + } + + /// Ends the drag and returns its start and end. Back to `Idle` either way. + pub fn release(&mut self, p: Point) -> Option<(Point, Point)> { + match std::mem::take(self) { + Selection::Dragging { start, .. } => Some((start, p)), + Selection::Idle => None, + } + } +} + +/// The part of the drag from `a` to `b` that falls on `out`, in the physical +/// pixels of a `frame_w` x `frame_h` frame. `None` if nothing is left. +/// +/// Edges round outwards so the rectangle covers every pixel the user touched. +pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option { + let sx = frame_w as f64 / out.width as f64; + let sy = frame_h as f64 / out.height as f64; + let to_px = |v: f64, origin: i32, s: f64, max: u32, round: fn(f64) -> f64| { + round((v - origin as f64) * s).clamp(0.0, max as f64) as u32 + }; + let x0 = to_px(a.x.min(b.x), out.x, sx, frame_w, f64::floor); + let x1 = to_px(a.x.max(b.x), out.x, sx, frame_w, f64::ceil); + let y0 = to_px(a.y.min(b.y), out.y, sy, frame_h, f64::floor); + let y1 = to_px(a.y.max(b.y), out.y, sy, frame_h, f64::ceil); + (x1 > x0 && y1 > y0).then(|| PixelRect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + }) +} + +/// Like [`clip`], but treats anything under [`MIN_PIXELS`] as a click. +pub fn resolve( + a: Point, + b: Point, + out: &OutputGeom, + frame_w: u32, + frame_h: u32, +) -> Option { + clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS) +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run selection::` +Expected: all 9 tests in `selection::tests` pass. + +- [ ] **Step 5: Write the failing tests for `render`** + +Create `src/render.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_and_dims() { + let img = RgbaImage::from_raw(1, 1, vec![200, 100, 50, 255]).unwrap(); + assert_eq!(to_argb8888(&img, 255), vec![50, 100, 200, 255]); + assert_eq!(to_argb8888(&img, DIM), vec![30, 60, 120, 255]); + } + + fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * width + x) * 4) as usize; + buf[i..i + 4].try_into().unwrap() + } + + #[test] + fn compose_outline_and_inside() { + let (w, h) = (6, 6); + let dark = vec![1u8; (w * h * 4) as usize]; + let bright = vec![2u8; (w * h * 4) as usize]; + let mut dst = vec![0u8; dark.len()]; + let sel = PixelRect { + x: 1, + y: 1, + width: 4, + height: 4, + }; + + compose(&mut dst, &dark, &bright, w, Some(sel)); + + assert_eq!(px(&dst, w, 0, 0), [1; 4], "outside"); + assert_eq!(px(&dst, w, 1, 1), [255; 4], "corner"); + assert_eq!(px(&dst, w, 4, 2), [255; 4], "right edge"); + assert_eq!(px(&dst, w, 2, 4), [255; 4], "bottom edge"); + assert_eq!(px(&dst, w, 2, 2), [2; 4], "inside"); + assert_eq!(px(&dst, w, 5, 5), [1; 4], "outside"); + } + + #[test] + fn compose_without_selection_is_all_dark() { + let dark = vec![1u8; 16]; + let mut dst = vec![0u8; 16]; + compose(&mut dst, &dark, &[2u8; 16], 2, None); + assert_eq!(dst, dark); + } + + #[test] + fn compose_one_pixel_selection() { + let dark = vec![1u8; 16]; + let mut dst = vec![0u8; 16]; + let sel = PixelRect { + x: 1, + y: 1, + width: 1, + height: 1, + }; + compose(&mut dst, &dark, &[2u8; 16], 2, Some(sel)); + assert_eq!(px(&dst, 2, 1, 1), [255; 4]); + } +} +``` + +Add `mod render;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 6: Run the tests and watch them fail** + +Run: `cargo nextest run render::` +Expected: compilation fails, e.g. `` cannot find function `to_argb8888` in this scope ``. + +- [ ] **Step 7: Implement `render`** + +Put this above the test module in `src/render.rs`: + +```rust +use image::RgbaImage; + +use crate::selection::PixelRect; + +/// Overlay brightness outside the selection (60%, i.e. darkened by 40%). +pub const DIM: u8 = 153; + +/// `img` as wl_shm ARGB8888 bytes (B, G, R, A in memory), with RGB scaled +/// by `brightness / 255`. +pub fn to_argb8888(img: &RgbaImage, brightness: u8) -> Vec { + let scale = |c: u8| (c as u16 * brightness as u16 / 255) as u8; + img.pixels() + .flat_map(|p| [scale(p.0[2]), scale(p.0[1]), scale(p.0[0]), 255]) + .collect() +} + +/// Draws one overlay frame into `dst`: `dark` everywhere, `bright` inside +/// `sel`, and a 1 px white outline on the edge of `sel`. +pub fn compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option) { + dst.copy_from_slice(dark); + let Some(r) = sel else { return }; + let stride = width as usize * 4; + let (x0, x1) = (r.x as usize * 4, (r.x + r.width) as usize * 4); + + for y in r.y..r.y + r.height { + let row = y as usize * stride; + dst[row + x0..row + x1].copy_from_slice(&bright[row + x0..row + x1]); + if y == r.y || y == r.y + r.height - 1 { + dst[row + x0..row + x1].fill(255); + } else { + dst[row + x0..row + x0 + 4].fill(255); + dst[row + x1 - 4..row + x1].fill(255); + } + } +} +``` + +- [ ] **Step 8: Run the tests and watch them pass** + +Run: `cargo nextest run render::` +Expected: all 4 tests in `render::tests` pass. + +- [ ] **Step 9: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 10: Commit** + +```sh +jj commit -m "feat: selection geometry and overlay compositing" +``` + +--- + + +### Task 8: Saving, stdout and clipboard + +**Files:** +- Create: `src/output.rs` +- Modify: `src/main.rs` (add `output` to the `mod` list) + +**Interfaces:** +- Consumes: `error::HintExt` (Task 2); `lock::Lock`, `detach::spawn` (Task 5). +- Produces: `pub fn file_name(template: &str, now: &DateTime, output: Option<&str>) -> Result`; `pub fn unique_path(dir: &Path, name: &str) -> PathBuf`; `pub fn encode_png(img: &RgbaImage) -> Result>`; `pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()>`; `pub fn write_stdout(bytes: &[u8]) -> Result<()>`; `pub fn copy_to_clipboard(png: Vec, lock: Lock) -> Result<()>`; `pub enum Target { Default, Path(PathBuf), Stdout, ClipboardOnly }` with `pub fn from_args(output: Option, clipboard_only: bool) -> Target`. + +- [ ] **Step 1: Write the failing tests for `output`** + +Create `src/output.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn now() -> DateTime { + Local.with_ymd_and_hms(2026, 9, 29, 19, 4, 5).unwrap() + } + + #[test] + fn default_template() { + let name = file_name("Screenshot %Y-%m-%d at %H.%M.%S.png", &now(), None).unwrap(); + assert_eq!(name, "Screenshot 2026-09-29 at 19.04.05.png"); + } + + #[test] + fn output_suffix() { + let name = file_name("shot.png", &now(), Some("HDMI-A-1")).unwrap(); + assert_eq!(name, "shot (HDMI-A-1).png"); + } + + #[test] + fn invalid_pattern_is_an_error_not_a_panic() { + let err = file_name("shot %Q.png", &now(), None).unwrap_err(); + assert!( + format!("{err:#}").contains("invalid strftime pattern"), + "{err:#}" + ); + } + + #[test] + fn rejects_slashes_and_empty_names() { + assert!(file_name("%Y/%m.png", &now(), None).is_err()); + assert!(file_name("", &now(), None).is_err()); + } + + #[test] + fn suffix_placement() { + assert_eq!(with_suffix("a.png", " (2)"), "a (2).png"); + assert_eq!(with_suffix("a.b.png", " (2)"), "a.b (2).png"); + assert_eq!(with_suffix("noext", " (2)"), "noext (2)"); + assert_eq!(with_suffix(".hidden", " (2)"), ".hidden (2)"); + } + + #[test] + fn unique_path_counts_up() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(unique_path(dir.path(), "a.png"), dir.path().join("a.png")); + fs::write(dir.path().join("a.png"), b"").unwrap(); + fs::write(dir.path().join("a (2).png"), b"").unwrap(); + assert_eq!( + unique_path(dir.path(), "a.png"), + dir.path().join("a (3).png") + ); + } + + #[test] + fn write_atomic_creates_dirs_and_leaves_no_temp() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("new/sub/a.png"); + write_atomic(&path, b"png").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"png"); + let names: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().collect(); + assert_eq!(names.len(), 1); + } + + #[test] + fn write_atomic_reports_unwritable_dir() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + fs::write(&file, b"").unwrap(); + let err = write_atomic(&file.join("a.png"), b"png").unwrap_err(); + assert!(format!("{err:#}").contains("could not create"), "{err:#}"); + } + + #[test] + fn write_atomic_relative_path() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + write_atomic(Path::new("a.png"), b"png").unwrap(); + assert_eq!(fs::read(dir.path().join("a.png")).unwrap(), b"png"); + } + + #[test] + fn png_round_trip() { + let img = RgbaImage::from_raw(1, 1, vec![1, 2, 3, 255]).unwrap(); + let png = encode_png(&img).unwrap(); + let back = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(back, img); + } + + #[test] + fn targets() { + use Target::*; + assert_eq!(Target::from_args(None, false), Default); + assert_eq!(Target::from_args(None, true), ClipboardOnly); + assert_eq!(Target::from_args(Some("-".into()), false), Stdout); + assert_eq!( + Target::from_args(Some("a.png".into()), false), + Path("a.png".into()) + ); + } +} +``` + +Add `mod output;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run output::` +Expected: compilation fails, e.g. `` cannot find function `file_name` in this scope ``. + +- [ ] **Step 3: Implement `output`** + +Put this above the test module in `src/output.rs`: + +```rust +use std::fmt::Write as _; +use std::fs; +use std::io::{Cursor, Write as _}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, Local}; +use image::{ImageFormat, RgbaImage}; +use wl_clipboard_rs::copy::{MimeType, Options, Source}; + +use crate::error::HintExt; +use crate::lock::Lock; + +/// Renders the strftime `template`. With `output`, appends ` (NAME)` before +/// the extension, for `valw screen --all`. +pub fn file_name(template: &str, now: &DateTime, output: Option<&str>) -> Result { + let mut name = String::new(); + write!(name, "{}", now.format(template)) + .map_err(|_| anyhow!("invalid strftime pattern in save.filename: {template:?}")) + .hint("see https://docs.rs/chrono/latest/chrono/format/strftime for the syntax")?; + if name.is_empty() || name.contains('/') { + return Err(anyhow!( + "save.filename must render to a plain file name, got {name:?}" + )) + .hint("put folders in save.directory, not save.filename"); + } + if let Some(output) = output { + name = with_suffix(&name, &format!(" ({output})")); + } + Ok(name) +} + +/// Inserts `suffix` before the extension: `a.png` + ` (2)` = `a (2).png`. +fn with_suffix(name: &str, suffix: &str) -> String { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem}{suffix}.{ext}"), + _ => format!("{name}{suffix}"), + } +} + +/// `dir/name`, or `dir/name (2)`, `(3)`, … if taken. +pub fn unique_path(dir: &Path, name: &str) -> PathBuf { + let first = dir.join(name); + if !first.exists() { + return first; + } + (2..) + .map(|n| dir.join(with_suffix(name, &format!(" ({n})")))) + .find(|p| !p.exists()) + .expect("some suffix is free") +} + +pub fn encode_png(img: &RgbaImage) -> Result> { + let mut png = Vec::new(); + img.write_to(&mut Cursor::new(&mut png), ImageFormat::Png) + .context("could not encode PNG")?; + Ok(png) +} + +/// Writes via a temp file in the same directory and a rename, so a partial +/// screenshot never appears under the final name. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + // "a.png" has the parent "", which means the current directory. + let dir = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => Path::new("."), + }; + fs::create_dir_all(dir) + .with_context(|| format!("could not create {}", dir.display())) + .hint("check save.directory in the config")?; + let name = path.file_name().context("path has no file name")?; + let tmp = dir.join(format!(".{}.tmp", name.to_string_lossy())); + let result = fs::write(&tmp, bytes).and_then(|()| fs::rename(&tmp, path)); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result.with_context(|| format!("could not write {}", path.display())) +} + +pub fn write_stdout(bytes: &[u8]) -> Result<()> { + let mut out = std::io::stdout().lock(); + out.write_all(bytes) + .and_then(|()| out.flush()) + .context("could not write to stdout") +} + +/// Copies `png` to the clipboard. The data is served by a forked child that +/// lives until something else is copied, so this consumes the lock. +pub fn copy_to_clipboard(png: Vec, lock: Lock) -> Result<()> { + let mut options = Options::new(); + options.foreground(true); + let prepared = options + .prepare_copy( + Source::Bytes(png.into()), + MimeType::Specific("image/png".into()), + ) + .context("could not copy to the clipboard") + .hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")?; + crate::detach::spawn(lock, move || { + let _ = prepared.serve(); + }) +} + +/// Where a capture goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// Save under the config's directory and filename template. + Default, + /// `--output `. + Path(PathBuf), + /// `--output -`. + Stdout, + /// `--clipboard-only`. + ClipboardOnly, +} + +impl Target { + /// clap rejects `--output` together with `--clipboard-only`. + pub fn from_args(output: Option, clipboard_only: bool) -> Target { + match output { + _ if clipboard_only => Target::ClipboardOnly, + Some(p) if p.as_os_str() == "-" => Target::Stdout, + Some(p) => Target::Path(p), + None => Target::Default, + } + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run output::` +Expected: all 11 tests in `output::tests` pass. + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: file names, atomic PNG writes and clipboard" +``` + +--- + + +### Task 9: Wayland connection, niri IPC and `valw doctor` (Phase 0 discovery) + +**Files:** +- Create: `src/niri.rs`, `src/wayland.rs`, `src/doctor.rs` +- Replace: `src/main.rs` + +**Interfaces:** +- Consumes: `config::{load, default_path, Config::save_dir}` (Task 3); `log::{init, default_dir, install_panic_hook}` (Task 4); `error::{render, Cancelled, HintExt}` (Task 2); `frame::OutputGeom` (Task 6). +- Produces: + - `niri`: `pub const IPC_VERSION: (u32, u32)`; `pub fn focused_output() -> Result>`; `pub fn version() -> Result`; `pub fn parse_version(v: &str) -> Option<(u32, u32)>`. + - `wayland`: `pub struct Output { pub wl: WlOutput, pub geom: OutputGeom, pub transform: Transform }`; `pub struct Wayland { pub conn, pub queue: EventQueue, pub globals: GlobalList, pub state: State }` with `connect() -> Result`, `outputs(&self) -> Vec`, `global_list(&self) -> Vec<(String, u32)>`, `dispatch_until(&mut self, deadline: Instant, done: impl FnMut(&State) -> bool) -> Result<()>`, `dispatch_blocking(&mut self, done) -> Result<()>`; `pub struct State` (SCTK state plus `layer_shell`, `screencopy`, `viewporter`, `cursor_shape`, `cursor_device`, `keyboard`, `pointer`). + - `doctor`: `pub enum Level { Ok, Warn, Fail }`, `pub struct Check { level, text }` (Display: `"ok text"`), `check_globals`, `check_niri`, `check_config`, `check_save_dir`, `pub fn run() -> Result<()>`. + - CLI: `valw doctor`; exit codes 0/1/3 wired in `main`. + +Background for the Wayland code: SCTK 0.21 dispatches through `delegate_dispatch2!`. Protocol objects that SCTK doesn't wrap (screencopy manager, viewporter) use `wayland_client::delegate_noop!`. The handlers that do nothing yet become meaningful in Tasks 10 and 11. + +- [ ] **Step 1: Write the failing tests for `niri`** + +Create `src/niri.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions() { + assert_eq!(parse_version("26.04 (Nixpkgs)"), Some((26, 4))); + assert_eq!(parse_version("25.11"), Some((25, 11))); + assert_eq!(parse_version("26.04.1-dev"), Some((26, 4))); + assert_eq!(parse_version("unknown"), None); + } + + #[test] + fn ipc_version_matches_cargo_toml() { + let (y, m) = IPC_VERSION; + let pin = format!("niri-ipc = \"={y}.{m}.0\""); + assert!( + include_str!("../Cargo.toml").contains(&pin), + "Cargo.toml should pin {pin}" + ); + } +} +``` + +Add `mod niri;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run niri::` +Expected: compilation fails, e.g. `` cannot find function `parse_version` in this scope ``. + +- [ ] **Step 3: Implement `niri`** + +Put this above the test module in `src/niri.rs`: + +```rust +use anyhow::{Context, Result, anyhow}; +use niri_ipc::{Request, Response, socket::Socket}; + +/// The niri release the pinned `niri-ipc` crate belongs to. Keep in sync +/// with the `niri-ipc = "=X.Y.0"` line in Cargo.toml (a test checks this). +pub const IPC_VERSION: (u32, u32) = (26, 4); + +fn request(request: Request) -> Result { + let mut socket = + Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)")?; + socket + .send(request) + .context("niri IPC request failed")? + .map_err(|e| anyhow!("niri replied with an error: {e}")) +} + +/// Name of the output niri has focused, if any. +pub fn focused_output() -> Result> { + match request(Request::FocusedOutput)? { + Response::FocusedOutput(output) => Ok(output.map(|o| o.name)), + other => Err(anyhow!("unexpected niri reply: {other:?}")), + } +} + +/// niri's version string, e.g. "26.04 (Nixpkgs)". +pub fn version() -> Result { + match request(Request::Version)? { + Response::Version(v) => Ok(v), + other => Err(anyhow!("unexpected niri reply: {other:?}")), + } +} + +/// `(year, month)` from a niri version string like "26.04 (Nixpkgs)". +pub fn parse_version(v: &str) -> Option<(u32, u32)> { + let (year, rest) = v.split_once('.')?; + let month: String = rest.chars().take_while(char::is_ascii_digit).collect(); + Some((year.parse().ok()?, month.parse().ok()?)) +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run niri::` +Expected: all 2 tests in `niri::tests` pass. + +- [ ] **Step 5: Write `src/wayland.rs`** + +This is glue with no unit tests; `valw doctor` exercises it in Step 11. Add `mod wayland;` to the `mod` list in `src/main.rs`. + +```rust +use std::time::Instant; + +use anyhow::{Context, Result, bail}; +use rustix::event::{PollFd, PollFlags, Timespec, poll}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState}, + delegate_registry, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + Capability, SeatHandler, SeatState, + keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers}, + pointer::{PointerEvent, PointerHandler, cursor_shape::CursorShapeManager}, + }, + shell::wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure}, + shm::{Shm, ShmHandler}, +}; +use wayland_client::{ + Connection, EventQueue, QueueHandle, + globals::{GlobalList, registry_queue_init}, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, +}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; +use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, +}; +use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; + +use crate::error::HintExt; +use crate::frame::OutputGeom; + +/// An output as valw sees it. +#[derive(Debug, Clone)] +pub struct Output { + pub wl: wl_output::WlOutput, + pub geom: OutputGeom, + pub transform: wl_output::Transform, +} + +pub struct Wayland { + pub conn: Connection, + pub queue: EventQueue, + pub globals: GlobalList, + pub state: State, +} + +pub struct State { + registry: RegistryState, + pub outputs: OutputState, + pub seat: SeatState, + pub shm: Shm, + pub compositor: CompositorState, + pub layer_shell: Option, + pub screencopy: Option, + pub viewporter: Option, + pub cursor_shape: Option, + pub cursor_device: Option, + pub keyboard: Option, + pub pointer: Option, +} + +impl Wayland { + pub fn connect() -> Result { + let conn = Connection::connect_to_env() + .context("could not connect to the Wayland compositor") + .hint("valw needs a Wayland session; is WAYLAND_DISPLAY set?")?; + let (globals, mut queue) = registry_queue_init::(&conn) + .context("could not read the compositor's globals")?; + let qh = queue.handle(); + + let mut state = State { + registry: RegistryState::new(&globals), + outputs: OutputState::new(&globals, &qh), + seat: SeatState::new(&globals, &qh), + shm: Shm::bind(&globals, &qh).context("compositor has no wl_shm")?, + compositor: CompositorState::bind(&globals, &qh) + .context("compositor has no wl_compositor")?, + layer_shell: LayerShell::bind(&globals, &qh).ok(), + screencopy: globals.bind(&qh, 1..=3, ()).ok(), + viewporter: globals.bind(&qh, 1..=1, ()).ok(), + cursor_shape: CursorShapeManager::bind(&globals, &qh).ok(), + cursor_device: None, + keyboard: None, + pointer: None, + }; + // Two round trips: one for wl_output, one for the xdg-output details. + queue + .roundtrip(&mut state) + .context("Wayland round trip failed")?; + queue + .roundtrip(&mut state) + .context("Wayland round trip failed")?; + + let wl = Wayland { + conn, + queue, + globals, + state, + }; + wl.log_environment(); + Ok(wl) + } + + /// All outputs with a known name and logical geometry. + pub fn outputs(&self) -> Vec { + self.state + .outputs + .outputs() + .filter_map(|wl| { + let info = self.state.outputs.info(&wl)?; + let (x, y) = info.logical_position?; + let (width, height) = info.logical_size?; + Some(Output { + geom: OutputGeom { + name: info + .name + .clone() + .unwrap_or_else(|| format!("output-{}", info.id)), + x, + y, + width, + height, + }, + transform: info.transform, + wl, + }) + }) + .collect() + } + + /// `(interface, version)` of every global, sorted. + pub fn global_list(&self) -> Vec<(String, u32)> { + let mut list: Vec<_> = self + .globals + .contents() + .clone_list() + .into_iter() + .map(|g| (g.interface, g.version)) + .collect(); + list.sort(); + list.dedup(); + list + } + + fn log_environment(&self) { + for (interface, version) in self.global_list() { + tracing::debug!("global {interface} v{version}"); + } + for o in self.outputs() { + let g = &o.geom; + tracing::info!( + "output {} at {},{} size {}x{} (logical) transform {:?}", + g.name, + g.x, + g.y, + g.width, + g.height, + o.transform + ); + } + } + + /// Dispatches events until `done` returns true or `deadline` passes. + pub fn dispatch_until( + &mut self, + deadline: Instant, + mut done: impl FnMut(&State) -> bool, + ) -> Result<()> { + loop { + self.queue + .dispatch_pending(&mut self.state) + .context("Wayland dispatch failed")?; + if done(&self.state) { + return Ok(()); + } + self.queue.flush().context("Wayland flush failed")?; + let Some(guard) = self.queue.prepare_read() else { + continue; + }; + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + bail!("the compositor did not answer in time"); + } + let timeout = Timespec::try_from(left).expect("duration fits"); + let fd = guard.connection_fd(); + let mut fds = [PollFd::new(&fd, PollFlags::IN)]; + match poll(&mut fds, Some(&timeout)) { + Ok(0) => continue, + Ok(_) => { + guard.read().context("Wayland read failed")?; + } + Err(rustix::io::Errno::INTR) => continue, + Err(e) => return Err(e).context("poll failed"), + } + } + } + + /// Dispatches events until `done` returns true. Waits as long as it takes. + pub fn dispatch_blocking(&mut self, mut done: impl FnMut(&State) -> bool) -> Result<()> { + while !done(&self.state) { + self.queue + .blocking_dispatch(&mut self.state) + .context("Wayland dispatch failed")?; + } + Ok(()) + } +} + +impl CompositorHandler for State { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: i32, + ) { + } + + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} + + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +impl OutputHandler for State { + fn output_state(&mut self) -> &mut OutputState { + &mut self.outputs + } + + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl LayerShellHandler for State { + fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) {} + + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &LayerSurface, + _: LayerSurfaceConfigure, + _: u32, + ) { + } +} + +impl SeatHandler for State { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat + } + + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} + + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard && self.keyboard.is_none() { + self.keyboard = self.seat.get_keyboard(qh, &seat, None).ok(); + } + if capability == Capability::Pointer + && self.pointer.is_none() + && let Ok(pointer) = self.seat.get_pointer(qh, &seat) + { + self.cursor_device = self + .cursor_shape + .as_ref() + .map(|m| m.get_shape_device(&pointer, qh)); + self.pointer = Some(pointer); + } + } + + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: wl_seat::WlSeat, + capability: Capability, + ) { + if capability == Capability::Keyboard + && let Some(k) = self.keyboard.take() + { + k.release(); + } + if capability == Capability::Pointer { + if let Some(d) = self.cursor_device.take() { + d.destroy(); + } + if let Some(p) = self.pointer.take() { + p.release(); + } + } + } + + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} +} + +impl KeyboardHandler for State { + fn enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + _: &[u32], + _: &[Keysym], + ) { + } + + fn leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + ) { + } + + fn press_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn repeat_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn release_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: Modifiers, + _: RawModifiers, + _: u32, + ) { + } +} + +impl PointerHandler for State { + fn pointer_frame( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_pointer::WlPointer, + _: &[PointerEvent], + ) { + } +} + +impl ShmHandler for State { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl ProvidesRegistryState for State { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry + } + registry_handlers![OutputState, SeatState]; +} + +delegate_registry!(State); +smithay_client_toolkit::delegate_dispatch2!(State); +wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); +wayland_client::delegate_noop!(State: WpViewporter); +wayland_client::delegate_noop!(State: WpViewport); +``` + +- [ ] **Step 6: Write the failing tests for `doctor`** + +Create `src/doctor.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn globals(list: &[(&str, u32)]) -> Vec<(String, u32)> { + list.iter().map(|&(n, v)| (n.to_string(), v)).collect() + } + + fn niri_26_04() -> Vec<(String, u32)> { + globals(&[ + ("wl_compositor", 6), + ("wl_shm", 2), + ("wl_seat", 9), + ("zxdg_output_manager_v1", 3), + ("zwlr_layer_shell_v1", 5), + ("zwlr_screencopy_manager_v1", 3), + ("wp_viewporter", 1), + ("wp_cursor_shape_manager_v1", 2), + ("zwlr_data_control_manager_v1", 2), + ("ext_data_control_manager_v1", 1), + ]) + } + + #[test] + fn all_present_is_all_ok() { + let checks = check_globals(&niri_26_04()); + assert!(checks.iter().all(|c| c.level == Level::Ok), "{checks:#?}"); + assert!( + checks + .iter() + .any(|c| c.text == "ext_data_control_manager_v1 v1 (clipboard copy)") + ); + } + + #[test] + fn missing_required_fails_missing_optional_warns() { + let list: Vec<_> = niri_26_04() + .into_iter() + .filter(|(n, _)| n != "zwlr_screencopy_manager_v1" && n != "wp_cursor_shape_manager_v1") + .collect(); + let checks = check_globals(&list); + assert!(checks.contains(&check( + Level::Fail, + "zwlr_screencopy_manager_v1 missing (screen capture)" + ))); + assert!(checks.contains(&check( + Level::Warn, + "wp_cursor_shape_manager_v1 missing (crosshair cursor in region mode disabled)" + ))); + } + + #[test] + fn old_version_fails() { + let mut list = niri_26_04(); + list.retain(|(n, _)| n != "zxdg_output_manager_v1"); + list.push(("zxdg_output_manager_v1".into(), 1)); + assert!(check_globals(&list).contains(&check( + Level::Fail, + "zxdg_output_manager_v1 v1 is too old, need v2 (output names and positions)" + ))); + } + + #[test] + fn niri_versions() { + assert_eq!(check_niri(Ok("26.04 (Nixpkgs)".into())).level, Level::Ok); + assert_eq!(check_niri(Ok("25.11".into())).level, Level::Warn); + assert_eq!( + check_niri(Err(anyhow::anyhow!("no socket"))).level, + Level::Warn + ); + } + + #[test] + fn save_dir_checks() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(check_save_dir(&dir.path().join("new")).level, Level::Ok); + let file = dir.path().join("file"); + std::fs::write(&file, b"").unwrap(); + assert_eq!(check_save_dir(&file).level, Level::Fail); + } + + #[test] + fn line_format() { + assert_eq!(check(Level::Warn, "x").to_string(), "warn x"); + assert_eq!(check(Level::Ok, "x").to_string(), "ok x"); + } +} +``` + +Add `mod doctor;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 7: Run the tests and watch them fail** + +Run: `cargo nextest run doctor::` +Expected: compilation fails, e.g. `` cannot find function `check_globals` in this scope ``. + +- [ ] **Step 8: Implement `doctor`** + +Put this above the test module in `src/doctor.rs`: + +```rust +use std::fmt; +use std::path::Path; + +use anyhow::{Result, bail}; + +use crate::config; +use crate::niri; +use crate::wayland::Wayland; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub level: Level, + pub text: String, +} + +impl fmt::Display for Check { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let tag = match self.level { + Level::Ok => "ok", + Level::Warn => "warn", + Level::Fail => "fail", + }; + write!(f, "{tag:<5} {}", self.text) + } +} + +fn check(level: Level, text: impl Into) -> Check { + Check { + level, + text: text.into(), + } +} + +/// Globals valw can't work without: (interface, minimum version, used for). +const REQUIRED: [(&str, u32, &str); 7] = [ + ("wl_compositor", 1, "surfaces"), + ("wl_shm", 1, "pixel buffers"), + ("wl_seat", 1, "mouse and keyboard"), + ("zxdg_output_manager_v1", 2, "output names and positions"), + ("zwlr_layer_shell_v1", 1, "the selection overlay"), + ("zwlr_screencopy_manager_v1", 1, "screen capture"), + ("wp_viewporter", 1, "sharp overlays at fractional scale"), +]; + +/// Globals that enable a feature: (any of these interfaces, feature). +const OPTIONAL: [(&[&str], &str); 2] = [ + ( + &["wp_cursor_shape_manager_v1"], + "crosshair cursor in region mode", + ), + ( + &[ + "ext_data_control_manager_v1", + "zwlr_data_control_manager_v1", + ], + "clipboard copy", + ), +]; + +pub fn check_globals(globals: &[(String, u32)]) -> Vec { + let version = |name: &str| globals.iter().find(|(i, _)| i == name).map(|&(_, v)| v); + let mut checks = Vec::new(); + for (name, min, purpose) in REQUIRED { + checks.push(match version(name) { + Some(v) if v >= min => check(Level::Ok, format!("{name} v{v} ({purpose})")), + Some(v) => check( + Level::Fail, + format!("{name} v{v} is too old, need v{min} ({purpose})"), + ), + None => check(Level::Fail, format!("{name} missing ({purpose})")), + }); + } + for (names, feature) in OPTIONAL { + checks.push( + match names.iter().find_map(|&n| version(n).map(|v| (n, v))) { + Some((n, v)) => check(Level::Ok, format!("{n} v{v} ({feature})")), + None => check( + Level::Warn, + format!("{} missing ({feature} disabled)", names.join(" / ")), + ), + }, + ); + } + checks +} + +pub fn check_niri(version: Result) -> Check { + let (y, m) = niri::IPC_VERSION; + match version { + Err(e) => check( + Level::Warn, + format!("niri IPC unavailable ({e:#}); using the first output for `screen`"), + ), + Ok(v) => match niri::parse_version(&v) { + Some(found) if found == niri::IPC_VERSION => check(Level::Ok, format!("niri {v}")), + _ => check( + Level::Warn, + format!( + "niri {v}, but valw was built against niri-ipc {y}.{m}; IPC replies may not parse" + ), + ), + }, + } +} + +pub fn check_config(path: &Path) -> Check { + match config::load(path) { + Ok(_) if path.exists() => check(Level::Ok, format!("config {}", path.display())), + Ok(_) => check( + Level::Ok, + format!("config {} not found, using defaults", path.display()), + ), + Err(e) => check(Level::Fail, format!("config: {e:#}")), + } +} + +pub fn check_save_dir(dir: &Path) -> Check { + let probe = dir.join(".valw-doctor"); + let result = std::fs::create_dir_all(dir) + .and_then(|()| std::fs::write(&probe, b"")) + .and_then(|()| std::fs::remove_file(&probe)); + match result { + Ok(()) => check( + Level::Ok, + format!("save directory {} is writable", dir.display()), + ), + Err(e) => check( + Level::Fail, + format!("save directory {}: {e}", dir.display()), + ), + } +} + +/// Runs every check and prints one line each. Fails if any check failed. +pub fn run() -> Result<()> { + let mut checks = Vec::new(); + match Wayland::connect() { + Ok(wl) => { + checks.extend(check_globals(&wl.global_list())); + for o in wl.outputs() { + let g = &o.geom; + checks.push(check( + Level::Ok, + format!( + "output {}: {}x{} logical at {},{}, transform {:?}", + g.name, g.width, g.height, g.x, g.y, o.transform + ), + )); + } + } + Err(e) => checks.push(check(Level::Fail, format!("wayland: {e:#}"))), + } + checks.push(check_niri(niri::version())); + let config_path = config::default_path(); + checks.push(check_config(&config_path)); + if let Ok(config) = config::load(&config_path) { + checks.push(check_save_dir(&config.save_dir())); + } + + for c in &checks { + println!("{c}"); + } + let failed = checks.iter().filter(|c| c.level == Level::Fail).count(); + if failed > 0 { + bail!("{failed} check(s) failed"); + } + Ok(()) +} +``` + +- [ ] **Step 9: Run the tests and watch them pass** + +Run: `cargo nextest run doctor::` +Expected: all 6 tests in `doctor::tests` pass. + +- [ ] **Step 10: Replace `src/main.rs`** + +```rust +// Modules land before the CLI uses them; Task 11 removes this. +#![allow(dead_code)] + +mod config; +mod detach; +mod doctor; +mod error; +mod frame; +mod lock; +mod log; +mod niri; +mod output; +mod render; +mod selection; +mod wayland; + +use std::process::ExitCode; + +use anyhow::Result; +use chrono::Local; +use clap::{Parser, Subcommand}; + +use crate::error::Cancelled; + +#[derive(Parser)] +#[command(version, about = "macOS-style screenshots for niri")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Report what the compositor and system support. + Doctor, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let log = log::init(&log::default_dir(), Local::now()); + log::install_panic_hook(log.clone()); + tracing::info!( + "valw {} {:?}", + env!("CARGO_PKG_VERSION"), + std::env::args().collect::>() + ); + + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(e) if e.downcast_ref::().is_some() => { + tracing::info!("cancelled by the user"); + ExitCode::from(3) + } + Err(e) => { + tracing::error!("{e:#}"); + eprint!("{}", error::render(&e, log.as_deref())); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Doctor => doctor::run(), + } +} +``` + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 11: Run `valw doctor` on niri (Phase 0 discovery report)** + +Run: `cargo run --release -- doctor; echo "exit=$?"` +Expected, on the user's machine, is something like the following (the spec's section 2 table lists the same globals): + +``` +ok wl_compositor v6 (surfaces) +ok wl_shm v2 (pixel buffers) +ok wl_seat v9 (mouse and keyboard) +ok zxdg_output_manager_v1 v3 (output names and positions) +ok zwlr_layer_shell_v1 v5 (the selection overlay) +ok zwlr_screencopy_manager_v1 v3 (screen capture) +ok wp_viewporter v1 (sharp overlays at fractional scale) +ok wp_cursor_shape_manager_v1 v2 (crosshair cursor in region mode) +ok ext_data_control_manager_v1 v1 (clipboard copy) +ok output LVDS-1: 1366x768 logical at 0,0, transform Normal +ok output HDMI-A-1: 1920x1080 logical at -277,-1080, transform Normal +ok niri 26.04 (Nixpkgs) +ok config /home/elars/.config/valw/config.toml not found, using defaults +ok save directory /home/elars/Pictures/Screenshots is writable +exit=0 +``` + +Output positions depend on the current monitor layout. Any `fail` line is a blocker; report it rather than working around it. Also check that `~/.local/state/valw/logs/` now holds a log file whose first lines include the valw version and the `globals:` line. + +- [ ] **Step 12: Check the error path** + +Run: `WAYLAND_DISPLAY=nope cargo run --release -- doctor; echo "exit=$?"` +Expected: a `fail wayland: could not connect to the Wayland compositor: ...` line and exit 1. This is the only path where doctor itself fails. + +- [ ] **Step 13: Commit** + +```sh +jj commit -m "feat: wayland connection, niri IPC and valw doctor" +``` + +--- + + +### Task 10: Screen capture and `valw screen` + +**Files:** +- Create: `src/capture.rs` +- Modify: `src/wayland.rs` (screencopy bookkeeping) +- Replace: `src/main.rs` + +**Interfaces:** +- Consumes: `wayland::{Wayland, State, Output}` and `Wayland::dispatch_until` (Task 9); `frame::{Frame, SUPPORTED, to_rgba, apply_transform}` (Task 6); `output::*` (Task 8); `lock::Lock` (Task 5); `niri::{focused_output, version}` (Task 9); `config` (Task 3). +- Produces: `pub trait Capturer { fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result>; }` implemented for `Wayland`; `pub struct Pending` (one in-flight screencopy, stored in `State::captures`); CLI `valw screen [--all] [--clipboard-only] [-o PATH|-] [--delay SECS] [--cursor]`. + +Two facts the code depends on, both verified on niri 26.04: +- **niri rejects a screencopy shm buffer unless its `wl_shm_pool` is exactly the buffer's size** (`src/protocols/screencopy.rs`: `shm_len == stride * height`). Every capture therefore gets its own `RawPool`. A shared, larger pool fails with `invalid buffer`. +- **wl-clipboard-rs never forks.** Its background mode is a thread, which dies with the process. `output::copy_to_clipboard` (Task 8) forks through `detach::spawn` instead. + +- [ ] **Step 1: Write `src/capture.rs`** + +Add `mod capture;` to the `mod` list in `src/main.rs`. The pixel logic here was unit-tested in Task 6; this file is protocol plumbing. + +```rust +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow}; +use smithay_client_toolkit::shm::raw::RawPool; +use wayland_client::{ + Connection, Dispatch, Proxy, QueueHandle, WEnum, + protocol::{wl_buffer::WlBuffer, wl_shm::Format}, +}; +use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ + self, ZwlrScreencopyFrameV1, +}; + +use crate::error::HintExt; +use crate::frame::{self, Frame, SUPPORTED}; +use crate::wayland::{Output, State, Wayland}; + +/// How long the compositor gets to deliver all frames. +const TIMEOUT: Duration = Duration::from_secs(5); + +/// Something that captures outputs. Only wlr-screencopy exists today; +/// ext-image-copy-capture would be a second implementation. +pub trait Capturer { + fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result>; +} + +/// A buffer layout the compositor offered. +#[derive(Debug, Clone, Copy)] +struct Layout { + format: Format, + width: u32, + height: u32, + stride: u32, +} + +/// One screencopy request in flight. +pub struct Pending { + output: Output, + offered: Vec, + layout: Option, + // niri rejects a screencopy buffer unless its pool is exactly the + // buffer's size, so every capture gets a pool of its own. + pool: Option, + buffer: Option, + y_invert: bool, + result: Option>, +} + +impl Capturer for Wayland { + fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result> { + let manager = self + .state + .screencopy + .clone() + .context("compositor does not support wlr-screencopy") + .hint("run `valw doctor` to see what the compositor supports")?; + let qh = self.queue.handle(); + + self.state.captures = outputs + .iter() + .map(|o| Pending { + output: o.clone(), + offered: Vec::new(), + layout: None, + pool: None, + buffer: None, + y_invert: false, + result: None, + }) + .collect(); + for (i, o) in outputs.iter().enumerate() { + manager.capture_output(cursor as i32, &o.wl, &qh, i); + } + + let started = Instant::now(); + self.dispatch_until(started + TIMEOUT, |s| { + s.captures.iter().all(|c| c.result.is_some()) + }) + .context("screen capture timed out")?; + tracing::info!( + "captured {} output(s) in {:?}", + outputs.len(), + started.elapsed() + ); + + std::mem::take(&mut self.state.captures) + .into_iter() + .map(|c| { + let name = c.output.geom.name.clone(); + c.result + .expect("checked above") + .with_context(|| format!("could not capture output {name}")) + }) + .collect() + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + frame: &ZwlrScreencopyFrameV1, + event: zwlr_screencopy_frame_v1::Event, + &i: &usize, + _: &Connection, + qh: &QueueHandle, + ) { + use zwlr_screencopy_frame_v1::Event; + match event { + Event::Buffer { + format: WEnum::Value(format), + width, + height, + stride, + } => { + let c = &mut state.captures[i]; + c.offered.push(format); + let better = c.layout.is_none_or(|l| rank(format) < rank(l.format)); + if SUPPORTED.contains(&format) && better { + c.layout = Some(Layout { + format, + width, + height, + stride, + }); + } + // Before v3 there is no buffer_done; the first buffer event is all we get. + if frame.version() < 3 { + start_copy(state, frame, i, qh); + } + } + Event::BufferDone => start_copy(state, frame, i, qh), + Event::Flags { flags } => { + state.captures[i].y_invert = flags + .into_result() + .is_ok_and(|f| f.contains(zwlr_screencopy_frame_v1::Flags::YInvert)); + } + Event::Ready { .. } => { + let c = &mut state.captures[i]; + c.result = Some(finish(c)); + if let Some(buffer) = c.buffer.take() { + buffer.destroy(); + } + frame.destroy(); + } + Event::Failed => { + state.captures[i].result = Some(Err(anyhow!("the compositor refused the capture"))); + frame.destroy(); + } + _ => {} + } + } +} + +fn rank(format: Format) -> usize { + SUPPORTED + .iter() + .position(|&f| f == format) + .unwrap_or(usize::MAX) +} + +fn start_copy(state: &mut State, frame: &ZwlrScreencopyFrameV1, i: usize, qh: &QueueHandle) { + let c = &mut state.captures[i]; + if c.buffer.is_some() || c.result.is_some() { + return; + } + let Some(l) = c.layout else { + c.result = Some( + Err(anyhow!( + "compositor offered only {:?}, which valw can't convert yet", + c.offered + )) + .hint("run `valw doctor` and include its output in a bug report"), + ); + frame.destroy(); + return; + }; + tracing::info!( + "{}: offered {:?}, using {:?} {}x{}", + c.output.geom.name, + c.offered, + l.format, + l.width, + l.height + ); + let len = (l.stride * l.height) as usize; + match RawPool::new(len, &state.shm) { + Ok(mut pool) => { + let buffer = pool.create_buffer( + 0, + l.width as i32, + l.height as i32, + l.stride as i32, + l.format, + (), + qh, + ); + frame.copy(&buffer); + c.pool = Some(pool); + c.buffer = Some(buffer); + } + Err(e) => { + c.result = Some(Err(e).context("could not allocate a capture buffer")); + frame.destroy(); + } + } +} + +fn finish(c: &mut Pending) -> Result { + let l = c.layout.context("ready without a buffer")?; + let pool = c.pool.as_mut().context("ready without a buffer")?; + let data = &pool.mmap()[..]; + let image = frame::to_rgba(l.format, l.width, l.height, l.stride, data, c.y_invert)?; + Ok(Frame { + image: frame::apply_transform(image, c.output.transform), + output: c.output.geom.clone(), + }) +} +``` + +- [ ] **Step 2: Track captures in `State`** + +Apply this change to `src/wayland.rs`: + +```diff +--- a/src/wayland.rs ++++ b/src/wayland.rs +@@ -19,7 +19,7 @@ + use wayland_client::{ + Connection, EventQueue, QueueHandle, + globals::{GlobalList, registry_queue_init}, +- protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, ++ protocol::{wl_buffer::WlBuffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, + }; + use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; + use wayland_protocols::wp::viewporter::client::{ +@@ -27,6 +27,7 @@ + }; + use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; + ++use crate::capture::Pending; + use crate::error::HintExt; + use crate::frame::OutputGeom; + +@@ -58,6 +59,8 @@ + pub cursor_device: Option, + pub keyboard: Option, + pub pointer: Option, ++ /// Screencopy requests in flight. ++ pub captures: Vec, + } + + impl Wayland { +@@ -83,6 +86,7 @@ + cursor_device: None, + keyboard: None, + pointer: None, ++ captures: Vec::new(), + }; + // Two round trips: one for wl_output, one for the xdg-output details. + queue +@@ -422,3 +426,4 @@ + wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); + wayland_client::delegate_noop!(State: WpViewporter); + wayland_client::delegate_noop!(State: WpViewport); ++wayland_client::delegate_noop!(State: ignore WlBuffer); +``` + +- [ ] **Step 3: Write the failing CLI tests** + +Replace `src/main.rs` with the version below. It adds the `screen` command, the capture → deliver flow, and a test module that pins down the flag conflicts and `--delay` parsing. + +```rust +// Modules land before the CLI uses them; Task 11 removes this. +#![allow(dead_code)] + +mod capture; +mod config; +mod detach; +mod doctor; +mod error; +mod frame; +mod lock; +mod log; +mod niri; +mod output; +mod render; +mod selection; +mod wayland; + +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::Local; +use clap::{Args, Parser, Subcommand}; +use image::RgbaImage; + +use crate::capture::Capturer; +use crate::config::Config; +use crate::error::Cancelled; +use crate::lock::Lock; +use crate::output::Target; +use crate::wayland::Wayland; + +#[derive(Parser)] +#[command(version, about = "macOS-style screenshots for niri")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Capture the focused output (Cmd+Shift+3). + Screen { + /// Capture every output, one file each. + #[arg(long, conflicts_with_all = ["output", "clipboard_only"])] + all: bool, + #[command(flatten)] + common: Common, + }, + /// Report what the compositor and system support. + Doctor, +} + +#[derive(Args)] +struct Common { + /// Copy to the clipboard without saving a file. + #[arg(long, conflicts_with = "output")] + clipboard_only: bool, + /// Write to this path instead of the configured directory; "-" for stdout. + #[arg(short, long, value_name = "PATH")] + output: Option, + /// Wait this many seconds before capturing. + #[arg(long, value_name = "SECS", value_parser = parse_delay)] + delay: Option, + /// Include the mouse cursor. + #[arg(long)] + cursor: bool, +} + +fn parse_delay(s: &str) -> Result { + let secs: f64 = s.parse().map_err(|_| format!("not a number: {s}"))?; + Duration::try_from_secs_f64(secs) + .map_err(|_| format!("must be a non-negative number of seconds: {s}")) +} + +enum Mode { + Screen { all: bool }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let log = log::init(&log::default_dir(), Local::now()); + log::install_panic_hook(log.clone()); + tracing::info!( + "valw {} {:?}", + env!("CARGO_PKG_VERSION"), + std::env::args().collect::>() + ); + + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(e) if e.downcast_ref::().is_some() => { + tracing::info!("cancelled by the user"); + ExitCode::from(3) + } + Err(e) => { + tracing::error!("{e:#}"); + eprint!("{}", error::render(&e, log.as_deref())); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Doctor => doctor::run(), + Command::Screen { all, common } => capture(Mode::Screen { all }, common), + } +} + +fn capture(mode: Mode, common: Common) -> Result<()> { + let config = config::load(&config::default_path())?; + let lock = Lock::acquire(&lock::default_path())?; + if let Some(delay) = common.delay { + std::thread::sleep(delay); + } + let cursor = common.cursor || config.capture.show_cursor; + let mut wl = Wayland::connect()?; + if let Ok(version) = niri::version() { + tracing::info!("niri {version}"); + } + let outputs = wl.outputs(); + anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); + + // (image, output name for the file name) and which one goes to the clipboard. + let (shots, clip): (Vec<(RgbaImage, Option)>, usize) = match mode { + Mode::Screen { all } => { + let focused = focused_output(&outputs); + if all { + let frames = wl.capture(&outputs, cursor)?; + let shots = frames + .into_iter() + .map(|f| (f.image, Some(f.output.name))) + .collect(); + (shots, focused) + } else { + let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); + (vec![(frame.image, None)], 0) + } + } + }; + drop(wl); + + let target = Target::from_args(common.output, common.clipboard_only); + deliver(shots, clip, &target, &config, lock) +} + +/// Index of niri's focused output, or 0 if niri can't tell us. +fn focused_output(outputs: &[wayland::Output]) -> usize { + let name = match niri::focused_output() { + Ok(Some(name)) => name, + Ok(None) => return 0, + Err(e) => { + tracing::warn!("using the first output: {e:#}"); + return 0; + } + }; + outputs + .iter() + .position(|o| o.geom.name == name) + .unwrap_or_else(|| { + tracing::warn!("niri's focused output {name} is not among the Wayland outputs"); + 0 + }) +} + +fn deliver( + shots: Vec<(RgbaImage, Option)>, + clip: usize, + target: &Target, + config: &Config, + lock: Lock, +) -> Result<()> { + let now = Local::now(); + let pngs = shots + .iter() + .map(|(img, _)| output::encode_png(img)) + .collect::>>()?; + + match target { + Target::Default => { + let dir = config.save_dir(); + for (png, (_, name)) in pngs.iter().zip(&shots) { + let file = output::file_name(&config.save.filename, &now, name.as_deref())?; + let path = output::unique_path(&dir, &file); + output::write_atomic(&path, png)?; + println!("{}", path.display()); + } + } + Target::Path(path) => { + output::write_atomic(path, &pngs[0])?; + println!("{}", path.display()); + } + Target::Stdout => output::write_stdout(&pngs[0])?, + Target::ClipboardOnly => {} + } + + if *target == Target::ClipboardOnly || config.save.copy_to_clipboard { + let png = pngs + .into_iter() + .nth(clip) + .context("no image for the clipboard")?; + output::copy_to_clipboard(png, lock)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + fn parses(args: &[&str]) -> bool { + Cli::try_parse_from(std::iter::once("valw").chain(args.iter().copied())).is_ok() + } + + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn flag_conflicts() { + assert!(parses(&["screen", "--all"])); + assert!(parses(&["screen", "-o", "-"])); + assert!(parses(&["screen", "--clipboard-only", "--cursor"])); + assert!(!parses(&["screen", "--all", "-o", "-"])); + assert!(!parses(&["screen", "--all", "--clipboard-only"])); + assert!(!parses(&["screen", "--clipboard-only", "-o", "a.png"])); + } + + #[test] + fn delay_values() { + assert_eq!(parse_delay("1.5"), Ok(Duration::from_millis(1500))); + assert_eq!(parse_delay("0"), Ok(Duration::ZERO)); + assert!(parse_delay("-1").is_err()); + assert!(parse_delay("soon").is_err()); + assert!(parse_delay("NaN").is_err()); + } +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cargo nextest run` +Expected: every test passes, including `tests::flag_conflicts`, `tests::delay_values` and `tests::cli_definition_is_valid`. (The CLI tests were written together with the code they test because clap's derive definitions are the implementation.) + +- [ ] **Step 5: Lint** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings` +Expected: no warnings. + +- [ ] **Step 6: Capture on niri without touching the real clipboard or Pictures** + +```sh +t=$(mktemp -d) +mkdir -p $t/config/valw +printf '[save]\ndirectory = "%s/shots"\ncopy_to_clipboard = false\n' $t > $t/config/valw/config.toml +export XDG_CONFIG_HOME=$t/config XDG_STATE_HOME=$t/state +cargo build --release +V=$CARGO_TARGET_DIR/release/valw +$V screen -o - | file - +$V screen --all && file $t/shots/* +$V screen --delay 0.5 -o $t/one.png && file $t/one.png +``` + +Expected: `PNG image data, 1366 x 768` (or whichever output niri has focused). With `--all`, one file per output named `Screenshot … (LVDS-1).png` / `(HDMI-A-1).png` at each output's physical size. The log in `$t/state/valw/logs/` shows `offered [Xrgb8888], using Xrgb8888 …` and `captured N output(s) in …ms`. + +- [ ] **Step 7: Clipboard and pipes** + +```sh +printf '[save]\ndirectory = "%s/shots"\n' $t > $t/config/valw/config.toml +time ($V screen -o - | wc -c) +wl-paste --list-types +wl-paste --type image/png | file - +``` + +Expected: the pipeline returns immediately (well under a second), even though the clipboard child is still running. `wl-paste --list-types` lists `image/png`, and the pasted data is the PNG. This step overwrites the clipboard, so tell the user before running it. + +- [ ] **Step 8: Hotkey spam (Review Focus 4)** + +```sh +$V screen --delay 3 -o $t/a.png & sleep 0.5; $V screen -o $t/b.png; echo "exit=$?"; wait +$V screen -o $t/c.png; echo "exit=$?" +``` + +Expected: the second command prints `error: valw is already running` with `hint: wait for the other capture to finish, or press Esc in it` and `exit=1`. The first one then succeeds. The third succeeds (`exit=0`) even though the first run's clipboard child is still alive. + +- [ ] **Step 9: Commit** + +```sh +jj commit -m "feat: wlr-screencopy capture and valw screen" +``` + +--- + + +### Task 11: Region selection overlay and `valw region` + +**Files:** +- Create: `src/region.rs` +- Modify: `src/wayland.rs` (route overlay events), `src/main.rs` (add `region`, drop `#![allow(dead_code)]`) + +**Interfaces:** +- Consumes: `selection::{Selection, Point, PixelRect, clip, resolve}` (Task 7); `render::{to_argb8888, compose, DIM}` (Task 7); `frame::{Frame, OutputGeom}` (Task 6); `wayland::{Wayland, State, Output}`, `Wayland::dispatch_blocking` (Task 9); `log::since_start` (Task 4); `error::{Cancelled, HintExt}` (Task 2). +- Produces: `pub const NAMESPACE: &str = "valw-overlay"`; `pub type Picked = (usize, PixelRect)`; `pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result` (returns `Cancelled` on Esc or right click); `pub struct Overlay` with `cancel`, `configure`, `frame_done`, `pointer`, called from `State`'s handlers. CLI: `valw region [COMMON]`. + +How it works: one fullscreen layer surface per output shows that output's frozen frame, darkened. The buffer has the frame's physical size, and `wp_viewporter` maps it onto the surface's logical size, so it is shown 1:1 at any scale. Pointer positions are converted to global logical coordinates. The drag is drawn (and later cropped) only on the output where it started. Redraws are paced by frame callbacks. + +- [ ] **Step 1: Write `src/region.rs`** + +Add `mod region;` to the `mod` list in `src/main.rs`. + +```rust +use anyhow::{Context, Result}; +use smithay_client_toolkit::{ + compositor::FrameCallbackData, + seat::pointer::{BTN_LEFT, BTN_RIGHT, PointerEvent, PointerEventKind}, + shell::{ + WaylandSurface, + wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, + }, + shm::{ + Shm, + slot::{Buffer, SlotPool}, + }, +}; +use wayland_client::{ + QueueHandle, + protocol::{wl_shm, wl_surface::WlSurface}, +}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ + Shape, WpCursorShapeDeviceV1, +}; +use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; + +use crate::error::{Cancelled, HintExt}; +use crate::frame::{Frame, OutputGeom}; +use crate::render; +use crate::selection::{self, PixelRect, Point, Selection}; +use crate::wayland::{Output, State, Wayland}; + +pub const NAMESPACE: &str = "valw-overlay"; + +/// What the user picked: an output index and a rectangle in its frame. +pub type Picked = (usize, PixelRect); + +/// One full-screen layer surface per output, showing the frozen frame. +pub struct Overlay { + surfaces: Vec, + selection: Selection, + /// Output where the current drag started; the capture is clipped to it. + anchor: Option, + outcome: Option>, +} + +struct Surface { + layer: LayerSurface, + viewport: WpViewport, + geom: OutputGeom, + width: u32, + height: u32, + dark: Vec, + bright: Vec, + pool: SlotPool, + buffer: Option, + configured: bool, + frame_pending: bool, + dirty: bool, +} + +/// Shows the overlay on every output and waits for a selection. +/// Returns `Cancelled` if the user presses Esc or right-clicks. +pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result { + let qh = wl.queue.handle(); + let s = &wl.state; + let layer_shell = s + .layer_shell + .as_ref() + .context("compositor does not support wlr-layer-shell") + .hint("run `valw doctor` to see what the compositor supports")?; + let viewporter = s + .viewporter + .as_ref() + .context("compositor does not support wp-viewporter") + .hint("run `valw doctor` to see what the compositor supports")?; + + let mut surfaces = Vec::new(); + for (output, frame) in outputs.iter().zip(frames) { + let surface = s.compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + surface, + Layer::Overlay, + Some(NAMESPACE), + Some(&output.wl), + ); + layer.set_anchor(Anchor::all()); + layer.set_exclusive_zone(-1); + layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive); + layer.commit(); + let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); + surfaces.push(Surface::new(layer, viewport, frame, &s.shm)?); + } + + wl.state.overlay = Some(Overlay { + surfaces, + selection: Selection::default(), + anchor: None, + outcome: None, + }); + let result = wl.dispatch_blocking(|s| s.overlay.as_ref().is_some_and(|o| o.outcome.is_some())); + let outcome = wl.state.overlay.take().and_then(|o| o.outcome); + // Dropping the overlay destroys the surfaces; make that visible right away. + let _ = wl.conn.flush(); + result?; + outcome.flatten().ok_or_else(|| Cancelled.into()) +} + +impl Surface { + fn new(layer: LayerSurface, viewport: WpViewport, frame: &Frame, shm: &Shm) -> Result { + let (width, height) = frame.image.dimensions(); + let len = (width * height * 4) as usize; + Ok(Surface { + layer, + viewport, + geom: frame.output.clone(), + width, + height, + dark: render::to_argb8888(&frame.image, render::DIM), + bright: render::to_argb8888(&frame.image, 255), + // Room for two buffers so one can be on screen while we draw the next. + pool: SlotPool::new(len * 2, shm).context("could not create shm pool")?, + buffer: None, + configured: false, + frame_pending: false, + dirty: true, + }) + } + + fn draw(&mut self, sel: Option, qh: &QueueHandle) { + let stride = self.width as i32 * 4; + let Ok((buffer, canvas)) = self.pool.create_buffer( + self.width as i32, + self.height as i32, + stride, + wl_shm::Format::Argb8888, + ) else { + tracing::warn!("could not allocate an overlay buffer"); + return; + }; + render::compose(canvas, &self.dark, &self.bright, self.width, sel); + + let surface = self.layer.wl_surface(); + surface.damage_buffer(0, 0, self.width as i32, self.height as i32); + surface.frame(qh, FrameCallbackData(surface.clone())); + if buffer.attach_to(surface).is_err() { + tracing::warn!("could not attach the overlay buffer"); + return; + } + self.layer.commit(); + self.buffer = Some(buffer); + self.frame_pending = true; + self.dirty = false; + } +} + +impl Overlay { + pub fn cancel(&mut self) { + self.outcome.get_or_insert(None); + } + + fn index_of(&self, surface: &WlSurface) -> Option { + self.surfaces + .iter() + .position(|s| s.layer.wl_surface() == surface) + } + + /// The selection as drawn on surface `i`: only the anchor output shows it. + fn selection_on(&self, i: usize) -> Option { + let Selection::Dragging { start, end } = self.selection else { + return None; + }; + let s = &self.surfaces[i]; + (self.anchor == Some(i)) + .then(|| selection::clip(start, end, &s.geom, s.width, s.height)) + .flatten() + } + + fn redraw(&mut self, i: usize, qh: &QueueHandle) { + let sel = self.selection_on(i); + let s = &mut self.surfaces[i]; + s.dirty = true; + if s.configured && !s.frame_pending { + s.draw(sel, qh); + } + } + + pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle) { + let Some(i) = self.index_of(layer.wl_surface()) else { + return; + }; + let s = &mut self.surfaces[i]; + // Show the physical-size buffer 1:1 on the logical-size surface. + s.viewport.set_destination(w as i32, h as i32); + if !s.configured { + s.configured = true; + let name = s.geom.name.clone(); + self.redraw(i, qh); + tracing::info!( + "overlay on {name} committed {:?} after start", + crate::log::since_start() + ); + } + } + + pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { + let Some(i) = self.index_of(surface) else { + return; + }; + self.surfaces[i].frame_pending = false; + if self.surfaces[i].dirty { + self.redraw(i, qh); + } + } + + pub fn pointer( + &mut self, + events: &[PointerEvent], + cursor: Option<&WpCursorShapeDeviceV1>, + qh: &QueueHandle, + ) { + for event in events { + let Some(i) = self.index_of(&event.surface) else { + continue; + }; + let g = &self.surfaces[i].geom; + let p = Point { + x: g.x as f64 + event.position.0, + y: g.y as f64 + event.position.1, + }; + match event.kind { + PointerEventKind::Enter { serial } => { + if let Some(cursor) = cursor { + cursor.set_shape(serial, Shape::Crosshair); + } + } + PointerEventKind::Press { + button: BTN_LEFT, .. + } => { + self.selection.press(p); + self.anchor = Some(i); + } + PointerEventKind::Press { + button: BTN_RIGHT, .. + } => self.cancel(), + PointerEventKind::Motion { .. } => { + self.selection.motion(p); + if let Some(a) = self.anchor { + self.redraw(a, qh); + } + } + PointerEventKind::Release { + button: BTN_LEFT, .. + } => { + let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor) + else { + continue; + }; + let s = &self.surfaces[a]; + match selection::resolve(start, end, &s.geom, s.width, s.height) { + Some(rect) => { + tracing::info!( + "selected {rect:?} on {} (logical {start:?} to {end:?})", + s.geom.name + ); + self.outcome = Some(Some((a, rect))); + } + // A click, not a drag: clear it and keep waiting. + None => self.redraw(a, qh), + } + } + _ => {} + } + } + } +} +``` + +- [ ] **Step 2: Route overlay events from `State`** + +Apply this change to `src/wayland.rs`: + +```diff +--- a/src/wayland.rs ++++ b/src/wayland.rs +@@ -30,6 +30,7 @@ + use crate::capture::Pending; + use crate::error::HintExt; + use crate::frame::OutputGeom; ++use crate::region::Overlay; + + /// An output as valw sees it. + #[derive(Debug, Clone)] +@@ -61,6 +62,8 @@ + pub pointer: Option, + /// Screencopy requests in flight. + pub captures: Vec, ++ /// The region selection overlay, while it is open. ++ pub overlay: Option, + } + + impl Wayland { +@@ -87,6 +90,7 @@ + keyboard: None, + pointer: None, + captures: Vec::new(), ++ overlay: None, + }; + // Two round trips: one for wl_output, one for the xdg-output details. + queue +@@ -148,9 +152,12 @@ + } + + fn log_environment(&self) { +- for (interface, version) in self.global_list() { +- tracing::debug!("global {interface} v{version}"); +- } ++ let globals: Vec = self ++ .global_list() ++ .into_iter() ++ .map(|(interface, version)| format!("{interface} v{version}")) ++ .collect(); ++ tracing::info!("globals: {}", globals.join(", ")); + for o in self.outputs() { + let g = &o.geom; + tracing::info!( +@@ -230,7 +237,17 @@ + ) { + } + +- fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} ++ fn frame( ++ &mut self, ++ _: &Connection, ++ qh: &QueueHandle, ++ surface: &wl_surface::WlSurface, ++ _: u32, ++ ) { ++ if let Some(overlay) = &mut self.overlay { ++ overlay.frame_done(surface, qh); ++ } ++ } + + fn surface_enter( + &mut self, +@@ -264,16 +281,23 @@ + } + + impl LayerShellHandler for State { +- fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) {} ++ fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { ++ if let Some(overlay) = &mut self.overlay { ++ overlay.cancel(); ++ } ++ } + + fn configure( + &mut self, + _: &Connection, +- _: &QueueHandle, +- _: &LayerSurface, +- _: LayerSurfaceConfigure, +- _: u32, +- ) { ++ qh: &QueueHandle, ++ layer: &LayerSurface, ++ configure: LayerSurfaceConfigure, ++ _: u32, ++ ) { ++ if let Some(overlay) = &mut self.overlay { ++ overlay.configure(layer, configure.new_size, qh); ++ } + } + } + +@@ -360,8 +384,13 @@ + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, +- _: KeyEvent, +- ) { ++ event: KeyEvent, ++ ) { ++ if event.keysym == Keysym::Escape ++ && let Some(overlay) = &mut self.overlay ++ { ++ overlay.cancel(); ++ } + } + + fn repeat_key( +@@ -401,10 +430,13 @@ + fn pointer_frame( + &mut self, + _: &Connection, +- _: &QueueHandle, ++ qh: &QueueHandle, + _: &wl_pointer::WlPointer, +- _: &[PointerEvent], +- ) { ++ events: &[PointerEvent], ++ ) { ++ if let Some(overlay) = &mut self.overlay { ++ overlay.pointer(events, self.cursor_device.as_ref(), qh); ++ } + } + } + +``` + +- [ ] **Step 3: Add the `region` command and remove the dead-code allowance** + +Apply this change to `src/main.rs`: + +```diff +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,6 +1,3 @@ +-// Modules land before the CLI uses them; Task 11 removes this. +-#![allow(dead_code)] +- + mod capture; + mod config; + mod detach; +@@ -11,6 +8,7 @@ + mod log; + mod niri; + mod output; ++mod region; + mod render; + mod selection; + mod wayland; +@@ -22,7 +20,7 @@ + use anyhow::{Context, Result}; + use chrono::Local; + use clap::{Args, Parser, Subcommand}; +-use image::RgbaImage; ++use image::{RgbaImage, imageops}; + + use crate::capture::Capturer; + use crate::config::Config; +@@ -45,6 +43,11 @@ + /// Capture every output, one file each. + #[arg(long, conflicts_with_all = ["output", "clipboard_only"])] + all: bool, ++ #[command(flatten)] ++ common: Common, ++ }, ++ /// Drag to select a region (Cmd+Shift+4). ++ Region { + #[command(flatten)] + common: Common, + }, +@@ -76,6 +79,7 @@ + + enum Mode { + Screen { all: bool }, ++ Region, + } + + fn main() -> ExitCode { +@@ -106,6 +110,7 @@ + match cli.command { + Command::Doctor => doctor::run(), + Command::Screen { all, common } => capture(Mode::Screen { all }, common), ++ Command::Region { common } => capture(Mode::Region, common), + } + } + +@@ -139,6 +144,13 @@ + (vec![(frame.image, None)], 0) + } + } ++ Mode::Region => { ++ let frames = wl.capture(&outputs, cursor)?; ++ let (i, r) = region::select(&mut wl, &outputs, &frames)?; ++ let image = ++ imageops::crop_imm(&frames[i].image, r.x, r.y, r.width, r.height).to_image(); ++ (vec![(image, None)], 0) ++ } + }; + drop(wl); + +@@ -224,10 +236,10 @@ + fn flag_conflicts() { + assert!(parses(&["screen", "--all"])); + assert!(parses(&["screen", "-o", "-"])); +- assert!(parses(&["screen", "--clipboard-only", "--cursor"])); ++ assert!(parses(&["region", "--clipboard-only", "--cursor"])); + assert!(!parses(&["screen", "--all", "-o", "-"])); + assert!(!parses(&["screen", "--all", "--clipboard-only"])); +- assert!(!parses(&["screen", "--clipboard-only", "-o", "a.png"])); ++ assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"])); + } + + #[test] +``` + +- [ ] **Step 4: Run tests and lint without the allowance** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings (in particular, no `dead_code`); every test passes. + +- [ ] **Step 5: Select a region on niri** + +Use the throwaway config from Task 10 (`copy_to_clipboard = false`): + +```sh +cargo build --release +$V region -o $t/r.png; echo "exit=$?"; file $t/r.png +grep -h "committed\|selected" $t/state/valw/logs/*.log | tail -3 +``` + +Ask the user to drag over a static area. Expected: every output freezes and dims, the cursor becomes a crosshair, the selection shows at full brightness with a white 1 px outline, and the overlay disappears on release. The command prints `exit=0`, and `r.png` has the logged pixel size. The log shows `overlay on … committed …ms after start` and `selected PixelRect { … } on …`. + +- [ ] **Step 6: Cancel paths** + +Ask the user to run `$V region -o $t/x.png; echo "exit=$?"` three times: press Esc, then right-click, then click once without dragging and press Esc afterwards. +Expected: `exit=3` each time, and no `x.png`. A plain click does **not** end the selection; the overlay stays until Esc. + +- [ ] **Step 7: Drag across monitors (Review Focus 3)** + +Ask the user to start a drag on one monitor and release it on the other, and once more to release outside every monitor's area (for example past a screen edge). +Expected: no panic. The result is clipped to the monitor where the drag started, and the log's `selected` line shows a rectangle inside that monitor. + +- [ ] **Step 8: No stuck overlay** + +```sh +$V region -o $t/k.png & sleep 1; kill -9 $! +$V screen -o /dev/null; echo "exit=$?" +``` + +Expected: the overlay vanishes when the process is killed, and the next command gets the lock (`exit=0`). + +- [ ] **Step 9: Commit** + +```sh +jj commit -m "feat: frozen-frame region selection overlay" +``` + +--- + + +### Task 12: Nix checks (clippy, fmt, nextest, headless sway) + +**Files:** +- Create: `nix/checks.ulu.nix` + +**Interfaces:** +- Consumes: `packages.default` (Task 1); `valw doctor` and `valw screen -o -` (Tasks 9–10). +- Produces: `checks..{clippy,fmt,nextest,headless}`, run by `nix flake check`. + +Notes: +- `cargoCheck` reuses the package derivation, so dependencies are vendored once. Setting `buildPhase` and `installPhase` replaces cargo's build and install hooks. +- `fmt` calls `rustfmt` directly because `cargo fmt` would try to resolve dependencies offline. +- The headless check must use `sway-unwrapped`: nixpkgs' `sway` wrapper starts through `dbus-run-session`, which does not exist in the sandbox. +- `valw screen -o - | cat` runs under `timeout 10`, so a clipboard child that kept the pipe open would fail the check. + +- [ ] **Step 1: Write `nix/checks.ulu.nix`** + +```nix +{ ... }: +{ + perSystem = + { pkgs, self', ... }: + let + valw = self'.packages.default; + + # Reuses the package's vendored dependencies, runs `cmd` instead of the build. + cargoCheck = + name: tools: cmd: + valw.overrideAttrs (old: { + pname = "valw-${name}"; + nativeBuildInputs = old.nativeBuildInputs ++ tools; + buildPhase = cmd; + installPhase = "touch $out"; + }); + in + { + checks = { + clippy = cargoCheck "clippy" [ pkgs.clippy ] "cargo clippy --all-targets --offline -- -D warnings"; + nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline"; + + fmt = pkgs.runCommand "valw-fmt" { nativeBuildInputs = [ pkgs.rustfmt ]; } '' + rustfmt --check --edition 2024 ${valw.src}/src/main.rs + touch $out + ''; + + # Runs the real screencopy path against a headless sway. This is + # wlroots, not niri, but it catches capture and format bugs on every build. + headless = + pkgs.runCommand "valw-headless" + { + nativeBuildInputs = [ + pkgs.file + pkgs.sway-unwrapped + valw + ]; + } + '' + export HOME=$TMPDIR/home XDG_RUNTIME_DIR=$TMPDIR/run + mkdir -p $HOME + mkdir -m 700 $XDG_RUNTIME_DIR + export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 + + echo 'output HEADLESS-1 resolution 1280x720' > sway.conf + sway -c sway.conf > sway.log 2>&1 & + + for _ in $(seq 100); do + socket=$(ls $XDG_RUNTIME_DIR | grep -m1 '^wayland-[0-9]*$' || true) + [ -n "$socket" ] && break + sleep 0.1 + done + [ -n "$socket" ] || { cat sway.log; exit 1; } + export WAYLAND_DISPLAY=$socket + + valw doctor | tee doctor.txt + ! grep -q '^fail' doctor.txt + + # Through a pipe: the clipboard child must not keep it open. + timeout 10 valw screen -o - | cat > shot.png + file shot.png | tee file.txt + grep -q 'PNG image data, 1280 x 720' file.txt + + touch $out + ''; + }; + }; +} +``` + +- [ ] **Step 2: Run every check** + +Run: `nix flake check -L --keep-going` +Expected: all four checks build. The `valw-headless` log shows the doctor lines, all `ok` except `warn niri IPC unavailable …` (sway is not niri), plus `shot.png: PNG image data, 1280 x 720`. + +- [ ] **Step 3: Format the Nix files** + +Run: `nix fmt` +Expected: no changes beyond whitespace. If anything changed, rerun Step 2. + +- [ ] **Step 4: Commit** + +```sh +jj commit -m "test: nix flake checks with headless sway capture" +``` + +--- + + +### Task 13: Acceptance on niri + +**Files:** none (verification only). If a check fails, fix the owning task's code, add a unit test that reproduces the failure where possible, and commit the fix separately. + +**Interfaces:** +- Consumes: the release binary from `nix build`. +- Produces: confirmation that Phase 0 + 1 meets the spec's sections 10.3 and 11. + +Before starting, tell the user this task temporarily changes the HDMI output's scale and rotation through `niri msg`. That is runtime only and doesn't touch the niri config, and every step restores it. Use the throwaway config from Task 10. + +- [ ] **Step 1: Build the real package** + +```sh +nix build && V=$PWD/result/bin/valw +``` + +- [ ] **Step 2: Pixel exactness at scale 1 and 1.25 (Review Focus 2)** + +For each output, and then again after `niri msg output HDMI-A-1 scale 1.25`: + +```sh +$V region -o $t/a.png # user drags over a static area +grep -h selected $t/state/valw/logs/*.log | tail -1 +# -> selected PixelRect { x: X, y: Y, width: W, height: H } on NAME ... +grim -o NAME $t/full.png +magick $t/full.png -crop WxH+X+Y +repage $t/b.png +magick compare -metric AE $t/a.png $t/b.png null:; echo +``` + +Expected: `0` every time. At 1.25 the overlay must also look sharp, with text in it as crisp as on the live screen. Restore with `niri msg output HDMI-A-1 scale 1`. + +- [ ] **Step 3: Latency** + +Run `$V region -o /dev/null` and press Esc; repeat three times. +Expected: every `overlay on … committed …ms after start` log line is under 150 ms. + +- [ ] **Step 4: Rotated output (Review Focus 1)** + +```sh +niri msg output HDMI-A-1 transform 90 +$V screen --all && grim -o HDMI-A-1 $t/g.png +magick compare -metric AE "$(ls $t/shots/*HDMI-A-1*.png | tail -1)" $t/g.png null:; echo +niri msg output HDMI-A-1 transform normal +``` + +Expected: `0`. If the images are rotated relative to each other, the mapping in `frame::apply_transform` is wrong for niri. Fix the table in `transforms_match_grim` and the match arms together. + +- [ ] **Step 5: Noctalia** + +Run `$V region -o /dev/null` and have the user confirm the dimmed overlay covers the Noctalia bar, then press Esc. + +- [ ] **Step 6: Unwritable log directory (Review Focus 5)** + +```sh +XDG_STATE_HOME=/proc $V screen -o $t/nolog.png; echo "exit=$?"; file $t/nolog.png +$V screen -o /proc/nope.png; echo "exit=$?" +``` + +Expected: the first prints a `WARN logging to stderr only: …` line, `exit=0`, and a valid PNG. The second prints `error: could not write /proc/nope.png`-style output with `cause:` lines and the `log:` path, and `exit=1`. + +- [ ] **Step 7: Full check suite** + +Run: `nix flake check -L --keep-going` +Expected: every check passes. + +- [ ] **Step 8: Report** + +Summarise the results of Steps 2–6 to the user, including exact numbers (AE values and latencies). There is nothing to commit unless a fix was needed. diff --git a/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md b/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md index f772e1d..5f3263a 100644 --- a/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md +++ b/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md @@ -91,18 +91,23 @@ src/ error.rs # user-facing error type and rendering log.rs # tracing setup, per-run log files lock.rs # single-instance lock + detach.rs # fork a background child (clipboard server) wayland.rs # SCTK state: registry, outputs, seat, shm + frame.rs # Frame type, pixel format conversion, transforms capture.rs # Capturer trait + wlr-screencopy implementation - niri.rs # niri IPC wrapper (focused output) - region.rs # region selection overlay + state machine - render.rs # tiny-skia helpers + niri.rs # niri IPC wrapper (focused output, version) + selection.rs # selection state machine, logical → pixel math + region.rs # region selection overlay (layer-shell surfaces) + render.rs # overlay pixels: dim, paste selection, outline output.rs # PNG saving, filename template, clipboard doctor.rs # `valw doctor` docs/ ``` Files stay flat for now; `overlay/` and `editor/` subdirectories appear once -Phases 3 to 5 give them enough content. +Phases 3 to 5 give them enough content. Pure logic (`frame`, `selection`, +`render`, `output`, `config`, `error`) is kept apart from Wayland code so it +can be unit tested without a compositor. ### 4.1 Nix @@ -179,16 +184,23 @@ is incomplete. | Purpose | Crate | |---|---| | Wayland client | `smithay-client-toolkit`, `wayland-client`, `wayland-protocols`, `wayland-protocols-wlr` | -| Drawing | `tiny-skia` | | PNG | `image` (png feature only) | | Clipboard | `wl-clipboard-rs` | -| niri IPC | `niri-ipc` (version pinned to match niri 26.04) | +| niri IPC | `niri-ipc` (`=26.4.0`, matches niri 26.04) | | CLI | `clap` (derive) | | Config | `serde`, `toml`, `serde_ignored` | | Time / filenames | `chrono` | | Errors | `anyhow` | -| Logging | `tracing`, `tracing-subscriber`, `tracing-appender` | -| Lock | `rustix` (flock) | +| Logging | `tracing`, `tracing-subscriber` | +| Lock, poll, stdio | `rustix` | +| fork | `libc` | + +Not needed in Phase 1: `tiny-skia` (the overlay is plain byte copies; shapes +and text arrive in Phase 3) and `tracing-appender` (one file per run needs no +rolling writer). + +Dependencies are built with `opt-level = 3` in the dev profile; unoptimised +PNG encoding is too slow to test with. ## 6. Capture pipeline @@ -205,12 +217,20 @@ Every capture command runs these steps in order. screencopy and layer-shell. A missing required global is an error that names the protocol. 5. **Capture.** `Capturer::capture(&[Output], cursor: bool) -> Vec` - issues one screencopy request per output in parallel and waits for every - `ready`. A `Frame` holds RGBA8 pixels at physical resolution plus its output - name, logical geometry and scale. `XRGB8888`, `ARGB8888`, `XBGR8888` and + issues one screencopy request per output in parallel and waits up to 5 s + for every `ready`. A `Frame` holds upright, opaque RGBA8 pixels at physical + resolution plus its output name and logical geometry; the scale is + `frame width / logical width`. `XRGB8888`, `ARGB8888`, `XBGR8888` and `ABGR8888` are converted; `y_invert` is honoured; any other format is an - error that names the format. `cursor` comes from `--cursor` or + error that names the format. Alpha is always 255, even where the + compositor's buffer says 0. `cursor` comes from `--cursor` or `capture.show_cursor`. + - **niri quirk (verified in its source):** niri rejects a shm buffer + with `invalid buffer` unless the whole `wl_shm_pool` is exactly the + buffer's size. Every capture therefore gets its own pool. + - **Rotated outputs:** the buffer arrives in the output's native + orientation. It is turned upright the way grim does it: y-invert, + rotate clockwise by the transform's angle, then mirror if flipped. ### 6.1 `valw screen` @@ -241,10 +261,11 @@ dies. No overlay can outlive the process. ### 7.2 HiDPI -- Each surface asks for its preferred scale via `wp_fractional_scale`. The - buffer is allocated at physical size, and `wp_viewporter` sets the - destination to the logical size. The frozen frame is shown 1:1 with no - resampling. +- Each surface's buffer is the frozen frame's size (physical pixels), and + `wp_viewporter` sets the destination to the logical size from the layer + configure. The frame is shown 1:1 with no resampling. The frame already + says how many physical pixels the output has, so `wp_fractional_scale` is + not needed. - Pointer events arrive in surface-local logical coordinates. They are converted to global logical coordinates by adding the output's logical position; the selection is stored in global logical coordinates. @@ -267,8 +288,9 @@ Idle --press--> Dragging{start} --release--> Done(rect) ### 7.4 Logical to physical conversion -For an output at logical position `(ox, oy)` with scale `s` and a selection -`(x, y, w, h)` in global logical coordinates: +For an output at logical position `(ox, oy)` with scale `s` (per axis: +frame size / logical size) and a selection `(x, y, w, h)` in global logical +coordinates: ``` x0 = floor((x - ox) * s) y0 = floor((y - oy) * s) @@ -312,10 +334,22 @@ show_cursor = false ### 8.3 Clipboard -`wl-clipboard-rs` copies the PNG as `image/png` and forks into the background -to serve paste requests. The forked child would inherit the lock file -descriptor and keep the lock held, so **the lock is released explicitly before -copying**. A test covers this. +A Wayland clipboard needs a process that stays alive to answer paste +requests. `wl-clipboard-rs` does **not** fork: its background mode serves from +a thread, which dies when valw exits. So valw does what `wl-copy` does: + +1. `Options::foreground(true).prepare_copy(...)` offers the PNG as + `image/png` (no threads are started). +2. `detach::spawn` releases the capture lock, then forks. The child calls + `setsid`, points stdin/stdout/stderr at `/dev/null`, serves paste requests + until something else is copied, and exits with `_exit`. +3. The parent exits right away. + +The lock is released before the fork because the child would otherwise +inherit it and block the next capture. stdio goes to `/dev/null` because an +inherited stdout pipe would keep `valw region -o - | consumer` waiting until +the clipboard changes. A unit test covers both, and the headless test runs +`screen -o -` through a pipe. ### 8.4 CLI @@ -326,15 +360,16 @@ valw doctor COMMON: --clipboard-only copy to clipboard, do not save a file - --output write to this path instead of directory + template; - "-" writes PNG to stdout (not allowed with --all) - --delay wait before capturing + -o, --output write to this path instead of directory + template; + "-" writes PNG to stdout + --delay wait before capturing (non-negative, fractions allowed) --cursor include the cursor in the capture ``` -Flag interactions: +Flag interactions (enforced by clap, so they are usage errors, exit 2): -- `--clipboard-only` conflicts with `--output` and `--all`. +- `--clipboard-only` conflicts with `--output`. +- `--all` conflicts with `--output` and `--clipboard-only`. - Otherwise the clipboard follows `save.copy_to_clipboard`, whatever the output target (including `--output -`). - `--clipboard-only` copies even if `save.copy_to_clipboard = false`. @@ -372,13 +407,19 @@ error: could not capture output HDMI-A-1 - `tracing` writes one file per run to `$XDG_STATE_HOME/valw/logs/`; the 10 most recent are kept. -- Each log starts with an environment summary: valw version, niri version, - bound globals and their versions, outputs (name, position, size, scale), and - the buffer formats screencopy offered. +- Each log starts with an environment summary: valw version and arguments, + niri version, all globals and their versions (one line), outputs (name, + position, size, transform). Each capture logs the buffer formats offered + and the one used, and how long capturing took. +- `region` logs the time from process start to the first overlay commit, and + the selected rectangle in both logical and pixel coordinates. - The default level is `info`; `VALW_LOG=debug` (EnvFilter syntax) raises it. -- stderr gets warnings and errors. Processes spawned by niri log stderr to - niri's journal (`journalctl --user -u niri`), but the log file is the +- stderr gets warnings from tracing (with colour only on a terminal) and + errors in the three-part format above. Processes spawned by niri log stderr + to niri's journal (`journalctl --user -u niri`), but the log file is the primary record. +- If the log directory can't be written, valw keeps working and logs to + stderr only; a broken log dir must not block a screenshot. ### 9.3 Panics @@ -390,10 +431,10 @@ same three-part format to stderr, with "this is a bug in valw" as the hint. Prints one `ok` / `warn` / `fail` line per check and exits 1 if any check fails: -- required globals present with a sufficient version (screencopy, layer-shell, - xdg-output, shm, seat), optional ones reported (fractional-scale, viewporter, - cursor-shape, data-control) -- outputs with position, size and scale +- required globals present with a sufficient version (compositor, shm, seat, + xdg-output v2, layer-shell, screencopy, viewporter); optional ones warn + when missing (cursor-shape, ext/wlr data-control) +- outputs with name, logical size, position and transform - niri IPC reachable, and niri's version compatible with the pinned `niri-ipc` - config file parses - save directory is writable @@ -417,17 +458,21 @@ Wayland-independent logic lives in pure functions: format error - filename template, collision suffix, `--output -` - config: defaults when missing, unknown key warning, invalid TOML line number -- lock: acquire, busy error, forked child does not hold the lock -- error rendering: snapshot of the three-part output +- lock: acquire, busy error; forked child holds neither the lock nor stdout +- output transforms (all eight) against grim's rules +- error rendering: exact text of the three-part output +- `niri-ipc` pin in Cargo.toml matches `niri::IPC_VERSION` ### 10.2 Headless integration test (`nix flake check`) -A check starts **sway** with `WLR_BACKENDS=headless` and -`WLR_RENDERER=pixman` inside the build sandbox, then: +A check starts **sway** (`sway-unwrapped`; the wrapped one needs D-Bus) with +`WLR_BACKENDS=headless` and `WLR_RENDERER=pixman` inside the build sandbox, +then: - runs `valw doctor` and asserts no `fail` lines -- runs `valw screen -o -` and asserts the PNG size equals the headless - output's size +- runs `valw screen -o - | cat` under a 10 s timeout (so a clipboard child + holding the pipe fails the check) and asserts the PNG is 1280×720, the + headless output's size This exercises the real screencopy path on every build. It runs on wlroots, not niri. `region` is not covered because it needs synthetic pointer input. @@ -443,6 +488,8 @@ not niri. `region` is not covered because it needs synthetic pointer input. - **No stuck overlay:** `kill -9` during selection; the overlay disappears and the next run gets the lock. - **Noctalia:** the overlay covers the bar. +- **Rotated output:** `niri msg output HDMI-A-1 transform 90`, then + `valw screen` and `grim -o HDMI-A-1` must match pixel for pixel. ## 11. Phase 0 exit criteria