diff --git a/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md b/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md new file mode 100644 index 0000000..de9e8bf --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md @@ -0,0 +1,280 @@ +# valw: Phase 2 design (floating preview) + +Date: 2026-09-30 +Status: approved in brainstorming, pending written-spec review +Builds on: `2026-09-29-valw-phase-0-1-design.md` (Phase 0+1, shipped as `main`) + +## 1. Context and goal + +After a capture, a small thumbnail slides in at the bottom-right corner of +the monitor, like macOS's floating screenshot thumbnail. Clicking it opens +the screenshot in Satty; swiping it right dismisses it; otherwise it slides +away after a few seconds. + +Phase 2 is deliberately minimal. Once it ships, valw is wired into copland +and the user runs it as their only screenshot tool for at least a day. The +bugs and gaps found then decide what comes next. + +### In scope + +- Floating thumbnail per capture, stacking up to 5. +- Click → Satty; swipe right → dismiss; timeout → dismiss. +- Thumbnails never appear in screenshots and never take keyboard focus. +- `[preview]` config (`enabled`, `timeout_secs`) and `--no-preview`. +- Satty on valw's PATH through the Nix package. +- Wiring valw into copland for the test day (section 9). + +### Out of scope (candidates after the test day) + +- Hover pauses the timer. +- Notifications (error notifications, preview-off fallback). +- Right-click menu, drag-and-drop out of the thumbnail. +- Rounded corners, shadow, configurable corner or size. +- Configurable editor command; valw's own editor (Phase 5). +- home-manager / NixOS modules (end of the project). + +## 2. Decisions + +| Question | Decision | Why | +|---|---|---| +| When is the file written? | At capture time, as in Phase 1. The preview is only a shortcut. | Nothing is lost if the preview crashes or the session ends. | +| Next capture while a preview is visible | Previews **stack** (max 5, oldest evicted). | User choice. | +| Who owns the stack? | One on-demand **preview host** process. | A single owner avoids position races between processes. | +| Editing | Satty, hardcoded: `satty --filename --output-filename `. | Satty 0.22.0 has both flags; saving overwrites the screenshot. | + +## 3. Preview host + +### 3.1 Startup + +After a capture has saved its file and set up the clipboard, and if the +preview is enabled (section 7): + +1. Connect to `$XDG_RUNTIME_DIR/valw.sock`. +2. If that fails, start a host: `std::process::Command` on + `/proc/self/exe` with the hidden subcommand `__preview-host`, `setsid` in + `pre_exec`, stdio to `/dev/null`. A fresh exec inherits no Wayland + connection, lock or threads from the capture process. +3. Retry connecting every 20 ms for up to 1 s. If it still fails, log a + warning; the capture itself has already succeeded. + +A new host takes `$XDG_RUNTIME_DIR/valw-preview.lock` (the existing `Lock` +type) before touching the socket. If the lock is busy, another host is +starting or running, and this one exits 0 without output. Holding the lock +makes it safe to delete a stale socket file (left by a crash) before +binding. + +### 3.2 Protocol + +Unix stream socket, one JSON object per line in each direction +(`serde_json`). Every request gets exactly one reply. + +| Request | Host action | Reply | +|---|---|---| +| `{"cmd":"hide"}` | Mark this connection as hiding; unmap all thumbnails if this is the first; Wayland round trip so the compositor has processed the unmap | `{"ok":true}` | +| `{"cmd":"add","path":"…","output":"HDMI-A-1"}` | Load and downscale the PNG, add a thumbnail on that output | `{"ok":true}` | + +Any malformed or unknown request gets `{"ok":false,"error":"…"}` and the +connection stays open. + +A connection that sent `hide` stops hiding when it **closes**, for any +reason: normal end, Esc, an error, or the process being killed. There is no +explicit `show` message. Thumbnails are visible only while no connection is +hiding. + +### 3.3 Lifetime + +The host exits when its stack is empty and no client connection is open. It +removes the socket file on the way out. If it crashes, the compositor +removes its surfaces and the next capture starts a new host. + +The host writes its own per-run log (`log::init`), like every valw process. + +### 3.4 Event loop + +calloop (SCTK's default `calloop` feature, `WaylandSource`), one loop for: + +- the Wayland connection, +- the listening socket and every client connection (line-buffered), +- a timer per thumbnail (`timeout_secs`), +- animation, paced by frame callbacks. + +## 4. Thumbnail + +### 4.1 Surface + +One layer-shell surface per thumbnail: + +- namespace `valw-preview`, layer `Overlay`, anchored bottom + right, +- `keyboard_interactivity = None`: the compositor never gives it keyboard + focus, +- input region = the image rectangle only (transparent padding lets clicks + through), +- buffer at the output's physical scale, `wp_viewporter` to logical size + (same approach as the region overlay). + +### 4.2 Size and look + +- Long edge 220 logical px, aspect ratio kept. Images smaller than that are + not upscaled. +- Downscaled once on `add` with `image::imageops::resize` (CatmullRom). +- 1 px light border (`#e0e0e0` at 80% opacity). No rounded corners or + shadow in Phase 2. + +### 4.3 Stack layout + +- Newest thumbnail in the corner: the image sits 16 px from the right and + bottom screen edges. (The surface itself touches the right edge, see 4.4; + the 16 px is padding inside it, outside the input region.) +- Older thumbnails above it, 12 px apart. +- When one closes, those above it move down (no animation). +- A 6th thumbnail evicts the oldest (slides out as on timeout). +- Stacks are per output. + +### 4.4 Animation + +- Slide in from the right edge: 200 ms, ease-out cubic. +- Slide out to the right: 150 ms, ease-in cubic. +- Implemented by moving the image's x offset inside the surface (the surface + reaches the screen edge; its right margin is 0). The layer position never + changes during an animation. + +### 4.5 Input + +- **Click:** press and release with less than 4 px of movement. Spawns + `satty --filename --output-filename ` (detached, stdio to + `/dev/null`) and slides the thumbnail out. A failed spawn is logged. +- **Swipe:** press, then horizontal drag moves the image with the pointer + (right only; leftward drag is clamped to 0). On release, if the offset is + at least 40% of the image width, it slides out; otherwise it animates + back. +- **Timeout:** after `timeout_secs`, slide out. The timer keeps running + during a drag; if it fires mid-drag, the thumbnail closes on release. + It also keeps running while thumbnails are hidden for a capture: after a + long region selection, older thumbnails may already be gone. Simplest + rule; revisit after the test day if it annoys. + +## 5. Capture integration + +In `main.rs`'s capture flow: + +1. After the lock and `--delay`, **immediately before screencopy**, open a + `HideGuard`: connect to the host socket if it exists, send `hide`, wait + up to 300 ms for the reply. No host: no-op. Timeout: log a warning and + carry on. +2. Keep the guard (and its connection) alive through region selection, so + thumbnails can't sit above the overlay or take its clicks. +3. After `deliver` has written the file: if a preview applies (5.1), send + `add` over the guard's connection, or, without a running host, start one + (3.1) and send `add` there. +4. Drop the guard: the connection closes and the stack shows again, the new + thumbnail sliding in. + +Cost: one extra round trip before screencopy, only when a host is running. + +### 5.1 When a preview is shown + +- Only when a file was written: default save location or `-o PATH` to a + regular file. +- Not for `-o -` (stdout), `--clipboard-only`, or `-o` onto a device or + FIFO. +- `--all`: one preview, for the focused output's file (the same one that + goes to the clipboard). +- Target output: `region` → the anchor output; `screen` → the captured + (focused) output; `--all` → the focused output. + +## 6. Code layout + +Flat files, pure logic kept separate: + +| File | Responsibility | +|---|---| +| `src/stack.rs` | Pure: stack positions, eviction, swipe/click decisions, easing, thumbnail size | +| `src/ipc.rs` | Message types (serde), `HideGuard`, `add`, starting the host | +| `src/host.rs` | Host entry point, lock, socket server, calloop loop, hide counter | +| `src/thumbnail.rs` | One thumbnail: surface, drawing, animation state, pointer handling | + +`wayland.rs` gains what the host needs (a calloop-based dispatch path and +routing of pointer/frame/configure events to thumbnails), following the +existing `overlay` pattern. `main.rs` gains the hidden `__preview-host` +subcommand and the capture integration. + +New dependency: `serde_json`. calloop comes with SCTK's default features. + +## 7. Config and CLI + +```toml +[preview] +enabled = true +timeout_secs = 5 +``` + +- `timeout_secs` must be at least 1; 0 or negative is a config error with a + hint. +- `--no-preview` (on `screen` and `region`) disables the preview for that + run. +- `valw doctor`: no new checks in Phase 2 (Satty ships in the package). + +## 8. Packaging + +`nix/package.ulu.nix` wraps the binary with `makeWrapper`: +`--suffix PATH : ${lib.makeBinPath [ pkgs.satty ]}`. A user's own Satty on +PATH still wins because of `--suffix`. + +## 9. copland wiring (test day) + +In the separate `~/copland` repo, done with the user's go-ahead: + +- Flake input: `valw.url = "path:/home/elars/Projects/valw";` with + `inputs.nixpkgs.follows = "nixpkgs"`. After valw changes: + `nix flake update valw`. +- Package: add `inputs.valw.packages.${system}.default` to the user's + packages (a small `modules/home/home-valw.ulu.nix`, no module options). +- Binds in `modules/home/niri/desktop-niri-keybinds.ulu.nix`: + - `Mod+Shift+S` → `valw region` (replaces Noctalia's `screenshot-region`) + - `Print` → `valw screen` + - `Mod+Shift+3/4` stay workspace moves. + +## 10. Testing + +### 10.1 Unit tests (`cargo nextest`) + +- `stack`: positions for 1–5 thumbnails; eviction at 6; swipe 39% → back, + 40% → close, leftward → back; click vs drag (< 4 px); easing endpoints + and monotonicity; thumbnail size (1920×1080 → 220×124, 1080×1920 → + 124×220, 100×50 unchanged). +- `ipc`: JSON round trip for each message; malformed and unknown requests + → `ok:false`. +- host state: hide counter (hide, hide, close, close → visible only at 0); + exit condition (empty stack and no connections). +- host startup: a second host fails the lock and exits quietly. +- config: `[preview]` defaults, `timeout_secs = 0` rejected. + +### 10.2 Headless sway check (`nix flake check`) + +The headless background is a solid colour, which makes these deterministic: + +1. `valw screen` with default target → `valw.sock` appears within 1 s. +2. While the thumbnail is visible, `valw screen -o -` → the bottom-right + 300×200 px of the PNG is entirely the background colour (thumbnails + never end up in screenshots). +3. With `timeout_secs = 1`, the socket is gone within 3 s (host exits). +4. `--no-preview` → no socket appears. + +Click and swipe need real pointer input and stay manual. + +### 10.3 Test-day checklist (manual, niri) + +- Thumbnail on the correct monitor; sharp at scale 1.25. +- Typing is never interrupted while a thumbnail is visible. +- Click opens Satty; saving there overwrites the screenshot file. +- Swipe right closes; a short drag snaps back. +- Several captures in a row stack up to 5; none appear in later + screenshots. +- During region selection thumbnails are hidden and not clickable. +- After the timeout, `pgrep -f __preview-host` finds nothing. + +## 11. Definition of done + +`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` +and `nix flake check` pass; the section 10.3 checklist is done on niri; +valw is wired into copland and the test day can start.