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