16 KiB
valw: Phase 0 + 1 design
Date: 2026-09-29 Status: approved in brainstorming, pending written-spec review
1. Context
valw is a macOS-style (Cmd+Shift+3/4/5) screenshot tool for niri on NixOS,
written in Rust. The full product vision (preview thumbnail, editor, zoom mode,
toolbar) is described in the user's original spec
(wayland_screenshot_tool_spec.md, where the project was called snapr).
That vision is split into six phases; each phase gets its own
design → plan → implementation cycle.
This document covers Phase 0 (discovery + scaffolding) and Phase 1 (core capture) only.
Goals
- A dendritic Nix flake that builds valw and provides a devShell.
valw screenandvalw regioncapture pixel-exact PNGs, save them and copy them to the clipboard.- Errors explain what happened, why, and what to do next, and every run leaves a log good enough to debug from.
valw doctorreports what the running compositor supports.
Non-goals (later phases)
- Preview thumbnail, desktop notifications,
--no-preview(Phase 2) - Window mode, Shift/Alt/Space modifiers, size indicator, fonts (Phase 3)
- Zoom mode (Phase 4)
- Editor (Phase 5)
- Toolbar, drag and drop, home-manager module, README (Phase 6)
ext-image-copy-capture-v1backend (not offered by niri 26.04)
2. Verified environment
Checked on the user's machine on 2026-09-29:
| Item | Value |
|---|---|
| niri | 26.04 (nixpkgs) |
zwlr_screencopy_manager_v1 |
v3 |
ext_image_copy_capture_* |
not offered |
zwlr_layer_shell_v1 |
v5 |
wp_fractional_scale_manager_v1 |
v1 |
wp_viewporter |
v1 |
wp_cursor_shape_manager_v1 |
v2 |
zxdg_output_manager_v1 |
v3 |
wl_output |
v4 |
wl_seat / wl_shm |
v9 / v2 |
ext_data_control_manager_v1 / zwlr_data_control_manager_v1 |
v1 / v2 |
| Outputs | LVDS-1 1366×768 @ scale 1, HDMI-A-1 1920×1080 @ scale 1 |
| Shell | Noctalia |
| jj | 0.45.1, repo is non-colocated (no .git at the root) |
| direnv | 2.37.1 with nix-direnv (home-manager) |
Consequences:
- Screen capture uses wlr-screencopy only. The
Capturertrait stays so a second backend can be added later, but only one implementation is written now. - Fractional scaling must be tested by temporarily setting a scale
(
niri msg output HDMI-A-1 scale 1.25).
3. Conventions
- Code, comments, error messages, logs and docs are in English. Comments are plain and short; they explain why, not what.
- Version control is jj. The repo is non-colocated, so Nix treats it as a
path:flake. - Rust toolchain: stable from nixpkgs (no rust-overlay/fenix).
- Test runner:
cargo nextest. Lints:cargo clippy -- -D warnings.
4. Repository layout
.envrc
.gitignore
flake.nix # flake-parts; auto-discovers nix/**/*.ulu.nix
flake.lock
nix/
package.ulu.nix # perSystem.packages.default (buildRustPackage)
devshell.ulu.nix # perSystem.devShells.default
formatter.ulu.nix # nixfmt-tree, same as copland
checks.ulu.nix # perSystem.checks: clippy, fmt, nextest, headless sway
Cargo.toml
Cargo.lock
src/
main.rs # clap CLI, subcommand dispatch
config.rs # config file loading
error.rs # user-facing error type and rendering
log.rs # tracing setup, per-run log files
lock.rs # single-instance lock
wayland.rs # SCTK state: registry, outputs, seat, shm
capture.rs # Capturer trait + wlr-screencopy implementation
niri.rs # niri IPC wrapper (focused output)
region.rs # region selection overlay + state machine
render.rs # tiny-skia helpers
output.rs # PNG saving, filename template, clipboard
doctor.rs # `valw doctor`
docs/
Files stay flat for now; overlay/ and editor/ subdirectories appear once
Phases 3 to 5 give them enough content.
4.1 Nix
flake.nix follows copland's pattern (flake-parts + automatic discovery), but
discovery is limited to ./nix so src/ is not walked:
imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix);
Systems: x86_64-linux, aarch64-linux.
package.ulu.nix:
pkgs.rustPlatform.buildRustPackage,cargoLock.lockFile = ./Cargo.lock.srcis filtered withlib.cleanSourceWithto droptarget,result,.jjand.direnv.nativeBuildInputs:pkg-config.buildInputs:wayland,libxkbcommon.meta.mainProgram = "valw".doCheck = false; tests run throughchecksinstead.
devshell.ulu.nix: rustc, cargo, clippy, rustfmt, rust-analyzer,
cargo-nextest, pkg-config, wayland, libxkbcommon, wayland-utils,
grim, imagemagick (the last three for manual verification). It sets:
CARGO_TARGET_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/valw/target"
Because the repo has no .git, Nix copies the whole directory into the store
on every evaluation and ignores .gitignore. Keeping target/ out of the tree
keeps that copy small.
checks.ulu.nix provides clippy, fmt, nextest and headless
(section 10.2).
4.2 direnv
.envrc:
watch_file nix/*.ulu.nix
use flake
nix-direnv only watches flake.nix and flake.lock by default; the devShell
lives in nix/devshell.ulu.nix, so it must be watched explicitly.
4.3 .gitignore
Whitelist style:
/*
!/.gitignore
!/.envrc
!/flake.nix
!/flake.lock
!/Cargo.toml
!/Cargo.lock
!/nix/
!/src/
!/docs/
!/README.md
Any new top-level file must be added here. Otherwise jj silently ignores it,
while the path: flake still sees it: the build works locally but the commit
is incomplete.
5. Dependencies
| Purpose | Crate |
|---|---|
| Wayland client | smithay-client-toolkit, wayland-client, wayland-protocols, wayland-protocols-wlr |
| Drawing | tiny-skia |
| PNG | image (png feature only) |
| Clipboard | wl-clipboard-rs |
| niri IPC | niri-ipc (version pinned to match niri 26.04) |
| CLI | clap (derive) |
| Config | serde, toml, serde_ignored |
| Time / filenames | chrono |
| Errors | anyhow |
| Logging | tracing, tracing-subscriber, tracing-appender |
| Lock | rustix (flock) |
6. Capture pipeline
Every capture command runs these steps in order.
- Log setup (section 9.2).
- Lock. Take a non-blocking
flockon$XDG_RUNTIME_DIR/valw.lock. If it is held, fail with "valw is already running" (exit 1). A second press of the hotkey is ignored rather than queued. The kernel releases the lock if the process dies. - Delay. Sleep for
--delay <secs>if given. - Connect. Bind the registry with SCTK and collect outputs (name, logical position and size via xdg-output, physical size, scale), seat, shm, screencopy and layer-shell. A missing required global is an error that names the protocol.
- Capture.
Capturer::capture(&[Output], cursor: bool) -> Vec<Frame>issues one screencopy request per output in parallel and waits for everyready. AFrameholds RGBA8 pixels at physical resolution plus its output name, logical geometry and scale.XRGB8888,ARGB8888,XBGR8888andABGR8888are converted;y_invertis honoured; any other format is an error that names the format.cursorcomes from--cursororcapture.show_cursor.
6.1 valw screen
- Default: ask niri IPC for the focused output (
FocusedOutput), capture only that output, save it and copy it to the clipboard. --all: capture every output and save one file per output, with the output name appended to the filename (... (HDMI-A-1).png). Only the focused output goes to the clipboard.- If niri IPC is unreachable (another compositor), log a warning and use the first output.
7. valw region
7.1 Surfaces
Capture all outputs first (section 6), then open one layer-shell surface per output:
- namespace
valw-overlay, layerOverlay, anchored to all four edges,exclusive_zone = -1,keyboard_interactivity = Exclusive - background: that output's frozen frame, darkened by 40%
- the selection area is copied from the undarkened frame and outlined with a 1 physical pixel white border
- cursor shape: crosshair (
wp_cursor_shape)
Surfaces are owned by the process, so the compositor removes them if valw dies. No overlay can outlive the process.
7.2 HiDPI
- Each surface asks for its preferred scale via
wp_fractional_scale. The buffer is allocated at physical size, andwp_viewportersets the destination to the logical size. The frozen frame is shown 1:1 with no resampling. - Pointer events arrive in surface-local logical coordinates. They are converted to global logical coordinates by adding the output's logical position; the selection is stored in global logical coordinates.
7.3 Selection state machine
Idle --press--> Dragging{start} --release--> Done(rect)
^ |
| +--release with size < 2 physical px--> Idle
+--Esc / right click (any state)--> Cancelled
- While
Dragging, pointer motion updates the rectangle. Redraws are throttled with frame callbacks. The darkened base pixmap is computed once; each redraw copies it and pastes the bright selection on top. - On release, the rectangle is clipped to the output where the drag started (v1 rule: one output per capture).
- Cancel closes all surfaces and exits with code 3 without saving anything.
7.4 Logical to physical conversion
For an output at logical position (ox, oy) with scale s and a selection
(x, y, w, h) in global logical coordinates:
x0 = floor((x - ox) * s) y0 = floor((y - oy) * s)
x1 = ceil((x + w - ox) * s) y1 = ceil((y + h - oy) * s)
The result is clamped to the frame bounds and cropped from the in-memory frame. The output always matches the frozen image the user saw, even if the screen changed during selection.
8. Output, config and CLI
8.1 Config
Path: $XDG_CONFIG_HOME/valw/config.toml. Phase 1 reads:
[save]
directory = "~/Pictures/Screenshots"
filename = "Screenshot %Y-%m-%d at %H.%M.%S.png"
copy_to_clipboard = true
[capture]
show_cursor = false
- A missing file means defaults (the values above).
- Invalid TOML is an error that includes the line number (exit 1).
- Unknown keys (e.g.
[preview], which later phases read) print a warning throughserde_ignoredand are otherwise ignored. ~and$HOMEindirectoryare expanded. The directory is created if missing.
8.2 Saving
- The filename is rendered with
chronostrftime. If the file exists,(2),(3), … is inserted before the extension. - PNGs are written to a temporary file in the target directory and then renamed, so a partial file never appears.
- Each saved path is printed to stdout on its own line.
8.3 Clipboard
wl-clipboard-rs copies the PNG as image/png and forks into the background
to serve paste requests. The forked child would inherit the lock file
descriptor and keep the lock held, so the lock is released explicitly before
copying. A test covers this.
8.4 CLI
valw screen [--all] [COMMON]
valw region [COMMON]
valw doctor
COMMON:
--clipboard-only copy to clipboard, do not save a file
--output <path> write to this path instead of directory + template;
"-" writes PNG to stdout (not allowed with --all)
--delay <secs> wait before capturing
--cursor include the cursor in the capture
Flag interactions:
--clipboard-onlyconflicts with--outputand--all.- Otherwise the clipboard follows
save.copy_to_clipboard, whatever the output target (including--output -). --clipboard-onlycopies even ifsave.copy_to_clipboard = false.
8.5 Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | error (missing protocol, lock busy, I/O, …) |
| 2 | usage error (clap) |
| 3 | cancelled by the user |
9. Errors and diagnostics
9.1 User-facing errors
Every error shown to the user has three parts:
error: could not capture output HDMI-A-1
cause: compositor offered only XBGR2101010, which valw can't convert yet
hint: run `valw doctor` and include its output in a bug report
log: ~/.local/state/valw/logs/2026-09-29T19-40-12.log
erroris the top-levelanyhowcontext;causelines are the rest of the context chain.hintis attached with a small extension trait (.hint("...")). Hints are added only where there is a concrete action; no generic "something went wrong" hints.logpoints at this run's log file.
9.2 Per-run logs
tracingwrites one file per run to$XDG_STATE_HOME/valw/logs/; the 10 most recent are kept.- Each log starts with an environment summary: valw version, niri version, bound globals and their versions, outputs (name, position, size, scale), and the buffer formats screencopy offered.
- The default level is
info;VALW_LOG=debug(EnvFilter syntax) raises it. - stderr gets warnings and errors. Processes spawned by niri log stderr to
niri's journal (
journalctl --user -u niri), but the log file is the primary record.
9.3 Panics
A panic hook writes the panic and its backtrace to the log and prints the same three-part format to stderr, with "this is a bug in valw" as the hint.
9.4 valw doctor
Prints one ok / warn / fail line per check and exits 1 if any check
fails:
- required globals present with a sufficient version (screencopy, layer-shell, xdg-output, shm, seat), optional ones reported (fractional-scale, viewporter, cursor-shape, data-control)
- outputs with position, size and scale
- niri IPC reachable, and niri's version compatible with the pinned
niri-ipc - config file parses
- save directory is writable
Phase 0's discovery report is produced by this command. Later phases add checks.
Desktop notifications for errors come in Phase 2.
10. Testing
10.1 Unit tests (cargo nextest)
Wayland-independent logic lives in pure functions:
- logical to physical conversion at scales 1, 1.25 and 1.5, negative output offsets, clamping to output bounds
- selection state machine: drag, sub-2px click, Esc, right click, drag that crosses into another output
- pixel format conversion for the four formats,
y_invert, unsupported format error - filename template, collision suffix,
--output - - config: defaults when missing, unknown key warning, invalid TOML line number
- lock: acquire, busy error, forked child does not hold the lock
- error rendering: snapshot of the three-part output
10.2 Headless integration test (nix flake check)
A check starts sway with WLR_BACKENDS=headless and
WLR_RENDERER=pixman inside the build sandbox, then:
- runs
valw doctorand asserts nofaillines - runs
valw screen -o -and asserts the PNG size equals the headless output's size
This exercises the real screencopy path on every build. It runs on wlroots,
not niri. region is not covered because it needs synthetic pointer input.
10.3 Manual checks on niri (end of each phase)
- Pixel exactness: capture a region with
valw regionand the same geometry withgrim -g "<geo>";magick compare -metric AEmust report 0. Repeat at scale 1.25 (niri msg output HDMI-A-1 scale 1.25) and on both outputs. - Latency: the log records time from process start to the first overlay commit; target under 150 ms.
- No stuck overlay:
kill -9during selection; the overlay disappears and the next run gets the lock. - Noctalia: the overlay covers the bar.
11. Phase 0 exit criteria
nix build,nix developand direnv work.nix builddoes not copytarget/(verified by store path size).- A minimal layer-shell surface opens and closes on niri.
- The pinned
niri-ipcversion talks to niri 26.04's socket. - wl-clipboard-rs fork behaviour and its interaction with the lock are confirmed.
valw doctorproduces its first report.
12. Definition of done (every phase)
cargo clippy -- -D warnings, cargo fmt --check, cargo nextest run and
nix build pass, and the manual checks in 10.3 that apply to the phase are
done on niri.