4345 lines
138 KiB
Markdown
4345 lines
138 KiB
Markdown
# valw Phase 0 + 1 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship `valw screen`, `valw region` and `valw doctor`: pixel-exact PNG screenshots on niri that are saved, copied to the clipboard, logged, and explained when they fail. Everything is packaged as a dendritic Nix flake.
|
||
|
||
**Architecture:** One short-lived Rust process per command. The process captures every needed output up front through wlr-screencopy and keeps the pixels in the compositor's own byte order (B, G, R, X), so the overlay appears fast even on a slow machine. For `region` it shows the frozen frames in layer-shell overlays and crops in memory; conversion to RGBA happens only for what gets saved. It then writes the PNG and forks a small child that serves the clipboard. Pure logic (pixel formats, selection math, overlay pixels, file names, config, errors) is kept apart from the SCTK/Wayland glue so it can be unit tested without a compositor.
|
||
|
||
**Tech Stack:** Rust 2024 (stable, from nixpkgs), smithay-client-toolkit 0.21, wayland-protocols(-wlr), niri-ipc 26.4.0, wl-clipboard-rs, image (png), clap, serde/toml, tracing, rustix/libc, anyhow; Nix flake-parts with `*.ulu.nix` auto-discovery; jj.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md` (read it first; this plan implements it).
|
||
|
||
## Global Constraints
|
||
|
||
- Code, comments, error/log messages: English. Comments are plain and short and say *why*.
|
||
- Version control is **jj** (the repo is non-colocated; there is no `.git`). Commit with `jj commit -m "<msg>"`. Never use git commands.
|
||
- Use `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`).
|
||
- Run every cargo command inside the devShell. direnv loads it automatically once `.envrc` is allowed; otherwise prefix the command with `nix develop -c`.
|
||
- `CARGO_TARGET_DIR` is `${XDG_CACHE_HOME:-$HOME/.cache}/valw/target` (set by the devShell). A `target/` directory must never appear in the repo, because the `path:` flake would copy it into the store on every evaluation.
|
||
- The flake discovers `nix/**/*.ulu.nix` automatically. Never write a manual `imports = [ ... ]` list.
|
||
- Prefer nix3 commands (`nix build`, `nix develop`, `nix flake check`). Don't `find` over `/nix/store`.
|
||
- `.gitignore` is a whitelist. Any new top-level file or directory must be added to it, or jj silently leaves it out of commits.
|
||
- Dependency versions are exactly those in Task 1's `Cargo.toml`; `niri-ipc` stays pinned at `=26.4.0`.
|
||
- Layer-shell namespace: `valw-overlay`. Exit codes: 0 success, 1 error, 2 usage (clap), 3 cancelled.
|
||
- Until Task 11, `src/main.rs` starts with `#![allow(dead_code)]` because modules land before the CLI uses them. Task 11 removes it, and clippy must then be clean without it.
|
||
|
||
## Review Focus
|
||
|
||
These are the input classes most likely to bite a real user that no unit test fully exercises. Each one has a check in the task that owns the code.
|
||
|
||
1. **Rotated or flipped outputs** (`transform != Normal`): `screen` and `region` must produce an upright image identical to grim's. Unit test: `frame::tests::transforms_match_grim` (Task 6). Real check: Task 13, step 4.
|
||
2. **Fractional scale (1.25)**: the overlay must look sharp (not resampled), and the crop must be pixel-exact against grim. Unit tests: `selection::tests::fractional_scale_rounds_outwards` (Task 7). Real check: Task 13, step 2.
|
||
3. **A drag that leaves the anchor output** (press on one monitor, release on another, or outside every monitor): the result must be clipped to the monitor where the drag began and never panic. Unit tests: `selection::tests::drag_across_outputs_is_clipped_to_the_output` and `output_offsets_including_negative` (Task 7). Real check: Task 11, step 7.
|
||
4. **Hotkey pressed again while an overlay is open**: the second process exits 1 with "valw is already running" and a hint, and the first keeps working. The same must hold after the first one has exited while its clipboard child is still alive. Unit tests: `lock::tests::second_acquire_fails_until_release`, `detach::tests::child_holds_neither_lock_nor_stdio` (Task 5). Real check: Task 10, step 8.
|
||
5. **Unwritable log or save locations**: an unwritable log dir must not block a screenshot (stderr-only fallback). An unwritable save dir must fail with a message that names the path. Unit tests: `log::tests::open_run_log_fails_on_unwritable_dir` (Task 4), `output::tests::write_atomic_reports_unwritable_dir` (Task 8). Real check: Task 13, step 6.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `flake.nix` | flake-parts entry; auto-discovers `nix/**/*.ulu.nix` |
|
||
| `nix/package.ulu.nix` | `packages.default` via `rustPlatform.buildRustPackage` |
|
||
| `nix/devshell.ulu.nix` | `devShells.default`; sets `CARGO_TARGET_DIR` outside the repo |
|
||
| `nix/formatter.ulu.nix` | `formatter` (nixfmt over all `.nix` files) |
|
||
| `nix/checks.ulu.nix` | `checks`: clippy, fmt, nextest, headless sway |
|
||
| `.envrc`, `.gitignore` | direnv hookup; whitelist ignore file |
|
||
| `Cargo.toml` | crate manifest, all Phase 1 dependencies |
|
||
| `src/main.rs` | clap CLI, run/exit-code plumbing, capture → deliver flow |
|
||
| `src/error.rs` | `Hint`, `HintExt`, `Cancelled`, three-part `render` |
|
||
| `src/config.rs` | config struct, defaults, TOML parse with unknown-key report |
|
||
| `src/log.rs` | per-run log file, pruning, stderr warnings, panic hook |
|
||
| `src/lock.rs` | single-instance `flock` |
|
||
| `src/detach.rs` | fork a child that outlives valw with no lock and no stdio |
|
||
| `src/frame.rs` | `OutputGeom`, `PixelRect`, `Frame` (B,G,R,X pixels), shm format → BGRX, RGBA for saving, output transforms |
|
||
| `src/selection.rs` | drag state machine; logical → physical pixel rectangles |
|
||
| `src/render.rs` | overlay pixels: dim, paste undimmed selection, outline |
|
||
| `src/output.rs` | file names, unique paths, PNG encode, atomic write, stdout, clipboard |
|
||
| `src/niri.rs` | niri IPC: focused output, version, version parsing |
|
||
| `src/wayland.rs` | SCTK connection, state, handlers, dispatch helpers |
|
||
| `src/capture.rs` | `Capturer` trait; wlr-screencopy implementation |
|
||
| `src/region.rs` | layer-shell overlay surfaces and pointer/keyboard handling |
|
||
| `src/doctor.rs` | `valw doctor` checks and report |
|
||
|
||
Unit tests live in a `#[cfg(test)] mod tests` block at the bottom of each file.
|
||
|
||
---
|
||
|
||
### Task 1: Flake, devShell and crate skeleton (Phase 0)
|
||
|
||
**Files:**
|
||
- Create: `flake.nix`, `nix/package.ulu.nix`, `nix/devshell.ulu.nix`, `nix/formatter.ulu.nix`, `.envrc`, `.gitignore`, `Cargo.toml`, `src/main.rs`
|
||
- Generated: `flake.lock`, `Cargo.lock`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: `nix build` → `result/bin/valw`; `nix develop` shell with `cargo`, `clippy`, `rustfmt`, `cargo-nextest`, `rust-analyzer`, `grim`, `imagemagick`, `wayland-info`; `CARGO_TARGET_DIR` set outside the repo.
|
||
|
||
- [ ] **Step 1: Write `flake.nix`**
|
||
|
||
```nix
|
||
{
|
||
description = "valw, macOS-style screenshots for niri";
|
||
|
||
inputs = {
|
||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||
|
||
flake-parts = {
|
||
url = "github:hercules-ci/flake-parts";
|
||
inputs.nixpkgs-lib.follows = "nixpkgs";
|
||
};
|
||
};
|
||
|
||
outputs =
|
||
inputs@{ flake-parts, ... }:
|
||
flake-parts.lib.mkFlake { inherit inputs; } (
|
||
{ lib, ... }:
|
||
let
|
||
inherit (lib.filesystem) listFilesRecursive;
|
||
inherit (lib.lists) filter;
|
||
inherit (lib.strings) hasSuffix;
|
||
in
|
||
{
|
||
systems = [
|
||
"x86_64-linux"
|
||
"aarch64-linux"
|
||
];
|
||
imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix);
|
||
}
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write `nix/package.ulu.nix`**
|
||
|
||
```nix
|
||
{ ... }:
|
||
{
|
||
perSystem =
|
||
{ pkgs, lib, ... }:
|
||
{
|
||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||
pname = "valw";
|
||
version = "0.1.0";
|
||
|
||
src = lib.cleanSourceWith {
|
||
src = ../.;
|
||
filter =
|
||
path: _type:
|
||
!(builtins.elem (baseNameOf path) [
|
||
".direnv"
|
||
".jj"
|
||
"result"
|
||
"target"
|
||
]);
|
||
};
|
||
|
||
cargoLock.lockFile = ../Cargo.lock;
|
||
|
||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||
buildInputs = [
|
||
pkgs.libxkbcommon
|
||
pkgs.wayland
|
||
];
|
||
|
||
# Tests run through `nix flake check` instead.
|
||
doCheck = false;
|
||
|
||
meta = {
|
||
description = "macOS-style screenshots for niri";
|
||
mainProgram = "valw";
|
||
platforms = lib.platforms.linux;
|
||
};
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write `nix/devshell.ulu.nix`**
|
||
|
||
```nix
|
||
{ ... }:
|
||
{
|
||
perSystem =
|
||
{ pkgs, self', ... }:
|
||
{
|
||
devShells.default = pkgs.mkShell {
|
||
inputsFrom = [ self'.packages.default ];
|
||
|
||
packages = with pkgs; [
|
||
cargo-nextest
|
||
clippy
|
||
rust-analyzer
|
||
rustfmt
|
||
# Manual verification: grim is the pixel oracle, magick compares.
|
||
grim
|
||
imagemagick
|
||
wayland-utils
|
||
];
|
||
|
||
# The repo is a path: flake, so Nix copies the whole tree on every
|
||
# evaluation. Keep the multi-gigabyte target dir out of it.
|
||
shellHook = ''
|
||
export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target"
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `nix/formatter.ulu.nix`**
|
||
|
||
```nix
|
||
{ ... }:
|
||
{
|
||
perSystem =
|
||
{ pkgs, ... }:
|
||
{
|
||
formatter = pkgs.writeShellApplication {
|
||
name = "nixfmt-tree";
|
||
runtimeInputs = [
|
||
pkgs.nixfmt
|
||
pkgs.fd
|
||
pkgs.findutils
|
||
];
|
||
text = ''
|
||
if [ "$#" -eq 0 ]; then
|
||
set -- .
|
||
fi
|
||
fd --type f --extension nix --hidden --exclude .jj . "$@" \
|
||
| xargs -r nixfmt
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `.envrc` and `.gitignore`**
|
||
|
||
`.envrc`:
|
||
|
||
```sh
|
||
watch_file nix/*.ulu.nix
|
||
use flake
|
||
```
|
||
|
||
`.gitignore`:
|
||
|
||
```gitignore
|
||
/*
|
||
!/.gitignore
|
||
!/.envrc
|
||
!/flake.nix
|
||
!/flake.lock
|
||
!/Cargo.toml
|
||
!/Cargo.lock
|
||
!/nix/
|
||
!/src/
|
||
!/docs/
|
||
!/README.md
|
||
```
|
||
|
||
- [ ] **Step 6: Write `Cargo.toml`**
|
||
|
||
```toml
|
||
[package]
|
||
name = "valw"
|
||
version = "0.1.0"
|
||
edition = "2024"
|
||
description = "macOS-style screenshots for niri"
|
||
license = "MIT"
|
||
|
||
[dependencies]
|
||
anyhow = "1.0.104"
|
||
chrono = "0.4.45"
|
||
clap = { version = "4.6.7", features = ["derive"] }
|
||
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||
libc = "0.2.189"
|
||
niri-ipc = "=26.4.0"
|
||
rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] }
|
||
serde = { version = "1.0.229", features = ["derive"] }
|
||
serde_ignored = "0.1.14"
|
||
smithay-client-toolkit = "0.21.1"
|
||
toml = "1.1.6"
|
||
tracing = "0.1.44"
|
||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||
wayland-client = "0.31.15"
|
||
wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
|
||
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||
wl-clipboard-rs = "0.9.4"
|
||
|
||
[dev-dependencies]
|
||
tempfile = "3.27.0"
|
||
|
||
# Image encoding is painfully slow unoptimised; keep dev builds usable.
|
||
[profile.dev.package."*"]
|
||
opt-level = 3
|
||
```
|
||
|
||
- [ ] **Step 7: Write `src/main.rs`**
|
||
|
||
```rust
|
||
// Modules land before the CLI uses them; Task 11 removes this.
|
||
#![allow(dead_code)]
|
||
|
||
use clap::Parser;
|
||
|
||
#[derive(Parser)]
|
||
#[command(version, about = "macOS-style screenshots for niri")]
|
||
struct Cli {}
|
||
|
||
fn main() {
|
||
Cli::parse();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Generate the lock files**
|
||
|
||
The devShell evaluates the package, and the package reads `Cargo.lock`, so `Cargo.lock` has to exist before `nix develop` can run:
|
||
|
||
```sh
|
||
nix shell nixpkgs#cargo -c cargo generate-lockfile
|
||
nix flake lock
|
||
```
|
||
|
||
Expected: `Cargo.lock` and `flake.lock` exist at the repo root.
|
||
|
||
- [ ] **Step 9: Enter the devShell and build**
|
||
|
||
```sh
|
||
direnv allow
|
||
cargo clippy --all-targets -- -D warnings
|
||
cargo run -- --version
|
||
```
|
||
|
||
Expected: clippy prints no warnings, and the last command prints `valw 0.1.0`. `ls` shows **no** `target/` directory in the repo, and `echo $CARGO_TARGET_DIR` prints a path under `~/.cache/valw/`.
|
||
|
||
- [ ] **Step 10: Build with Nix and check the flake copy stays small**
|
||
|
||
```sh
|
||
nix build
|
||
./result/bin/valw --version
|
||
du -sh "$(nix flake metadata --json | jq -r .path)"
|
||
```
|
||
|
||
Expected: `valw 0.1.0`, and the flake source is a few MB at most. If it is much larger, something like `target/` or `result` has leaked into the tree.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```sh
|
||
jj commit -m "build: dendritic flake, devShell and crate skeleton"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Error type and rendering
|
||
|
||
**Files:**
|
||
- Create: `src/error.rs`
|
||
- Modify: `src/main.rs` (add `error` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: `pub struct Hint(pub String)`; `pub struct Cancelled` (implements `std::error::Error`); `pub trait HintExt<T> { fn hint(self, hint: impl Into<String>) -> anyhow::Result<T>; }` implemented for every `Result<T, E: Into<anyhow::Error>>`; `pub fn render(err: &anyhow::Error, log: Option<&Path>) -> String`.
|
||
|
||
- [ ] **Step 1: Write the failing tests for `error`**
|
||
|
||
Create `src/error.rs` with only the test module:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use anyhow::{Context, anyhow};
|
||
use std::path::PathBuf;
|
||
|
||
#[test]
|
||
fn renders_all_parts() {
|
||
let err = Err::<(), _>(anyhow!("compositor offered only XBGR2101010"))
|
||
.context("could not convert the frame")
|
||
.hint("run `valw doctor` and include its output in a bug report")
|
||
.context("could not capture output HDMI-A-1")
|
||
.unwrap_err();
|
||
let log = PathBuf::from("/tmp/valw.log");
|
||
|
||
assert_eq!(
|
||
render(&err, Some(&log)),
|
||
"error: could not capture output HDMI-A-1\n\
|
||
\x20 cause: could not convert the frame\n\
|
||
\x20 cause: compositor offered only XBGR2101010\n\
|
||
\x20 hint: run `valw doctor` and include its output in a bug report\n\
|
||
\x20 log: /tmp/valw.log\n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn renders_without_hint_or_log() {
|
||
let err = anyhow!("disk full");
|
||
assert_eq!(render(&err, None), "error: disk full\n");
|
||
}
|
||
|
||
#[test]
|
||
fn hint_as_outermost_layer_is_not_the_headline() {
|
||
let err = Err::<(), _>(anyhow!("valw is already running"))
|
||
.hint("wait for the other capture to finish")
|
||
.unwrap_err();
|
||
assert_eq!(
|
||
render(&err, None),
|
||
"error: valw is already running\n\
|
||
\x20 hint: wait for the other capture to finish\n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cancelled_is_detectable() {
|
||
let err = anyhow::Error::new(Cancelled).context("region selection");
|
||
assert!(err.downcast_ref::<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`:
|
||
|
||
```rust
|
||
use std::fmt;
|
||
use std::path::Path;
|
||
|
||
/// A concrete next step for the user, attached to an error with [`HintExt::hint`].
|
||
#[derive(Debug)]
|
||
pub struct Hint(pub String);
|
||
|
||
impl fmt::Display for Hint {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(&self.0)
|
||
}
|
||
}
|
||
|
||
/// The user cancelled the capture. Not a failure; maps to exit code 3.
|
||
#[derive(Debug)]
|
||
pub struct Cancelled;
|
||
|
||
impl fmt::Display for Cancelled {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str("cancelled")
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for Cancelled {}
|
||
|
||
pub trait HintExt<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: three-part error rendering with hints"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 3: Config file
|
||
|
||
**Files:**
|
||
- Create: `src/config.rs`
|
||
- Modify: `src/main.rs` (add `config` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `error::HintExt` (Task 2).
|
||
- Produces: `pub struct Config { pub save: Save, pub capture: Capture }` (Default, Deserialize, PartialEq); `pub struct Save { pub directory: String, pub filename: String, pub copy_to_clipboard: bool }`; `pub struct Capture { pub show_cursor: bool }`; `impl Config { pub fn save_dir(&self) -> PathBuf }`; `pub fn home() -> PathBuf`; `pub fn default_path() -> PathBuf`; `pub fn load(path: &Path) -> Result<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:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn empty_file_gives_defaults() {
|
||
let (config, unknown) = parse("").unwrap();
|
||
assert_eq!(config, Config::default());
|
||
assert!(unknown.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn partial_file_keeps_other_defaults() {
|
||
let (config, _) = parse("[save]\ncopy_to_clipboard = false\n").unwrap();
|
||
assert!(!config.save.copy_to_clipboard);
|
||
assert_eq!(config.save.directory, "~/Pictures/Screenshots");
|
||
assert!(!config.capture.show_cursor);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_keys_are_reported_not_rejected() {
|
||
let text = "[preview]\nenabled = true\n\n[save]\nfolder = \"x\"\n";
|
||
let (config, unknown) = parse(text).unwrap();
|
||
assert_eq!(config, Config::default());
|
||
assert_eq!(
|
||
unknown,
|
||
vec!["preview".to_string(), "save.folder".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_toml_names_the_line() {
|
||
let err = parse("[save]\ndirectory = \n").unwrap_err();
|
||
assert!(format!("{err:#}").contains("line 2"), "{err:#}");
|
||
}
|
||
|
||
#[test]
|
||
fn wrong_type_is_an_error() {
|
||
assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn missing_file_gives_defaults() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
assert_eq!(
|
||
load(&dir.path().join("nope.toml")).unwrap(),
|
||
Config::default()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn expands_home() {
|
||
let home = Path::new("/home/u");
|
||
assert_eq!(
|
||
expand_home("~/Pictures", home),
|
||
Path::new("/home/u/Pictures")
|
||
);
|
||
assert_eq!(
|
||
expand_home("$HOME/Pictures", home),
|
||
Path::new("/home/u/Pictures")
|
||
);
|
||
assert_eq!(expand_home("~", home), Path::new("/home/u"));
|
||
assert_eq!(expand_home("/srv/shots", home), Path::new("/srv/shots"));
|
||
assert_eq!(expand_home("~other/x", home), Path::new("~other/x"));
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod config;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 2: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run config::`
|
||
Expected: compilation fails, e.g. `` cannot find function `parse` in this scope ``.
|
||
|
||
- [ ] **Step 3: Implement `config`**
|
||
|
||
Put this above the test module in `src/config.rs`:
|
||
|
||
```rust
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{Context, Result};
|
||
use serde::Deserialize;
|
||
|
||
use crate::error::HintExt;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
|
||
#[serde(default)]
|
||
pub struct Config {
|
||
pub save: Save,
|
||
pub capture: Capture,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct Save {
|
||
pub directory: String,
|
||
pub filename: String,
|
||
pub copy_to_clipboard: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
|
||
#[serde(default)]
|
||
pub struct Capture {
|
||
pub show_cursor: bool,
|
||
}
|
||
|
||
impl Default for Save {
|
||
fn default() -> Self {
|
||
Self {
|
||
directory: "~/Pictures/Screenshots".into(),
|
||
filename: "Screenshot %Y-%m-%d at %H.%M.%S.png".into(),
|
||
copy_to_clipboard: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Config {
|
||
/// The save directory with `~` and `$HOME` expanded.
|
||
pub fn save_dir(&self) -> PathBuf {
|
||
expand_home(&self.save.directory, &home())
|
||
}
|
||
}
|
||
|
||
pub fn home() -> PathBuf {
|
||
std::env::var_os("HOME")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| "/".into())
|
||
}
|
||
|
||
/// `$XDG_CONFIG_HOME/valw/config.toml`, falling back to `~/.config`.
|
||
pub fn default_path() -> PathBuf {
|
||
std::env::var_os("XDG_CONFIG_HOME")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| home().join(".config"))
|
||
.join("valw/config.toml")
|
||
}
|
||
|
||
/// Loads the config. A missing file means defaults.
|
||
pub fn load(path: &Path) -> Result<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: config file with defaults and unknown-key warnings"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 4: Per-run logs and panic hook
|
||
|
||
**Files:**
|
||
- Create: `src/log.rs`
|
||
- Modify: `src/main.rs` (add `log` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `config::home` (Task 3); `error::{Hint, render}` (Task 2).
|
||
- Produces: `pub fn default_dir() -> PathBuf`; `pub fn init(dir: &Path, now: DateTime<Local>) -> Option<PathBuf>` (installs the global subscriber, returns this run's log path or `None` if it fell back to stderr); `pub fn since_start() -> Duration`; `pub fn install_panic_hook(log: Option<PathBuf>)`.
|
||
|
||
- [ ] **Step 1: Write the failing tests for `log`**
|
||
|
||
Create `src/log.rs` with only the test module:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::TimeZone;
|
||
|
||
#[test]
|
||
fn log_names_sort_by_time() {
|
||
let a = Local.with_ymd_and_hms(2026, 9, 29, 9, 5, 1).unwrap();
|
||
let b = Local.with_ymd_and_hms(2026, 9, 29, 19, 0, 0).unwrap();
|
||
assert_eq!(log_name(a), "2026-09-29T09-05-01.000.log");
|
||
assert!(log_name(a) < log_name(b));
|
||
}
|
||
|
||
#[test]
|
||
fn prune_keeps_newest() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
for i in 0..12 {
|
||
File::create(dir.path().join(format!("2026-09-29T10-00-{i:02}.000.log"))).unwrap();
|
||
}
|
||
File::create(dir.path().join("notes.txt")).unwrap();
|
||
|
||
prune(dir.path(), 10).unwrap();
|
||
|
||
let mut left: Vec<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`:
|
||
|
||
```rust
|
||
use std::fs::{self, File};
|
||
use std::io::IsTerminal;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Mutex, OnceLock};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use anyhow::{Context, Result};
|
||
use chrono::{DateTime, Local};
|
||
use tracing::Level;
|
||
use tracing_subscriber::filter::{EnvFilter, filter_fn};
|
||
use tracing_subscriber::prelude::*;
|
||
|
||
use crate::error::{Hint, render};
|
||
|
||
/// Number of run logs kept on disk, including the current one.
|
||
const KEEP: usize = 10;
|
||
|
||
static START: OnceLock<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: per-run log files and panic hook"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 5: Single-instance lock and detached child
|
||
|
||
**Files:**
|
||
- Create: `src/lock.rs`
|
||
- Create: `src/detach.rs`
|
||
- Modify: `src/main.rs` (add `lock`, `detach` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `error::HintExt` (Task 2).
|
||
- Produces: `pub struct Lock` with `pub fn acquire(path: &Path) -> Result<Lock>` (busy → error `valw is already running` + hint) and `pub fn release(self)`; `pub fn lock::default_path() -> PathBuf`; `pub fn detach::spawn(lock: Lock, work: impl FnOnce()) -> Result<()>` (releases the lock, forks; the child gets `setsid` and /dev/null stdio, runs `work`, then `_exit(0)`).
|
||
|
||
- [ ] **Step 1: Write the failing tests for `lock`**
|
||
|
||
Create `src/lock.rs` with only the test module:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn second_acquire_fails_until_release() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("valw.lock");
|
||
|
||
let first = Lock::acquire(&path).unwrap();
|
||
let err = Lock::acquire(&path).unwrap_err();
|
||
assert_eq!(
|
||
err.to_string(),
|
||
"wait for the other capture to finish, or press Esc in it"
|
||
);
|
||
assert_eq!(err.root_cause().to_string(), "valw is already running");
|
||
|
||
first.release();
|
||
Lock::acquire(&path).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn stale_lock_file_is_fine() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("valw.lock");
|
||
File::create(&path).unwrap();
|
||
Lock::acquire(&path).unwrap();
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod lock;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 2: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run lock::`
|
||
Expected: compilation fails, e.g. `` failed to resolve: use of undeclared type `Lock` ``.
|
||
|
||
- [ ] **Step 3: Implement `lock`**
|
||
|
||
Put this above the test module in `src/lock.rs`:
|
||
|
||
```rust
|
||
use std::fs::File;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{Context, Result, bail};
|
||
use rustix::fs::{FlockOperation, flock};
|
||
use rustix::io::Errno;
|
||
|
||
use crate::error::HintExt;
|
||
|
||
/// Held for the whole capture so two captures never overlap. The kernel
|
||
/// drops the lock when the process dies, so a crash can't leave it stuck.
|
||
#[derive(Debug)]
|
||
pub struct Lock {
|
||
_file: File,
|
||
}
|
||
|
||
/// `$XDG_RUNTIME_DIR/valw.lock`, falling back to the temp dir.
|
||
pub fn default_path() -> PathBuf {
|
||
std::env::var_os("XDG_RUNTIME_DIR")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(std::env::temp_dir)
|
||
.join("valw.lock")
|
||
}
|
||
|
||
impl Lock {
|
||
pub fn acquire(path: &Path) -> Result<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:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn child_holds_neither_lock_nor_stdio() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("valw.lock");
|
||
let lock = Lock::acquire(&path).unwrap();
|
||
let (report_r, report_w) = rustix::pipe::pipe().unwrap();
|
||
let (go_r, go_w) = rustix::pipe::pipe().unwrap();
|
||
|
||
spawn(lock, move || {
|
||
// Report whether stdout is /dev/null, then wait for the parent.
|
||
let null = rustix::fs::stat("/dev/null").unwrap();
|
||
let out = rustix::fs::fstat(rustix::stdio::stdout()).unwrap();
|
||
let is_null = out.st_rdev == null.st_rdev && out.st_ino == null.st_ino;
|
||
rustix::io::write(&report_w, &[is_null as u8]).unwrap();
|
||
let _ = rustix::io::read(&go_r, &mut [0u8; 1]);
|
||
})
|
||
.unwrap();
|
||
|
||
// The child is still running, yet the lock is free.
|
||
Lock::acquire(&path).unwrap();
|
||
|
||
let mut is_null = [0u8; 1];
|
||
rustix::io::read(&report_r, &mut is_null).unwrap();
|
||
assert_eq!(is_null, [1], "child stdout should be /dev/null");
|
||
|
||
// Closing our end lets the child exit.
|
||
drop(go_w);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod detach;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 6: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run detach::`
|
||
Expected: compilation fails, e.g. `` cannot find function `spawn` in this scope ``.
|
||
|
||
- [ ] **Step 7: Implement `detach`**
|
||
|
||
Put this above the test module in `src/detach.rs`:
|
||
|
||
```rust
|
||
use std::fs::File;
|
||
use std::io;
|
||
|
||
use anyhow::{Context, Result};
|
||
|
||
use crate::lock::Lock;
|
||
|
||
/// Runs `work` in a forked child that outlives valw, like `wl-copy` does.
|
||
/// The parent returns right away.
|
||
///
|
||
/// The child drops the capture lock (so the next capture isn't blocked) and
|
||
/// points stdio at /dev/null (so `valw ... -o - | x` sees EOF without waiting
|
||
/// for the clipboard to be replaced).
|
||
pub fn spawn(lock: Lock, work: impl FnOnce()) -> Result<()> {
|
||
lock.release();
|
||
// SAFETY: valw is single-threaded at this point, and the child only
|
||
// touches its own state before exiting with _exit.
|
||
match unsafe { libc::fork() } {
|
||
-1 => Err(io::Error::last_os_error()).context("could not fork the clipboard server"),
|
||
0 => {
|
||
let _ = rustix::process::setsid();
|
||
let _ = detach_stdio();
|
||
work();
|
||
// SAFETY: skips atexit handlers and destructors that belong to the parent.
|
||
unsafe { libc::_exit(0) }
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
fn detach_stdio() -> io::Result<()> {
|
||
let null = File::options().read(true).write(true).open("/dev/null")?;
|
||
rustix::stdio::dup2_stdin(&null)?;
|
||
rustix::stdio::dup2_stdout(&null)?;
|
||
rustix::stdio::dup2_stderr(&null)?;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run the tests and watch them pass**
|
||
|
||
Run: `cargo nextest run detach::`
|
||
Expected: all 1 tests in `detach::tests` pass.
|
||
|
||
- [ ] **Step 9: Lint**
|
||
|
||
Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run`
|
||
Expected: no warnings; every test passes.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```sh
|
||
jj commit -m "feat: single-instance lock and detached background child"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 6: Frames, pixel formats and output transforms
|
||
|
||
**Files:**
|
||
- Create: `src/frame.rs`
|
||
- Modify: `src/main.rs` (add `frame` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: `pub struct OutputGeom { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32 }` (logical, global coordinates); `pub struct PixelRect { pub x: u32, pub y: u32, pub width: u32, pub height: u32 }` (a frame's physical pixels); `pub type Bgrx = RgbaImage` (container whose bytes are B, G, R, X, i.e. XRGB8888 in memory); `pub struct Frame { pub output: OutputGeom, pub pixels: Arc<Bgrx> }` with `full(&self) -> PixelRect` and `to_rgba(&self, rect: PixelRect) -> RgbaImage` (opaque); `pub const SUPPORTED: [Format; 4]` (preference order); `pub fn to_bgrx(format: Format, width: u32, height: u32, stride: u32, data: &[u8], y_invert: bool) -> Result<Bgrx>`; `pub fn apply_transform(img: Bgrx, transform: Transform) -> Bgrx`. `Format` is `wayland_client::protocol::wl_shm::Format`, `Transform` is `wayland_client::protocol::wl_output::Transform`.
|
||
|
||
- [ ] **Step 1: Write the failing tests for `frame`**
|
||
|
||
Create `src/frame.rs` with only the test module:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 2x1 buffer: a red pixel then a blue one, in the given byte order.
|
||
fn two_pixels(format: Format) -> Vec<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`:
|
||
|
||
```rust
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::{Result, bail, ensure};
|
||
use image::{RgbaImage, imageops};
|
||
use wayland_client::protocol::{wl_output::Transform, wl_shm::Format};
|
||
|
||
/// An output's place in the global logical coordinate space.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct OutputGeom {
|
||
pub name: String,
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub width: i32,
|
||
pub height: i32,
|
||
}
|
||
|
||
/// A rectangle in a frame's physical pixels.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct PixelRect {
|
||
pub x: u32,
|
||
pub y: u32,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
/// Pixels in wl_shm XRGB8888 memory order: B, G, R, X. This is what niri
|
||
/// hands out and what the overlay shows, so nothing on the way to the first
|
||
/// overlay frame has to reorder bytes. `RgbaImage` is only the container;
|
||
/// its channels do not mean R, G, B, A here.
|
||
pub type Bgrx = RgbaImage;
|
||
|
||
/// One captured output: upright pixels at physical resolution.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Frame {
|
||
pub output: OutputGeom,
|
||
/// Shared with the overlay, which shows it as the undimmed selection.
|
||
pub pixels: Arc<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: screencopy frames, pixel formats and output transforms"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 7: Selection math and overlay pixels
|
||
|
||
**Files:**
|
||
- Create: `src/selection.rs`
|
||
- Create: `src/render.rs`
|
||
- Modify: `src/main.rs` (add `selection`, `render` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `frame::{OutputGeom, PixelRect}` (Task 6).
|
||
- Produces: `selection::{MIN_PIXELS, Point { x: f64, y: f64 }, Selection { Idle, Dragging { start, end } }}` with `press`, `motion`, `release(p) -> Option<(Point, Point)>`; `selection::clip(a, b, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option<PixelRect>`; `selection::resolve(...)` (same, but `None` under `MIN_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:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom {
|
||
OutputGeom {
|
||
name: name.into(),
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
}
|
||
}
|
||
|
||
fn p(x: f64, y: f64) -> Point {
|
||
Point { x, y }
|
||
}
|
||
|
||
fn rect(x: u32, y: u32, width: u32, height: u32) -> PixelRect {
|
||
PixelRect {
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scale_one() {
|
||
let o = out("A", 0, 0, 1920, 1080);
|
||
assert_eq!(
|
||
clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080),
|
||
Some(rect(10, 20, 100, 50))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_direction_does_not_matter() {
|
||
let o = out("A", 0, 0, 1920, 1080);
|
||
assert_eq!(
|
||
clip(p(110.0, 70.0), p(10.0, 20.0), &o, 1920, 1080),
|
||
clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fractional_scale_rounds_outwards() {
|
||
// 1920x1080 at 1.25 is 1536x864 logical.
|
||
let o = out("A", 0, 0, 1536, 864);
|
||
// 10.5 * 1.25 = 13.125 -> 13; 20.3 * 1.25 = 25.375 -> 26.
|
||
assert_eq!(
|
||
clip(p(10.5, 0.0), p(20.3, 8.0), &o, 1920, 1080),
|
||
Some(rect(13, 0, 13, 10))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scale_one_and_a_half() {
|
||
let o = out("A", 0, 0, 1280, 720);
|
||
assert_eq!(
|
||
clip(p(100.0, 100.0), p(200.0, 150.0), &o, 1920, 1080),
|
||
Some(rect(150, 150, 150, 75))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn output_offsets_including_negative() {
|
||
let left = out("L", -1366, 0, 1366, 768);
|
||
let right = out("R", 0, 0, 1920, 1080);
|
||
assert_eq!(
|
||
clip(p(-1356.0, 10.0), p(-1256.0, 60.0), &left, 1366, 768),
|
||
Some(rect(10, 10, 100, 50))
|
||
);
|
||
assert_eq!(
|
||
clip(
|
||
p(1930.0, 10.0),
|
||
p(2030.0, 60.0),
|
||
&out("R2", 1920, 0, 800, 600),
|
||
800,
|
||
600
|
||
),
|
||
Some(rect(10, 10, 100, 50))
|
||
);
|
||
assert_eq!(
|
||
clip(p(-10.0, 10.0), p(10.0, 20.0), &right, 1920, 1080),
|
||
Some(rect(0, 10, 10, 10))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn drag_across_outputs_is_clipped_to_the_output() {
|
||
let left = out("L", 0, 0, 1366, 768);
|
||
// From (1300, 100) on the left output to (1500, 900) on the right one.
|
||
assert_eq!(
|
||
clip(p(1300.0, 100.0), p(1500.0, 900.0), &left, 1366, 768),
|
||
Some(rect(1300, 100, 66, 668))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn nothing_left_on_other_output() {
|
||
let o = out("A", 0, 0, 100, 100);
|
||
assert_eq!(clip(p(200.0, 200.0), p(300.0, 300.0), &o, 100, 100), None);
|
||
}
|
||
|
||
#[test]
|
||
fn click_is_not_a_selection() {
|
||
let o = out("A", 0, 0, 1920, 1080);
|
||
assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None);
|
||
assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None);
|
||
assert_eq!(
|
||
resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080),
|
||
Some(rect(10, 10, 2, 2))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn state_machine() {
|
||
let mut s = Selection::default();
|
||
assert_eq!(s.release(p(1.0, 1.0)), None);
|
||
|
||
s.press(p(1.0, 2.0));
|
||
s.motion(p(5.0, 6.0));
|
||
assert_eq!(
|
||
s,
|
||
Selection::Dragging {
|
||
start: p(1.0, 2.0),
|
||
end: p(5.0, 6.0)
|
||
}
|
||
);
|
||
assert_eq!(s.release(p(7.0, 8.0)), Some((p(1.0, 2.0), p(7.0, 8.0))));
|
||
assert_eq!(s, Selection::Idle);
|
||
|
||
s.motion(p(9.0, 9.0));
|
||
assert_eq!(s, Selection::Idle);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod selection;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 2: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run selection::`
|
||
Expected: compilation fails, e.g. `` cannot find struct, variant or union type `Point` in this scope ``.
|
||
|
||
- [ ] **Step 3: Implement `selection`**
|
||
|
||
Put this above the test module in `src/selection.rs`:
|
||
|
||
```rust
|
||
use crate::frame::{OutputGeom, PixelRect};
|
||
|
||
/// Selections smaller than this (in physical pixels) count as a click.
|
||
pub const MIN_PIXELS: u32 = 2;
|
||
|
||
/// A point in global logical coordinates.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Point {
|
||
pub x: f64,
|
||
pub y: f64,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||
pub enum Selection {
|
||
#[default]
|
||
Idle,
|
||
Dragging {
|
||
start: Point,
|
||
end: Point,
|
||
},
|
||
}
|
||
|
||
impl Selection {
|
||
pub fn press(&mut self, p: Point) {
|
||
*self = Selection::Dragging { start: p, end: p };
|
||
}
|
||
|
||
pub fn motion(&mut self, p: Point) {
|
||
if let Selection::Dragging { end, .. } = self {
|
||
*end = p;
|
||
}
|
||
}
|
||
|
||
/// Ends the drag and returns its start and end. Back to `Idle` either way.
|
||
pub fn release(&mut self, p: Point) -> Option<(Point, Point)> {
|
||
match std::mem::take(self) {
|
||
Selection::Dragging { start, .. } => Some((start, p)),
|
||
Selection::Idle => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The part of the drag from `a` to `b` that falls on `out`, in the physical
|
||
/// pixels of a `frame_w` x `frame_h` frame. `None` if nothing is left.
|
||
///
|
||
/// Edges round outwards so the rectangle covers every pixel the user touched.
|
||
pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option<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:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn dims_every_byte() {
|
||
assert_eq!(dim(&[200, 100, 50, 0], 255), vec![200, 100, 50, 0]);
|
||
assert_eq!(dim(&[200, 100, 50, 255], DIM), vec![120, 60, 30, 153]);
|
||
assert_eq!(dim(&[255, 0, 1, 2], 0), vec![0, 0, 0, 0]);
|
||
}
|
||
|
||
fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
|
||
let i = ((y * width + x) * 4) as usize;
|
||
buf[i..i + 4].try_into().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn compose_outline_and_inside() {
|
||
let (w, h) = (6, 6);
|
||
let dark = vec![1u8; (w * h * 4) as usize];
|
||
let bright = vec![2u8; (w * h * 4) as usize];
|
||
let mut dst = vec![0u8; dark.len()];
|
||
let sel = PixelRect {
|
||
x: 1,
|
||
y: 1,
|
||
width: 4,
|
||
height: 4,
|
||
};
|
||
|
||
compose(&mut dst, &dark, &bright, w, Some(sel));
|
||
|
||
assert_eq!(px(&dst, w, 0, 0), [1; 4], "outside");
|
||
assert_eq!(px(&dst, w, 1, 1), [255; 4], "corner");
|
||
assert_eq!(px(&dst, w, 4, 2), [255; 4], "right edge");
|
||
assert_eq!(px(&dst, w, 2, 4), [255; 4], "bottom edge");
|
||
assert_eq!(px(&dst, w, 2, 2), [2; 4], "inside");
|
||
assert_eq!(px(&dst, w, 5, 5), [1; 4], "outside");
|
||
}
|
||
|
||
#[test]
|
||
fn compose_without_selection_is_all_dark() {
|
||
let dark = vec![1u8; 16];
|
||
let mut dst = vec![0u8; 16];
|
||
compose(&mut dst, &dark, &[2u8; 16], 2, None);
|
||
assert_eq!(dst, dark);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_one_pixel_selection() {
|
||
let dark = vec![1u8; 16];
|
||
let mut dst = vec![0u8; 16];
|
||
let sel = PixelRect {
|
||
x: 1,
|
||
y: 1,
|
||
width: 1,
|
||
height: 1,
|
||
};
|
||
compose(&mut dst, &dark, &[2u8; 16], 2, Some(sel));
|
||
assert_eq!(px(&dst, 2, 1, 1), [255; 4]);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod render;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 6: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run render::`
|
||
Expected: compilation fails, e.g. `` cannot find function `dim` in this scope ``.
|
||
|
||
- [ ] **Step 7: Implement `render`**
|
||
|
||
Put this above the test module in `src/render.rs`:
|
||
|
||
```rust
|
||
use crate::frame::PixelRect;
|
||
|
||
/// Overlay brightness outside the selection (60%, i.e. darkened by 40%).
|
||
pub const DIM: u8 = 153;
|
||
|
||
/// B, G, R, X pixels with every byte scaled by about `brightness / 255`.
|
||
///
|
||
/// This runs on every output before the overlay can appear, so it is one
|
||
/// branch-free pass the compiler can vectorise. Scaling X too is harmless:
|
||
/// the overlay buffer is XRGB8888, which ignores it.
|
||
pub fn dim(pixels: &[u8], brightness: u8) -> Vec<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: selection geometry and overlay compositing"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 8: Saving, stdout and clipboard
|
||
|
||
**Files:**
|
||
- Create: `src/output.rs`
|
||
- Modify: `src/main.rs` (add `output` to the `mod` list)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `error::HintExt` (Task 2); `lock::Lock`, `detach::spawn` (Task 5).
|
||
- Produces: `pub fn file_name(template: &str, now: &DateTime<Local>, output: Option<&str>) -> Result<String>`; `pub fn unique_path(dir: &Path, name: &str) -> PathBuf`; `pub fn encode_png(img: &RgbaImage) -> Result<Vec<u8>>`; `pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()>`; `pub fn write_stdout(bytes: &[u8]) -> Result<()>`; `pub fn copy_to_clipboard(png: Vec<u8>, lock: Lock) -> Result<()>`; `pub enum Target { Default, Path(PathBuf), Stdout, ClipboardOnly }` with `pub fn from_args(output: Option<PathBuf>, clipboard_only: bool) -> Target`.
|
||
|
||
- [ ] **Step 1: Write the failing tests for `output`**
|
||
|
||
Create `src/output.rs` with only the test module:
|
||
|
||
```rust
|
||
#[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`:
|
||
|
||
```rust
|
||
use std::fmt::Write as _;
|
||
use std::fs;
|
||
use std::io::{Cursor, Write as _};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{Context, Result, anyhow};
|
||
use chrono::{DateTime, Local};
|
||
use image::{ImageFormat, RgbaImage};
|
||
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
||
|
||
use crate::error::HintExt;
|
||
use crate::lock::Lock;
|
||
|
||
/// Renders the strftime `template`. With `output`, appends ` (NAME)` before
|
||
/// the extension, for `valw screen --all`.
|
||
pub fn file_name(template: &str, now: &DateTime<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**
|
||
|
||
```sh
|
||
jj commit -m "feat: file names, atomic PNG writes and clipboard"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 9: Wayland connection, niri IPC and `valw doctor` (Phase 0 discovery)
|
||
|
||
**Files:**
|
||
- Create: `src/niri.rs`, `src/wayland.rs`, `src/doctor.rs`
|
||
- Replace: `src/main.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `config::{load, default_path, Config::save_dir}` (Task 3); `log::{init, default_dir, install_panic_hook}` (Task 4); `error::{render, Cancelled, HintExt}` (Task 2); `frame::OutputGeom` (Task 6).
|
||
- Produces:
|
||
- `niri`: `pub const IPC_VERSION: (u32, u32)`; `pub fn focused_output() -> Result<Option<String>>`; `pub fn version() -> Result<String>`; `pub fn parse_version(v: &str) -> Option<(u32, u32)>`.
|
||
- `wayland`: `pub struct Output { pub wl: WlOutput, pub geom: OutputGeom, pub transform: Transform }`; `pub struct Wayland { pub conn, pub queue: EventQueue<State>, pub globals: GlobalList, pub state: State }` with `connect() -> Result<Wayland>`, `outputs(&self) -> Vec<Output>`, `global_list(&self) -> Vec<(String, u32)>`, `dispatch_until(&mut self, deadline: Instant, done: impl FnMut(&State) -> bool) -> Result<()>`, `dispatch_blocking(&mut self, done) -> Result<()>`; `pub struct State` (SCTK state plus `layer_shell`, `screencopy`, `viewporter`, `cursor_shape`, `cursor_device`, `keyboard`, `pointer`).
|
||
- `doctor`: `pub enum Level { Ok, Warn, Fail }`, `pub struct Check { level, text }` (Display: `"ok text"`), `check_globals`, `check_niri`, `check_config`, `check_save_dir`, `pub fn run() -> Result<()>`.
|
||
- CLI: `valw doctor`; exit codes 0/1/3 wired in `main`.
|
||
|
||
Background for the Wayland code: SCTK 0.21 dispatches through `delegate_dispatch2!`. Protocol objects that SCTK doesn't wrap (screencopy manager, viewporter) use `wayland_client::delegate_noop!`. The handlers that do nothing yet become meaningful in Tasks 10 and 11.
|
||
|
||
- [ ] **Step 1: Write the failing tests for `niri`**
|
||
|
||
Create `src/niri.rs` with only the test module:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses_versions() {
|
||
assert_eq!(parse_version("26.04 (Nixpkgs)"), Some((26, 4)));
|
||
assert_eq!(parse_version("25.11"), Some((25, 11)));
|
||
assert_eq!(parse_version("26.04.1-dev"), Some((26, 4)));
|
||
assert_eq!(parse_version("unknown"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn ipc_version_matches_cargo_toml() {
|
||
let (y, m) = IPC_VERSION;
|
||
let pin = format!("niri-ipc = \"={y}.{m}.0\"");
|
||
assert!(
|
||
include_str!("../Cargo.toml").contains(&pin),
|
||
"Cargo.toml should pin {pin}"
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod niri;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 2: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run niri::`
|
||
Expected: compilation fails, e.g. `` cannot find function `parse_version` in this scope ``.
|
||
|
||
- [ ] **Step 3: Implement `niri`**
|
||
|
||
Put this above the test module in `src/niri.rs`:
|
||
|
||
```rust
|
||
use anyhow::{Context, Result, anyhow};
|
||
use niri_ipc::{Request, Response, socket::Socket};
|
||
|
||
/// The niri release the pinned `niri-ipc` crate belongs to. Keep in sync
|
||
/// with the `niri-ipc = "=X.Y.0"` line in Cargo.toml (a test checks this).
|
||
pub const IPC_VERSION: (u32, u32) = (26, 4);
|
||
|
||
fn request(request: Request) -> Result<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`.
|
||
|
||
```rust
|
||
use std::time::Instant;
|
||
|
||
use anyhow::{Context, Result, bail};
|
||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||
use smithay_client_toolkit::{
|
||
compositor::{CompositorHandler, CompositorState},
|
||
delegate_registry,
|
||
output::{OutputHandler, OutputState},
|
||
registry::{ProvidesRegistryState, RegistryState},
|
||
registry_handlers,
|
||
seat::{
|
||
Capability, SeatHandler, SeatState,
|
||
keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers},
|
||
pointer::{PointerEvent, PointerHandler, cursor_shape::CursorShapeManager},
|
||
},
|
||
shell::wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure},
|
||
shm::{Shm, ShmHandler},
|
||
};
|
||
use wayland_client::{
|
||
Connection, EventQueue, QueueHandle,
|
||
globals::{GlobalList, registry_queue_init},
|
||
protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
|
||
};
|
||
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1;
|
||
use wayland_protocols::wp::viewporter::client::{
|
||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||
};
|
||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||
|
||
use crate::error::HintExt;
|
||
use crate::frame::OutputGeom;
|
||
|
||
/// An output as valw sees it.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Output {
|
||
pub wl: wl_output::WlOutput,
|
||
pub geom: OutputGeom,
|
||
pub transform: wl_output::Transform,
|
||
}
|
||
|
||
pub struct Wayland {
|
||
pub conn: Connection,
|
||
pub queue: EventQueue<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:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn globals(list: &[(&str, u32)]) -> Vec<(String, u32)> {
|
||
list.iter().map(|&(n, v)| (n.to_string(), v)).collect()
|
||
}
|
||
|
||
fn niri_26_04() -> Vec<(String, u32)> {
|
||
globals(&[
|
||
("wl_compositor", 6),
|
||
("wl_shm", 2),
|
||
("wl_seat", 9),
|
||
("zxdg_output_manager_v1", 3),
|
||
("zwlr_layer_shell_v1", 5),
|
||
("zwlr_screencopy_manager_v1", 3),
|
||
("wp_viewporter", 1),
|
||
("wp_cursor_shape_manager_v1", 2),
|
||
("zwlr_data_control_manager_v1", 2),
|
||
("ext_data_control_manager_v1", 1),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn all_present_is_all_ok() {
|
||
let checks = check_globals(&niri_26_04());
|
||
assert!(checks.iter().all(|c| c.level == Level::Ok), "{checks:#?}");
|
||
assert!(
|
||
checks
|
||
.iter()
|
||
.any(|c| c.text == "ext_data_control_manager_v1 v1 (clipboard copy)")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_required_fails_missing_optional_warns() {
|
||
let list: Vec<_> = niri_26_04()
|
||
.into_iter()
|
||
.filter(|(n, _)| n != "zwlr_screencopy_manager_v1" && n != "wp_cursor_shape_manager_v1")
|
||
.collect();
|
||
let checks = check_globals(&list);
|
||
assert!(checks.contains(&check(
|
||
Level::Fail,
|
||
"zwlr_screencopy_manager_v1 missing (screen capture)"
|
||
)));
|
||
assert!(checks.contains(&check(
|
||
Level::Warn,
|
||
"wp_cursor_shape_manager_v1 missing (crosshair cursor in region mode disabled)"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn old_version_fails() {
|
||
let mut list = niri_26_04();
|
||
list.retain(|(n, _)| n != "zxdg_output_manager_v1");
|
||
list.push(("zxdg_output_manager_v1".into(), 1));
|
||
assert!(check_globals(&list).contains(&check(
|
||
Level::Fail,
|
||
"zxdg_output_manager_v1 v1 is too old, need v2 (output names and positions)"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn niri_versions() {
|
||
assert_eq!(check_niri(Ok("26.04 (Nixpkgs)".into())).level, Level::Ok);
|
||
assert_eq!(check_niri(Ok("25.11".into())).level, Level::Warn);
|
||
assert_eq!(
|
||
check_niri(Err(anyhow::anyhow!("no socket"))).level,
|
||
Level::Warn
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn save_dir_checks() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
assert_eq!(check_save_dir(&dir.path().join("new")).level, Level::Ok);
|
||
let file = dir.path().join("file");
|
||
std::fs::write(&file, b"").unwrap();
|
||
assert_eq!(check_save_dir(&file).level, Level::Fail);
|
||
}
|
||
|
||
#[test]
|
||
fn line_format() {
|
||
assert_eq!(check(Level::Warn, "x").to_string(), "warn x");
|
||
assert_eq!(check(Level::Ok, "x").to_string(), "ok x");
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `mod doctor;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
|
||
|
||
- [ ] **Step 7: Run the tests and watch them fail**
|
||
|
||
Run: `cargo nextest run doctor::`
|
||
Expected: compilation fails, e.g. `` cannot find function `check_globals` in this scope ``.
|
||
|
||
- [ ] **Step 8: Implement `doctor`**
|
||
|
||
Put this above the test module in `src/doctor.rs`:
|
||
|
||
```rust
|
||
use std::fmt;
|
||
use std::path::Path;
|
||
|
||
use anyhow::{Result, bail};
|
||
|
||
use crate::config;
|
||
use crate::niri;
|
||
use crate::wayland::Wayland;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Level {
|
||
Ok,
|
||
Warn,
|
||
Fail,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Check {
|
||
pub level: Level,
|
||
pub text: String,
|
||
}
|
||
|
||
impl fmt::Display for Check {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
let tag = match self.level {
|
||
Level::Ok => "ok",
|
||
Level::Warn => "warn",
|
||
Level::Fail => "fail",
|
||
};
|
||
write!(f, "{tag:<5} {}", self.text)
|
||
}
|
||
}
|
||
|
||
fn check(level: Level, text: impl Into<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`**
|
||
|
||
```rust
|
||
// Modules land before the CLI uses them; Task 11 removes this.
|
||
#![allow(dead_code)]
|
||
|
||
mod config;
|
||
mod detach;
|
||
mod doctor;
|
||
mod error;
|
||
mod frame;
|
||
mod lock;
|
||
mod log;
|
||
mod niri;
|
||
mod output;
|
||
mod render;
|
||
mod selection;
|
||
mod wayland;
|
||
|
||
use std::process::ExitCode;
|
||
|
||
use anyhow::Result;
|
||
use chrono::Local;
|
||
use clap::{Parser, Subcommand};
|
||
|
||
use crate::error::Cancelled;
|
||
|
||
#[derive(Parser)]
|
||
#[command(version, about = "macOS-style screenshots for niri")]
|
||
struct Cli {
|
||
#[command(subcommand)]
|
||
command: Command,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Command {
|
||
/// Report what the compositor and system support.
|
||
Doctor,
|
||
}
|
||
|
||
fn main() -> ExitCode {
|
||
let cli = Cli::parse();
|
||
let log = log::init(&log::default_dir(), Local::now());
|
||
log::install_panic_hook(log.clone());
|
||
tracing::info!(
|
||
"valw {} {:?}",
|
||
env!("CARGO_PKG_VERSION"),
|
||
std::env::args().collect::<Vec<_>>()
|
||
);
|
||
|
||
match run(cli) {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) if e.downcast_ref::<Cancelled>().is_some() => {
|
||
tracing::info!("cancelled by the user");
|
||
ExitCode::from(3)
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("{e:#}");
|
||
eprint!("{}", error::render(&e, log.as_deref()));
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
fn run(cli: Cli) -> Result<()> {
|
||
match cli.command {
|
||
Command::Doctor => doctor::run(),
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run`
|
||
Expected: no warnings; every test passes.
|
||
|
||
- [ ] **Step 11: Run `valw doctor` on niri (Phase 0 discovery report)**
|
||
|
||
Run: `cargo run --release -- doctor; echo "exit=$?"`
|
||
Expected, on the user's machine, is something like the following (the spec's section 2 table lists the same globals):
|
||
|
||
```
|
||
ok wl_compositor v6 (surfaces)
|
||
ok wl_shm v2 (pixel buffers)
|
||
ok wl_seat v9 (mouse and keyboard)
|
||
ok zxdg_output_manager_v1 v3 (output names and positions)
|
||
ok zwlr_layer_shell_v1 v5 (the selection overlay)
|
||
ok zwlr_screencopy_manager_v1 v3 (screen capture)
|
||
ok wp_viewporter v1 (sharp overlays at fractional scale)
|
||
ok wp_cursor_shape_manager_v1 v2 (crosshair cursor in region mode)
|
||
ok ext_data_control_manager_v1 v1 (clipboard copy)
|
||
ok output LVDS-1: 1366x768 logical at 0,0, transform Normal
|
||
ok output HDMI-A-1: 1920x1080 logical at -277,-1080, transform Normal
|
||
ok niri 26.04 (Nixpkgs)
|
||
ok config /home/elars/.config/valw/config.toml not found, using defaults
|
||
ok save directory /home/elars/Pictures/Screenshots is writable
|
||
exit=0
|
||
```
|
||
|
||
Output positions depend on the current monitor layout. Any `fail` line is a blocker; report it rather than working around it. Also check that `~/.local/state/valw/logs/` now holds a log file whose first lines include the valw version and the `globals:` line.
|
||
|
||
- [ ] **Step 12: Check the error path**
|
||
|
||
Run: `WAYLAND_DISPLAY=nope cargo run --release -- doctor; echo "exit=$?"`
|
||
Expected: a `fail wayland: could not connect to the Wayland compositor: ...` line and exit 1. This is the only path where doctor itself fails.
|
||
|
||
- [ ] **Step 13: Commit**
|
||
|
||
```sh
|
||
jj commit -m "feat: wayland connection, niri IPC and valw doctor"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 10: Screen capture and `valw screen`
|
||
|
||
**Files:**
|
||
- Create: `src/capture.rs`
|
||
- Modify: `src/wayland.rs` (screencopy bookkeeping)
|
||
- Replace: `src/main.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `wayland::{Wayland, State, Output}` and `Wayland::dispatch_until` (Task 9); `frame::{Frame, SUPPORTED, to_bgrx, apply_transform, Frame::to_rgba, Frame::full}` (Task 6); `output::*` (Task 8); `lock::Lock` (Task 5); `niri::{focused_output, version}` (Task 9); `config` (Task 3).
|
||
- Produces: `pub trait Capturer { fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result<Vec<Frame>>; }` implemented for `Wayland`; `pub struct Pending` (one in-flight screencopy, stored in `State::captures`); CLI `valw screen [--all] [--clipboard-only] [-o PATH|-] [--delay SECS] [--cursor]`.
|
||
|
||
Two facts the code depends on, both verified on niri 26.04:
|
||
- **niri rejects a screencopy shm buffer unless its `wl_shm_pool` is exactly the buffer's size** (`src/protocols/screencopy.rs`: `shm_len == stride * height`). Every capture therefore gets its own `RawPool`. A shared, larger pool fails with `invalid buffer`.
|
||
- **wl-clipboard-rs never forks.** Its background mode is a thread, which dies with the process. `output::copy_to_clipboard` (Task 8) forks through `detach::spawn` instead.
|
||
|
||
- [ ] **Step 1: Write `src/capture.rs`**
|
||
|
||
Add `mod capture;` to the `mod` list in `src/main.rs`. The pixel logic here was unit-tested in Task 6; this file is protocol plumbing.
|
||
|
||
```rust
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use anyhow::{Context, Result, anyhow};
|
||
use smithay_client_toolkit::shm::raw::RawPool;
|
||
use wayland_client::{
|
||
Connection, Dispatch, Proxy, QueueHandle, WEnum,
|
||
protocol::{wl_buffer::WlBuffer, wl_shm::Format},
|
||
};
|
||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{
|
||
self, ZwlrScreencopyFrameV1,
|
||
};
|
||
|
||
use crate::error::HintExt;
|
||
use crate::frame::{self, Frame, SUPPORTED};
|
||
use crate::wayland::{Output, State, Wayland};
|
||
|
||
/// How long the compositor gets to deliver all frames.
|
||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||
|
||
/// Something that captures outputs. Only wlr-screencopy exists today;
|
||
/// ext-image-copy-capture would be a second implementation.
|
||
pub trait Capturer {
|
||
fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result<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`:
|
||
|
||
```diff
|
||
--- a/src/wayland.rs
|
||
+++ b/src/wayland.rs
|
||
@@ -19,7 +19,7 @@
|
||
use wayland_client::{
|
||
Connection, EventQueue, QueueHandle,
|
||
globals::{GlobalList, registry_queue_init},
|
||
- protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
|
||
+ protocol::{wl_buffer::WlBuffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
|
||
};
|
||
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1;
|
||
use wayland_protocols::wp::viewporter::client::{
|
||
@@ -27,6 +27,7 @@
|
||
};
|
||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||
|
||
+use crate::capture::Pending;
|
||
use crate::error::HintExt;
|
||
use crate::frame::OutputGeom;
|
||
|
||
@@ -58,6 +59,8 @@
|
||
pub cursor_device: Option<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.
|
||
|
||
```rust
|
||
// Modules land before the CLI uses them; Task 11 removes this.
|
||
#![allow(dead_code)]
|
||
|
||
mod capture;
|
||
mod config;
|
||
mod detach;
|
||
mod doctor;
|
||
mod error;
|
||
mod frame;
|
||
mod lock;
|
||
mod log;
|
||
mod niri;
|
||
mod output;
|
||
mod render;
|
||
mod selection;
|
||
mod wayland;
|
||
|
||
use std::path::PathBuf;
|
||
use std::process::ExitCode;
|
||
use std::time::Duration;
|
||
|
||
use anyhow::{Context, Result};
|
||
use chrono::Local;
|
||
use clap::{Args, Parser, Subcommand};
|
||
use image::RgbaImage;
|
||
|
||
use crate::capture::Capturer;
|
||
use crate::config::Config;
|
||
use crate::error::Cancelled;
|
||
use crate::lock::Lock;
|
||
use crate::output::Target;
|
||
use crate::wayland::Wayland;
|
||
|
||
#[derive(Parser)]
|
||
#[command(version, about = "macOS-style screenshots for niri")]
|
||
struct Cli {
|
||
#[command(subcommand)]
|
||
command: Command,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Command {
|
||
/// Capture the focused output (Cmd+Shift+3).
|
||
Screen {
|
||
/// Capture every output, one file each.
|
||
#[arg(long, conflicts_with_all = ["output", "clipboard_only"])]
|
||
all: bool,
|
||
#[command(flatten)]
|
||
common: Common,
|
||
},
|
||
/// Report what the compositor and system support.
|
||
Doctor,
|
||
}
|
||
|
||
#[derive(Args)]
|
||
struct Common {
|
||
/// Copy to the clipboard without saving a file.
|
||
#[arg(long, conflicts_with = "output")]
|
||
clipboard_only: bool,
|
||
/// Write to this path instead of the configured directory; "-" for stdout.
|
||
#[arg(short, long, value_name = "PATH")]
|
||
output: Option<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**
|
||
|
||
```sh
|
||
t=$(mktemp -d)
|
||
mkdir -p $t/config/valw
|
||
printf '[save]\ndirectory = "%s/shots"\ncopy_to_clipboard = false\n' $t > $t/config/valw/config.toml
|
||
export XDG_CONFIG_HOME=$t/config XDG_STATE_HOME=$t/state
|
||
cargo build --release
|
||
V=$CARGO_TARGET_DIR/release/valw
|
||
$V screen -o - | file -
|
||
$V screen --all && file $t/shots/*
|
||
$V screen --delay 0.5 -o $t/one.png && file $t/one.png
|
||
```
|
||
|
||
Expected: `PNG image data, 1366 x 768` (or whichever output niri has focused). With `--all`, one file per output named `Screenshot … (LVDS-1).png` / `(HDMI-A-1).png` at each output's physical size. The log in `$t/state/valw/logs/` shows `offered [Xrgb8888], using Xrgb8888 …` and `captured N output(s) in …ms`.
|
||
|
||
- [ ] **Step 7: Clipboard and pipes**
|
||
|
||
```sh
|
||
printf '[save]\ndirectory = "%s/shots"\n' $t > $t/config/valw/config.toml
|
||
time ($V screen -o - | wc -c)
|
||
wl-paste --list-types
|
||
wl-paste --type image/png | file -
|
||
```
|
||
|
||
Expected: the pipeline returns immediately (well under a second), even though the clipboard child is still running. `wl-paste --list-types` lists `image/png`, and the pasted data is the PNG. This step overwrites the clipboard, so tell the user before running it.
|
||
|
||
- [ ] **Step 8: Hotkey spam (Review Focus 4)**
|
||
|
||
```sh
|
||
$V screen --delay 3 -o $t/a.png & sleep 0.5; $V screen -o $t/b.png; echo "exit=$?"; wait
|
||
$V screen -o $t/c.png; echo "exit=$?"
|
||
```
|
||
|
||
Expected: the second command prints `error: valw is already running` with `hint: wait for the other capture to finish, or press Esc in it` and `exit=1`. The first one then succeeds. The third succeeds (`exit=0`) even though the first run's clipboard child is still alive.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```sh
|
||
jj commit -m "feat: wlr-screencopy capture and valw screen"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 11: Region selection overlay and `valw region`
|
||
|
||
**Files:**
|
||
- Create: `src/region.rs`
|
||
- Modify: `src/wayland.rs` (route overlay events), `src/main.rs` (add `region`, drop `#![allow(dead_code)]`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `selection::{Selection, Point, clip, resolve}` (Task 7); `render::{dim, compose, DIM}` (Task 7); `frame::{Bgrx, Frame, OutputGeom, PixelRect}` (Task 6); `wayland::{Wayland, State, Output}`, `Wayland::dispatch_blocking` (Task 9); `log::since_start` (Task 4); `error::{Cancelled, HintExt}` (Task 2).
|
||
- Produces: `pub const NAMESPACE: &str = "valw-overlay"`; `pub type Picked = (usize, PixelRect)`; `pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<Picked>` (returns `Cancelled` on Esc or right click); `pub struct Overlay` with `cancel`, `configure`, `frame_done`, `pointer`, called from `State`'s handlers. CLI: `valw region [COMMON]`.
|
||
|
||
How it works: one fullscreen layer surface per output shows that output's frozen frame, darkened. The buffer 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`.
|
||
|
||
```rust
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::{Context, Result};
|
||
use smithay_client_toolkit::{
|
||
compositor::FrameCallbackData,
|
||
seat::pointer::{BTN_LEFT, BTN_RIGHT, PointerEvent, PointerEventKind},
|
||
shell::{
|
||
WaylandSurface,
|
||
wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface},
|
||
},
|
||
shm::{
|
||
Shm,
|
||
slot::{Buffer, SlotPool},
|
||
},
|
||
};
|
||
use wayland_client::{
|
||
QueueHandle,
|
||
protocol::{wl_shm, wl_surface::WlSurface},
|
||
};
|
||
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{
|
||
Shape, WpCursorShapeDeviceV1,
|
||
};
|
||
use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
|
||
|
||
use crate::error::{Cancelled, HintExt};
|
||
use crate::frame::{Bgrx, Frame, OutputGeom, PixelRect};
|
||
use crate::render;
|
||
use crate::selection::{self, Point, Selection};
|
||
use crate::wayland::{Output, State, Wayland};
|
||
|
||
pub const NAMESPACE: &str = "valw-overlay";
|
||
|
||
/// What the user picked: an output index and a rectangle in its frame.
|
||
pub type Picked = (usize, PixelRect);
|
||
|
||
/// One full-screen layer surface per output, showing the frozen frame.
|
||
pub struct Overlay {
|
||
surfaces: Vec<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`:
|
||
|
||
```diff
|
||
--- a/src/wayland.rs
|
||
+++ b/src/wayland.rs
|
||
@@ -30,6 +30,7 @@
|
||
use crate::capture::Pending;
|
||
use crate::error::HintExt;
|
||
use crate::frame::OutputGeom;
|
||
+use crate::region::Overlay;
|
||
|
||
/// An output as valw sees it.
|
||
#[derive(Debug, Clone)]
|
||
@@ -61,6 +62,8 @@
|
||
pub pointer: Option<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 `region` command and remove the dead-code allowance**
|
||
|
||
Apply this change to `src/main.rs`:
|
||
|
||
```diff
|
||
--- a/src/main.rs
|
||
+++ b/src/main.rs
|
||
@@ -1,6 +1,3 @@
|
||
-// Modules land before the CLI uses them; Task 11 removes this.
|
||
-#![allow(dead_code)]
|
||
-
|
||
mod capture;
|
||
mod config;
|
||
mod detach;
|
||
@@ -11,6 +8,7 @@
|
||
mod log;
|
||
mod niri;
|
||
mod output;
|
||
+mod region;
|
||
mod render;
|
||
mod selection;
|
||
mod wayland;
|
||
@@ -45,6 +43,11 @@
|
||
/// Capture every output, one file each.
|
||
#[arg(long, conflicts_with_all = ["output", "clipboard_only"])]
|
||
all: bool,
|
||
+ #[command(flatten)]
|
||
+ common: Common,
|
||
+ },
|
||
+ /// Drag to select a region (Cmd+Shift+4).
|
||
+ Region {
|
||
#[command(flatten)]
|
||
common: Common,
|
||
},
|
||
@@ -76,6 +79,7 @@
|
||
|
||
enum Mode {
|
||
Screen { all: bool },
|
||
+ Region,
|
||
}
|
||
|
||
fn main() -> ExitCode {
|
||
@@ -106,6 +110,7 @@
|
||
match cli.command {
|
||
Command::Doctor => doctor::run(),
|
||
Command::Screen { all, common } => capture(Mode::Screen { all }, common),
|
||
+ Command::Region { common } => capture(Mode::Region, common),
|
||
}
|
||
}
|
||
|
||
@@ -139,6 +144,11 @@
|
||
(vec![(frame.to_rgba(frame.full()), None)], 0)
|
||
}
|
||
}
|
||
+ Mode::Region => {
|
||
+ let frames = wl.capture(&outputs, cursor)?;
|
||
+ let (i, r) = region::select(&mut wl, &outputs, &frames)?;
|
||
+ (vec![(frames[i].to_rgba(r), None)], 0)
|
||
+ }
|
||
};
|
||
drop(wl);
|
||
|
||
@@ -224,10 +234,10 @@
|
||
fn flag_conflicts() {
|
||
assert!(parses(&["screen", "--all"]));
|
||
assert!(parses(&["screen", "-o", "-"]));
|
||
- assert!(parses(&["screen", "--clipboard-only", "--cursor"]));
|
||
+ assert!(parses(&["region", "--clipboard-only", "--cursor"]));
|
||
assert!(!parses(&["screen", "--all", "-o", "-"]));
|
||
assert!(!parses(&["screen", "--all", "--clipboard-only"]));
|
||
- assert!(!parses(&["screen", "--clipboard-only", "-o", "a.png"]));
|
||
+ assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"]));
|
||
}
|
||
|
||
#[test]
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and lint without the allowance**
|
||
|
||
Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run`
|
||
Expected: no warnings (in particular, no `dead_code`); every test passes.
|
||
|
||
- [ ] **Step 5: Select a region on niri**
|
||
|
||
Use the throwaway config from Task 10 (`copy_to_clipboard = false`):
|
||
|
||
```sh
|
||
cargo build --release
|
||
$V region -o $t/r.png; echo "exit=$?"; file $t/r.png
|
||
grep -h "committed\|selected" $t/state/valw/logs/*.log | tail -3
|
||
```
|
||
|
||
Ask the user to drag over a static area. Expected: every output freezes and dims, the cursor becomes a crosshair, the selection shows at full brightness with a white 1 px outline, and the overlay disappears on release. The command prints `exit=0`, and `r.png` has the logged pixel size. The log shows `overlay on … committed …ms after start` and `selected PixelRect { … } on …`.
|
||
|
||
- [ ] **Step 6: Cancel paths**
|
||
|
||
Ask the user to run `$V region -o $t/x.png; echo "exit=$?"` three times: press Esc, then right-click, then click once without dragging and press Esc afterwards.
|
||
Expected: `exit=3` each time, and no `x.png`. A plain click does **not** end the selection; the overlay stays until Esc.
|
||
|
||
- [ ] **Step 7: Drag across monitors (Review Focus 3)**
|
||
|
||
Ask the user to start a drag on one monitor and release it on the other, and once more to release outside every monitor's area (for example past a screen edge).
|
||
Expected: no panic. The result is clipped to the monitor where the drag started, and the log's `selected` line shows a rectangle inside that monitor.
|
||
|
||
- [ ] **Step 8: No stuck overlay**
|
||
|
||
```sh
|
||
$V region -o $t/k.png & sleep 1; kill -9 $!
|
||
$V screen -o /dev/null; echo "exit=$?"
|
||
```
|
||
|
||
Expected: the overlay vanishes when the process is killed, and the next command gets the lock (`exit=0`).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```sh
|
||
jj commit -m "feat: frozen-frame region selection overlay"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 12: Nix checks (clippy, fmt, nextest, headless sway)
|
||
|
||
**Files:**
|
||
- Create: `nix/checks.ulu.nix`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `packages.default` (Task 1); `valw doctor` and `valw screen -o -` (Tasks 9–10).
|
||
- Produces: `checks.<system>.{clippy,fmt,nextest,headless}`, run by `nix flake check`.
|
||
|
||
Notes:
|
||
- `cargoCheck` reuses the package derivation, so dependencies are vendored once. Setting `buildPhase` and `installPhase` replaces cargo's build and install hooks.
|
||
- `fmt` calls `rustfmt` directly because `cargo fmt` would try to resolve dependencies offline.
|
||
- The headless check must use `sway-unwrapped`: nixpkgs' `sway` wrapper starts through `dbus-run-session`, which does not exist in the sandbox.
|
||
- `valw screen -o - | cat` runs under `timeout 10`, so a clipboard child that kept the pipe open would fail the check.
|
||
|
||
- [ ] **Step 1: Write `nix/checks.ulu.nix`**
|
||
|
||
```nix
|
||
{ ... }:
|
||
{
|
||
perSystem =
|
||
{ pkgs, self', ... }:
|
||
let
|
||
valw = self'.packages.default;
|
||
|
||
# Reuses the package's vendored dependencies, runs `cmd` instead of the build.
|
||
cargoCheck =
|
||
name: tools: cmd:
|
||
valw.overrideAttrs (old: {
|
||
pname = "valw-${name}";
|
||
nativeBuildInputs = old.nativeBuildInputs ++ tools;
|
||
buildPhase = cmd;
|
||
installPhase = "touch $out";
|
||
});
|
||
in
|
||
{
|
||
checks = {
|
||
clippy = cargoCheck "clippy" [ pkgs.clippy ] "cargo clippy --all-targets --offline -- -D warnings";
|
||
nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline";
|
||
|
||
fmt = pkgs.runCommand "valw-fmt" { nativeBuildInputs = [ pkgs.rustfmt ]; } ''
|
||
rustfmt --check --edition 2024 ${valw.src}/src/main.rs
|
||
touch $out
|
||
'';
|
||
|
||
# Runs the real screencopy path against a headless sway. This is
|
||
# wlroots, not niri, but it catches capture and format bugs on every build.
|
||
headless =
|
||
pkgs.runCommand "valw-headless"
|
||
{
|
||
nativeBuildInputs = [
|
||
pkgs.file
|
||
pkgs.sway-unwrapped
|
||
valw
|
||
];
|
||
}
|
||
''
|
||
export HOME=$TMPDIR/home XDG_RUNTIME_DIR=$TMPDIR/run
|
||
mkdir -p $HOME
|
||
mkdir -m 700 $XDG_RUNTIME_DIR
|
||
export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1
|
||
|
||
echo 'output HEADLESS-1 resolution 1280x720' > sway.conf
|
||
sway -c sway.conf > sway.log 2>&1 &
|
||
|
||
for _ in $(seq 100); do
|
||
socket=$(ls $XDG_RUNTIME_DIR | grep -m1 '^wayland-[0-9]*$' || true)
|
||
[ -n "$socket" ] && break
|
||
sleep 0.1
|
||
done
|
||
[ -n "$socket" ] || { cat sway.log; exit 1; }
|
||
export WAYLAND_DISPLAY=$socket
|
||
|
||
valw doctor | tee doctor.txt
|
||
! grep -q '^fail' doctor.txt
|
||
|
||
# Through a pipe: the clipboard child must not keep it open.
|
||
timeout 10 valw screen -o - | cat > shot.png
|
||
file shot.png | tee file.txt
|
||
grep -q 'PNG image data, 1280 x 720' file.txt
|
||
|
||
touch $out
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run every check**
|
||
|
||
Run: `nix flake check -L --keep-going`
|
||
Expected: all four checks build. The `valw-headless` log shows the doctor lines, all `ok` except `warn niri IPC unavailable …` (sway is not niri), plus `shot.png: PNG image data, 1280 x 720`.
|
||
|
||
- [ ] **Step 3: Format the Nix files**
|
||
|
||
Run: `nix fmt`
|
||
Expected: no changes beyond whitespace. If anything changed, rerun Step 2.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```sh
|
||
jj commit -m "test: nix flake checks with headless sway capture"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 13: Acceptance on niri
|
||
|
||
**Files:** none (verification only). If a check fails, fix the owning task's code, add a unit test that reproduces the failure where possible, and commit the fix separately.
|
||
|
||
**Interfaces:**
|
||
- Consumes: the release binary from `nix build`.
|
||
- Produces: confirmation that Phase 0 + 1 meets the spec's sections 10.3 and 11.
|
||
|
||
Before starting, tell the user this task temporarily changes the HDMI output's scale and rotation through `niri msg`. That is runtime only and doesn't touch the niri config, and every step restores it. Use the throwaway config from Task 10.
|
||
|
||
- [ ] **Step 1: Build the real package**
|
||
|
||
```sh
|
||
nix build && V=$PWD/result/bin/valw
|
||
```
|
||
|
||
- [ ] **Step 2: Pixel exactness at scale 1 and 1.25 (Review Focus 2)**
|
||
|
||
For each output, and then again after `niri msg output HDMI-A-1 scale 1.25`:
|
||
|
||
```sh
|
||
$V region -o $t/a.png # user drags over a static area
|
||
grep -h selected $t/state/valw/logs/*.log | tail -1
|
||
# -> selected PixelRect { x: X, y: Y, width: W, height: H } on NAME ...
|
||
grim -o NAME $t/full.png
|
||
magick $t/full.png -crop WxH+X+Y +repage $t/b.png
|
||
magick compare -metric AE $t/a.png $t/b.png null:; echo
|
||
```
|
||
|
||
Expected: `0` every time. At 1.25 the overlay must also look sharp, with text in it as crisp as on the live screen. Restore with `niri msg output HDMI-A-1 scale 1`.
|
||
|
||
- [ ] **Step 3: Latency**
|
||
|
||
Run `$V region -o /dev/null` and press Esc; repeat three times.
|
||
Expected: every `overlay on … committed …ms after start` log line is under 150 ms. (Prototype measurements: 38–42 ms warm and 90 ms cold on a headless 1080p output; 270 ms before frames were kept in B, G, R, X order.) If it's over, the `captured … (… after start)` and `overlay buffers ready …` lines (`VALW_LOG=debug`) show which phase is slow.
|
||
|
||
- [ ] **Step 4: Rotated output (Review Focus 1)**
|
||
|
||
```sh
|
||
niri msg output HDMI-A-1 transform 90
|
||
$V screen --all && grim -o HDMI-A-1 $t/g.png
|
||
magick compare -metric AE "$(ls $t/shots/*HDMI-A-1*.png | tail -1)" $t/g.png null:; echo
|
||
niri msg output HDMI-A-1 transform normal
|
||
```
|
||
|
||
Expected: `0`. If the images are rotated relative to each other, the mapping in `frame::apply_transform` is wrong for niri. Fix the table in `transforms_match_grim` and the match arms together.
|
||
|
||
- [ ] **Step 5: Noctalia**
|
||
|
||
Run `$V region -o /dev/null` and have the user confirm the dimmed overlay covers the Noctalia bar, then press Esc.
|
||
|
||
- [ ] **Step 6: Unwritable log directory (Review Focus 5)**
|
||
|
||
```sh
|
||
XDG_STATE_HOME=/proc $V screen -o $t/nolog.png; echo "exit=$?"; file $t/nolog.png
|
||
$V screen -o /proc/nope.png; echo "exit=$?"
|
||
```
|
||
|
||
Expected: the first prints a `WARN logging to stderr only: …` line, `exit=0`, and a valid PNG. The second prints `error: could not write /proc/nope.png`-style output with `cause:` lines and the `log:` path, and `exit=1`.
|
||
|
||
- [ ] **Step 7: Full check suite**
|
||
|
||
Run: `nix flake check -L --keep-going`
|
||
Expected: every check passes.
|
||
|
||
- [ ] **Step 8: Report**
|
||
|
||
Summarise the results of Steps 2–6 to the user, including exact numbers (AE values and latencies). There is nothing to commit unless a fix was needed.
|