Files
valw/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md
T

135 KiB
Raw Blame History

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 "<msg>". 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

{
  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
{ ... }:
{
  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
{ ... }:
{
  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
{ ... }:
{
  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:

watch_file nix/*.ulu.nix
use flake

.gitignore:

/*
!/.gitignore
!/.envrc
!/flake.nix
!/flake.lock
!/Cargo.toml
!/Cargo.lock
!/nix/
!/src/
!/docs/
!/README.md
  • Step 6: Write Cargo.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
// 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:

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
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
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
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<T> { fn hint(self, hint: impl Into<String>) -> anyhow::Result<T>; } implemented for every Result<T, E: Into<anyhow::Error>>; 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:

#[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::<Cancelled>().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:

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<T> {
    fn hint(self, hint: impl Into<String>) -> anyhow::Result<T>;
}

impl<T, E> HintExt<T> for Result<T, E>
where
    E: Into<anyhow::Error>,
{
    fn hint(self, hint: impl Into<String>) -> anyhow::Result<T> {
        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::<Hint>().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
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<Config>; pub fn parse(text: &str) -> Result<(Config, Vec<String>)>; 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:

#[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:

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<Config> {
    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<String>)> {
    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
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<Local>) -> Option<PathBuf> (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<PathBuf>).

  • Step 1: Write the failing tests for log

Create src/log.rs with only the test module:

#[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<String> = 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:

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<Instant> = 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<Local>) -> Option<PathBuf> {
    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<Local>) -> 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<Local>) -> 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<PathBuf> = 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<PathBuf>) {
    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
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<Lock> (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:

#[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:

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<Lock> {
        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:

#[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:

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
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<RgbaImage>; 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:

#[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<u8> {
        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:

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<RgbaImage> {
    // 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
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<PixelRect>; selection::resolve(...) (same, but None under MIN_PIXELS); render::{DIM, to_argb8888(img: &RgbaImage, brightness: u8) -> Vec<u8>, compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option<PixelRect>)}.

  • Step 1: Write the failing tests for selection

Create src/selection.rs with only the test module:

#[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:

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<PixelRect> {
    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<PixelRect> {
    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:

#[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:

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<u8> {
    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<PixelRect>) {
    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
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<Local>, output: Option<&str>) -> Result<String>; pub fn unique_path(dir: &Path, name: &str) -> PathBuf; pub fn encode_png(img: &RgbaImage) -> Result<Vec<u8>>; pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()>; pub fn write_stdout(bytes: &[u8]) -> Result<()>; pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()>; pub enum Target { Default, Path(PathBuf), Stdout, ClipboardOnly } with pub fn from_args(output: Option<PathBuf>, clipboard_only: bool) -> Target.

  • Step 1: Write the failing tests for output

Create src/output.rs with only the test module:

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;

    fn now() -> DateTime<Local> {
        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:

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<Local>, output: Option<&str>) -> Result<String> {
    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<Vec<u8>> {
    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<u8>, 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>`.
    Path(PathBuf),
    /// `--output -`.
    Stdout,
    /// `--clipboard-only`.
    ClipboardOnly,
}

impl Target {
    /// clap rejects `--output` together with `--clipboard-only`.
    pub fn from_args(output: Option<PathBuf>, 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
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<Option<String>>; pub fn version() -> Result<String>; 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<State>, pub globals: GlobalList, pub state: State } with connect() -> Result<Wayland>, outputs(&self) -> Vec<Output>, 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:

#[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:

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<Response> {
    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<Option<String>> {
    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<String> {
    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.

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<State>,
    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<LayerShell>,
    pub screencopy: Option<ZwlrScreencopyManagerV1>,
    pub viewporter: Option<WpViewporter>,
    pub cursor_shape: Option<CursorShapeManager>,
    pub cursor_device: Option<WpCursorShapeDeviceV1>,
    pub keyboard: Option<wl_keyboard::WlKeyboard>,
    pub pointer: Option<wl_pointer::WlPointer>,
}

impl Wayland {
    pub fn connect() -> Result<Wayland> {
        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::<State>(&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<Output> {
        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<Self>,
        _: &wl_surface::WlSurface,
        _: i32,
    ) {
    }

    fn transform_changed(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_surface::WlSurface,
        _: wl_output::Transform,
    ) {
    }

    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}

    fn surface_enter(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_surface::WlSurface,
        _: &wl_output::WlOutput,
    ) {
    }

    fn surface_leave(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &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<Self>, _: wl_output::WlOutput) {}

    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}

    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
}

impl LayerShellHandler for State {
    fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {}

    fn configure(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &LayerSurface,
        _: LayerSurfaceConfigure,
        _: u32,
    ) {
    }
}

impl SeatHandler for State {
    fn seat_state(&mut self) -> &mut SeatState {
        &mut self.seat
    }

    fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}

    fn new_capability(
        &mut self,
        _: &Connection,
        qh: &QueueHandle<Self>,
        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<Self>,
        _: 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<Self>, _: wl_seat::WlSeat) {}
}

impl KeyboardHandler for State {
    fn enter(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: &wl_surface::WlSurface,
        _: u32,
        _: &[u32],
        _: &[Keysym],
    ) {
    }

    fn leave(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: &wl_surface::WlSurface,
        _: u32,
    ) {
    }

    fn press_key(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _: KeyEvent,
    ) {
    }

    fn repeat_key(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _: KeyEvent,
    ) {
    }

    fn release_key(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _: KeyEvent,
    ) {
    }

    fn update_modifiers(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &wl_keyboard::WlKeyboard,
        _: u32,
        _: Modifiers,
        _: RawModifiers,
        _: u32,
    ) {
    }
}

impl PointerHandler for State {
    fn pointer_frame(
        &mut self,
        _: &Connection,
        _: &QueueHandle<Self>,
        _: &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:

#[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:

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<String>) -> 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<Check> {
    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<String>) -> 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
// 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::<Vec<_>>()
    );

    match run(cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) if e.downcast_ref::<Cancelled>().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
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<Vec<Frame>>; } 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.

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<Vec<Frame>>;
}

/// 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<Format>,
    layout: Option<Layout>,
    // 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<RawPool>,
    buffer: Option<WlBuffer>,
    y_invert: bool,
    result: Option<Result<Frame>>,
}

impl Capturer for Wayland {
    fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result<Vec<Frame>> {
        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<ZwlrScreencopyFrameV1, usize> for State {
    fn event(
        state: &mut Self,
        frame: &ZwlrScreencopyFrameV1,
        event: zwlr_screencopy_frame_v1::Event,
        &i: &usize,
        _: &Connection,
        qh: &QueueHandle<Self>,
    ) {
        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<State>) {
    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<Frame> {
    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:

--- 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<WpCursorShapeDeviceV1>,
     pub keyboard: Option<wl_keyboard::WlKeyboard>,
     pub pointer: Option<wl_pointer::WlPointer>,
+    /// Screencopy requests in flight.
+    pub captures: Vec<Pending>,
 }
 
 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.

// 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<PathBuf>,
    /// Wait this many seconds before capturing.
    #[arg(long, value_name = "SECS", value_parser = parse_delay)]
    delay: Option<Duration>,
    /// Include the mouse cursor.
    #[arg(long)]
    cursor: bool,
}

fn parse_delay(s: &str) -> Result<Duration, String> {
    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::<Vec<_>>()
    );

    match run(cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) if e.downcast_ref::<Cancelled>().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<String>)>, 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<String>)>,
    clip: usize,
    target: &Target,
    config: &Config,
    lock: Lock,
) -> Result<()> {
    let now = Local::now();
    let pngs = shots
        .iter()
        .map(|(img, _)| output::encode_png(img))
        .collect::<Result<Vec<_>>>()?;

    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
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
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)
$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
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<Picked> (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.

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<Surface>,
    selection: Selection,
    /// Output where the current drag started; the capture is clipped to it.
    anchor: Option<usize>,
    outcome: Option<Option<Picked>>,
}

struct Surface {
    layer: LayerSurface,
    viewport: WpViewport,
    geom: OutputGeom,
    width: u32,
    height: u32,
    dark: Vec<u8>,
    bright: Vec<u8>,
    pool: SlotPool,
    buffer: Option<Buffer>,
    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<Picked> {
    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<Surface> {
        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<PixelRect>, qh: &QueueHandle<State>) {
        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<usize> {
        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<PixelRect> {
        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<State>) {
        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<State>) {
        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<State>) {
        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<State>,
    ) {
        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:

--- 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<wl_pointer::WlPointer>,
     /// Screencopy requests in flight.
     pub captures: Vec<Pending>,
+    /// The region selection overlay, while it is open.
+    pub overlay: Option<Overlay>,
 }
 
 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<String> = 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<Self>, _: &wl_surface::WlSurface, _: u32) {}
+    fn frame(
+        &mut self,
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+        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<Self>, _: &LayerSurface) {}
+    fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &LayerSurface) {
+        if let Some(overlay) = &mut self.overlay {
+            overlay.cancel();
+        }
+    }
 
     fn configure(
         &mut self,
         _: &Connection,
-        _: &QueueHandle<Self>,
-        _: &LayerSurface,
-        _: LayerSurfaceConfigure,
-        _: u32,
-    ) {
+        qh: &QueueHandle<Self>,
+        layer: &LayerSurface,
+        configure: LayerSurfaceConfigure,
+        _: u32,
+    ) {
+        if let Some(overlay) = &mut self.overlay {
+            overlay.configure(layer, configure.new_size, qh);
+        }
     }
 }
 
@@ -360,8 +384,13 @@
         _: &QueueHandle<Self>,
         _: &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<Self>,
+        qh: &QueueHandle<Self>,
         _: &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:

--- 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):

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
$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
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.<system>.{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

{ ... }:
{
  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
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
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:

$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)
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)
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.