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