docs: phase 2 preview design spec
This commit is contained in:
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.
|
||||
Reference in new issue
Block a user