Files
valw/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md
T

16 KiB
Raw Blame History

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:

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:

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.

  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 <secs> 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<Frame> 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:

[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 <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-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 "<geo>"; 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.