# 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 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: ```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` | | 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. 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 for every `ready`. A `Frame` holds RGBA8 pixels at physical resolution plus its output name, logical geometry and scale. `XRGB8888`, `ARGB8888`, `XBGR8888` and `ABGR8888` are converted; `y_invert` is honoured; any other format is an error that names the format. `cursor` comes from `--cursor` or `capture.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`, 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 asks for its preferred scale via `wp_fractional_scale`. The buffer is allocated at physical size, and `wp_viewporter` sets 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: ```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 `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 write to this path instead of directory + template; "-" writes PNG to stdout (not allowed with --all) --delay wait before capturing --cursor include the cursor in the capture ``` Flag interactions: - `--clipboard-only` conflicts with `--output` and `--all`. - 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, 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 doctor` and asserts no `fail` lines - 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 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. ## 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.