# 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 screen` and `valw region` capture 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 doctor` reports 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-v1` backend (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 `Capturer` trait 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 detach.rs # fork a background child (clipboard server) wayland.rs # SCTK state: registry, outputs, seat, shm frame.rs # Frame type, pixel format conversion, transforms capture.rs # Capturer trait + wlr-screencopy implementation niri.rs # niri IPC wrapper (focused output, version) selection.rs # selection state machine, logical → pixel math region.rs # region selection overlay (layer-shell surfaces) render.rs # overlay pixels: dim, paste selection, outline 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. Pure logic (`frame`, `selection`, `render`, `output`, `config`, `error`) is kept apart from Wayland code so it can be unit tested without a compositor. ### 4.1 Nix `flake.nix` follows copland's pattern (flake-parts + automatic discovery), but discovery is limited to `./nix` so `src/` is not walked: ```nix imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix); ``` Systems: `x86_64-linux`, `aarch64-linux`. `package.ulu.nix`: - `pkgs.rustPlatform.buildRustPackage`, `cargoLock.lockFile = ./Cargo.lock`. - `src` is filtered with `lib.cleanSourceWith` to drop `target`, `result`, `.jj` and `.direnv`. - `nativeBuildInputs`: `pkg-config`. `buildInputs`: `wayland`, `libxkbcommon`. - `meta.mainProgram = "valw"`. - `doCheck = false`; tests run through `checks` instead. `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: ```sh 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`: ```sh 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 /* !/.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` | | PNG | `image` (png feature only) | | Clipboard | `wl-clipboard-rs` | | niri IPC | `niri-ipc` (`=26.4.0`, matches niri 26.04) | | CLI | `clap` (derive) | | Config | `serde`, `toml`, `serde_ignored` | | Time / filenames | `chrono` | | Errors | `anyhow` | | Logging | `tracing`, `tracing-subscriber` | | Lock, poll, stdio | `rustix` | | fork | `libc` | Not needed in Phase 1: `tiny-skia` (the overlay is plain byte copies; shapes and text arrive in Phase 3) and `tracing-appender` (one file per run needs no rolling writer). Dependencies are built with `opt-level = 3` in the dev profile; unoptimised PNG encoding is too slow to test with. ## 6. Capture pipeline Every capture command runs these steps in order. 1. **Log setup** (section 9.2). 2. **Lock.** Take a non-blocking `flock` on `$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. 3. **Delay.** Sleep for `--delay ` if given. 4. **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. 5. **Capture.** `Capturer::capture(&[Output], cursor: bool) -> Vec` issues one screencopy request per output in parallel and waits up to 5 s for every `ready`. A `Frame` holds upright, opaque RGBA8 pixels at physical resolution plus its output name and logical geometry; the scale is `frame width / logical width`. `XRGB8888`, `ARGB8888`, `XBGR8888` and `ABGR8888` are converted; `y_invert` is honoured; any other format is an error that names the format. Alpha is always 255, even where the compositor's buffer says 0. `cursor` comes from `--cursor` or `capture.show_cursor`. - **niri quirk (verified in its source):** niri rejects a shm buffer with `invalid buffer` unless the whole `wl_shm_pool` is exactly the buffer's size. Every capture therefore gets its own pool. - **Rotated outputs:** the buffer arrives in the output's native orientation. It is turned upright the way grim does it: y-invert, rotate clockwise by the transform's angle, then mirror if flipped. ### 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`, layer `Overlay`, 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's buffer is the frozen frame's size (physical pixels), and `wp_viewporter` sets the destination to the logical size from the layer configure. The frame is shown 1:1 with no resampling. The frame already says how many physical pixels the output has, so `wp_fractional_scale` is not needed. - 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` (per axis: frame size / logical size) 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: ```toml [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 through `serde_ignored` and are otherwise ignored. - `~` and `$HOME` in `directory` are expanded. The directory is created if missing. ### 8.2 Saving - The filename is rendered with `chrono` strftime. 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 A Wayland clipboard needs a process that stays alive to answer paste requests. `wl-clipboard-rs` does **not** fork: its background mode serves from a thread, which dies when valw exits. So valw does what `wl-copy` does: 1. `Options::foreground(true).prepare_copy(...)` offers the PNG as `image/png` (no threads are started). 2. `detach::spawn` releases the capture lock, then forks. The child calls `setsid`, points stdin/stdout/stderr at `/dev/null`, serves paste requests until something else is copied, and exits with `_exit`. 3. The parent exits right away. The lock is released before the fork because the child would otherwise inherit it and block the next capture. stdio goes to `/dev/null` because an inherited stdout pipe would keep `valw region -o - | consumer` waiting until the clipboard changes. A unit test covers both, and the headless test runs `screen -o -` through a pipe. ### 8.4 CLI ``` valw screen [--all] [COMMON] valw region [COMMON] valw doctor COMMON: --clipboard-only copy to clipboard, do not save a file -o, --output write to this path instead of directory + template; "-" writes PNG to stdout --delay wait before capturing (non-negative, fractions allowed) --cursor include the cursor in the capture ``` Flag interactions (enforced by clap, so they are usage errors, exit 2): - `--clipboard-only` conflicts with `--output`. - `--all` conflicts with `--output` and `--clipboard-only`. - Otherwise the clipboard follows `save.copy_to_clipboard`, whatever the output target (including `--output -`). - `--clipboard-only` copies even if `save.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 ``` - `error` is the top-level `anyhow` context; `cause` lines are the rest of the context chain. - `hint` is attached with a small extension trait (`.hint("...")`). Hints are added only where there is a concrete action; no generic "something went wrong" hints. - `log` points at this run's log file. ### 9.2 Per-run logs - `tracing` writes 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 and arguments, niri version, all globals and their versions (one line), outputs (name, position, size, transform). Each capture logs the buffer formats offered and the one used, and how long capturing took. - `region` logs the time from process start to the first overlay commit, and the selected rectangle in both logical and pixel coordinates. - The default level is `info`; `VALW_LOG=debug` (EnvFilter syntax) raises it. - stderr gets warnings from tracing (with colour only on a terminal) and errors in the three-part format above. Processes spawned by niri log stderr to niri's journal (`journalctl --user -u niri`), but the log file is the primary record. - If the log directory can't be written, valw keeps working and logs to stderr only; a broken log dir must not block a screenshot. ### 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 (compositor, shm, seat, xdg-output v2, layer-shell, screencopy, viewporter); optional ones warn when missing (cursor-shape, ext/wlr data-control) - outputs with name, logical size, position and transform - 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 holds neither the lock nor stdout - output transforms (all eight) against grim's rules - error rendering: exact text of the three-part output - `niri-ipc` pin in Cargo.toml matches `niri::IPC_VERSION` ### 10.2 Headless integration test (`nix flake check`) A check starts **sway** (`sway-unwrapped`; the wrapped one needs D-Bus) with `WLR_BACKENDS=headless` and `WLR_RENDERER=pixman` inside the build sandbox, then: - runs `valw doctor` and asserts no `fail` lines - runs `valw screen -o - | cat` under a 10 s timeout (so a clipboard child holding the pipe fails the check) and asserts the PNG is 1280×720, 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 region` and the same geometry with `grim -g ""`; `magick compare -metric AE` must 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 -9` during selection; the overlay disappears and the next run gets the lock. - **Noctalia:** the overlay covers the bar. - **Rotated output:** `niri msg output HDMI-A-1 transform 90`, then `valw screen` and `grim -o HDMI-A-1` must match pixel for pixel. ## 11. Phase 0 exit criteria - `nix build`, `nix develop` and direnv work. - `nix build` does not copy `target/` (verified by store path size). - A minimal layer-shell surface opens and closes on niri. - The pinned `niri-ipc` version talks to niri 26.04's socket. - wl-clipboard-rs fork behaviour and its interaction with the lock are confirmed. - `valw doctor` produces 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.