docs: phase 2 preview design spec

This commit is contained in:
elwrcl committed 2026-09-30 02:10:07 +03:00
1 parent a9d979d38f
commit f52cb2a430
1 file changed
+280
@@ -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 <path> --output-filename <path>`. | 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 <path> --output-filename <path>` (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.