From eac7460ff072b5a7ef064426c4ccbee13c8ee62a Mon Sep 17 00:00:00 2001 From: elwrcl Date: Tue, 29 Sep 2026 19:23:23 +0300 Subject: [PATCH] docs: add phase 0+1 design spec --- .../specs/2026-09-29-valw-phase-0-1-design.md | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md diff --git a/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md b/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md new file mode 100644 index 0000000..f772e1d --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md @@ -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 ` 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.