docs: add phase 0+1 design spec

This commit is contained in:
elwrcl committed 2026-09-29 19:23:42 +03:00
commit eac7460ff0
1 file changed
+461
@@ -0,0 +1,461 @@
# 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 <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:
```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 <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.