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

518 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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 up to 5 s
for every `ready`. A `Frame` holds upright pixels at physical resolution
in **B, G, R, X byte order** (XRGB8888 in memory, which is what niri
delivers), plus its output name and logical geometry; the scale is
`frame width / logical width`. `XRGB8888` and `ARGB8888` are copied
as-is, `XBGR8888` and `ABGR8888` get R and B swapped, `y_invert` is
honoured, and any other format is an error that names the format.
Conversion to RGBA happens only for what is saved (`Frame::to_rgba`,
after the overlay has closed), and alpha is then always 255, even where
the compositor's buffer says 0. `cursor` comes from `--cursor` or
`capture.show_cursor`.
- **Why BGRX:** on the user's machine a full 1080p pass over the pixels
costs 5–10 ms. Converting to RGBA up front and back to ARGB for the
overlay put the first overlay frame at 270 ms. Keeping the native
order brought it to about 40 ms (headless 1080p, warm).
- **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`
- buffer format XRGB8888, the frame's own byte order, so nothing is
converted and alpha is ignored
- background: that output's frozen frame, darkened by 40% (computed once)
- the selection area is copied from the undarkened frame (shared, not
copied) 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 <path> write to this path instead of directory + template;
"-" writes PNG to stdout
--delay <secs> 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 "<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.
- **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.